摘要:本文旨在向年輕的嵌入式軟體工程師們介紹如何在裸機環境下編寫優質嵌入式C程式。首先分析了C語言的陷阱和缺陷,對容易犯錯的地方進行歸納整理;分析了編譯器語義檢查的不足之處并給出防範措施,以Keil MDK編譯器為例,介紹了該編譯器的特性、對未定義行為的處理以及一些進階應用。
本文思維導圖(已上傳至gitee)
1. 簡介
市面上介紹C語言以及程式設計方法的書數目繁多,但對如何編寫優質嵌入式C程式卻鮮有介紹,特别是對應用于單片機、ARM7、Cortex-M3這類微控制器上的優質C程式編寫方法幾乎是個空白。本文面向的,正是使用單片機、ARM7、Cortex-M3這類微控制器的底層程式設計人員。
編寫優質嵌入式C程式絕非易事,它跟設計者的思維和經驗積累關系密切。嵌入式C程式員不僅需要熟知硬體的特性、硬體的缺陷等,更要深入一門語言程式設計,不浮于表面。為了更友善的操作硬體,還需要對編譯器進行深入的了解。
2. C語言特性
語言是程式設計的基石,C語言詭異且有種種陷阱和缺陷,需要程式員多年曆練才能達到較為完善的地步。雖然有衆多書籍、雜志、專題讨論過C語言的陷阱和缺陷,但這并不影響本節再次讨論它。總是有大批的初學者,前仆後繼的倒在這些陷阱和缺陷上,民用裝置、工業裝置甚至是航天裝置都不例外。本節将結合具體例子再次審視它們,希望引起足夠重視。深入了解C語言特性,是編寫優質嵌入式C程式的基礎。
2.1處處都是陷阱
2.1.1 無心之過
1、 =和==
将比較運算符”==”誤寫成指派運算符”=”,可能是絕大多數人都遇到過的,比如下面代碼:
if(x=5)
{
//其它代碼
}
代碼的本意是比較變量x是否等于常量5,但是誤将==寫成了=,if語句恒為真。如果在邏輯判斷表達式中出現指派運算符,現在的大多數編譯器會給出警告資訊。比如keil MDK會給出警告提示:warning: #187-D: use of "=" where"==" may have been intended,但并非所有程式員都會注意到這類警告,是以有經驗的程式員使用下面的代碼來避免此類錯誤:
if(5=x)
{
//其它代碼
}
将常量放在變量x的左邊,即使程式員誤将==寫成了=,編譯器會産生一個任誰也不能無視的文法錯誤資訊:不可給常量指派!
2、複合指派運算符
複合指派運算符(+=、*=等等)雖然可以使表達式更加簡潔并有可能産生更高效的機器代碼,但某些複合指派運算符也會給程式帶來隐含Bug,比如+=容易誤寫成=+,代碼如下:
tmp=+1;
代碼本意是想表達tmp=tmp+1,但是将複合指派運算符+=誤寫成=+:将正整數常量1指派給變量tmp。編譯器會欣然接受這類代碼,連警告都不會産生。
如果你能在調試階段就發現這個Bug,真應該慶祝一下,否則這很可能會成為一個重大隐含Bug,且不易被察覺。
複合指派運算符-=也有類似問題存在。
3、 其它容易誤寫
- 使用了中文标點
- 頭檔案聲明語句最後忘記結束分号
- 邏輯與&&和位與&、邏輯或||和位或|、邏輯非!和位取反~
- 字母l和數字1、字母O和數字0
這些誤寫其實容易被編譯器檢測出,隻需要關注編譯器對此的提示資訊,就能很快解決。
2.1.2 數組下标
數組常常也是引起程式不穩定的重要因素,C語言數組的迷惑性與數組下标從0開始密不可分,你可以定義int test[30],但是你絕不可以使用數組元素test [30],除非你自己明确知道在做什麼。
2.1.3 容易被忽略的break關鍵字
1、不能漏加的break
switch…case語句可以很友善的實作多分支結構,但要注意在合适的位置添加break關鍵字。程式員往往容易漏加break進而引起順序執行多個case語句,這也許是C的一個缺陷之處。
對于switch…case語句,從機率論上說,絕大多數程式一次隻需執行一個比對的case語句,而每一個這樣的case語句後都必須跟一個break。去複雜化大機率事件,這多少有些不合常情。
2、不能亂加的break
break關鍵字用于跳出最近的那層循環語句或者switch語句,但程式員往往不夠重視這一點。
1990年1月15日,AT&T電話網絡位于紐約的一台交換機當機并且重新開機,引起它鄰近交換機癱瘓,由此及彼,一個連着一個,很快,114型交換機每六秒當機重新開機一次,六萬人九小時内不能打長途電話。當時的解決方式:工程師重裝了以前的軟體版本。。。事後的事故調查發現,這是break關鍵字誤用造成的。《C專家程式設計》提供了一個簡化版的問題源碼:
network code()
{
switch(line)
{
case THING1:
{
doit1();
} break;
case THING2:
{
if(x==STUFF)
{
do_first_stuff();
if(y==OTHER_STUFF)
break;
do_later_stuff();
} /*代碼的意圖是跳轉到這裡… …*/
initialize_modes_pointer();
} break;
default :
processing();
} /*… …但事實上跳到了這裡。*/
use_modes_pointer(); /*緻使modes_pointer未初始化*/
}
那個程式員希望從if語句跳出,但他卻忘記了break關鍵字實際上跳出最近的那層循環語句或者switch語句。現在它跳出了switch語句,執行了use_modes_pointer()函數。但必要的初始化工作并未完成,為将來程式的失敗埋下了伏筆。
2.1.4 意想不到的八進制
将一個整形常量指派給變量,代碼如下所示:
int a=34, b=034;
變量a和b相等嗎?
答案是不相等的。我們知道,16進制常量以0x為字首,10進制常量不需要字首,那麼8進制呢?它與10進制和16進制表示方法都不相通,它以數字0為字首,這多少有點奇葩:三種進制的表示方法完全不相通。如果8進制也像16進制那樣以數字和字母表示字首的話,或許更有利于減少軟體Bug,畢竟你使用8進制的次數可能都不會有誤使用的次數多!下面展示一個誤用8進制的例子,最後一個數組元素指派錯誤:
a[0]=106; /*十進制數106*/
a[1]=112; /*十進制數112*/
a[2]=052; /*實際為十進制數42,本意為十進制52*/
2.1.5指針加減運算
指針的加減運算是特殊的。下面的代碼運作在32位ARM架構上,執行之後,a和p的值分别是多少?
int a=1;
int *p=(int *)0x00001000;
a=a+1;
p=p+1;
對于a的值很容判斷出結果為2,但是p的結果卻是0x00001004。指針p加1後,p的值增加了4,這是為什麼呢?原因是指針做加減運算時是以指針的資料類型為機關。p+1實際上是按照公式p+1*sizeof(int)來計算的。不了解這一點,在使用指針直接操作資料時極易犯錯。
某項目使用下面代碼對連續RAM初始化零操作,但運作發現有些RAM并沒有被真正清零。
unsigned int *pRAMaddr; //定義位址指針變量
for(pRAMaddr=StartAddr;pRAMaddr<EndAddr;pRAMaddr+=4)
{
*pRAMaddr=0x00000000; //指定RAM位址清零
}
通過分析我們發現,由于pRAMaddr是一個無符号int型指針變量,是以pRAMaddr+=4代碼其實使pRAMaddr偏移了4*sizeof(int)=16個位元組,是以每執行一次for循環,會使變量pRAMaddr偏移16個位元組空間,但隻有4位元組空間被初始化為零。其它的12位元組資料的内容,在大多數架構處理器中都會是随機數。
2.1.6關鍵字sizeof
不知道有多少人最初認為sizeof是一個函數。其實它是一個關鍵字,其作用是傳回一個對象或者類型所占的記憶體位元組數,對絕大多數編譯器而言,傳回值為無符号整形資料。需要注意的是,使用sizeof擷取數組長度時,不要對指針應用sizeof操作符,比如下面的例子:
void ClearRAM(char array[])
{
int i ;
for(i=0;i<sizeof(array)/sizeof(array[0]);i++) //這裡用法錯誤,array實際上是指針
{
array[i]=0x00;
}
}
int main(void)
{
char Fle[20];
ClearRAM(Fle); //隻能清除數組Fle中的前四個元素
}
2.1.7增量運算符’++’和減量運算符’—‘
增量運算符++和減量運算符--既可以做字首也可以做字尾。字首和字尾的差別在于值的增加或減少這一動作發生的時間是不同的。作為字首是先自加或自減然後做别的運算,作為字尾時,是先做運算,之後再自加或自減。許多程式員對此認識不夠,就容易埋下隐患。下面的例子可以很好的解釋字首和字尾的差別。
int a=8,b=2,y;
y=a+++--b;
代碼執行後,y的值是多少?
這個例子并非是挖空心思設計出來專門讓你絞盡腦汁的C難題(),你甚至可以将這個難懂的語句作為不友好代碼的例子。但是它也可以讓你更好的了解C語言。根據運算符優先級以及編譯器識别字元的貪心法原則,第二句代碼可以寫成更明确的形式:
y=(a++)+(--b);
當指派給變量y時,a的值為8,b的值為1,是以變量y的值為9;指派完成後,變量a自加,a的值變為9,千萬不要以為y的值為10。這條指派語句相當于下面的兩條語句:
y=a+(--b);
a=a+1;
2.1.8邏輯與’&&’和邏輯或’||’的陷阱
3年嵌入式物聯網學習資源整理分享:C語言、Linux開發、資料結構;軟體開發,STM32單片機、ARM硬體開發、物聯網通信開發、綜合項目開發教程資料;筆試面試真題。點選下方插件免費領取↓↓↓
加微信領取資料(知乎)
為了提高系統效率,邏輯與和邏輯或操作的規定如下:如果對第一個操作數求值後就可以推斷出最終結果,第二個操作數就不會進行求值!比如下面代碼:
if((i>=0)&&(i++ <=max))
{
//其它代碼
}
在這個代碼中,隻有當i>=0時,i++才會被執行。這樣,i是否自增是不夠明确的,這可能會埋下隐患。邏輯或與之類似。
2.1.9結構體的填充
結構體可能産生填充,因為對大多數處理器而言,通路按字或者半字對齊的資料速度更快,當定義結構體時,編譯器為了性能優化,可能會将它們按照半字或字對齊,這樣會帶來填充問題。比如以下兩個結構體:
第一個結構體:
struct {
char c;
short s;
int x;
}str_test1;
第二個結構體:
struct {
char c;
int x;
short s;
}str_test2;
這兩個結構體元素都是相同的變量,隻是元素換了下位置,那麼這兩個結構體變量占用的記憶體大小相同嗎?
其實這兩個結構體變量占用的記憶體是不同的,對于Keil MDK編譯器,預設情況下第一個結構體變量占用8個位元組,第二個結構體占用12個位元組,差别很大。第一個結構體變量在記憶體中的存儲格式如圖所示:
第二個結構體變量在記憶體中的存儲格式如圖所示。對比兩個圖可以看出MDK編譯器是是怎麼将資料對齊的,這其中的填充内容是之前記憶體中的資料,是随機的,是以不能再結構之間逐位元組比較;另外,合理的排布結構體内的元素位置,可以最大限度減少填充,節省RAM。
2.2不可輕視的優先級
C語言有32個關鍵字,卻有34個運算符。要記住所有運算符的優先級是困難的。稍不注意,你的代碼邏輯和實際執行就會有很大出入。
比如下面将BCD碼轉換為十六進制數的代碼:
result=(uTimeValue>>4)*10+uTimeValue&0x0F;
這裡uTimeValue存放的BCD碼,想要轉換成16進制資料,實際運作發現,如果uTimeValue的值為0x23,按照我設定的邏輯,result的值應該是0x17,但運算結果卻是0x07。經過種種排查後,才發現’+’的優先級是大于’&’的,相當于(uTimeValue>>4)*10+uTimeValue與0x0F位與,結果自然與邏輯不符。符合邏輯的代碼應該是:
result=(uTimeValue>>4)*10+(uTimeValue&0x0F);
不合理的#define會加重優先級問題,讓問題變得更加隐蔽。
#define READSDA IO0PIN&(1<<11) //讀IO口p0.11的端口狀态
if(READSDA==(1<<11)) //判斷端口p0.11是否為高電平
{
//其它代碼
}
編譯器在編譯後将宏帶入,原代碼語句變為:
if(IO0PIN&(1<<11) ==(1<<11))
{
//其它代碼
}
運算符==的優先級是大于'&'的,代碼IO0PIN&(1<<11) ==(1<<11))等效為IO0PIN&0x00000001:判斷端口P0.0是否為高電平,這與原意相差甚遠。是以,使用宏定義的時候,最好将被定義的内容用括号括起來。
按照正常方式使用時,可能引起誤會的運算符還有很多,如表所示。C語言的運算符當然不會隻止步于數目繁多!
有一個簡便方法可以避免優先級問題:不清楚的優先級就加上(),但這樣至少有會帶來兩個問題:
- 過多的括号影響代碼的可讀性,包括自己和以後的維護人員
- 别人的代碼不一定用括号來解決優先級問題,但你總要讀别人的代碼
無論如何,在嵌入式程式設計方面,該掌握的基礎知識,偷巧不得。建議花一些時間,将優先級順序以及容易出錯的優先級運算符理清幾遍。
2.3隐式轉換
C語言的設計理念一直被人吐槽,因為它認為C程式員完全清楚自己在做什麼,其中一個證據就是隐式轉換。C語言規定,不同類型的資料(比如char和int型資料)需要轉換成同一類型後,才可進行計算。如果你混合使用類型,比如用char類型資料和int類型資料做減法,C使用一個規則集合來自動(隐式的)完成類型轉換。這可能很友善,但也很危險。
這就要求我們了解這個轉換規則并且能應用到程式中去!
1、當出現在表達式裡時,有符号和無符号的char和short類型都将自動被轉換為int類型,在需要的情況下,将自動被轉換為unsigned int(在short和int具有相同大小時)。這稱為類型提升。
提升在算數運算中通常不會有什麼大的壞處,但如果位運算符 ~ 和 << 應用在基本類型為unsigned char或unsigned short 的操作數,結果應該立即強制轉換為unsigned char或者unsigned short類型(取決于操作時使用的類型)。
uint8_t port =0x5aU;
uint8_t result_8;
result_8= (~port) >> 4;
假如我們不了解表達式裡的類型提升,認為在運算過程中變量port一直是unsigned char類型的。我們來看一下運算過程:~ port結果為0xa5,0xa5>>4結果為0x0a,這是我們期望的值。但實際上,result_8的結果卻是0xfa!在ARM結構下,int類型為32位。變量port在運算前被提升為int類型:~port結果為0xffffffa5,0xa5>>4結果為0x0ffffffa,指派給變量result_8,發生類型截斷(這也是隐式的!),result_8=0xfa。經過這麼詭異的隐式轉換,結果跟我們期望的值,已經大相徑庭!正确的表示:
result_8=(unsigned char) (~port) >> 4; /*強制轉換*/
2、在包含兩種資料類型的任何運算裡,兩個值都會被轉換成兩種類型裡較高的級别。類型級别從高到低的順序是long double、double、float、unsigned long long、long long、unsigned long、long、unsigned int、int。
這種類型提升通常都是件好事,但往往有很多程式員不能真正了解這句話,比如下面的例子(int類型表示16位)。
uint16_t u16a = 40000; /* 16位無符号變量*/
uint16_t u16b= 30000; /*16位無符号變量*/
uint32_t u32x; /*32位無符号變量 */
uint32_t u32y;
u32x = u16a +u16b; /* u32x = 70000還是4464 ? */
u32y =(uint32_t)(u16a + u16b); /* u32y = 70000 還是4464 ? */
u32x和u32y的結果都是4464(70000%65536)!不要認為表達式中有一個高類别uint32_t類型變量,編譯器都會幫你把所有其他低類别都提升到uint32_t類型。正确的書寫方式:
u32x = (uint32_t)u16a +(uint32_t)u16b; 或者:
u32x = (uint32_t)u16a + u16b;
後一種寫法在本表達式中是正确的,但是在其它表達式中不一定正确,比如:
uint16_t u16a,u16b,u16c;
uint32_t u32x;
u32x= u16a + u16b + (uint32_t)u16c;/*錯誤寫法,u16a+ u16b仍可能溢出*/
3、在指派語句裡,計算的最後結果被轉換成将要被賦予值的那個變量的類型。這一過程可能導緻類型提升也可能導緻類型降級。降級可能會導緻問題。比如将運算結果為321的值指派給8位char類型變量。程式必須對運算時的資料溢出做合理的處理。很多其他語言,像Pascal(C語言設計者之一曾撰文狠狠批評過Pascal語言),都不允許混合使用類型,但C語言不會限制你的自由,即便這經常引起Bug。
4、 當作為函數的參數被傳遞時,char和short會被轉換為int,float會被轉換為double。
當不得已混合使用類型時,一個比較好的習慣是使用類型強制轉換。強制類型轉換可以避免編譯器隐式轉換帶來的錯誤,同時也向以後的維護人員傳遞一些有用資訊。這有個前提:你要對強制類型轉換有足夠的了解!下面總結一些規則:
并非所有強制類型轉換都是由風險的,把一個整數值轉換為一種具有相同符号的更寬類型時,是絕對安全的。
精度高的類型強制轉換為精度低的類型時,通過丢棄适當數量的最高有效位來擷取結果,也就是說會發生資料截斷,并且可能改變資料的符号位。
精度低的類型強制轉換為精度高的類型時,如果兩種類型具有相同的符号,那麼沒什麼問題;需要注意的是負的有符号精度低類型強制轉換為無符号精度高類型時,會不直覺的執行符号擴充,
原文作者:果果小師弟
原文标題:幹貨|如何編寫優質的嵌入式C程式
原文連結:https://mp.weixin.qq.com/s/bjPZ20Mmiw4cUfK9kpfTfA