天天看點

編寫優質嵌入式C程式1. 簡介2. C語言特性3.編譯器 4.防禦性程式設計5.測試,再測試6.程式設計思想7.總結和閱讀書目

前言:這是一年前我為公司内部寫的一個文檔,旨在向年輕的嵌入式軟體工程師們介紹如何在裸機環境下編寫優質嵌入式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)       “=”和”==”

              将比較運算符”==”誤寫成指派運算符”=”,可能是絕大多數人都遇到過的,比如下面代碼:

  1. 1. if(x=5)

  2. 2. {

  3. 3. //其它代碼

  4. 4. }

       代碼的本意是比較變量x是否等于常量5,但是誤将”==”寫成了”=”,if語句恒為真。如果在邏輯判斷表達式中出現指派運算符,現在的大多數編譯器會給出警告資訊。比如keil MDK會給出警告提示:“warning:  #187-D: use of "=" where"==" may have been intended”,但并非所有程式員都會注意到這類警告,是以有經驗的程式員使用下面的代碼來避免此類錯誤:

  1. 1. if(5==x)

  2. 2. {

  3. 3. //其它代碼

  4. 4. }

       将常量放在變量x的左邊,即使程式員誤将’==’寫成了’=’,編譯器會産生一個任誰也不能無視的文法錯誤資訊:不可給常量指派!

       2)       複合指派運算符

       複合指派運算符(+=、*=等等)雖然可以使表達式更加簡潔并有可能産生更高效的機器代碼,但某些複合指派運算符也會給程式帶來隐含Bug,比如”+=”容易誤寫成”=+”,代碼如下:

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專家程式設計》提供了一個簡化版的問題源碼:

  1. 1. network code()

  2. 2. {

  3. 3. switch(line)

  4. 4. {

  5. 5. case THING1:

  6. 6. {

  7. 7. doit1();

  8. 8. } break;

  9. 9. case THING2:

  10. 10. {

  11. 11. if(x==STUFF)

  12. 12. {

  13. 13. do_first_stuff();

  14. 14. if(y==OTHER_STUFF)

  15. 15. break;

  16. 16. do_later_stuff();

  17. 17. }

  18. 18. initialize_modes_pointer();

  19. 19. } break;

  20. 20. default :

  21. 21. processing();

  22. 22. }

  23. 23. use_modes_pointer();

  24. 24. }

       那個程式員希望從if語句跳出,但他卻忘記了break關鍵字實際上跳出最近的那層循環語句或者switch語句。現在它跳出了switch語句,執行了use_modes_pointer()函數。但必要的初始化工作并未完成,為将來程式的失敗埋下了伏筆。

2.1.4 意想不到的八進制

       将一個整形常量指派給變量,代碼如下所示:

1.	int a=34, b=034; 
           

       變量a和b相等嗎?

       答案是不相等的。我們知道,16進制常量以’0x’為字首,10進制常量不需要字首,那麼8進制呢?它與10進制和16進制表示方法都不相通,它以數字’0’為字首,這多少有點奇葩:三種進制的表示方法完全不相通。如果8進制也像16進制那樣以數字和字母表示字首的話,或許更有利于減少軟體Bug,畢竟你使用8進制的次數可能都不會有誤使用的次數多!下面展示一個誤用8進制的例子,最後一個數組元素指派錯誤:

  1. 1. a[0]=106;

  2. 2. a[1]=112;

  3. 3. a[2]=052;

2.1.5指針加減運算

       指針的加減運算是特殊的。下面的代碼運作在32位ARM架構上,執行之後,a和p的值分别是多少?

  1. 1. int a=1;

  2. 2. int *p=(int *)0x00001000;

  3. 3. a=a+1;

  4. 4. p=p+1;

       對于a的值很容判斷出結果為2,但是p的結果卻是0x00001004。指針p加1後,p的值增加了4,這是為什麼呢?原因是指針做加減運算時是以指針的資料類型為機關。p+1實際上是按照公式p+1*sizeof(int)來計算的。不了解這一點,在使用指針直接操作資料時極易犯錯。

      某項目使用下面代碼對連續RAM初始化零操作,但運作發現有些RAM并沒有被真正清零。

  1. 1. unsigned int *pRAMaddr; //定義位址指針變量

  2. 2. for(pRAMaddr=StartAddr;pRAMaddr<EndAddr;pRAMaddr+=4)

  3. 3. {

  4. 4. *pRAMaddr=0x00000000; //指定RAM位址清零

  5. 5. }

       通過分析我們發現,由于pRAMaddr是一個無符号int型指針變量,是以pRAMaddr+=4代碼其實使pRAMaddr偏移了4*sizeof(int)=16個位元組,是以每執行一次for循環,會使變量pRAMaddr偏移16個位元組空間,但隻有4位元組空間被初始化為零。其它的12位元組資料的内容,在大多數架構處理器中都會是随機數。

2.1.6關鍵字sizeof

       不知道有多少人最初認為sizeof是一個函數。其實它是一個關鍵字,其作用是傳回一個對象或者類型所占的記憶體位元組數,對絕大多數編譯器而言,傳回值為無符号整形資料。需要注意的是,使用sizeof擷取數組長度時,不要對指針應用sizeof操作符,比如下面的例子:

  1. 1. void ClearRAM(char array[])

  2. 2. {

  3. 3. int i ;

  4. 4. for(i=0;i<sizeof(array)/sizeof(array[0]);i++) //這裡用法錯誤,array實際上是指針

  5. 5. {

  6. 6. array[i]=0x00;

  7. 7. }

  8. 8. }

  9. 9.

  10. 10. int main(void)

  11. 11. {

  12. 12. char Fle[20];

  13. 13.

  14. 14. ClearRAM(Fle); //隻能清除數組Fle中的前四個元素

  15. 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增量運算符’++’和減量運算符’—‘

       增量運算符”++”和減量運算符”--“既可以做字首也可以做字尾。字首和字尾的差別在于值的增加或減少這一動作發生的時間是不同的。作為字首是先自加或自減然後做别的運算,作為字尾時,是先做運算,之後再自加或自減。許多程式員對此認識不夠,就容易埋下隐患。下面的例子可以很好的解釋字首和字尾的差別。

  1. 1. int a=8,b=2,y;

  2. 2. y=a+++--b;

       代碼執行後,y的值是多少?

       這個例子并非是挖空心思設計出來專門讓你絞盡腦汁的C難題(如果你覺得自己對C細節掌握很有信心,做一些C難題檢驗一下是個不錯的選擇。那麼,《The C Puzzle Book》這本書一定不要錯過),你甚至可以将這個難懂的語句作為不友好代碼的例子。但是它也可以讓你更好的了解C語言。根據運算符優先級以及編譯器識别字元的貪心法原則,第二句代碼可以寫成更明确的形式:

1.	y=(a++)+(--b); 
           

       當指派給變量y時,a的值為8,b的值為1,是以變量y的值為9;指派完成後,變量a自加,a的值變為9,千萬不要以為y的值為10。這條指派語句相當于下面的兩條語句:

  1. 1. y=a+(--b);

  2. 2. a=a+1;

2.1.8邏輯與’&&’和邏輯或’||’的陷阱

       為了提高系統效率,邏輯與和邏輯或操作的規定如下:如果對第一個操作數求值後就可以推斷出最終結果,第二個操作數就不會進行求值!比如下面代碼:

  1. 1. if((i>=0)&&(i++ <=max))

  2. 2. {

  3. 3. //其它代碼

  4. 4. }

       在這個代碼中,隻有當i>=0時,i++才會被執行。這樣,i是否自增是不夠明确的,這可能會埋下隐患。邏輯或與之類似。

2.1.9結構體的填充

       結構體可能産生填充,因為對大多數處理器而言,通路按字或者半字對齊的資料速度更快,當定義結構體時,編譯器為了性能優化,可能會将它們按照半字或字對齊,這樣會帶來填充問題。比如以下兩個個結構體:

       第一個結構體:

  1. 1. struct {

  2. 2. char c;

  3. 3. short s;

  4. 4. int x;

  5. 5. }str_test1;

       第二個結構體:

  1. 1. struct {

  2. 2. char c;

  3. 3. int x;

  4. 4. short s;

  5. 5. }str_test2;

       這兩個結構體元素都是相同的變量,隻是元素換了下位置,那麼這兩個結構體變量占用的記憶體大小相同嗎?

       其實這兩個結構體變量占用的記憶體是不同的,對于Keil MDK編譯器,預設情況下第一個結構體變量占用8個位元組,第二個結構體占用12個位元組,差别很大。第一個結構體變量在記憶體中的存儲格式如圖2-1所示:

編寫優質嵌入式C程式1. 簡介2. C語言特性3.編譯器 4.防禦性程式設計5.測試,再測試6.程式設計思想7.總結和閱讀書目

圖2-1:結構體變量1記憶體分布

       第二個結構體變量在記憶體中的存儲格式如圖2-2所示。對比兩個圖可以看出MDK編譯器是是怎麼将資料對齊的,這其中的填充内容是之前記憶體中的資料,是随機的,是以不能再結構之間逐位元組比較;另外,合理的排布結構體内的元素位置,可以最大限度減少填充,節省RAM。

編寫優質嵌入式C程式1. 簡介2. C語言特性3.編譯器 4.防禦性程式設計5.測試,再測試6.程式設計思想7.總結和閱讀書目

圖2-2 :結構體變量2記憶體分布

2.2不可輕視的優先級

       C語言有32個關鍵字,卻有34個運算符。要記住所有運算符的優先級是困難的。稍不注意,你的代碼邏輯和實際執行就會有很大出入。

      比如下面将BCD碼轉換為十六進制數的代碼:

1.	result=(uTimeValue>>4)*10+uTimeValue&0x0F; 
           

      這裡uTimeValue存放的BCD碼,想要轉換成16進制資料,實際運作發現,如果uTimeValue的值為0x23,按照我設定的邏輯,result的值應該是0x17,但運算結果卻是0x07。經過種種排查後,才發現’+’的優先級是大于’&’的,相當于(uTimeValue>>4)*10+uTimeValue與0x0F位與,結果自然與邏輯不符。符合邏輯的代碼應該是:

1.	result=(uTimeValue>>4)*10+(uTimeValue&0x0F); 
           

      不合理的#define會加重優先級問題,讓問題變得更加隐蔽。

  1. 1. #define READSDA IO0PIN&(1<<11) //讀IO口p0.11的端口狀态

  2. 2.

  3. 3. if(READSDA==(1<<11)) //判斷端口p0.11是否為高電平

  4. 4. {

  5. 5. //其它代碼

  6. 6. }

      編譯器在編譯後将宏帶入,原代碼語句變為:  

  1. 1. if(IO0PIN&(1<<11) ==(1<<11))

  2. 2. {

  3. 3. //其它代碼

  4. 4. }

       運算符'=='的優先級是大于'&'的,代碼IO0PIN&(1<<11) ==(1<<11))等效為IO0PIN&0x00000001:判斷端口P0.0是否為高電平,這與原意相差甚遠。是以,使用宏定義的時候,最好将被定義的内容用括号括起來。

       按照正常方式使用時,可能引起誤會的運算符還有很多,如表2-1所示。C語言的運算符當然不會隻止步于數目繁多!

編寫優質嵌入式C程式1. 簡介2. C語言特性3.編譯器 4.防禦性程式設計5.測試,再測試6.程式設計思想7.總結和閱讀書目

      有一個簡便方法可以避免優先級問題:不清楚的優先級就加上”()”,但這樣至少有會帶來兩個問題:

  •  過多的括号影響代碼的可讀性,包括自己和以後的維護人員
  •  别人的代碼不一定用括号來解決優先級問題,但你總要讀别人的代碼 

       無論如何,在嵌入式程式設計方面,該掌握的基礎知識,偷巧不得。建議花一些時間,将優先級順序以及容易出錯的優先級運算符理清幾遍。

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類型(取決于操作時使用的類型)。

  1. 1. uint8_t port =0x5aU;

  2. 2. uint8_t result_8;

  3. 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。經過這麼詭異的隐式轉換,結果跟我們期望的值,已經大相徑庭!正确的表達式語句應該為:

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位)。

  1. 1. uint16_t u16a = 40000;

  2. 2. uint16_t u16b= 30000;

  3. 3. uint32_t u32x;

  4. 4. uint32_t u32y;

  5. 5. u32x = u16a +u16b;

  6. 6. u32y =(uint32_t)(u16a + u16b);

      u32x和u32y的結果都是4464(70000%65536)!不要認為表達式中有一個高類别uint32_t類型變量,編譯器都會幫你把所有其他低類别都提升到uint32_t類型。正确的書寫方式:

  1. 1. u32x = (uint32_t)u16a +(uint32_t)u16b; 或者:

  2. 2. u32x = (uint32_t)u16a + u16b;

       後一種寫法在本表達式中是正确的,但是在其它表達式中不一定正确,比如:

  1. 1. uint16_t u16a,u16b,u16c;

  2. 2. uint32_t u32x;

  3. 3. u32x= u16a + u16b + (uint32_t)u16c;

       3)       在指派語句裡,計算的最後結果被轉換成将要被賦予值的那個變量的類型。這一過程可能導緻類型提升也可能導緻類型降級。降級可能會導緻問題。比如将運算結果為321的值指派給8位char類型變量。程式必須對運算時的資料溢出做合理的處理。很多其他語言,像Pascal(C語言設計者之一曾撰文狠狠批評過Pascal語言),都不允許混合使用類型,但C語言不會限制你的自由,即便這經常引起Bug。

      4)       當作為函數的參數被傳遞時,char和short會被轉換為int,float會被轉換為double。

      當不得已混合使用類型時,一個比較好的習慣是使用類型強制轉換。強制類型轉換可以避免編譯器隐式轉換帶來的錯誤,同時也向以後的維護人員傳遞一些有用資訊。這有個前提:你要對強制類型轉換有足夠的了解!下面總結一些規則:

  •  并非所有強制類型轉換都是由風險的,把一個整數值轉換為一種具有相同符号的更寬類型時,是絕對安全的。
  •  精度高的類型強制轉換為精度低的類型時,通過丢棄适當數量的最高有效位來擷取結果,也就是說會發生資料截斷,并且可能改變資料的符号位。
  •  精度低的類型強制轉換為精度高的類型時,如果兩種類型具有相同的符号,那麼沒什麼問題;需要注意的是負的有符号精度低類型強制轉換為無符号精度高類型時,會不直覺的執行符号擴充,例如:
  1. 1. unsigned int bob;

  2. 2. signed char fred = -1;

  3. 3.

  4. 4. bob=(unsigned int )fred;

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莫名的當機

       下面的兩個例子都是死循環,如果在不常用分支中出現類似代碼,将會造成看似莫名其妙的當機或者重新開機。

  1. 1. unsigned char i; //例程1

  2. 2. for(i=0;i<256;i++)

  3. 3. {

  4. 4. //其它代碼

  5. 5. }

  6. 1. unsigned char i; //例程2

  7. 2. for(i=10;i>=0;i--)

  8. 3. {

  9. 4. //其它代碼

  10. 5. }

       對于無符号char類型,表示的範圍為0~255,是以無符号char類型變量i永遠小于256(第一個for循環無限執行),永遠大于等于0(第二個for循環無線執行)。需要說明的是,指派代碼i=256是被C語言允許的,即使這個初值已經超出了變量i可以表示的範圍。C語言會千方百計的為程式員創造出錯的機會,可見一斑。

3.2.2不起眼的改變

       假如你在if語句後誤加了一個分号,可能會完全改變了程式邏輯。編譯器也會很配合的幫忙掩蓋,甚至連警告都不提示。代碼如下:

  1. 1. if(a>b); //這裡誤加了一個分号

  2. 2. a=b; //這句代碼一直被執行

       不但如此,編譯器還會忽略掉多餘的空格符和換行符,就像下面的代碼也不會給出足夠提示:

  1. 1. if(n<3)

  2. 2. return //這裡少加了一個分号

  3. 3. logrec.data=x[0];

  4. 4. logrec.time=x[1];

  5. 5. logrec.code=x[2];

       這段代碼的本意是n<3時程式直接傳回,由于程式員的失誤,return少了一個結束分号。編譯器将它翻譯成傳回表達式logrec.data=x[0]的結果,return後面即使是一個表達式也是C語言允許的。這樣當n>=3時,表達式logrec.data=x[0];就不會被執行,給程式埋下了隐患。

3.2.3 難查的數組越界

       上文曾提到數組常常是引起程式不穩定的重要因素,程式員往往不經意間就會寫數組越界。

         一位同僚的代碼在硬體上運作,一段時間後就會發現LCD顯示屏上的一個數字不正常的被改變。經過一段時間的調試,問題被定位到下面的一段代碼中:

  1. 1. int SensorData[30];

  2. 2. //其他代碼

  3. 3. for(i=30;i>0;i--)

  4. 4. {

  5. 5. SensorData[i]=…;

  6. 6. //其他代碼

  7. 7. }

    這裡聲明了擁有30個元素的數組,不幸的是for循環代碼中誤用了本不存在的數組元素SensorData[30],但C語言卻默許這麼使用,并欣然的按照代碼改變了數組元素SensorData[30]所在位置的值, SensorData[30]所在的位置原本是一個LCD顯示變量,這正是顯示屏上的那個值不正常被改變的原因。真慶幸這麼輕而易舉的發現了這個Bug。

       其實很多編譯器會對上述代碼産生一個警告:指派超出數組界限。但并非所有程式員都對編譯器警告保持足夠敏感,況且,編譯器也并不能檢查出數組越界的所有情況。比如下面的例子:

     你在子產品A中定義數組:

1.	int SensorData[30];
           

     在子產品B中引用該數組,但由于你引用代碼并不規範,這裡沒有顯示聲明數組大小,但編譯器也允許這麼做:

1.	extern int SensorData[]; 
           

    這次,編譯器不會給出警告資訊,因為編譯器壓根就不知道數組的元素個數。是以,當一個數組聲明為具有外部連結,它的大小應該顯式聲明。

    再舉一個編譯器檢查不出數組越界的例子。函數func()的形參是一個數組形式,函數代碼簡化如下所示:

  1. 1. char * func(char SensorData[30])

  2. 2. {

  3. 3. unsignedint i;

  4. 4. for(i=30;i>0;i--)

  5. 5. {

  6. 6. SensorData[i]=…;

  7. 7. //其他代碼

  8. 8. }

  9. 9. }

     這個給SensorData[30]賦初值的語句,編譯器也是不給任何警告的。實際上,編譯器是将數組名Sensor隐含的轉化為指向數組第一個元素的指針,函數體是使用指針的形式來通路數組的,它當然也不會知道數組元素的個數了。造成這種局面的原因之一是C編譯器的作者們認為指針代替數組可以提高程式效率,而且,可以簡化編譯器的複雜度。

      指針和數組是容易給程式造成混亂的,我們有必要仔細的區分它們的不同。其實換一個角度想想,它們也是容易區分的:可以将數組名等同于指針的情況有且隻有一處,就是上面例子提到的數組作為函數形參時。其它時候,數組名是數組名,指針是指針。

      下面的例子編譯器同樣檢查不出數組越界。

      我們常常用數組來緩存通訊中的一幀資料。在通訊中斷中将接收的資料儲存到數組中,直到一幀資料完全接收後再進行處理。即使定義的數組長度足夠長,接收資料的過程中也可能發生數組越界,特别是幹擾嚴重時。這是由于外界的幹擾破壞了資料幀的某些位,對一幀的資料長度判斷錯誤,接收的資料超出數組範圍,多餘的資料改寫與數組相鄰的變量,造成系統崩潰。由于中斷事件的異步性,這類數組越界編譯器無法檢查到。

      如果局部數組越界,可能引發ARM架構硬體異常。

       同僚的一個裝置用于接收無線傳感器的資料,一次軟體更新後,發現接收裝置工作一段時間後會當機。調試表明ARM7處理器發生了硬體異常,異常處理代碼是一段死循環(當機的直接原因)。接收裝置有一個硬體子產品用于接收無線傳感器的整包資料并存在自己的緩沖區中,當硬體子產品接收資料完成後,使用外部中斷通知裝置取資料,外部中斷服務程式精簡後如下所示:

  1. 1. __irq ExintHandler(void)

  2. 2. {

  3. 3. unsignedchar DataBuf[50];

  4. 4. GetData(DataBug); //從硬體緩沖區取一幀資料

  5. 5. //其他代碼

  6. 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限定符,是一個嵌入式程式員的必修内容。

       一個程式子產品通常由兩個檔案組成,源檔案和頭檔案。如果你在源檔案定義變量:

1.	unsigned int test; 
           

      并在頭檔案中聲明該變量:

1.	extern unsigned long test;
           

      編譯器會提示一個文法錯誤:變量’ test’聲明類型不一緻。但如果你在源檔案定義變量:

1.	volatile unsigned int test;
           

      在頭檔案中這樣聲明變量:

1.	extern unsigned int test;     /*缺少volatile限定符*/
           

      編譯器卻不會給出錯誤資訊(有些編譯器僅給出一條警告)。當你在另外一個子產品(該子產品包含聲明變量test的頭檔案)使用變量test時,它已經不再具有volatile限定,這樣很可能造成一些重大錯誤。比如下面的例子,注意該例子是為了說明volatile限定符而專門構造出的,因為現實中的volatile使用Bug大都隐含,并且難以了解。

       在子產品A的源檔案中,定義變量:

1.	volatile unsigned int TimerCount=0;
           

      該變量用來在一個定時器中斷服務程式中進行軟體計時:

1.	TimerCount++; 
           

      在子產品A的頭檔案中,聲明變量:

1.	extern unsigned int TimerCount;   //這裡漏掉了類型限定符volatile  
           

      在子產品B中,要使用TimerCount變量進行精确的軟體延時:

  1. 1. #include “…A.h” //首先包含子產品A的頭檔案

  2. 2. //其他代碼

  3. 3. TimerCount=0;

  4. 4. while(TimerCount<=TIMER_VALUE); //延時一段時間(感謝網友chhfish指出這裡的邏輯錯誤)

  5. 5. //其他代碼

      實際上,這是一個死循環。由于子產品A頭檔案中聲明變量TimerCount時漏掉了volatile限定符,在子產品B中,變量TimerCount是被當作unsigned int類型變量。由于寄存器速度遠快于RAM,編譯器在使用非volatile限定變量時是先将變量從RAM中拷貝到寄存器中,如果同一個代碼塊再次用到該變量,就不再從RAM中拷貝資料而是直接使用之前寄存器備份值。代碼while(TimerCount<=TIMER_VALUE)中,變量TimerCount僅第一次執行時被使用,之後都是使用的寄存器備份值,而這個寄存器值一直為0,是以程式無限循環。圖3-1的流程圖說明了程式使用限定符volatile和不使用volatile的執行過程。

編寫優質嵌入式C程式1. 簡介2. C語言特性3.編譯器 4.防禦性程式設計5.測試,再測試6.程式設計思想7.總結和閱讀書目

       為了更容易的了解編譯器如何處理volatile限定符,這裡給出未使用volatile限定符和使用volatile限定符程式的反彙編代碼:

  •  沒有使用關鍵字volatile,在keil MDK V4.54下編譯,預設優化級别,如下所示(注意最後兩行):
  1. 122: unIdleCount=0;

  2. 2. 123:

  3. 3. 0x00002E10 E59F11D4 LDR R1,[PC,#0x01D4]

  4. 4. 0x00002E14 E3A05000 MOV R5,#key1(0x00000000)

  5. 5. 0x00002E18 E1A00005 MOV R0,R5

  6. 6. 0x00002E1C E5815000 STR R5,[R1]

  7. 7. 124: while(unIdleCount!=200); //延時2S鐘

  8. 8. 125:

  9. 9. 0x00002E20 E35000C8 CMP R0,#0x000000C8

  10. 10. 0x00002E24 1AFFFFFD BNE 0x00002E20</span>

  •  使用關鍵字volatile,在keil MDK V4.54下編譯,預設優化級别,如下所示(注意最後三行):
  1. 122: unIdleCount=0;

  2. 2. 123:

  3. 3. 0x00002E10 E59F01D4 LDR R0,[PC,#0x01D4]

  4. 4. 0x00002E14 E3A05000 MOV R5,#key1(0x00000000)

  5. 5. 0x00002E18 E5805000 STR R5,[R0]

  6. 6. 124: while(unIdleCount!=200); //延時2S鐘

  7. 7. 125:

  8. 8. 0x00002E1C E5901000 LDR R1,[R0]

  9. 9. 0x00002E20 E35100C8 CMP R1,#0x000000C8

  10. 10. 0x00002E24 1AFFFFFC BNE 0x00002E1C

      可以看到,如果沒有使用volatile關鍵字,程式一直比較R0内資料與0xC8是否相等,但R0中的資料是0,是以程式會一直在這裡循環比較(死循環);再看使用了volatile關鍵字的反彙編代碼,程式會先從變量中讀出資料放到R1寄存器中,然後再讓R1内資料與0xC8相比較,這才是我們C代碼的正确邏輯!

3.2.5局部變量

      ARM架構下的編譯器會頻繁的使用堆棧,堆棧用于存儲函數的傳回值、AAPCS規定的必須保護的寄存器以及局部變量,包括局部數組、結構體、聯合體和C++的類。預設情況下,堆棧的位置、初始值都是由編譯器設定,是以需要對編譯器的堆棧有一定了解。從堆棧中配置設定的局部變量的初值是不确定的,是以需要運作時顯式初始化該變量。一旦離開局部變量的作用域,這個變量立即被釋放,其它代碼也就可以使用它,是以堆棧中的一個記憶體位置可能對應整個程式的多個變量。

      局部變量必須顯式初始化,除非你确定知道你要做什麼。下面的代碼得到的溫度值跟預期會有很大差别,因為在使用局部變量sum時,并不能保證它的初值為0。編譯器會在第一次運作時清零堆棧區域,這加重了此類Bug的隐蔽性。

  1. 1. unsigned intGetTempValue(void)

  2. 2. {

  3. 3. unsigned int sum; //定義局部變量,儲存總值

  4. 4. for(i=0;i<10;i++)

  5. 5. {

  6. 6. sum+=CollectTemp(); //函數CollectTemp可以得到目前的溫度值

  7. 7. }

  8. 8. return (sum/10);

  9. 9. }

      由于一旦程式離開局部變量的作用域即被釋放,是以下面代碼傳回指向局部變量的指針是沒有實際意義的,該指針指向的區域可能會被其它程式使用,其值會被改變。

  1. 1. char * GetData(void)

  2. 2. {

  3. 3. char buffer[100]; //局部數組

  4. 4. …

  5. 5. return buffer;

  6. 6. }

3.2.6使用外部工具

      由于編譯器的語義檢查比較弱,我們可以使用第三方代碼分析工具,使用這些工具來發現潛在的問題,這裡介紹其中比較著名的是PC-Lint。

      PC-Lint由Gimpel Software公司開發,可以檢查C代碼的文法和語義并給出潛在的BUG報告。PC-Lint可以顯著降低調試時間。

      目前公司ARM7和Cortex-M3核心多是使用Keil MDK編譯器來開發程式,通過簡單配置,PC-Lint可以被內建到MDK上,以便更友善的檢查代碼。MDK已經提供了PC-Lint的配置模闆,是以整個配置過程十分簡單,Keil MDK開發套件并不包含PC-Lint程式,在此之前,需要預先安裝可用的PC-Lint程式,配置過程如下:

      1)       點選菜單Tools---Set-up PC-Lint…

編寫優質嵌入式C程式1. 簡介2. C語言特性3.編譯器 4.防禦性程式設計5.測試,再測試6.程式設計思想7.總結和閱讀書目

      PC-Lint Include Folders:該清單路徑下的檔案才會被PC-Lint檢查,此外,這些路徑下的檔案内使用#include包含的檔案也會被檢查;

      Lint Executable:指定PC-Lint程式的路徑

      Configuration File:指定配置檔案的路徑,該配置檔案由MDK編譯器提供。

      2)       菜單Tools---Lint 檔案路徑.c/.h

            檢查目前檔案。

      3)       菜單Tools---Lint All C-Source Files

            檢查所有C源檔案。

      PC-Lint的輸出資訊顯示在MDK編譯器的Build Output視窗中,輕按兩下其中的一條資訊可以跳轉到源檔案所在位置。

      編譯器語義檢查的弱小在很大程度上助長了不可靠代碼的廣泛存在。随着時代的進步,現在越來越多的編譯器開發商意識到了語義檢查的重要性,編譯器的語義檢查也越來越強大,比如公司使用的Keil MDK編譯器,雖然它的編輯器依然不盡人意,但在其 V4.47及以上版本中增加了動态文法檢查并加強了語義檢查,可以友好的提示更多警告資訊。建議經常關注編譯器官方網站并将編譯器更新到V4.47或以上版本,更新的另一個好處是這些版本的編輯器增加了辨別符自動補全功能,可以大大節省編碼的時間。

3.3你覺得有意義的代碼未必正确

      C語言标準特别的規定某些行為是未定義的,編寫未定義行為的代碼,其輸出結果由編譯器決定! C标準委員會定義未定義行為的原因如下:

  •  簡化标準,并給予實作一定的靈活性,比如不捕捉那些難以診斷的程式錯誤;
  •  編譯器開發商可以通過未定義行為對語言進行擴充

      C語言的未定義行為,使得C極度高效靈活并且給編譯器實作帶來了友善,但這并不利于優質嵌入式C程式的編寫。因為許多 C 語言中看起來有意義的東西都是未定義的,并且這也容易使你的代碼埋下隐患,并且不利于跨編譯器移植。Java程式會極力避免未定義行為,并用一系列手段進行運作時檢查,使用Java可以相對容易的寫出安全代碼,但體積龐大效率低下。作為嵌入式程式員,我們需要了解這些未定義行為,利用C語言的靈活性,寫出比Java更安全、效率更高的代碼來。

3.3.1常見的未定義行為

      1)       自增自減在表達式中連續出現并作用于同一變量或者自增自減在表達式中出現一次,但作用的變量多次出現

            自增(++)和自減(--)這一動作發生在表達式的哪個時刻是由編譯器決定的,比如:

1.	r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
           

      不同的編譯器可能有着不同的彙編代碼,可能是先執行i++再進行乘法和加法運作,也可能是先進行加法和乘法運算,再執行i++,因為這句代碼在一個表達式中出現了連續的自增并作用于同一變量。更加隐蔽的是自增自減在表達式中出現一次,但作用的變量多次出現,比如:

1.	a[i] = i++; /* 未定義行為 */
           

      先執行i++再指派,還是先指派再執行i++是由編譯器決定的,而兩種不同的執行順序的結果差别是巨大的。

      2)       函數實參被求值的順序

            函數如果有多個實參,這些實參的求值順序是由編譯器決定的,比如:

1.	printf("%d %d\n", ++n, power(2, n));    /* 未定義行為 */ 
           

      是先執行++n還是先執行power(2,n)是由編譯器決定的。

      3)       有符号整數溢出

            有符号整數溢出是未定義的行為,編譯器決定有符号整數溢出按照哪種方式取值。比如下面代碼:

  1. 1. int value1,value2,sum

  2. 2.

  3. 3. //其它操作

  4. 4. sum=value1+value;

     4)       有符号數右移、移位的數量是負值或者大于操作數的位數

     5)       除數為零

     6)       malloc()、calloc()或realloc()配置設定零位元組記憶體

3.3.2如何避免C語言未定義行為

      代碼中引入未定義行為會為代碼埋下隐患,防止代碼中出現未定義行為是困難的,我們總能不經意間就會在代碼中引入未定義行為。但是還是有一些方法可以降低這種事件,總結如下:

  •  了解C語言未定義行為

           标準C99附錄J.2“未定義行為”列舉了C99中的顯式未定義行為,通過檢視該文檔,了解那些行為是未定義的,并在編碼中時刻保持警惕;

  •  尋求工具幫助

          編譯器警告資訊以及PC-Lint等靜态檢查工具能夠發現很多未定義行為并警告,要時刻關注這些工具回報的資訊;

  •  總結并使用一些編碼标準

          1)避免構造複雜的自增或者自減表達式,實際上,應該避免構造所有複雜表達式;

                比如a[i] = i++;語句可以改為a[i] = i;  i++;這兩句代碼。

          2)隻對無符号操作數使用位操作;

  •  必要的運作時檢查

           檢查是否溢出、除數是否為零,申請的記憶體數量是否為零等等,比如上面的有符号整數溢出例子,可以按照如下方式編寫,以消除未定義特性:

  1. 1. int value1,value2,sum;

  2. 2.

  3. 3. //其它代碼

  4. 4. if((value1>0 && value2>0 && value1>(INT_MAX-value2))||

  5. 5. (value1<0 && value2<0 && value1<(INT_MIN-value2)))

  6. 6. {

  7. 7. //處理錯誤

  8. 8. }

  9. 9. else

  10. 10. {

  11. 11. sum=value1+value2;

  12. 12. }

   上面的代碼是通用的,不依賴于任何CPU架構,但是代碼效率很低。如果是有符号數使用補碼的CPU架構(目前常見CPU絕大多數都是使用補碼),還可以用下面的代碼來做溢出檢查:

  1. int value1, value2, sum;

  2. unsigned int usum = (unsigned int)value1 + value2;

  3. if((usum ^ value1) & (usum ^ value2) & INT_MIN)

  4. {

  5. }

  6. else

  7. {

  8. sum = value1 + value2;

  9. }

使用的原了解釋一下,因為在加法運算中,操作數value1和value2隻有符号相同時,才可能發生溢出,是以我們先将這兩個數轉換為無符号類型,兩個數的和儲存在變量usum中。如果發生溢出,則value1、value2和usum的最高位(符号位)一定不同,表達式(usum ^ value1) & (usum ^ value2) 的最高位一定為1,這個表達式位與(&)上INT_MIN是為了将最高位之外的其它位設定為0。

  •  了解你所用的編譯器對未定義行為的處理政策

            很多引入了未定義行為的程式也能運作良好,這要歸功于編譯器處理未定義行為的政策。不是你的代碼寫的正确,而是恰好編譯器處理政策跟你需要的邏輯相同。了解編譯器的未定義行為處理政策,可以讓你更清楚的認識到那些引入了未定義行為程式能夠運作良好是多麼幸運的事,不然多換幾個編譯器試試!

            以Keil MDK為例,列舉常用的處理政策如下:

           1) 有符号量的右移是算術移位,即移位時要保證符号位不改變。

            2)對于int類的值:超過31位的左移結果為零;無符号值或正的有符号值超過31位的右移結果為零。負的有符号值移位結果為-1。

            3)整型數除以零傳回零

3.4 了解你的編譯器

       在嵌入式開發過程中,我們需要經常和編譯器打交道,隻有深入了解編譯器,才能用好它,編寫更高效代碼,更靈活的操作硬體,實作一些進階功能。下面以公司最常用的Keil MDK為例,來描述一下編譯器的細節。

3.4.1編譯器的一些小知識

      1)       預設情況下,char類型的資料項是無符号的,是以它的取值範圍是0~255;

      2)       在所有的内部和外部辨別符中,大寫和小寫字元不同;

      3)       通常局部變量儲存在寄存器中,但當局部變量太多放到棧裡的時候,它們總是字對齊的。

      4)       壓縮類型的自然對齊方式為1。使用關鍵字__packed來壓縮特定結構,将所有有效類型的對齊邊界設定為1;

      5)       整數以二進制補碼形式表示;浮點量按IEEE格式存儲;

      6)       整數除法的餘數的符号于被除數相同,由ISO C90标準得出;

      7)       如果整型值被截斷為短的有符号整型,則通過放棄适當數目的最高有效位來得到結果。如果原始數是太大的正或負數,對于新的類型,無法保證結果的符号将于原始數相同。

      8)       整型數超界不引發異常;像unsigned char test;  test=1000;這類是不會報錯的;

      9)       在嚴格C中,枚舉值必須被表示為整型。例如,必須在‑2147483648 到+2147483647的範圍内。但MDK自動使用對象包含enum範圍的最小整型來實作(比如char類型),除非使用編譯器指令‑‑enum_is_int 來強制将enum的基礎類型設為至少和整型一樣寬。超出範圍的枚舉值預設僅産生警告:#66:enumeration value is out of "int" range;

      10)    對于結構體填充,根據定義結構的方式,keil MDK編譯器用以下方式的一種來填充結構:

          I>  定義為static或者extern的結構用零填充;

          II> 棧或堆上的結構,例如,用malloc()或者auto定義的結構,使用先前存儲在那些存儲器位置的任何内容進行填充。不能使用memcmp()來比較以這種方式定義的填充結構!

      11)    編譯器不對聲明為volatile類型的資料進行優化;

      12)    __nop():延時一個指令周期,編譯器絕不會優化它。如果硬體支援NOP指令,則該句被替換為NOP指令,如果硬體不支援NOP指令,編譯器将它替換為一個等效于NOP的指令,具體指令由編譯器自己決定;

      13)    __align(n):訓示編譯器在n 位元組邊界上對齊變量。對于局部變量,n的值為1、2、4、8;

      14)    __attribute__((at(address))):可以使用此變量屬性指定變量的絕對位址;

      15)    __inline:提示編譯器在合理的情況下内聯編譯C或C++ 函數;

3.4.2初始化的全局變量和靜态變量的初始值被放到了哪裡?

       我們程式中的一些全局變量和靜态變量在定義時進行了初始化,經過編譯器編譯後,這些初始值被存放在了代碼的哪裡?我們舉個例子說明:

  1. 1. unsigned int g_unRunFlag=0xA5;

  2. 2. static unsigned int s_unCountFlag=0x5A;

       我曾做過一個項目,項目中的一個裝置需要線上程式設計,也就是通過協定,将上位機發給裝置的資料通過在應用程式設計(IAP)技術寫入到裝置的内部Flash中。我将内部Flash做了劃分,一小部分運作程式,大部分用來存儲上位機發來的資料。随着程式量的增加,在一次更新程式後發現,線上程式設計之後,裝置運作正常,但是重新開機裝置後,運作出現了故障!經過一系列排查,發現故障的原因是一個全局變量的初值被改變了。這是件很不可思議的事情,你在定義這個變量的時候指定了初始值,當你在第一次使用這個變量時卻發現這個初值已經被改掉了!這中間沒有對這個變量做任何指派操作,其它變量也沒有任何溢出,并且多次線上調試表明,進入main函數的時候,該變量的初值已經被改為一個恒定值。

       要想知道為什麼全局變量的初值被改變,就要了解這些初值編譯後被放到了二進制檔案的哪裡。在此之前,需要先了解一點連結原理。

      ARM映象檔案各組成部分在存儲系統中的位址有兩種:一種是映象檔案位于存儲器時(通俗的說就是存儲在Flash中的二進制代碼)的位址,稱為加載位址;一種是映象檔案運作時(通俗的說就是給闆子上電,開始運作Flash中的程式了)的位址,稱為運作時位址。賦初值的全局變量和靜态變量在程式還沒運作的時候,初值是被放在Flash中的,這個時候他們的位址稱為加載位址,當程式運作後,這些初值會從Flash中拷貝到RAM中,這時候就是運作時位址了。

      原來,對于在程式中賦初值的全局變量和靜态變量,程式編譯後,MDK将這些初值放到Flash中,位于緊靠在可執行代碼的後面。在程式進入main函數前,會運作一段庫代碼,将這部分資料拷貝至相應RAM位置。由于我的裝置程式量不斷增加,超過了為裝置程式預留的Flash空間,線上程式設計時,将一部分存儲全局變量和靜态變量初值的Flash給重新程式設計了。在重新開機裝置前,初值已經被拷貝到RAM中,是以這個時候程式運作是正常的,但重新上電後,這部分初值實際上是線上程式設計的資料,自然與初值不同了。

3.4.3在C代碼中使用的變量,編譯器将他們配置設定到RAM的哪裡?

      我們會在代碼中使用各種變量,比如全局變量、靜态變量、局部變量,并且這些變量時由編譯器統一管理的,有時候我們需要知道變量用掉了多少RAM,以及這些變量在RAM中的具體位置。這是一個經常會遇到的事情,舉一個例子,程式中的一個變量在運作時總是不正常的被改變,那麼有理由懷疑它臨近的變量或數組溢出了,溢出的資料更改了這個變量值。要排查掉這個可能性,就必須知道該變量被配置設定到RAM的哪裡、這個位置附近是什麼變量,以便針對性的做跟蹤。

      其實MDK編譯器的輸出檔案中有一個“工程名.map”檔案,裡面記錄了代碼、變量、堆棧的存儲位置,通過這個檔案,可以檢視使用的變量被配置設定到RAM的哪個位置。要生成這個檔案,需要在Options for Targer視窗,Listing标簽欄下,勾選Linker Listing前的複選框,如圖3-1所示。

編寫優質嵌入式C程式1. 簡介2. C語言特性3.編譯器 4.防禦性程式設計5.測試,再測試6.程式設計思想7.總結和閱讀書目

圖3-1 設定編譯器生産MAP檔案

3.4.4預設情況下,棧被配置設定到RAM的哪個地方?

       MDK中,我們隻需要在配置檔案中定義堆棧大小,編譯器會自動在RAM的空閑區域選擇一塊合适的地方來配置設定給我們定義的堆棧,這個地方位于RAM的那個地方呢?

       通過檢視MAP檔案,原來MDK将堆棧放到程式使用到的RAM空間的後面,比如你的RAM空間從0x4000 0000開始,你的程式用掉了0x200位元組RAM,那麼堆棧空間就從0x4000 0200處開始。

       使用了多少堆棧,是否溢出?

2.4.5 有多少RAM會被初始化?

       在進入main()函數之前,MDK會把未初始化的RAM給清零的,我們的RAM可能很大,隻使用了其中一小部分,MDK會不會把所有RAM都初始化呢?

       答案是否定的,MDK隻是把你的程式用到的RAM以及堆棧RAM給初始化,其它RAM的内容是不管的。如果你要使用絕對位址通路MDK未初始化的RAM,那就要小心翼翼的了,因為這些RAM上電時的内容很可能是随機的,每次上電都不同。

3.4.6 MDK編譯器如何設定非零初始化變量?

      對于控制類産品,當系統複位後(非上電複位),可能要求保持住複位前RAM中的資料,用來快速恢複現場,或者不至于因瞬間複位而重新開機現場裝置。而keil mdk在預設情況下,任何形式的複位都會将RAM區的非初始化變量資料清零。

      MDK編譯程式生成的可執行檔案中,每個輸出段都最多有三個屬性:RO屬性、RW屬性和ZI屬性。對于一個全局變量或靜态變量,用const修飾符修飾的變量最可能放在RO屬性區,初始化的變量會放在RW屬性區,那麼剩下的變量就要放到ZI屬性區了。預設情況下,ZI屬性區的資料在每次複位後,程式執行main函數内的代碼之前,由編譯器“自作主張”的初始化為零。是以我們要在C代碼中設定一些變量在複位後不被零初始化,那一定不能任由編譯器“胡作非為”,我們要用一些規則,限制一下編譯器。

      分散加載檔案對于連接配接器來說至關重要,在分散加載檔案中,使用UNINIT來修飾一個執行節,可以避免編譯器對該區節的ZI資料進行零初始化。這是要解決非零初始化變量的關鍵。是以我們可以定義一個UNINIT修飾的資料節,然後将希望非零初始化的變量放入這個區域中。于是,就有了第一種方法:

      1)       修改分散加載檔案,增加一個名為MYRAM的執行節,該執行節起始位址為0x1000A000,長度為0x2000位元組(8KB),由UNINIT修飾:

  1. 1: LR_IROM1 0x00000000 0x00080000 { ; load region size_region

  2. 2: ER_IROM1 0x00000000 0x00080000 { ; load address = execution address

  3. 3: *.o (RESET, +First)

  4. 4: *(InRoot$$Sections)

  5. 5: .ANY (+RO)

  6. 6: }

  7. 7: RW_IRAM1 0x10000000 0x0000A000 { ; RW data

  8. 8: .ANY (+RW +ZI)

  9. 9: }

  10. 10: MYRAM 0x1000A000 UNINIT 0x00002000 {

  11. 11: .ANY (NO_INIT)

  12. 12: }

  13. 13: }

      那麼,如果在程式中有一個數組,你不想讓它複位後零初始化,就可以這樣來定義變量:

1.	unsigned char  plc_eu_backup[32] __attribute__((at(0x1000A000)));
           

      變量屬性修飾符__attribute__((at(adde)))用來将變量強制定位到adde所在位址處。由于位址0x1000A000開始的8KB區域ZI變量不會被零初始化,是以位于這一區域的數組plc_eu_backup也就不會被零初始化了。

      這種方法的缺點是顯而易見的:要程式員手動配置設定變量的位址。如果非零初始化資料比較多,這将是件難以想象的大工程(以後的維護、增加、修改代碼等等)。是以要找到一種辦法,讓編譯器去自動配置設定這一區域的變量。

      2)       分散加載檔案同方法1,如果還是定義一個數組,可以用下面方法:

unsigned char  plc_eu_backup[32] __attribute__((section("NO_INIT"),zero_init));
           

      變量屬性修飾符__attribute__((section(“name”),zero_init))用于将變量強制定義到name屬性資料節中,zero_init表示将未初始化的變量放到ZI資料節中。因為“NO_INIT”這顯性命名的自定義節,具有UNINIT屬性。

      3)       将一個子產品内的非初始化變量都非零初始化

       假如該子產品名字為test.c,修改分散加載檔案如下所示:

  1. 1: LR_IROM1 0x00000000 0x00080000 { ; load region size_region

  2. 2: ER_IROM1 0x00000000 0x00080000 { ; load address = execution address

  3. 3: *.o (RESET, +First)

  4. 4: *(InRoot$$Sections)

  5. 5: .ANY (+RO)

  6. 6: }

  7. 7: RW_IRAM1 0x10000000 0x0000A000 { ; RW data

  8. 8: .ANY (+RW +ZI)

  9. 9: }

  10. 10: RW_IRAM2 0x1000A000 UNINIT 0x00002000 {

  11. 11: test.o (+ZI)

  12. 12: }

  13. 13: }

      在該子產品定義時變量時使用如下方法:

      這裡,變量屬性修飾符__attribute__((zero_init))用于将未初始化的變量放到ZI資料節中變量,其實MDK預設情況下,未初始化的變量就是放在ZI資料區的。

4.防禦性程式設計

       嵌入式産品的可靠性自然與硬體密不可分,但在硬體确定、并且沒有第三方測試的前提下,使用防禦性程式設計思想寫出的代碼,往往具有更高的穩定性。

       防禦性程式設計首先需要認清C語言的種種缺陷和陷阱,C語言對于運作時的檢查十分弱小,需要程式員謹慎的考慮代碼,在必要的時候增加判斷;防禦性程式設計的另一個核心思想是假設代碼運作在并不可靠的硬體上,外接幹擾有可能會打亂程式執行順序、更改RAM存儲資料等等。

4.1具有形參的函數,需判斷傳遞來的實參是否合法。

       程式員可能無意識的傳遞了錯誤參數;外界的強幹擾可能将傳遞的參數修改掉,或者使用随機參數意外的調用函數,是以在執行函數主體前,需要先确定實參是否合法。

  1. 1. int exam_fun( unsigned char *str )

  2. 2. {

  3. 3. if( str != NULL ) // 檢查“假設指針不為空”這個條件

  4. 4. {

  5. 5. //正常處理代碼

  6. 6. }

  7. 7. else

  8. 8. {

  9. 9. //處理錯誤代碼

  10. 10. }

  11. 11. }

4.2仔細檢查函數的傳回值

       對函數傳回的錯誤碼,要進行全面仔細處理,必要時做錯誤記錄。

  1. 1. char *DoSomething(…)

  2. 2. {

  3. 3. char * p;

  4. 4. p=malloc(1024);

  5. 5. if(p==NULL)

  6. 6. {

  7. 7. UARTprintf(…);

  8. 8. return NULL;

  9. 9. }

  10. 10. retuen p;

  11. 11. }

4.3 防止指針越界

       如果動态計算一個位址時,要保證被計算的位址是合理的并指向某個有意義的地方。特别對于指向一個結構或數組的内部的指針,當指針增加或者改變後仍然指向同一個結構或數組。

4.4 防止數組越界

       數組越界的問題前文已經講述的很多了,由于C不會對數組進行有效的檢測,是以必須在應用中顯式的檢測數組越界問題。下面的例子可用于中斷接收通訊資料。

  1. 1. #define REC_BUF_LEN 100

  2. 2. unsigned char RecBuf[REC_BUF_LEN];

  3. 3. //其它代碼

  4. 4. void Uart_IRQHandler(void)

  5. 5. {

  6. 6. static RecCount=0; //接收資料長度計數器

  7. 7. //其它代碼

  8. 8. if(RecCount< REC_BUF_LEN) //判斷數組是否越界

  9. 9. {

  10. 10. RecBuf[RecCount]=…; //從硬體取資料

  11. 11. RecCount++;

  12. 12. //其它代碼

  13. 13. }

  14. 14. else

  15. 15. {

  16. 16. //錯誤處理代碼

  17. 17. }

  18. 18. //其它代碼

  19. 19. }

在使用一些庫函數時,同樣需要對邊界進行檢查,比如下面的memset(RecBuf,0,len)函數把RecBuf指指向的記憶體區的前len個位元組用0填充,如果不注意len的長度,就會将數組RecBuf之外的記憶體區清零:

  1. 1. #define REC_BUF_LEN 100

  2. 2. unsigned char RecBuf[REC_BUF_LEN];

  3. 3.

  4. 4. if(len< REC_BUF_LEN)

  5. 5. {

  6. 6. memset(RecBuf,0,len); //将數組RecBuf清零

  7. 7. }

  8. 8. else

  9. 9. {

  10. 10. //處理錯誤

  11. 11. }

4.5 數學算數運算

4.5.1除法運算,隻檢測除數為零就可靠嗎?

       除法運算前,檢查除數是否為零幾乎已經成為共識,但是僅檢查除數是否為零就夠了嗎?

       考慮兩個整數相除,對于一個signed long類型變量,它能表示的數值範圍為:-2147483648 ~+2147483647,如果讓-2147483648/ -1,那麼結果應該是+2147483648,但是這個結果已經超出了signedlong所能表示的範圍了。是以,在這種情況下,除了要檢測除數是否為零外,還要檢測除法是否溢出。

  1. 1. #include <limits.h>

  2. 2. signed long sl1,sl2,result;

  3. 3.

  4. 4. if((sl2==0)||(sl1==LONG_MIN && sl2==-1))

  5. 5. {

  6. 6. //處理錯誤

  7. 7. }

  8. 8. else

  9. 9. {

  10. 10. result = sl1 / sl2;

  11. 11. }

4.5.2檢測運算溢出

      整數的加減乘運算都有可能發生溢出,在讨論未定義行為時,給出過一個有符号整形加法溢出判斷代碼,這裡再給出一個無符号整形加法溢出判斷代碼段:

  1. 1. #include <limits.h>

  2. 2. unsigned int a,b,result;

  3. 3.

  4. 4. if(UINT_MAX-a<b)

  5. 5. {

  6. 6. //處理溢出

  7. 7. }

  8. 8. else

  9. 9. {

  10. 10. result=a+b;

  11. 11. }

      嵌入式硬體一般沒有浮點處理器,浮點數運算在嵌入式也比較少見并且溢出判斷嚴重依賴C庫支援,這裡不讨論。

4.5.3檢測移位

      在讨論未定義行為時,提到有符号數右移、移位的數量是負值或者大于操作數的位數都是未定義行為,也提到不對有符号數進行位操作,但要檢測移位的數量是否大于操作數的位數。下面給出一個無符号整數左移檢測代碼段:

  1. 1. unsigned int ui1;

  2. 2. unsigned int ui2;

  3. 3. unsigned int uresult;

  4. 4.

  5. 5.

  6. 6. if(ui2>=sizeof(unsigned int)*CHAR_BIT)

  7. 7. {

  8. 8. //處理錯誤

  9. 9. }

  10. 10. else

  11. 11. {

  12. 12. uresult=ui1<<ui2;

  13. 13. }

4.6如果有硬體看門狗,則使用它

       在其它一切措施都失效的情況下,看門狗可能是最後的防線。它的原理特别簡單,但卻能大大提高裝置的可靠性。如果裝置有硬體看門狗,一定要為它編寫驅動程式。

  •  要盡可能早的開啟看門狗

           這是因為從上電複位結束到開啟看門狗的這段時間内,裝置有可能被幹擾而跳過看門狗初始化程式,導緻看門狗失效。盡可能早的開啟看門狗,可以降低這種機率;

  •  不要在中斷中喂狗,除非有其他關聯措施

           在中斷程式喂狗,由于幹擾的存在,程式可能一直處于中斷之中,這樣會導緻看門狗失效。如果在主程式中設定标志位,中斷程式喂狗時與這個标志位聯合判斷,也是允許的;

  •  喂狗間隔跟産品需求有關,并非特定的時間

           産品的特性決定了喂狗間隔。對于不涉及安全性、實時性的裝置,喂狗間隔比較寬松,但間隔時間不宜過長,否則被使用者感覺到,是影響使用者體驗的。對于設計安全性、有實時控制類的裝置,原則是盡可能快的複位,否則會造成事故。

    克萊門汀号在進行第二階段的任務時,原本預訂要從月球飛行到太空深處的Geographos小行星進行探勘,然而這艘太空探測器在飛向小行星時卻由于一個軟體缺陷而使其中斷運作20分鐘,不但未能到達小行星,也因為控制噴嘴燃燒了11分鐘使電力供應降低,無法再透過遠端控制探測器,最終結束這項任務,但也導緻了資源與資金的浪費。

    “克萊門汀太空任務失敗這件事讓我感到十分震驚,它其實可以透過硬體中一款簡單的看門狗計時器避免掉這項意外,但由于當時的開發時間相當緊縮,程式設計人員沒時間編寫程式來啟動它,”Ganssle說。

遺憾的是,1998年發射的近地号太空梭(NEAR)也遇到了相同的問題。由于程式設計人員并未采納建議,是以,當推進器減速器系統故障時,29公斤的儲備燃料也随之報帳──這同樣是一個本來可經由看門狗定時器程式設計而避免的問題,同時也證明要從其他程式設計人員的錯誤中學習并不容易。

4.7關鍵資料儲存多個備份,取資料采用“表決法”

      RAM中的資料在受到幹擾情況下有可能被改變,對于系統關鍵資料應該進行保護。關鍵資料包括全局變量、靜态變量以及需要保護的資料區域。備份資料與原資料不應該處于相鄰位置,是以不應由編譯器預設配置設定備份資料位置,而應該由程式員指定區域存儲。可以将RAM分為3個區域,第一個區域儲存原碼,第二個區域儲存反碼,第三個區域儲存異或碼,區域之間預留一定量的“空白”RAM作為隔離。可以使用編譯器的“分散加載”機制将變量分别存儲在這些區域。需要進行讀取時,同時讀出3份資料并進行表決,取至少有兩個相同的那個值。

      假如裝置的RAM從0x1000_0000開始,我需要在RAM的0x1000_0000~0x10007FFF記憶體儲原碼,在0x1000_9000~0x10009FFF記憶體儲反碼,在0x1000_B000~0x1000BFFF記憶體儲0xAA的異或碼,編譯器的分散加載可以設定為:

  1. 1. LR_IROM1 0x00000000 0x00080000 { ; load region size_region

  2. 2. ER_IROM1 0x00000000 0x00080000 { ; load address = execution address

  3. 3. *.o (RESET, +First)

  4. 4. *(InRoot$$Sections)

  5. 5. .ANY (+RO)

  6. 6. }

  7. 7. RW_IRAM1 0x10000000 0x00008000 { ;儲存原碼

  8. 8. .ANY (+RW +ZI )

  9. 9. }

  10. 10.

  11. 11. RW_IRAM3 0x10009000 0x00001000{ ;儲存反碼

  12. 12. .ANY (MY_BK1)

  13. 13. }

  14. 14.

  15. 15. RW_IRAM2 0x1000B000 0x00001000 { ;儲存異或碼

  16. 16. .ANY (MY_BK2)

  17. 17. }

  18. 18. }

      如果一個關鍵變量需要多處備份,可以按照下面方式定義變量,将三個變量分别指定到三個不連續的RAM區中,并在定義時按照原碼、反碼、0xAA的異或碼進行初始化。

  1. 1. uint32 plc_pc=0; //原碼

  2. 2. __attribute__((section("MY_BK1"))) uint32 plc_pc_not=~0x0; //反碼

  3. 3. __attribute__((section("MY_BK2"))) uint32 plc_pc_xor=0x0^0xAAAAAAAA; //異或碼

      當需要寫這個變量時,這三個位置都要更新;讀取變量時,讀取三個值做判斷,取至少有兩個相同的那個值。

      為什麼選取異或碼而不是補碼?這是因為MDK的整數是按照補碼存儲的,正數的補碼與原碼相同,在這種情況下,原碼和補碼是一緻的,不但起不到備援作用,反而對可靠性有害。比如存儲的一個非零整數區因為幹擾,RAM都被清零,由于原碼和補碼一緻,按照3取2的“表決法”,會将幹擾值0當做正确的資料。

4.8對非易失性存儲器進行備份存儲

      非易失性存儲器包括但不限于Flash、EEPROM、鐵電。僅僅将寫入非易失性存儲器中的資料再讀出校驗是不夠的。強幹擾情況下可能導緻非易失性存儲器内的資料錯誤,在寫非易失性存儲器的期間系統掉電将導緻資料丢失,因幹擾導緻程式跑飛到寫非易失性存儲器函數中,将導緻資料存儲紊亂。一種可靠的辦法是将非易失性存儲器分成多個區,每個資料都将按照不同的形式寫入到這些分區中,需要進行讀取時,同時讀出多份資料并進行表決,取相同數目較多的那個值。

4.9軟體鎖

      對于初始化序列或者有一定先後順序的函數調用,為了保證調用順序或者確定每個函數都被調用,我們可以使用環環相扣,實質上這也是一種軟體鎖。此外對于一些安全關鍵代碼語句(是語句,而不是函數),可以給它們設定軟體鎖,隻有持有特定鑰匙的,才可以通路這些關鍵代碼。也可以通俗的了解為,關鍵安全代碼不能按照單一條件執行,要額外的多設定一個标志。

      比如,向Flash寫一個資料,我們會判斷資料是否合法、寫入的位址是否合法,計算要寫入的扇區。之後調用寫Flash子程式,在這個子程式中,判斷扇區位址是否合法、資料長度是否合法,之後就要将資料寫入Flash。由于寫Flash語句是安全關鍵代碼,是以程式給這些語句上鎖:必須具有正确的鑰匙才可以寫Flash。這樣即使是程式跑飛到寫Flash子程式,也能大大降低誤寫的風險。

  1. 1.

  2. 11. void RamToFlash(uint32 dst, uint32 src, uint32 no,uint8 ProgStart)

  3. 12. {

  4. 13. PLC_ASSERT("Sector number",(dst>=0x00040000)&&(dst<=0x0007FFFF));

  5. 14. PLC_ASSERT("Copy bytes number is 512",(no==512));

  6. 15. PLC_ASSERT("ProgStart==0xA5",(ProgStart==0xA5));

  7. 16.

  8. 17. paramin[0] = IAP_RAMTOFLASH; // 設定指令字

  9. 18. paramin[1] = dst; // 設定參數

  10. 19. paramin[2] = src;

  11. 20. paramin[3] = no;

  12. 21. paramin[4] = Fcclk/1000;

  13. 22. if(ProgStart==0xA5) //隻有軟體鎖标志正确時,才執行關鍵代碼

  14. 23. {

  15. 24. iap_entry(paramin, paramout); // 調用IAP服務程式

  16. 25. ProgStart=0;

  17. 26. }

  18. 27. else

  19. 28. {

  20. 29. paramout[0]=PROG_UNSTART;

  21. 30. }

  22. 31. }

      該程式段是程式設計lpc1778内部Flash,其中調用IAP程式的函數iap_entry(paramin, paramout)是關鍵安全代碼,是以在執行該代碼前,先判斷一個特定設定的安全鎖标志ProgStart,隻有這個标志符合設定值,才會執行程式設計Flash操作。如果因為意外程式跑飛到該函數,由于ProgStart标志不正确,是不會對Flash進行程式設計的。

4.10通信

      通訊線上的資料誤碼相對嚴重,通訊線越長,所處的環境越惡劣,誤碼會越嚴重。抛開硬體和環境的作用,我們的軟體應能識别錯誤的通訊資料。對此有一些應用措施:

  •  制定協定時,限制每幀的位元組數;

           每幀位元組數越多,發生誤碼的可能性就越大,無效的資料也會越多。對此以太網規定每幀資料不大于1500位元組,高可靠性的CAN收發器規定每幀資料不得多于8位元組,對于RS485,基于RS485鍊路應用最廣泛的Modbus協定一幀資料規定不超過256位元組。是以,建議制定内部通訊協定時,使用RS485時規定每幀資料不超過256位元組;

  •  使用多種校驗

           編寫程式時應使能奇偶校驗,每幀超過16位元組的應用,建議至少編寫CRC16校驗程式;

  •  增加額外判斷

           1)增加緩沖區溢出判斷。這是因為資料接收多是在中斷中完成,編譯器檢測不出緩沖區是否溢出,需要手動檢查,在上文介紹資料溢出一節中已經詳細說明。

           2)增加逾時判斷。當一幀資料接收到一半,長時間接收不到剩餘資料,則認為這幀資料無效,重新開始接收。可選,跟不同的協定有關,但緩沖區溢出判斷必須實作。這是因為對于需要幀頭判斷的協定,上位機可能發送完幀頭後突然斷電,重新開機後上位機是從新的幀開始發送的,但是下位機已經接收到了上次未發送完的幀頭,是以上位機的這次幀頭會被下位機當成正常資料接收。這有可能造成資料長度字段為一個很大的值,填滿該長度的緩沖區需要相當多的資料(比如一幀可能1000位元組),影響響應時間;另一方面,如果程式沒有緩沖區溢出判斷,那麼緩沖區很可能溢出,後果是災難性的。

  •  重傳機制

           如果檢測到通訊資料發生了錯誤,則要有重傳機制重新發送出錯的幀。

4.11開關量輸入的檢測、确認

      開關量容易受到尖脈沖幹擾,如果不進行濾除,可能會造成誤動作。一般情況下,需要對開關量輸入信号進行多次采樣,并進行邏輯判斷直到确認信号無誤為止。

4.12開關量輸出

      開關信号簡單的一次輸出是不安全的,幹擾信号可能會翻轉開關量輸出的狀态。采取重複重新整理輸出可以有效防止電平的翻轉。

4.13初始化資訊的儲存和恢複

      微處理器的寄存器值也可能會因外界幹擾而改變,外設初始化值需要在寄存器中長期儲存,最容易被破壞。由于Flash中的資料相對不易被破壞,可以将初始化資訊預先寫入Flash,待程式空閑時比較與初始化相關的寄存器值是否被更改,如果發現非法更改則使用Flash中的值進行恢複。

    公司目前使用的4.3寸LCD顯示屏抗幹擾能力一般。如果顯示屏與控制器之間的排線距離過長或者對使用該顯示屏的裝置打靜電或者脈沖群,顯示屏有可能會花屏或者白屏。對此,我們可以将初始化顯示屏的資料儲存在Flash中,程式運作後,每隔一段時間從顯示屏的寄存器讀出目前值和Flash存儲的值相比較,如果發現兩者不同,則重新初始化顯示屏。下面給出校驗源碼,僅供參考。

    定義資料結構:

  1. 1. typedef struct {

  2. 2. uint8_t lcd_command; //LCD寄存器

  3. 3. uint8_t lcd_get_value[8]; //初始化時寫入寄存器的值

  4. 4. uint8_t lcd_value_num; //初始化時寫入寄存器值的數目

  5. 5. }lcd_redu_list_struct;

    定義const修飾的結構體變量,存儲LCD部分寄存器的初始值,這個初始值跟具體的應用初始化有關,不一定是表中的資料,通常情況下,這個結構體變量被存儲到Flash中。

  1. 1.

  2. 2. lcd_redu_list_struct const lcd_redu_list_str[]=

  3. 3. {

  4. 4. {SSD1963_Get_Address_Mode,{0x20} ,1},

  5. 5. {SSD1963_Get_Pll_Mn ,{0x3b,0x02,0x04} ,3},

  6. 6. {SSD1963_Get_Pll_Status ,{0x04} ,1},

  7. 7. {SSD1963_Get_Lcd_Mode ,{0x24,0x20,0x01,0xdf,0x01,0x0f,0x00} ,7},

  8. 8. {SSD1963_Get_Hori_Period ,{0x02,0x0c,0x00,0x2a,0x07,0x00,0x00,0x00},8},

  9. 9. {SSD1963_Get_Vert_Period ,{0x01,0x1d,0x00,0x0b,0x09,0x00,0x00} ,7},

  10. 10. {SSD1963_Get_Power_Mode ,{0x1c} ,1},

  11. 11. {SSD1963_Get_Display_Mode,{0x03} ,1},

  12. 12. {SSD1963_Get_Gpio_Conf ,{0x0F,0x01} ,2},

  13. 13. {SSD1963_Get_Lshift_Freq ,{0x00,0xb8} ,2},

  14. 14. };

    實作函數如下所示,函數會周遊結構體變量中的每一個指令,以及每一個指令下的初始值,如果有一個不正确,則跳出循環,執行重新初始化和恢複措施。這個函數中的MY_DEBUGF宏是我自己的調試函數,使用序列槽列印調試資訊,在接下來的第五部分将詳細叙述。通過這個函數,我可以長時間監控顯示屏的哪些指令、哪些位容易被幹擾。程式裡使用了一個被妖魔化的關鍵字:goto。大多數C語言書籍對goto關鍵字談之色變,但你應該有自己的判斷。在函數内部跳出多重循環,除了goto關鍵字,又有哪種方法能如此簡潔高效!

  1. 1.

  2. 5. void lcd_redu(void)

  3. 6. {

  4. 7. uint8_t tmp[8];

  5. 8. uint32_t i,j;

  6. 9. uint32_t lcd_init_flag;

  7. 10.

  8. 11. lcd_init_flag =0;

  9. 12. for(i=0;i<sizeof(lcd_redu_list_str)/sizeof(lcd_redu_list_str[0]);i++)

  10. 13. {

  11. 14. LCD_SendCommand(lcd_redu_list_str[i].lcd_command);

  12. 15. uyDelay(10);

  13. 16. for(j=0;j<lcd_redu_list_str[i].lcd_value_num;j++)

  14. 17. {

  15. 18. tmp[j]=LCD_ReadData();

  16. 19. if(tmp[j]!=lcd_redu_list_str[i].lcd_get_value[j])

  17. 20. {

  18. 21. lcd_init_flag=0x55;

  19. 22. MY_DEBUGF(MENU_DEBUG,("讀lcd寄存器值與預期不符,指令為:0x%x,第%d個參數,

  20. 23. 該參數正确值為:0x%x,實際讀出值為:0x%x\n",lcd_redu_list_str[i].lcd_command,j+1,

  21. 24. lcd_redu_list_str[i].lcd_get_value[j],tmp[j]));

  22. 25. goto handle_lcd_init;

  23. 26. }

  24. 27. }

  25. 28. }

  26. 29.

  27. 30. handle_lcd_init:

  28. 31. if(lcd_init_flag==0x55)

  29. 32. {

  30. 33. //重新初始化LCD

  31. 34. //一些必要的恢複措施

  32. 35. }

  33. 36. }

4.14陷阱

      對于8051核心單片機,由于沒有相應的硬體支援,可以用純軟體設定軟體陷阱,用來攔截一些程式跑飛。對于ARM7或者Cortex-M系列單片機,硬體已經内建了多種異常,軟體需要根據硬體異常來編寫陷阱程式,用來快速定位甚至恢複錯誤。

4.15阻塞處理

      有時候程式員會使用while(!flag);語句阻塞在此等待标志flag改變,比如序列槽發送時用來等待一位元組資料發送完成。這樣的代碼時存在風險的,如果因為某些原因标志位一直不改變則會造成系統當機。

      一個良好備援的程式是設定一個逾時定時器,超過一定時間後,強制程式退出while循環。

    2003年8月11日發生的W32.Blaster.Worm蠕蟲事件導緻全球經濟損失高達5億美元,這個漏洞是利用了Windows分布式元件對象模型的遠端過程調用接口中的一個邏輯缺陷:在調用GetMachineName()函數時,循環隻設定了一個不充分的結束條件。

原代碼簡化如下所示:

  1. 1. HRESULT GetMachineName ( WCHAR *pwszPath,

  2. 2. WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])

  3. 3. {

  4. 4. WCHAR *pwszServerName = wszMachineName;

  5. 5. WCHAR *pwszTemp = pwszPath + 2;

  6. 6. while ( *pwszTemp != L’\\’ )

  7. 7. *pwszServerName++= *pwszTemp++;

  8. 8.

  9. 9. }

    微軟釋出的安全更新檔MS03-026解決了這個問題,為GetMachineName()函數設定了充分終止條件。一個解決代碼簡化如下所示(并非微軟更新檔代碼):

  1. 1. HRESULT GetMachineName( WCHAR *pwszPath,

  2. 2. WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])

  3. 3. {

  4. 4. WCHAR *pwszServerName = wszMachineName;

  5. 5. WCHAR *pwszTemp = pwszPath + 2;

  6. 6. WCHAR *end_addr = pwszServerName +MAX_COMPUTTERNAME_LENGTH_FQDN;

  7. 7. while ((*pwszTemp != L’\\’ ) && (*pwszTemp != L’\0’)

  8. 8. && (pwszServerName<end_addr))

  9. 9. *pwszServerName++= *pwszTemp++;

  10. 10.

  11. 11. }

5.測試,再測試

       思維再缜密的程式員也不可能編寫完全無缺陷的程式,測試的目的正是盡可能多的發現這些缺陷并改正。這裡說的測試,是指程式員的自測試。前期的自測試能夠更早的發現錯誤,相應的修複成本也會很低,如果你不徹底測試自己的代碼,恐怕你開發的就不隻是代碼,可能還會聲名狼藉。

優質嵌入式C程式跟優質的基礎元素關系密切,可以将函數作為基礎元素,我們的測試正是從最基本的函數開始。判斷哪些函數需要測試需要一定的經驗積累,雖然代碼行數跟邏輯複雜度并不成正比,但如果你不能判斷某個函數是否要測試,一個簡單粗暴的方法是:當函數有效代碼超過20行,就測試它。

       程式員對自己的代碼以及邏輯關系十厘清楚,測試時,按照每一個邏輯分支全面測試。很多錯誤發生在我們認為不會出錯的地方,是以即便某個邏輯分支很簡單,也建議測試一遍。第一個原因是我們自己看自己的代碼總是不容易發現錯誤,而測試能暴露這些錯誤;另一方面,文法正确、邏輯正确的代碼,經過編譯器編譯後,生成的彙編代碼很可能與你的邏輯相差甚遠。比如我們前文提及的使用volatile以及不使用volatile關鍵字編譯後生成的彙編代碼,再比如我們用低優化級别編譯和使用高優化級别編譯後生成的彙編代碼,都可能相差很大,實際運作測試,可以暴漏這些隐含錯誤。最後,雖然可能性極小,編譯器本身也可能有BUG,特别是構造複雜表達式的情況下(應極力避免複雜表達式)。

5.1使用硬體調試器測試

       使用硬體調試器(比如J-link)測試是最通用的手段。可以單步運作、設定斷點,可以很友善的檢視目前寄存器、變量的值。在尋找缺陷方面,使用硬體調試器測試是最簡單卻又最有效的手段。

       硬體調試器已經在公司普遍使用,這方面的測試不做介紹,想必大家都已經很熟悉了。

5.2有些缺陷很難纏

       就像沒有一種方法能完美解決所有問題,在實際項目中,硬體調試器也有難以觸及的地方。可以舉幾個例子說明:

  •  使用了比較大的協定棧,需要跟進到協定棧内部調試的缺陷

           比如公司使用lwIP協定棧,如果跟蹤資料的處理過程,需要從接收資料開始一直到應用層處理資料,之間會經過驅動層、IP層、TCP層和應用層,會經過十幾個檔案幾十個函數,使用硬體調試器跟蹤費時費力;

  •  具有随機性的缺陷

          有一些缺陷,可能是不定時出現的,有可能是幾分鐘出現,也有可能是幾個小時甚至幾天才出現,像這樣的缺陷很難用硬體調試器捕捉到;

  •  需要外界一系列有時間限制的輸入條件觸發,但這一過程中有缺陷

      比如我們用組合鍵來完成某個功能,規定按下按鍵1不小于3秒後松開,然後在6秒内分别按下按鍵2、按鍵3、按鍵4這三個按鍵來執行我們的特定程式,要測試類似這種過程,硬體調試器很難做到;

      除了測試缺陷需要,有時候我們在做穩定性測試時,需要知道軟體每時每刻運作到那些分支、執行了哪些操作、我們關心的變量目前值是什麼等等,這些都表明,我們還需要一種和硬體調試器互補的測試手段。

      這個測試手段就是在程式中增加額外調試語句,當程式運作時,通過這些調試語句将運作資訊輸出到可以友善檢視的裝置上,可以是PC機、LCD顯示屏、存儲卡等等。

      以序列槽輸出到PC機為例,下面提供完整的測試思路。在此之前,我們先對這種測試手段提一些要求:

  •  必須簡單易用

           我們在初學C語言的時候,都接觸過printf函數,這個函數可以友善的輸出資訊,并可以将各種變量格式化為指定格式的字元串,我們應當提供類似的函數;

  •  調試語句必須友善的從代碼中移除

          在編碼階段,我們可能會往程式中加入大量的調試語句,但是程式釋出時,需要将這些調試語句從代碼中移除,這将是件恐怖的過程。我們必須提供一種政策,可以友善的移除這些調試語句。

5.2.1簡單易用的調試函數

      1)       使用庫函數printf。以MDK為例,方法如下:

             I>初始化序列槽

             II>重構fputc函數,printf函數會調用fputc函數執行底層序列槽的資料發送。

  1. 1.

  2. 6. int fputc(int ch, FILE *f)

  3. 7. {

  4. 8.

  5. 9.

  6. 10. //舉例:USART_SendData(UART_COM1, (uint8_t) ch);

  7. 11.

  8. 12. return ch;

  9. 13. }

           III> 在Options for Targer視窗,Targer标簽欄下,勾選Use MicroLIB前的複選框以便避免使用半主機功能。(注:标準C庫printf函數預設開啟半主機功能,如果非要使用标準C庫,請自行查閱資料)

      2)       建構自己的調試函數

      使用庫函數比較友善,但也少了一些靈活性,不利于随心所欲的定制輸出格式。自己編寫類似printf函數則會更靈活一些,而且不依賴任何編譯器。下面給出一個完整的類printf函數實作,該函數支援有限的格式參數,使用方法與庫函數一緻。同庫函數類似,該也需要提供一個底層序列槽發送函數(原型為:int32_t UARTwrite(const uint8_t *pcBuf, uint32_t ulLen)),用來發送指定數目的字元,并傳回最終發送的字元個數。

  1. 1. #include <stdarg.h>

  2. 2.

  3. 3. const char * const g_pcHex = "0123456789abcdef";

  4. 4.

  5. 5.

  6. 8. void UARTprintf(const uint8_t *pcString, ...)

  7. 9. {

  8. 10. uint32_t ulIdx;

  9. 11. uint32_t ulValue; //儲存從不定量參數堆棧中取出的數值型變量

  10. 12. uint32_t ulPos, ulCount;

  11. 13. uint32_t ulBase; //儲存進制基數,如十進制則為10,十六進制數則為16

  12. 14. uint32_t ulNeg; //為1表示從變量為負數

  13. 15. uint8_t *pcStr; //儲存從不定量參數堆棧中取出的字元型變量

  14. 16. uint8_t pcBuf[32]; //儲存數值型變量字元化後的字元

  15. 17. uint8_t cFill; //'%08x'->不足8個字元用'0'填充,cFill='0';

  16. 18. //'%8x '->不足8個字元用空格填充,cFill=' '

  17. 19. va_list vaArgP;

  18. 20.

  19. 21. va_start(vaArgP, pcString);

  20. 22. while(*pcString)

  21. 23. {

  22. 24. // 首先搜尋非%核字元串結束字元

  23. 25. for(ulIdx = 0; (pcString[ulIdx] != '%') && (pcString[ulIdx] != '\0'); ulIdx++)

  24. 26. { }

  25. 27. UARTwrite(pcString, ulIdx);

  26. 28.

  27. 29. pcString += ulIdx;

  28. 30. if(*pcString == '%')

  29. 31. {

  30. 32. pcString++;

  31. 33.

  32. 34. ulCount = 0;

  33. 35. cFill = ' ';

  34. 36. again:

  35. 37. switch(*pcString++)

  36. 38. {

  37. 39. case '0': case '1': case '2': case '3': case '4':

  38. 40. case '5': case '6': case '7': case '8': case '9':

  39. 41. {

  40. 42. // 如果第一個數字為0, 則使用0做填充,則用空格填充)

  41. 43. if((pcString[-1] == '0') && (ulCount == 0))

  42. 44. {

  43. 45. cFill = '0';

  44. 46. }

  45. 47. ulCount *= 10;

  46. 48. ulCount += pcString[-1] - '0';

  47. 49. goto again;

  48. 50. }

  49. 51. case 'c':

  50. 52. {

  51. 53. ulValue = va_arg(vaArgP, unsigned long);

  52. 54. UARTwrite((unsigned char *)&ulValue, 1);

  53. 55. break;

  54. 56. }

  55. 57. case 'd':

  56. 58. {

  57. 59. ulValue = va_arg(vaArgP, unsigned long);

  58. 60. ulPos = 0;

  59. 61.

  60. 62. if((long)ulValue < 0)

  61. 63. {

  62. 64. ulValue = -(long)ulValue;

  63. 65. ulNeg = 1;

  64. 66. }

  65. 67. else

  66. 68. {

  67. 69. ulNeg = 0;

  68. 70. }

  69. 71. ulBase = 10;

  70. 72. goto convert;

  71. 73. }

  72. 74. case 's':

  73. 75. {

  74. 76. pcStr = va_arg(vaArgP, unsigned char *);

  75. 77.

  76. 78. for(ulIdx = 0; pcStr[ulIdx] != '\0'; ulIdx++)

  77. 79. {

  78. 80. }

  79. 81. UARTwrite(pcStr, ulIdx);

  80. 82.

  81. 83. if(ulCount > ulIdx)

  82. 84. {

  83. 85. ulCount -= ulIdx;

  84. 86. while(ulCount--)

  85. 87. {

  86. 88. UARTwrite(" ", 1);

  87. 89. }

  88. 90. }

  89. 91. break;

  90. 92. }

  91. 93. case 'u':

  92. 94. {

  93. 95. ulValue = va_arg(vaArgP, unsigned long);

  94. 96. ulPos = 0;

  95. 97. ulBase = 10;

  96. 98. ulNeg = 0;

  97. 99. goto convert;

  98. 100. }

  99. 101. case 'x': case 'X': case 'p':

  100. 102. {

  101. 103. ulValue = va_arg(vaArgP, unsigned long);

  102. 104. ulPos = 0;

  103. 105. ulBase = 16;

  104. 106. ulNeg = 0;

  105. 107. convert: //将數值轉換成字元

  106. 108. for(ulIdx = 1; (((ulIdx * ulBase) <= ulValue) &&(((ulIdx * ulBase) / ulBase) == ulIdx)); ulIdx *= ulBase, ulCount--)

  107. 109. { }

  108. 110. if(ulNeg)

  109. 111. {

  110. 112. ulCount--;

  111. 113. }

  112. 114. if(ulNeg && (cFill == '0'))

  113. 115. {

  114. 116. pcBuf[ulPos++] = '-';

  115. 117. ulNeg = 0;

  116. 118. }

  117. 119. if((ulCount > 1) && (ulCount < 16))

  118. 120. {

  119. 121. for(ulCount--; ulCount; ulCount--)

  120. 122. {

  121. 123. pcBuf[ulPos++] = cFill;

  122. 124. }

  123. 125. }

  124. 126.

  125. 127. if(ulNeg)

  126. 128. {

  127. 129. pcBuf[ulPos++] = '-';

  128. 130. }

  129. 131.

  130. 132. for(; ulIdx; ulIdx /= ulBase)

  131. 133. {

  132. 134. pcBuf[ulPos++] = g_pcHex[(ulValue / ulIdx) % ulBase];

  133. 135. }

  134. 136. UARTwrite(pcBuf, ulPos);

  135. 137. break;

  136. 138. }

  137. 139. case '%':

  138. 140. {

  139. 141. UARTwrite(pcString - 1, 1);

  140. 142. break;

  141. 143. }

  142. 144. default:

  143. 145. {

  144. 146. UARTwrite("ERROR", 5);

  145. 147. break;

  146. 148. }

  147. 149. }

  148. 150. }

  149. 151. }

  150. 152. //可變參數處理結束

  151. 153. va_end(vaArgP);

  152. 154. }

5.2.2對調試函數進一步封裝

      上文說到,我們增加的調試語句應能很友善的從最終發行版中去掉,是以我們不能直接調用printf或者自定義的UARTprintf函數,需要将這些調試函數做一層封裝,以便随時從代碼中去除這些調試語句。參考方法如下:

  1. 1. #ifdef MY_DEBUG

  2. 2. #define MY_DEBUGF(message) do { \

  3. 3. {UARTprintf message;} \

  4. 4. } while(0)

  5. 5. #else

  6. 6. #define MY_DEBUGF(message)

  7. 7. #endif

在我們編碼測試期間,定義宏MY_DEBUG,并使用宏MY_DEBUGF(注意比前面那個宏多了一個‘F’)輸出調試資訊。經過預處理後,宏MY_DEBUGF(message)會被UARTprintf message代替,進而實作了調試資訊的輸出;當正式釋出時,隻需要将宏MY_DEBUG注釋掉,經過預處理後,所有MY_DEBUGF(message)語句都會被空格代替,而從将調試資訊從代碼中去除掉。

6.程式設計思想

6.1程式設計風格

       《計算機程式的構造和解釋》一書在開篇寫到:程式寫出來是給人看的,附帶能在機器上運作。

6.1.1 整潔的樣式

      使用什麼樣的編碼樣式一直都頗具争議性的,比如縮進和大括号的位置。因為編碼的樣式也會影響程式的可讀性,面對一個亂放括号、對齊都不一緻的源碼,我們很難提起閱讀它的興趣。我們總要看别人的程式,如果彼此編碼樣式相近,讀起源碼來會覺得比較舒适。但是編碼風格的問題是主觀的,永遠不可能在編碼風格上達成統一意見。是以隻要你的編碼樣式整潔、結構清晰就足夠了。除此之外,對編碼樣式再沒有其它要求。

      提出匈牙利命名法的程式員、前微軟首席架構師Charles Simonyi說:我覺得代碼清單帶給人的愉快同整潔的家差不多。你一眼就能分辨出家裡是雜亂無章還是整潔如新。這也許意義不大。因為光是房子整潔說明不了什麼,它仍可能藏污納垢!但是第一印象很重要,它至少反映了程式的某些方面。我敢打賭,我在3米開外就能看出程式拙劣與否。我也許沒法保證它很不錯,但如果從3米外看起來就很糟,我敢保證這程式寫得不用心。如果寫得不用心,那它在邏輯上也許就不會優美。

6.1.2清晰的命名

      變量、函數、宏等等都需要命名,清晰的命名是優秀代碼的特點之一。命名的要點之一是名稱應能清晰的描述這個對象,以至于一個初級程式員也能不費力的讀懂你的代碼邏輯。我們寫的代碼主要給誰看是需要思考的:給自己、給編譯器還是給别人看?我覺得代碼最主要的是給别人看,其次是給自己看。如果沒有一個清晰的命名,别人在維護你的程式時很難在整個全貌上看清代碼,因為要記住十多個以上的糟糕命名的變量是件非常困難的事;而且一段時間之後你回過頭來看自己的代碼,很有可能不記得那些糟糕命名的變量是什麼意思。

      為對象起一個清晰的名字并不是簡單的事情。首先能認識到名稱的重要性需要有一個過程,這也許跟譚式C程式教材被大學廣泛使用有關:滿書的a、b、c、x、y、z變量名是很難在關鍵的初學階段給人傳達優秀程式設計思想的;其次如何恰當的為對象命名也很有挑戰性,要準确、無歧義、不羅嗦,要對英文有一定水準,所有這些都要滿足時,就會變得很困難;此外,命名還需要考慮整體一緻性,在同一個項目中要有統一的風格,堅持這種風格也并不容易。

       關于如何命名,Charles Simonyi說:面對一個具備某些屬性的結構,不要随随便便地取個名字,然後讓所有人去琢磨名字和屬性之間有什麼關聯,你應該把屬性本身,用作結構的名字。

6.1.3恰當的注釋

       注釋向來也是争議之一,不加注釋和過多的注釋我都是反對的。不加注釋的代碼顯然是很糟糕的,但過多的注釋也會妨礙程式的可讀性,由于注釋可能存在的歧義,有可能會誤解程式真實意圖,此外,過多的注釋會增加程式員不必要的時間。如果你的編碼樣式整潔、命名又很清晰,那麼,你的代碼可讀性不會差到哪去,而注釋的本意就是為了便于了解程式。

       這裡建議使用良好的編碼樣式和清晰的命名來減少注釋,對子產品、函數、變量、資料結構、算法和關鍵代碼做注釋,應重視注釋的品質而不是數量。如果你需要一大段注釋才能說清楚程式做什麼,那麼你應該注意了:是否是因為程式變量命名不夠清晰,或者代碼邏輯過于混亂,這個時候你應該考慮的可能就不是注釋,而是如何精簡這個程式了。

6.2資料結構

      資料結構是程式設計的基礎。在設計程式之前,應該先考慮好所需要的資料結構。

      前微軟首席架構師Charles Simonyi:程式設計的第一步是想象。就是要在腦海中對來龍去脈有極為清晰的把握。在這個初始階段,我會使用紙和鉛筆。我隻是信手塗鴉,并不寫代碼。我也許會畫些方框或箭頭,但基本上隻是塗鴉,因為真正的想法在我腦海裡。我喜歡想象那些有待維護的結構,那些結構代表着我想編碼的真實世界。一旦這個結構考慮得相當嚴謹和明确,我便開始寫代碼。我會坐到終端前,或者換在以前的話,就會拿張白紙,開始寫代碼。這相當容易。我隻要把頭腦中的想法變換成代碼寫下來,我知道結果應該是什麼樣的。大部分代碼會水到渠成,不過我維護的那些資料結構才是關鍵。我會先想好資料結構,并在整個編碼過程中将它們牢記于心。

    開發過以太網和作業系統SDS 940的Butler Lampson:(程式員)最重要的素質是能夠把問題的解決方案組織成容易操控的結構。

    開發CP/M作業系統的Gary.A:如果不能确認資料結構是正确的,我是決不會開始編碼的。我會先畫資料結構,然後花很長時間思考資料結構。在确定資料結構之後我就開始寫一些小段的代碼,并不斷地改善和監測。在編碼過程中進行測試可以確定所做的修改是局部的,并且如果有什麼問題的話,能夠馬上發現。

    微軟創始人比爾·蓋茨:編寫程式最重要的部分是設計資料結構。接下來重要的部分是分解各種代碼塊。

    編寫世界上第一個電子表格軟體的Dan Bricklin:在我看來,寫程式最重要的部分是設計資料結構,此外,你還必須知道人機界面會是什麼樣的。

       我們舉個例子來說明。在介紹防禦性程式設計的時候,提到公司使用的LCD顯示屏抗幹擾能力一般,為了提高LCD的穩定性,需要定期讀出LCD内部的關鍵寄存器值,然後跟存在Flash中的初始值相比較。需要讀出的LCD寄存器有十多個,從每個寄存器讀出的值也不盡相同,從1個到8個位元組都有可能。如果不考慮資料結構,編寫出的程式将會很冗長。

  1. 1. void lcd_redu(void)

  2. 2. {

  3. 3. 讀第一個寄存器值;

  4. 4. if(第一個寄存器值==Flash存儲值)

  5. 5. {

  6. 6. 讀第二個寄存器值;

  7. 7. if(第二個寄存器值==Flash存儲值)

  8. 8. {

  9. 9. ...

  10. 10.

  11. 11. 讀第十個寄存器值;

  12. 12. if(第十個寄存器值==Flash存儲值)

  13. 13. {

  14. 14. 傳回;

  15. 15. }

  16. 16. else

  17. 17. {

  18. 18. 重新初始化LCD;

  19. 19. }

  20. 20. }

  21. 21. else

  22. 22. {

  23. 23. 重新初始化LCD;

  24. 24. }

  25. 25. }

  26. 26. else

  27. 27. {

  28. 28. 重新初始化LCD;

  29. 29. }

  30. 30. }

      我們分析這個過程,發現能提取出很多相同的元素,比如每次讀LCD寄存器都需要該寄存器的指令号,都會經過讀寄存器、判斷值是否相同、處理異常情況這一過程。是以我們可以提取一些相同的元素,組織成資料結構,用統一的方法去處理這些資料,将資料與處理過程分開來。

      我們可以先提取相同的元素,将之組織成資料結構:

  1. 1. typedef struct {

  2. 2. uint8_t lcd_command; //LCD寄存器

  3. 3. uint8_t lcd_get_value[8]; //初始化時寫入寄存器的值

  4. 4. uint8_t lcd_value_num; //初始化時寫入寄存器值的數目

  5. 5. }lcd_redu_list_struct;

      這裡lcd_command表示的是LCD寄存器指令号;lcd_get_value是一個數組,表示寄存器要初始化的值,這是因為對于一個LCD寄存器,可能要初始化多個位元組,這是硬體特性決定的;lcd_value_num是指一個寄存器要多少個位元組的初值,這是因為每一個寄存器的初值數目是不同的,我們用同一個方法處理資料時,是需要這個資訊的。

      就本例而言,我們将要處理的資料都是事先固定的,是以定義好資料結構後,我們可以将這些資料組織成表格:

  1. 1.

  2. 2. lcd_redu_list_struct const lcd_redu_list_str[]=

  3. 3. {

  4. 4. {SSD1963_Get_Address_Mode,{0x20} ,1},

  5. 5. {SSD1963_Get_Pll_Mn ,{0x3b,0x02,0x04} ,3},

  6. 6. {SSD1963_Get_Pll_Status ,{0x04} ,1},

  7. 8. {SSD1963_Get_Hori_Period ,{0x02,0x0c,0x00,0x2a,0x07,0x00,0x00,0x00},8},

  8. 9. {SSD1963_Get_Vert_Period ,{0x01,0x1d,0x00,0x0b,0x09,0x00,0x00} ,7},

  9. 10. {SSD1963_Get_Power_Mode ,{0x1c} ,1},

  10. 11. {SSD1963_Get_Display_Mode,{0x03} ,1},

  11. 12. {SSD1963_Get_Gpio_Conf ,{0x0F,0x01} ,2},

  12. 13. {SSD1963_Get_Lshift_Freq ,{0x00,0xb8} ,2}, /*10*

  13. 14. };

      至此,我們就可以用一個處理過程來完成數十個LCD寄存器的讀取、判斷和異常處理了:

  1. 1.

  2. 5. void lcd_redu(void)

  3. 6. {

  4. 7. uint8_t tmp[8];

  5. 8. uint32_t i,j;

  6. 9. uint32_t lcd_init_flag;

  7. 10.

  8. 11. lcd_init_flag =0;

  9. 12. for(i=0;i<sizeof(lcd_redu_list_str)/sizeof(lcd_redu_list_str[0]);i++)

  10. 13. {

  11. 14. LCD_SendCommand(lcd_redu_list_str[i].lcd_command);

  12. 15. uyDelay(10);

  13. 16. for(j=0;j<lcd_redu_list_str[i].lcd_value_num;j++)

  14. 17. {

  15. 18. tmp[j]=LCD_ReadData();

  16. 19. if(tmp[j]!=lcd_redu_list_str[i].lcd_get_value[j])

  17. 20. {

  18. 21. lcd_init_flag=0x55;

  19. 22. //一些調試語句,列印出錯的具體資訊

  20. 23. goto handle_lcd_init;

  21. 24. }

  22. 25. }

  23. 26. }

  24. 27.

  25. 28. handle_lcd_init:

  26. 29. if(lcd_init_flag==0x55)

  27. 30. {

  28. 31. //重新初始化LCD

  29. 32. //一些必要的恢複措施

  30. 33. }

  31. 34. }

      通過合理的資料結構,我們可以将資料和處理過程分開,LCD備援判斷過程可以用很簡潔的代碼來實作。更重要的是,将資料和處理過程分開更有利于代碼的維護。比如,通過實驗發現,我們還需要增加一個LCD寄存器的值進行判斷,這時候隻需要将新增加的寄存器資訊按照資料結構格式,放到LCD寄存器設定值清單中的任意位置即可,不用增加任何處理代碼即可實作!這僅僅是資料結構的優勢之一,使用資料結構還能簡化程式設計,使複雜過程變的簡單,這個隻有實際程式設計後才會有更深的了解。

7.總結和閱讀書目

      本文介紹了編寫優質嵌入式C程式涉及的多個方面。每年都有億萬計的C程式運作在單片機、ARM7、Cortex-M3這些微處理器上,但在這些處理器上如何編寫優質高效的C程式,幾乎沒有書籍做專門介紹。本文試圖在這方面做一些努力。編寫優質嵌入式C程式需要大量的專業知識,本文雖盡力描述編寫嵌入式C程式所需要的各種技能,但本文卻無力将每一個方面都面面俱到的描述出來,是以本文最後會列舉一些閱讀書目,這些書大多都是真正大師的經驗之談。站在巨人的肩膀上,可以看的更遠。

7.1關于語言特性

  •  Stephen Prata 著 雲巅工作室 譯 《C Primer Plus(第五版)中文版》
  •  Andrew Koenig 著 高巍 譯 《C陷阱與缺陷》
  •  Peter Van Der Linden 著 徐波 譯 《C專家程式設計》
  •  陳正沖 編著 《C語言深度解剖》

7.2關于編譯器

  •  杜春雷 編著 《ARM體系結構與程式設計》
  •  Keil MDK 編譯器幫助手冊

7.3關于防禦性程式設計

  •  MISRA-C-:2004 Guidelines for the use of the C language in criticalsystems
  •  Robert C.Seacord 著 徐波 譯 《C安全編碼标準》

7.4關于程式設計思想

  •  Pete Goodliffe 著 韓江、陳玉 譯 《程式設計匠藝---編寫卓越的代碼》
  •  Susan Lammers 著 李琳骁、吳詠炜、張菁《程式設計大師訪談錄》

繼續閱讀