如何編寫優質嵌入式C程式
前言:這是一年前我為公司內部寫的一個文件,旨在向年輕的嵌入式軟體工程師們介紹如何在裸機環境下編寫優質嵌入式C程式。感覺是有一定的參考價值,所以拿出來分享,拋磚引玉。
轉載請註明出處:http://blog.csdn.net/zhzht19861011/article/details/45508029
摘要:本文首先分析了C語言的陷阱和缺陷,對容易犯錯的地方進行歸納整理;分析了編譯器語義檢查的不足之處並給出防範措施,以Keil MDK編譯器為例,介紹了該編譯器的特性、對未定義行為的處理以及一些高階應用;在此基礎上,介紹了防禦性程式設計的概念,提出了程式設計過程中就應該防範於未然的多種措施;提出了
1. 簡介
市面上介紹c語言以及程式設計方法的書數目繁多,但對如何編寫優質嵌入式C程式卻鮮有介紹,特別是對應用於微控制器、ARM7、Cortex-M3這類微控制器上的優質C程式編寫方法幾乎是個空白。本文面向的,正是使用微控制器、ARM7、Cortex-M3這類微控制器的底層程式設計人員。
編寫優質嵌入式C程式絕非易事,它跟設計者的思維和經驗積累關係密切。嵌入式C程式設計師不僅需要熟知硬體的特性、硬體的缺陷等,更要深入一門語言程式設計,不浮於表面。為了更方便的操作硬體,還需要對編譯器進行深入的瞭解。
本文將從語言特性、編譯器、防禦性程式設計、測試和程式設計思想這幾個方面來討論如何編寫優質嵌入式C程式。與很多雜誌、書籍不同,本文提供大量真實例項、程式碼段和參考書目,不僅介紹應該做什麼,還重點介紹如何做、以及為什麼這樣做。編寫優質嵌入式C程式涉及面十分廣,需要程式設計師長時間的經驗積累,本文希望能縮短這一過程。
2. C語言特性
語言是程式設計的基石,C語言詭異且有種種陷阱和缺陷,需要程式設計師多年曆練才能達到較為完善的地步。雖然有眾多書籍、雜誌、專題討論過C語言的陷阱和缺陷,但這並不影響本節再次討論它。總是有大批的初學者,前仆後繼的倒在這些陷阱和缺陷上,民用裝置、工業裝置甚至是航天裝置都不例外。本節將結合具體例子再次審視它們,希望引起足夠重視。深入理解C語言特性,是編寫優質嵌入式C程式的基礎。
2.1處處都是陷阱
2.1.1 無心之過
1) “=”和”==”
將比較運算子”==”誤寫成賦值運算子”=”,可能是絕大多數人都遇到過的,比如下面程式碼:
[cpp] view plain copy
- 1. if(x=5)
- 2. {
- 3. //其它程式碼
- 4. }
程式碼的本意是比較變數x是否等於常量5,但是誤將”==”寫成了”=”,if語句恆為真。如果在邏輯判斷表示式中出現賦值運算子,現在的大多數編譯器會給出警告資訊。比如keil MDK會給出警告提示:“warning: #187-D: use of "=" where"==" may have been intended”,但並非所有程式設計師都會注意到這類警告,因此有經驗的程式設計師使用下面的程式碼來避免此類錯誤:
[cpp] view plain copy
- 1. if(5==x)
- 2. {
- 3. //其它程式碼
- 4. }
將常量放在變數x的左邊,即使程式設計師誤將’==’寫成了’=’,編譯器會產生一個任誰也不能無視的語法錯誤資訊:不可給常量賦值!
2) 複合賦值運算子
複合賦值運算子(+=、*=等等)雖然可以使表示式更加簡潔並有可能產生更高效的機器程式碼,但某些複合賦值運算子也會給程式帶來隱含Bug,比如”+=”容易誤寫成”=+”,程式碼如下:
[cpp] view plain copy
- 1. tmp=+1;
程式碼本意是想表達tmp=tmp+1,但是將複合賦值運算子”+=”誤寫成”=+”:將正整數常量1賦值給變數tmp。編譯器會欣然接受這類程式碼,連警告都不會產生。
如果你能在除錯階段就發現這個Bug,真應該慶祝一下,否則這很可能會成為一個重大隱含Bug,且不易被察覺。
複合賦值運算子”-=”也有類似問題存在。
3) 其它容易誤寫
- 使用了中文標點
- 標頭檔案宣告語句最後忘記結束分號
- 邏輯與&&和位與&、邏輯或||和位或|、邏輯非!和位取反~
- 字母l和數字1、字母O和數字0
這些誤寫其實容易被編譯器檢測出,只需要關注編譯器對此的提示資訊,就能很快解決。
很多的軟體Bug源自於輸入錯誤。在Google上搜索的時候,有些結果列表項中帶有一條警告,表明Google認為它帶有惡意程式碼。如果你在2009年1月31日一大早使用Google搜尋的話,你就會看到,在那天早晨55分鐘的時間內,Google的搜尋結果標明每個站點對你的PC都是有害的。這涉及到整個Internet上的所有站點,包括Google自己的所有站點和服務。Google的惡意軟體檢測功能通過在一個已知攻擊者的列表上查詢站點,從而識別出危險站點。在1月31日早晨,對這個列表的更新意外地包含了一條斜槓(“/”)。所有的URL都包含一條斜槓,並且,反惡意軟體功能把這條斜槓理解為所有的URL都是可疑的,因此,它愉快地對搜尋結果中的每個站點都新增一條警告。很少見到如此簡單的一個輸入錯誤帶來的結果如此奇怪且影響如此廣泛,但程式就是這樣,容不得一絲疏忽。
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專家程式設計》提供了一個簡化版的問題原始碼:
[cpp] view plain copy
- 1. network code()
- 2. {
- 3. switch(line)
- 4. {
- 5. case THING1:
- 6. {
- 7. doit1();
- 8. } break;
- 9. case THING2:
- 10. {
- 11. if(x==STUFF)
- 12. {
- 13. do_first_stuff();
- 14. if(y==OTHER_STUFF)
- 15. break;
- 16. do_later_stuff();
- 17. } /*程式碼的意圖是跳轉到這裡… …*/
- 18. initialize_modes_pointer();
- 19. } break;
- 20. default :
- 21. processing();
- 22. } /*… …但事實上跳到了這裡。*/
- 23. use_modes_pointer(); /*致使modes_pointer未初始化*/
- 24. }
那個程式設計師希望從if語句跳出,但他卻忘記了break關鍵字實際上跳出最近的那層迴圈語句或者switch語句。現在它跳出了switch語句,執行了use_modes_pointer()函式。但必要的初始化工作並未完成,為將來程式的失敗埋下了伏筆。
2.1.4 意想不到的八進位制
將一個整形常量賦值給變數,程式碼如下所示:
[cpp] view plain copy
- 1. int a=34, b=034;
變數a和b相等嗎?
答案是不相等的。我們知道,16進位制常量以’0x’為字首,10進位制常量不需要字首,那麼8進位制呢?它與10進位制和16進製表示方法都不相通,它以數字’0’為字首,這多少有點奇葩:三種進位制的表示方法完全不相通。如果8進位制也像16進位制那樣以數字和字母表示字首的話,或許更有利於減少軟體Bug,畢竟你使用8進位制的次數可能都不會有誤使用的次數多!下面展示一個誤用8進位制的例子,最後一個數組元素賦值錯誤:
[cpp] view plain copy
- 1. a[0]=106; /*十進位制數106*/
- 2. a[1]=112; /*十進位制數112*/
- 3. a[2]=052; /*實際為十進位制數42,本意為十進位制52*/
2.1.5指標加減運算
指標的加減運算是特殊的。下面的程式碼執行在32位ARM架構上,執行之後,a和p的值分別是多少?
[cpp] view plain copy
- 1. int a=1;
- 2. int *p=(int *)0x00001000;
- 3. a=a+1;
- 4. p=p+1;
對於a的值很容判斷出結果為2,但是p的結果卻是0x00001004。指標p加1後,p的值增加了4,這是為什麼呢?原因是指標做加減運算時是以指標的資料型別為單位。p+1實際上是按照公式p+1*sizeof(int)來計算的。不理解這一點,在使用指標直接操作資料時極易犯錯。
某專案使用下面程式碼對連續RAM初始化零操作,但執行發現有些RAM並沒有被真正清零。
[cpp] view plain copy
- 1. unsigned int *pRAMaddr; //定義地址指標變數
- 2. for(pRAMaddr=StartAddr;pRAMaddr<EndAddr;pRAMaddr+=4)
- 3. {
- 4. *pRAMaddr=0x00000000; //指定RAM地址清零
- 5. }
通過分析我們發現,由於pRAMaddr是一個無符號int型指標變數,所以pRAMaddr+=4程式碼其實使pRAMaddr偏移了4*sizeof(int)=16個位元組,所以每執行一次for迴圈,會使變數pRAMaddr偏移16個位元組空間,但只有4位元組空間被初始化為零。其它的12位元組資料的內容,在大多數架構處理器中都會是隨機數。
2.1.6關鍵字sizeof
不知道有多少人最初認為sizeof是一個函式。其實它是一個關鍵字,其作用是返回一個物件或者型別所佔的記憶體位元組數,對絕大多數編譯器而言,返回值為無符號整形資料。需要注意的是,使用sizeof獲取陣列長度時,不要對指標應用sizeof操作符,比如下面的例子:
[cpp] view plain copy
- 1. void ClearRAM(char array[])
- 2. {
- 3. int i ;
- 4. for(i=0;i<sizeof(array)/sizeof(array[0]);i++) //這裡用法錯誤,array實際上是指標
- 5. {
- 6. array[i]=0x00;
- 7. }
- 8. }
- 9.
- 10. int main(void)
- 11. {
- 12. char Fle[20];
- 13.
- 14. ClearRAM(Fle); //只能清除陣列Fle中的前四個元素
- 15. }
我們知道,對於一個數組array[20],我們使用程式碼sizeof(array)/sizeof(array[0])可以獲得陣列的元素(這裡為20),但陣列名和指標往往是容易混淆的,有且只有一種情況下陣列名是可以當做指標的,那就是陣列名作為函式形參時,陣列名被認為是指標,同時,它不能再兼任陣列名。注意只有這種情況下,陣列名才可以當做指標,但不幸的是這種情況下容易引發風險。在ClearRAM函式內,作為形參的array[]不再是陣列名了,而成了指標。sizeof(array)相當於求指標變數佔用的位元組數,在32位系統下,該值為4,sizeof(array)/sizeof(array[0])的運算結果也為4。所以在main函式中呼叫ClearRAM(Fle),也只能清除陣列Fle中的前四個元素了。
2.1.7增量運算子’++’和減量運算子’—‘
增量運算子”++”和減量運算子”--“既可以做字首也可以做字尾。字首和字尾的區別在於值的增加或減少這一動作發生的時間是不同的。作為字首是先自加或自減然後做別的運算,作為字尾時,是先做運算,之後再自加或自減。許多程式設計師對此認識不夠,就容易埋下隱患。下面的例子可以很好的解釋字首和字尾的區別。
[cpp] view plain copy
- 1. int a=8,b=2,y;
- 2. y=a+++--b;
程式碼執行後,y的值是多少?
這個例子並非是挖空心思設計出來專門讓你絞盡腦汁的C難題(如果你覺得自己對C細節掌握很有信心,做一些C難題檢驗一下是個不錯的選擇。那麼,《The C Puzzle Book》這本書一定不要錯過),你甚至可以將這個難懂的語句作為不友好程式碼的例子。但是它也可以讓你更好的理解C語言。根據運算子優先順序以及編譯器識別字符的貪心法原則,第二句程式碼可以寫成更明確的形式:
[cpp] view plain copy
- 1. y=(a++)+(--b);
當賦值給變數y時,a的值為8,b的值為1,所以變數y的值為9;賦值完成後,變數a自加,a的值變為9,千萬不要以為y的值為10。這條賦值語句相當於下面的兩條語句:
[cpp] view plain copy
- 1. y=a+(--b);
- 2. a=a+1;
2.1.8邏輯與’&&’和邏輯或’||’的陷阱
為了提高系統效率,邏輯與和邏輯或操作的規定如下:如果對第一個運算元求值後就可以推斷出最終結果,第二個運算元就不會進行求值!比如下面程式碼:
[cpp] view plain copy
- 1. if((i>=0)&&(i++ <=max))
- 2. {
- 3. //其它程式碼
- 4. }
在這個程式碼中,只有當i>=0時,i++才會被執行。這樣,i是否自增是不夠明確的,這可能會埋下隱患。邏輯或與之類似。
2.1.9結構體的填充
結構體可能產生填充,因為對大多數處理器而言,訪問按字或者半字對齊的資料速度更快,當定義結構體時,編譯器為了效能優化,可能會將它們按照半字或字對齊,這樣會帶來填充問題。比如以下兩個個結構體:
第一個結構體:
[cpp] view plain copy
- 1. struct {
- 2. char c;
- 3. short s;
- 4. int x;
- 5. }str_test1;
第二個結構體:
[cpp] view plain copy
- 1. struct {
- 2. char c;
- 3. int x;
- 4. short s;
- 5. }str_test2;
這兩個結構體元素都是相同的變數,只是元素換了下位置,那麼這兩個結構體變數佔用的記憶體大小相同嗎?
其實這兩個結構體變數佔用的記憶體是不同的,對於Keil MDK編譯器,預設情況下第一個結構體變數佔用8個位元組,第二個結構體佔用12個位元組,差別很大。第一個結構體變數在記憶體中的儲存格式如圖2-1所示:
圖2-1:結構體變數1記憶體分佈
第二個結構體變數在記憶體中的儲存格式如圖2-2所示。對比兩個圖可以看出MDK編譯器是是怎麼將資料對齊的,這其中的填充內容是之前記憶體中的資料,是隨機的,所以不能再結構之間逐位元組比較;另外,合理的排布結構體內的元素位置,可以最大限度減少填充,節省RAM。
圖2-2 :結構體變數2記憶體分佈
2.2不可輕視的優先順序
C語言有32個關鍵字,卻有34個運算子。要記住所有運算子的優先順序是困難的。稍不注意,你的程式碼邏輯和實際執行就會有很大出入。
比如下面將BCD碼轉換為十六進位制數的程式碼:
[cpp] view plain copy
- 1. result=(uTimeValue>>4)*10+uTimeValue&0x0F;
這裡uTimeValue存放的BCD碼,想要轉換成16進位制資料,實際執行發現,如果uTimeValue的值為0x23,按照我設定的邏輯,result的值應該是0x17,但運算結果卻是0x07。經過種種排查後,才發現’+’的優先順序是大於’&’的,相當於(uTimeValue>>4)*10+uTimeValue與0x0F位與,結果自然與邏輯不符。符合邏輯的程式碼應該是:
[cpp] view plain copy
- 1. result=(uTimeValue>>4)*10+(uTimeValue&0x0F);
不合理的#define會加重優先順序問題,讓問題變得更加隱蔽。
[cpp] view plain copy
- 1. #define READSDA IO0PIN&(1<<11) //讀IO口p0.11的埠狀態
- 2.
- 3. if(READSDA==(1<<11)) //判斷埠p0.11是否為高電平
- 4. {
- 5. //其它程式碼
- 6. }
編譯器在編譯後將巨集帶入,原始碼語句變為:
[cpp] view plain copy
- 1. if(IO0PIN&(1<<11) ==(1<<11))
- 2. {
- 3. //其它程式碼
- 4. }
運算子'=='的優先順序是大於'&'的,程式碼IO0PIN&(1<<11) ==(1<<11))等效為IO0PIN&0x00000001:判斷埠P0.0是否為高電平,這與原意相差甚遠。因此,使用巨集定義的時候,最好將被定義的內容用括號括起來。
按照常規方式使用時,可能引起誤會的運算子還有很多,如表2-1所示。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型別(取決於操作時使用的型別)。
[cpp] view plain copy
- 1. uint8_t port =0x5aU;
- 2. uint8_t result_8;
- 3. 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。經過這麼詭異的隱式轉換,結果跟我們期望的值,已經大相徑庭!正確的表示式語句應該為:
[cpp] view plain copy
- 1. result_8=(unsigned char) (~port) >> 4; /*強制轉換*/
2) 在包含兩種資料型別的任何運算裡,兩個值都會被轉換成兩種型別裡較高的級別。型別級別從高到低的順序是long double、double、float、unsigned long long、long long、unsigned long、long、unsigned int、int。
這種型別提升通常都是件好事,但往往有很多程式設計師不能真正理解這句話,比如下面的例子(int型別表示16位)。
[cpp] view plain copy
- 1. uint16_t u16a = 40000; /* 16位無符號變數*/
- 2. uint16_t u16b= 30000; /*16位無符號變數*/
- 3. uint32_t u32x; /*32位無符號變數 */
- 4. uint32_t u32y;
- 5. u32x = u16a +u16b; /* u32x = 70000還是4464 ? */
- 6. u32y =(uint32_t)(u16a + u16b); /* u32y = 70000 還是4464 ? */
u32x和u32y的結果都是4464(70000%65536)!不要認為表示式中有一個高類別uint32_t型別變數,編譯器都會幫你把所有其他低類別都提升到uint32_t型別。正確的書寫方式:
[cpp] view plain copy
- 1. u32x = (uint32_t)u16a +(uint32_t)u16b; 或者:
- 2. u32x = (uint32_t)u16a + u16b;
後一種寫法在本表示式中是正確的,但是在其它表示式中不一定正確,比如:
[cpp] view plain copy
- 1. uint16_t u16a,u16b,u16c;
- 2. uint32_t u32x;
- 3. u32x= u16a + u16b + (uint32_t)u16c;/*錯誤寫法,u16a+ u16b仍可能溢位*/
3) 在賦值語句裡,計算的最後結果被轉換成將要被賦予值的那個變數的型別。這一過程可能導致型別提升也可能導致型別降級。降級可能會導致問題。比如將運算結果為321的值賦值給8位char型別變數。程式必須對運算時的資料溢位做合理的處理。很多其他語言,像Pascal(C語言設計者之一曾撰文狠狠批評過Pascal語言),都不允許混合使用型別,但C語言不會限制你的自由,即便這經常引起Bug。
4) 當作為函式的引數被傳遞時,char和short會被轉換為int,float會被轉換為double。
當不得已混合使用型別時,一個比較好的習慣是使用型別強制轉換。強制型別轉換可以避免編譯器隱式轉換帶來的錯誤,同時也向以後的維護人員傳遞一些有用資訊。這有個前提:你要對強制型別轉換有足夠的瞭解!下面總結一些規則:
- 並非所有強制型別轉換都是由風險的,把一個整數值轉換為一種具有相同符號的更寬型別時,是絕對安全的。
- 精度高的型別強制轉換為精度低的型別時,通過丟棄適當數量的最高有效位來獲取結果,也就是說會發生資料截斷,並且可能改變資料的符號位。
- 精度低的型別強制轉換為精度高的型別時,如果兩種型別具有相同的符號,那麼沒什麼問題;需要注意的是負的有符號精度低型別強制轉換為無符號精度高型別時,會不直觀的執行符號擴充套件,例如:
[cpp] view plain copy
- 1. unsigned int bob;
- 2. signed char fred = -1;
- 3.
- 4. bob=(unsigned int )fred; /*發生符號擴充套件,此時bob為0xFFFFFFFF*/
3.編譯器
如果你和一個優秀的程式設計師共事,你會發現他對他使用的工具非常熟悉,就像一個畫家瞭解他的畫具一樣。----比爾.蓋茨
3.1不能簡單的認為是個工具
- 嵌入式程式開發跟硬體密切相關,需要使用C語言來讀寫底層暫存器、存取資料、控制硬體等,C語言和硬體之間由編譯器來聯絡,一些C標準不支援的硬體特性操作,由編譯器提供。
- 彙編可以很輕易的讀寫指定RAM地址、可以將程式碼段放入指定的Flash地址、可以精確的設定變數在RAM中分佈等等,所有這些操作,在深入瞭解編譯器後,也可以使用C語言實現。
- C語言標準並非完美,有著數目繁多的未定義行為,這些未定義行為完全由編譯器自主決定,瞭解你所用的編譯器對這些未定義行為的處理,是必要的。
- 嵌入式編譯器對除錯做了優化,會提供一些工具,可以分析程式碼效能,檢視外設元件等,瞭解編譯器的這些特性有助於提高線上除錯的效率。
- 此外,堆疊操作、程式碼優化、資料型別的範圍等等,都是要深入瞭解編譯器的理由。
- 如果之前你認為編譯器只是個工具,能夠編譯就好。那麼,是時候改變這種思想了。
3.2不能依賴編譯器的語義檢查
編譯器的語義檢查很弱小,甚至還會“掩蓋”錯誤。現代的編譯器設計是件浩瀚的工程,為了讓編譯器設計簡單一些,目前幾乎所有編譯器的語義檢查都比較弱小。為了獲得更快的執行效率,C語言被設計的足夠靈活且幾乎不進行任何執行時檢查,比如陣列越界、指標是否合法、運算結果是否溢位等等。這就造成了很多編譯正確但執行奇怪的程式。
C語言足夠靈活,對於一個數組test[30],它允許使用像test[-1]這樣的形式來快速獲取陣列首元素所在地址前面的資料;允許將一個常數強制轉換為函式指標,使用程式碼(*((void(*)())0))()來呼叫位於0地址的函式。C語言給了程式設計師足夠的自由,但也由程式設計師承擔濫用自由帶來的責任。
3.2.1莫名的宕機
下面的兩個例子都是死迴圈,如果在不常用分支中出現類似程式碼,將會造成看似莫名其妙的宕機或者重啟。
[cpp] view plain copy
- 1. unsigned char i; //例程1
- 2. for(i=0;i<256;i++)
- 3. {
- 4. //其它程式碼
- 5. }
- 1. unsigned char i; //例程2
- 2. for(i=10;i>=0;i--)
- 3. {
- 4. //其它程式碼
- 5. }
對於無符號char型別,表示的範圍為0~255,所以無符號char型別變數i永遠小於256(第一個for迴圈無限執行),永遠大於等於0(第二個for迴圈無線執行)。需要說明的是,賦值程式碼i=256是被C語言允許的,即使這個初值已經超出了變數i可以表示的範圍。C語言會千方百計的為程式設計師創造出錯的機會,可見一斑。
3.2.2不起眼的改變
假如你在if語句後誤加了一個分號,可能會完全改變了程式邏輯。編譯器也會很配合的幫忙掩蓋,甚至連警告都不提示。程式碼如下:
[cpp] view plain copy
- 1. if(a>b); //這裡誤加了一個分號
- 2. a=b; //這句程式碼一直被執行
不但如此,編譯器還會忽略掉多餘的空格符和換行符,就像下面的程式碼也不會給出足夠提示:
[cpp] view plain copy
- 1. if(n<3)
- 2. return //這裡少加了一個分號
- 3. logrec.data=x[0];
- 4. logrec.time=x[1];
- 5. logrec.code=x[2];
這段程式碼的本意是n<3時程式直接返回,由於程式設計師的失誤,return少了一個結束分號。編譯器將它翻譯成返回表示式logrec.data=x[0]的結果,return後面即使是一個表示式也是C語言允許的。這樣當n>=3時,表示式logrec.data=x[0];就不會被執行,給程式埋下了隱患。
3.2.3 難查的陣列越界
上文曾提到陣列常常是引起程式不穩定的重要因素,程式設計師往往不經意間就會寫陣列越界。
一位同事的程式碼在硬體上執行,一段時間後就會發現LCD顯示屏上的一個數字不正常的被改變。經過一段時間的除錯,問題被定位到下面的一段程式碼中:
[cpp] view plain copy
- 1. int SensorData[30];
- 2. //其他程式碼
- 3. for(i=30;i>0;i--)
- 4. {
- 5. SensorData[i]=…;
- 6. //其他程式碼
- 7. }
這裡聲明瞭擁有30個元素的陣列,不幸的是for迴圈程式碼中誤用了本不存在的陣列元素SensorData[30],但C語言卻默許這麼使用,並欣然的按照程式碼改變了陣列元素SensorData[30]所在位置的值, SensorData[30]所在的位置原本是一個LCD顯示變數,這正是顯示屏上的那個值不正常被改變的原因。真慶幸這麼輕而易舉的發現了這個Bug。
其實很多編譯器會對上述程式碼產生一個警告:賦值超出陣列界限。但並非所有程式設計師都對編譯器警告保持足夠敏感,況且,編譯器也並不能檢查出陣列越界的所有情況。比如下面的例子:
你在模組A中定義陣列:
[cpp] view plain copy
- 1. int SensorData[30];
在模組B中引用該陣列,但由於你引用程式碼並不規範,這裡沒有顯示宣告陣列大小,但編譯器也允許這麼做:
[cpp] view plain copy
- 1. extern int SensorData[];
這次,編譯器不會給出警告資訊,因為編譯器壓根就不知道陣列的元素個數。所以,當一個數組宣告為具有外部連結,它的大小應該顯式宣告。
再舉一個編譯器檢查不出陣列越界的例子。函式func()的形參是一個數組形式,函式程式碼簡化如下所示:
[cpp] view plain copy
- 1. char * func(char SensorData[30])
- 2. {
- 3. unsignedint i;
- 4. for(i=30;i>0;i--)
- 5. {
- 6. SensorData[i]=…;
- 7. //其他程式碼
- 8. }
- 9. }
這個給SensorData[30]賦初值的語句,編譯器也是不給任何警告的。實際上,編譯器是將陣列名Sensor隱含的轉化為指向陣列第一個元素的指標,函式體是使用指標的形式來訪問陣列的,它當然也不會知道陣列元素的個數了。造成這種局面的原因之一是C編譯器的作者們認為指標代替陣列可以提高程式效率,而且,可以簡化編譯器的複雜度。
指標和陣列是容易給程式造成混亂的,我們有必要仔細的區分它們的不同。其實換一個角度想想,它們也是容易區分的:可以將陣列名等同於指標的情況有且只有一處,就是上面例子提到的陣列作為函式形參時。其它時候,陣列名是陣列名,指標是指標。
下面的例子編譯器同樣檢查不出陣列越界。
我們常常用陣列來快取通訊中的一幀資料。在通訊中斷中將接收的資料儲存到陣列中,直到一幀資料完全接收後再進行處理。即使定義的陣列長度足夠長,接收資料的過程中也可能發生陣列越界,特別是干擾嚴重時。這是由於外界的干擾破壞了資料幀的某些位,對一幀的資料長度判斷錯誤,接收的資料超出陣列範圍,多餘的資料改寫與陣列相鄰的變數,造成系統崩潰。由於中斷事件的非同步性,這類陣列越界編譯器無法檢查到。
如果區域性陣列越界,可能引發ARM架構硬體異常。
同事的一個裝置用於接收無線感測器的資料,一次軟體升級後,發現接收裝置工作一段時間後會宕機。調試表明ARM7處理器發生了硬體異常,異常處理程式碼是一段死迴圈(宕機的直接原因)。接收裝置有一個硬體模組用於接收無線感測器的整包資料並存在自己的緩衝區中,當硬體模組接收資料完成後,使用外部中斷通知裝置取資料,外部中斷服務程式精簡後如下所示:
[cpp] view plain copy
- 1. __irq ExintHandler(void)
- 2. {
- 3. unsignedchar DataBuf[50];
- 4. GetData(DataBug); //從硬體緩衝區取一幀資料
- 5. //其他程式碼
- 6. }
由於存在多個無線感測器近乎同時傳送資料的可能加之GetData()函式保護力度不夠,陣列DataBuf在取資料過程中發生越界。由於陣列DataBuf為區域性變數,被分配在堆疊中,同在此堆疊中的還有中斷髮生時的執行環境以及中斷返回地址。溢位的資料將這些資料破壞掉,中斷返回時PC指標可能變成一個不合法值,硬體異常由此產生。
如果我們精心設計溢位部分的資料,化資料為指令,就可以利用陣列越界來修改PC指標的值,使之指向我們希望執行的程式碼。
1988年,第一個網路蠕蟲在一天之內感染了2000到6000臺計算機,這個蠕蟲程式利用的正是一個標準輸入庫函式的陣列越界Bug。起因是一個標準輸入輸出庫函式gets(),原來設計為從資料流中獲取一段文字,遺憾的是,gets()函式沒有規定輸入文字的長度。gets()函式內部定義了一個500位元組的陣列,攻擊者傳送了大於500位元組的資料,利用溢位的資料修改了堆疊中的PC指標,從而獲取了系統許可權。目前,雖然有更好的庫函式來代替gets函式,但gets函式仍然存在著。
3.2.4神奇的volatile
做嵌入式裝置開發,如果不對volatile修飾符具有足夠了解,實在是說不過去。volatile是C語言32個關鍵字中的一個,屬於型別限定符,常用的const關鍵字也屬於型別限定符。
volatile限定符用來告訴編譯器,該物件的值無任何永續性,不要對它進行任何優化;它迫使編譯器每次需要該物件資料內容時都必須讀該物件,而不是隻讀一次資料並將它放在暫存器中以便後續訪問之用(這樣的優化可以提高系統速度)。
這個特性在嵌入式應用中很有用,比如你的IO口的資料不知道什麼時候就會改變,這就要求編譯器每次都必須真正的讀取該IO埠。這裡使用了詞語“真正的讀”,是因為由於編譯器的優化,你的邏輯反應到程式碼上是對的,但是程式碼經過編譯器翻譯後,有可能與你的邏輯不符。你的程式碼邏輯可能是每次都會讀取IO埠資料,但實際上編譯器將程式碼翻譯成彙編時,可能只是讀一次IO埠資料並儲存到暫存器中,接下來的多次讀IO口都是使用暫存器中的值來進行處理。因為讀寫暫存器是最快的,這樣可以優化程式效率。與之類似的,中斷裡的變數、多執行緒中的共享變數等都存在這樣的問題。
不使用volatile,可能造成執行邏輯錯誤,但是不必要的使用volatile會造成程式碼效率低下(編譯器不優化volatile限定的變數),因此清楚的知道何處該使用volatile限定符,是一個嵌入式程式設計師的必修內容。
一個程式模組通常由兩個檔案組成,原始檔和標頭檔案。如果你在原始檔定義變數:
[cpp] view plain copy
- 1. unsigned int test;
並在標頭檔案中宣告該變數:
[cpp] view plain copy
- 1. extern unsigned long test;
編譯器會提示一個語法錯誤:變數’ test’宣告型別不一致。但如果你在原始檔定義變數:
[cpp] view plain copy
- 1. volatile unsigned int test;
在標頭檔案中這樣宣告變數:
[cpp] view plain copy
- 1. extern unsigned int test; /*缺少volatile限定符*/
編譯器卻不會給出錯誤資訊(有些編譯器僅給出一條警告)。當你在另外一個模組(該模組包含宣告變數test的標頭檔案)使用變數test時,它已經不再具有volatile限定,這樣很可能造成一些重大錯誤。比如下面的例子,注意該例子是為了說明volatile限定符而專門構造出的,因為現實中的volatile使用Bug大都隱含,並且難以理解。
在模組A的原始檔中,定義變數:
[cpp] view plain copy
- 1. volatile unsigned int TimerCount=0;
該變數用來在一個定時器中斷服務程式中進行軟體計時:
[cpp] view plain copy
- 1. TimerCount++;
在模組A的標頭檔案中,宣告變數:
[cpp] view plain copy
- 1. extern unsigned int TimerCount; //這裡漏掉了型別限定符volatile
在模組B中,要使用TimerCount變數進行精確的軟體延時:
[cpp] view plain copy
- 1. #include “…A.h” //首先包含模組A的標頭檔案
- 2. //其他程式碼
- 3. TimerCount=0;
- 4. while(TimerCount<=TIMER_VALUE); //延時一段時間(感謝網友chhfish指出這裡的邏輯錯誤)
- 5. //其他程式碼
實際上,這是一個死迴圈。由於模組A標頭檔案中宣告變數TimerCount時漏掉了volatile限定符,在模組B中,變數TimerCount是被當作unsigned int型別變數。由於暫存器速度遠快於RAM,編譯器在使用非volatile限定變數時是先將變數從RAM中拷貝到暫存器中,如果同一個程式碼塊再次用到該變數,就不再從RAM中拷貝資料而是直接使用之前暫存器備份值。程式碼while(TimerCount<=TIMER_VALUE)中,變數TimerCount僅第一次執行時被使用,之後都是使用的暫存器備份值,而這個暫存器值一直為0,