嵌入式c程序易錯點(轉自http://blog.csdn.net/zhzht19861011/article/details/45508029)

今天第一次在網上發表博客,雖然是轉載的別人的文章,但還是有點小激動。轉載的這篇文章裏提到的問題很多都自己親自踩過,有個別還踩過多次(感覺自己有點傻),也許是親生經歷的原因,看完後果斷的轉了,以後可以經常回顧一下,在前人的指引下還是可以少掉不少的坑大笑。裏面還有一些規範向我這樣的初學者養成個好的編碼習慣的一些經驗建議,感覺很有用。下面是大神原文總結:

 

 

 

前言:這是一年前我爲公司內部寫的一個文檔,旨在向年輕的嵌入式軟件工程師們介紹如何在裸機環境下編寫優質嵌入式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.	if(x=5)   
2.	{   
3.	    //其它代碼   
4.	}

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

1.	if(5==x)   
2.	{   
3.	    //其它代碼   
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.	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 意想不到的八進制

       將一個整形常量賦值給變量,代碼如下所示:

1.	int a=34, b=034; 

       變量a和b相等嗎?

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

1.	a[0]=106;       /*十進制數106*/  
2.	a[1]=112;   	  /*十進制數112*/   
3.	a[2]=052;       /*實際爲十進制數42,本意爲十進制52*/ 

2.1.5指針加減運算

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

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並沒有被真正清零。

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操作符,比如下面的例子:

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

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

1.	int a=8,b=2,y;  
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.	y=a+(--b);  
2.	a=a+1; 

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

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

1.	if((i>=0)&&(i++ <=max))  
2.	{  
3.	       //其它代碼  
4.	}  

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

2.1.9結構體的填充

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

       第一個結構體:

1.	struct {  
2.	    char  c;  
3.	    short s;  
4.	    int   x;  
5.	}str_test1; 
       第二個結構體:

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碼轉換爲十六進制數的代碼:

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.	#define READSDA IO0PIN&(1<<11)  //讀IO口p0.11的端口狀態  
2.	          
3.	if(READSDA==(1<<11))          //判斷端口p0.11是否爲高電平   
4.	{    
5.	    //其它代碼  
6.	}  
      編譯器在編譯後將宏帶入,原代碼語句變爲:  

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類型(取決於操作時使用的類型)。
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。經過這麼詭異的隱式轉換,結果跟我們期望的值,已經大相徑庭!正確的表達式語句應該爲:

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.	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類型。正確的書寫方式:

1.	u32x = (uint32_t)u16a +(uint32_t)u16b;      或者:  
2.	u32x = (uint32_t)u16a + u16b; 
       後一種寫法在本表達式中是正確的,但是在其它表達式中不一定正確,比如:

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。

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

  •  並非所有強制類型轉換都是由風險的,把一個整數值轉換爲一種具有相同符號的更寬類型時,是絕對安全的。
  •  精度高的類型強制轉換爲精度低的類型時,通過丟棄適當數量的最高有效位來獲取結果,也就是說會發生數據截斷,並且可能改變數據的符號位。
  •  精度低的類型強制轉換爲精度高的類型時,如果兩種類型具有相同的符號,那麼沒什麼問題;需要注意的是負的有符號精度低類型強制轉換爲無符號精度高類型時,會不直觀的執行符號擴展,例如:

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

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

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語句後誤加了一個分號,可能會完全改變了程序邏輯。編譯器也會很配合的幫忙掩蓋,甚至連警告都不提示。代碼如下:

1.	if(a>b);          	//這裏誤加了一個分號  
2.	a=b;             	//這句代碼一直被執行 
       不但如此,編譯器還會忽略掉多餘的空格符和換行符,就像下面的代碼也不會給出足夠提示:

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顯示屏上的一個數字不正常的被改變。經過一段時間的調試,問題被定位到下面的一段代碼中:

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中定義數組:

1.	int SensorData[30];
     在模塊B中引用該數組,但由於你引用代碼並不規範,這裏沒有顯示聲明數組大小,但編譯器也允許這麼做:

1.	extern int SensorData[]; 

    這次,編譯器不會給出警告信息,因爲編譯器壓根就不知道數組的元素個數。所以,當一個數組聲明爲具有外部鏈接,它的大小應該顯式聲明。

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

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處理器發生了硬件異常,異常處理代碼是一段死循環(死機的直接原因)。接收設備有一個硬件模塊用於接收無線傳感器的整包數據並存在自己的緩衝區中,當硬件模塊接收數據完成後,使用外部中斷通知設備取數據,外部中斷服務程序精簡後如下所示:

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

       一個程序模塊通常由兩個文件組成,源文件和頭文件。如果你在源文件定義變量:

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.	#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,所以程序無限循環。圖3-1的流程圖說明了程序使用限定符volatile和不使用volatile的執行過程。


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

  •  沒有使用關鍵字volatile,在keil MDK V4.54下編譯,默認優化級別,如下所示(注意最後兩行):

122:     unIdleCount=0;   
2.	   123:        
3.	0x00002E10  E59F11D4  LDR       R1,[PC,#0x01D4]  
4.	0x00002E14  E3A05000  MOV       R5,#key1(0x00000000)  
5.	0x00002E18  E1A00005  MOV       R0,R5  
6.	0x00002E1C  E5815000  STR       R5,[R1]  
7.	   124:     while(unIdleCount!=200);   //延時2S鍾   
8.	   125:        
9.      0x00002E20  E35000C8  CMP       R0,#0x000000C8  
10.	0x00002E24  1AFFFFFD  BNE       0x00002E20</span>

  •  使用關鍵字volatile,在keil MDK V4.54下編譯,默認優化級別,如下所示(注意最後三行):

122:     unIdleCount=0;   
2.	   123:        
3.	0x00002E10  E59F01D4  LDR       R0,[PC,#0x01D4]  
4.	0x00002E14  E3A05000  MOV       R5,#key1(0x00000000)  
5.	0x00002E18  E5805000  STR       R5,[R0]  
6.	   124:     while(unIdleCount!=200);   //延時2S鍾   
7.	   125:        
8.	0x00002E1C  E5901000  LDR       R1,[R0]  
9.	0x00002E20  E35100C8  CMP       R1,#0x000000C8  
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.	unsigned intGetTempValue(void)  
2.	{  
3.	    unsigned int sum;                     //定義局部變量,保存總值  
4.	    for(i=0;i<10;i++)  
5.	    {  
6.	        sum+=CollectTemp();               //函數CollectTemp可以得到當前的溫度值  
7.	    }  
8.	    return (sum/10);  
9.	} 

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

1.	char * GetData(void)  
2.	{  
3.	     char buffer[100];                 //局部數組  
4.	     …  
5.	     return buffer;  
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…



      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.	int value1,value2,sum  
2.	  
3.	//其它操作  
4.	sum=value1+value;    /*sum可能發生溢出*/

     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.	int value1,value2,sum;  
2.	  
3.	//其它代碼  
4.	if((value1>0 && value2>0 && value1>(INT_MAX-value2))||  
5.	   (value1<0 && value2<0 && value1<(INT_MIN-value2)))  
6.	{  
7.	    //處理錯誤  
8.	}  
9.	else  
10.	{  
11.	    sum=value1+value2;  
12.	}

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

int value1, value2, sum;
unsigned int usum = (unsigned int)value1 + value2;

if((usum ^ value1) & (usum ^ value2) & INT_MIN)
{
	/*處理溢出情況*/
}
else
{
	sum = value1 + value2;
}
使用的原理解釋一下,因爲在加法運算中,操作數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類型的數據項是無符號的,所以它的取值範圍是0255

      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.	unsigned int g_unRunFlag=0xA5;  
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所示。


圖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:   LR_IROM1 0x00000000 0x00080000  {    ; load region size_region
   	    2:   ER_IROM1 0x00000000 0x00080000  {  ; load address = execution address
   	    3:    *.o (RESET, +First)
   	    4:    *(InRoot$$Sections)
   	    5:    .ANY (+RO)
   	    6:   }
   	    7:   RW_IRAM1 0x10000000 0x0000A000  {  ; RW data
   	    8:    .ANY (+RW +ZI)
   	    9:   }
  	    10:   MYRAM 0x1000A000 UNINIT 0x00002000  {
  	    11:    .ANY (NO_INIT)
  	    12:   }
  	    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: LR_IROM1 0x00000000 0x00080000  {    ; load region size_region
   	2:   ER_IROM1 0x00000000 0x00080000  {  ; load address = execution address
   	3:    *.o (RESET, +First)
   	4:    *(InRoot$$Sections)
   	5:    .ANY (+RO)
   	6:   }
   	7:   RW_IRAM1 0x10000000 0x0000A000  {  ; RW data
   	8:    .ANY (+RW +ZI)
   	9:   }
  	10:   RW_IRAM2 0x1000A000 UNINIT 0x00002000  {
  	11:    test.o (+ZI)
  	12:   }
  	13: }
      在該模塊定義時變量時使用如下方法:

      這裏,變量屬性修飾符__attribute__((zero_init))用於將未初始化的變量放到ZI數據節中變量,其實MDK默認情況下,未初始化的變量就是放在ZI數據區的。

4.防禦性編程

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

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

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

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

1.	int exam_fun( unsigned char *str )   
2.	{   
3.	      if( str != NULL )     //  檢查“假設指針不爲空”這個條件 
4.		     {   
5.	           //正常處理代碼               
6.	      } 
7.	      else 
8.	      {  
9.	          //處理錯誤代碼  
10.	     }  
11.	}  

4.2仔細檢查函數的返回值

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

1.	char *DoSomething(…)  
2.	{  
3.	    char * p;  
4.	     p=malloc(1024);  
5.	     if(p==NULL)          /*對函數返回值作出判斷*/
6.	     {         
7.	        UARTprintf(…);   /*打印錯誤信息*/  
8.	         return NULL;  
9.	     }  
10.	     retuen p;  
11.	}  

4.3 防止指針越界

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

4.4 防止數組越界

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

1.	#define REC_BUF_LEN 100  
2.	unsigned char RecBuf[REC_BUF_LEN];  
3.	//其它代碼  
4.	void Uart_IRQHandler(void)  
5.	{  
6.	    static RecCount=0;          //接收數據長度計數器  
7.	    //其它代碼  
8.	    if(RecCount< REC_BUF_LEN)   //判斷數組是否越界
9.	    {  
10.	         RecBuf[RecCount]=…;     //從硬件取數據  
11.	         RecCount++;  
12.	         //其它代碼  
13.	    } 
14.	    else 
15.	    {  
16.	        //錯誤處理代碼   
17.	    }  
18.	     //其它代碼 
19.	}  
在使用一些庫函數時,同樣需要對邊界進行檢查,比如下面的memset(RecBuf,0,len)函數把RecBuf指指向的內存區的前len個字節用0填充,如果不注意len的長度,就會將數組RecBuf之外的內存區清零:
1.	#define REC_BUF_LEN 100  
2.	unsigned char RecBuf[REC_BUF_LEN];  
3.	  
4.	if(len< REC_BUF_LEN)
5.	{  
6.	    memset(RecBuf,0,len);       //將數組RecBuf清零  
7.	} 
8.	else 
9.	{  
10.	    //處理錯誤  
11.	}  

4.5 數學算數運算

4.5.1除法運算,只檢測除數爲零就可靠嗎?

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

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

1.	#include <limits.h>    
2.	signed long sl1,sl2,result;    
3.	/*初始化sl1和sl2*/    
4.	if((sl2==0)||(sl1==LONG_MIN && sl2==-1))  
5.	{    
6.	    //處理錯誤    
7.	}   
8.	else   
9.	{    
10.	    result = sl1 / sl2;    
11.	}

4.5.2檢測運算溢出

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

1.	#include <limits.h>    
2.	unsigned int a,b,result;    
3.	/*初始化a,b*/    
4.	if(UINT_MAX-a<b)  
5.	{    
6.	    //處理溢出    
7.	}   
8.	else   
9.	{    
10.	    result=a+b;    
11.	}

      嵌入式硬件一般沒有浮點處理器,浮點數運算在嵌入式也比較少見並且溢出判斷嚴重依賴C庫支持,這裏不討論。

4.5.3檢測移位

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

1.	unsigned int ui1;  
2.	unsigned int ui2;  
3.	unsigned int uresult;  
4.	  
5.	/*初始化ui1,ui2*/  
6.	if(ui2>=sizeof(unsigned int)*CHAR_BIT)  
7.	{  
8.	    //處理錯誤  
9.	}  
10.	else  
11.	{  
12.	    uresult=ui1<<ui2;  
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.	LR_IROM1 0x00000000 0x00080000  {    ; load region size_region  
2.	  ER_IROM1 0x00000000 0x00080000  {  ; load address = execution address  
3.	   *.o (RESET, +First)  
4.	   *(InRoot$$Sections)  
5.	   .ANY (+RO)  
6.	  }  
7.	  RW_IRAM1 0x10000000 0x00008000  {  ;保存原碼  
8.	   .ANY (+RW +ZI )  
9.	  }  
10.	    
11.	  RW_IRAM3 0x10009000 0x00001000{    ;保存反碼  
12.	   .ANY (MY_BK1)  
13.	  }  
14.	    
15.	  RW_IRAM2 0x1000B000 0x00001000  {  ;保存異或碼  
16.	   .ANY (MY_BK2)  
17.	  }  
18.	} 

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

1.	uint32  plc_pc=0;                                                       //原碼  
2.	__attribute__((section("MY_BK1"))) uint32 plc_pc_not=~0x0;              //反碼  
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.	/**************************************************************************** 
2.	* 名稱:RamToFlash() 
3.	* 功能:複製RAM的數據到FLASH,命令代碼51。 
4.	* 入口參數: dst        目標地址,即FLASH起始地址。以512字節爲分界 
5.	*           src        源地址,即RAM地址。地址必須字對齊 
6.	*           no         複製字節個數,爲512/1024/4096/8192 
7.	*           ProgStart  軟件鎖標誌    
8.	* 出口參數:IAP返回值(paramout緩衝區) CMD_SUCCESS,SRC_ADDR_ERROR,DST_ADDR_ERROR, 
9.	SRC_ADDR_NOT_MAPPED,DST_ADDR_NOT_MAPPED,COUNT_ERROR,BUSY,未選擇扇區 
10.	****************************************************************************/  
11.	void  RamToFlash(uint32 dst, uint32 src, uint32 no,uint8 ProgStart)  
12.	{     
13.	    PLC_ASSERT("Sector number",(dst>=0x00040000)&&(dst<=0x0007FFFF));  
14.	    PLC_ASSERT("Copy bytes number is 512",(no==512));  
15.	    PLC_ASSERT("ProgStart==0xA5",(ProgStart==0xA5));  
16.	      
17.	    paramin[0] = IAP_RAMTOFLASH;             // 設置命令字  
18.	    paramin[1] = dst;                        // 設置參數  
19.	    paramin[2] = src;  
20.	    paramin[3] = no;  
21.	    paramin[4] = Fcclk/1000;                      
22.	    if(ProgStart==0xA5)                     //只有軟件鎖標誌正確時,才執行關鍵代碼  
23.	    {  
24.	        iap_entry(paramin, paramout);       // 調用IAP服務程序                 
25.	        ProgStart=0;   
26.	    }  
27.	    else  
28.	    {  
29.	        paramout[0]=PROG_UNSTART;     
30.	    }  
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.	typedef struct {  
2.	    uint8_t  lcd_command;           //LCD寄存器  
3.	    uint8_t  lcd_get_value[8];      //初始化時寫入寄存器的值  
4.	    uint8_t  lcd_value_num;         //初始化時寫入寄存器值的數目  
5.	}lcd_redu_list_struct; 

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

1.	/*LCD部分寄存器設置值列表*/  
2.	lcd_redu_list_struct const lcd_redu_list_str[]=  
3.	{  
4.	  {SSD1963_Get_Address_Mode,{0x20}                                   ,1}, /*1*/ 
5.	  {SSD1963_Get_Pll_Mn      ,{0x3b,0x02,0x04}                         ,3}, /*2*/ 
6.	  {SSD1963_Get_Pll_Status  ,{0x04}                                   ,1}, /*3*/ 
7.	  {SSD1963_Get_Lcd_Mode    ,{0x24,0x20,0x01,0xdf,0x01,0x0f,0x00}     ,7}, /*4*/ 
8.	  {SSD1963_Get_Hori_Period ,{0x02,0x0c,0x00,0x2a,0x07,0x00,0x00,0x00},8}, /*5*/ 
9.	  {SSD1963_Get_Vert_Period ,{0x01,0x1d,0x00,0x0b,0x09,0x00,0x00}     ,7}, /*6*/ 
10.	  {SSD1963_Get_Power_Mode  ,{0x1c}                                   ,1}, /*7*/ 
11.	  {SSD1963_Get_Display_Mode,{0x03}                                   ,1}, /*8*/ 
12.	  {SSD1963_Get_Gpio_Conf   ,{0x0F,0x01}                              ,2}, /*9*/ 
13.	  {SSD1963_Get_Lshift_Freq ,{0x00,0xb8}                              ,2}, /*10*/
14.	}; 

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

1.	/** 
2.	* lcd 顯示冗餘 
3.	* 每隔一段時間調用該程序一次 
4.	*/  
5.	void lcd_redu(void)  
6.	{  
7.	    uint8_t  tmp[8];  
8.	    uint32_t i,j;  
9.	    uint32_t lcd_init_flag;  
10.	      
11.	    lcd_init_flag =0;  
12.	    for(i=0;i<sizeof(lcd_redu_list_str)/sizeof(lcd_redu_list_str[0]);i++)  
13.	    {  
14.	        LCD_SendCommand(lcd_redu_list_str[i].lcd_command);  
15.	        uyDelay(10);  
16.	        for(j=0;j<lcd_redu_list_str[i].lcd_value_num;j++)  
17.	        {  
18.	            tmp[j]=LCD_ReadData();  
19.	            if(tmp[j]!=lcd_redu_list_str[i].lcd_get_value[j])  
20.	            {  
21.	                lcd_init_flag=0x55;  
22.	                MY_DEBUGF(MENU_DEBUG,("讀lcd寄存器值與預期不符,命令爲:0x%x,第%d個參數,
23.	            該參數正確值爲:0x%x,實際讀出值爲:0x%x\n",lcd_redu_list_str[i].lcd_command,j+1,
24.	            lcd_redu_list_str[i].lcd_get_value[j],tmp[j]));  
25.	                goto handle_lcd_init;  
26.	            }  
27.	        }  
28.	    }  
29.	      
30.	    handle_lcd_init:  
31.	    if(lcd_init_flag==0x55)  
32.	    {  
33.	        //重新初始化LCD  
34.	        //一些必要的恢復措施  
35.	    }     
36.	}  

4.14陷阱

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

4.15阻塞處理

      有時候程序員會使用while(!flag);語句阻塞在此等待標誌flag改變,比如串口發送時用來等待一字節數據發送完成。這樣的代碼時存在風險的,如果因爲某些原因標誌位一直不改變則會造成系統死機。

      一個良好冗餘的程序是設置一個超時定時器,超過一定時間後,強制程序退出while循環。

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

原代碼簡化如下所示:

1.	HRESULT GetMachineName ( WCHAR *pwszPath,  
2.	WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])  
3.	{  
4.	       WCHAR *pwszServerName = wszMachineName;  
5.	       WCHAR *pwszTemp = pwszPath + 2;  
6.	       while ( *pwszTemp != L’\\’ )           /* 這句代碼循環結束條件不充分 */  
7.	             *pwszServerName++= *pwszTemp++;  
8.	       /*… */  
9.	}  

    微軟發佈的安全補丁MS03-026解決了這個問題,爲GetMachineName()函數設置了充分終止條件。一個解決代碼簡化如下所示(並非微軟補丁代碼):

1.	HRESULT GetMachineName( WCHAR *pwszPath,  
2.	WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])  
3.	{  
4.	       WCHAR *pwszServerName = wszMachineName;  
5.	       WCHAR *pwszTemp = pwszPath + 2;  
6.	       WCHAR *end_addr = pwszServerName +MAX_COMPUTTERNAME_LENGTH_FQDN;  
7.	       while ((*pwszTemp != L’\\’ ) && (*pwszTemp != L’\0’)  
8.	&& (pwszServerName<end_addr))  /*充分終止條件*/  
9.	             *pwszServerName++= *pwszTemp++;  
10.	       /*… */  
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.	/** 
2.	  * @brief  將C庫中的printf函數重定向到指定的串口. 
3.	  * @param  ch:要發送的字符 
4.	  * @param  f :文件指針 
5.	  */  
6.	int fputc(int ch, FILE *f)  
7.	{  
8.	  
9.	    /*這裏是一個跟硬件相關函數,將一個字符寫到UART */  
10.	    //舉例:USART_SendData(UART_COM1, (uint8_t) ch);  
11.	      
12.	    return ch;  
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.	#include <stdarg.h>               /*支持函數接收不定量參數*/  
2.	  
3.	const char * const g_pcHex = "0123456789abcdef";  
4.	  
5.	/** 
6.	* 簡介:   一個簡單的printf函數,支持\%c, \%d, \%p, \%s, \%u,\%x, and \%X. 
7.	*/  
8.	void UARTprintf(const uint8_t *pcString, ...)  
9.	{  
10.	    uint32_t ulIdx;  
11.	    uint32_t ulValue;       //保存從不定量參數堆棧中取出的數值型變量  
12.	    uint32_t ulPos, ulCount;  
13.	    uint32_t ulBase;        //保存進制基數,如十進制則爲10,十六進制數則爲16  
14.	    uint32_t ulNeg;         //爲1表示從變量爲負數  
15.	    uint8_t *pcStr;         //保存從不定量參數堆棧中取出的字符型變量  
16.	    uint8_t pcBuf[32];      //保存數值型變量字符化後的字符  
17.	    uint8_t cFill;          //'%08x'->不足8個字符用'0'填充,cFill='0';    
18.	                            //'%8x '->不足8個字符用空格填充,cFill=' '  
19.	    va_list vaArgP;  
20.	  
21.	    va_start(vaArgP, pcString);  
22.	    while(*pcString)  
23.	    {  
24.	        // 首先搜尋非%覈字符串結束字符  
25.	        for(ulIdx = 0; (pcString[ulIdx] != '%') && (pcString[ulIdx] != '\0'); ulIdx++)  
26.	        { }  
27.	        UARTwrite(pcString, ulIdx);  
28.	  
29.	        pcString += ulIdx;  
30.	        if(*pcString == '%')  
31.	        {  
32.	            pcString++;  
33.	  
34.	            ulCount = 0;  
35.	            cFill = ' ';  
36.	again:  
37.	            switch(*pcString++)  
38.	            {  
39.	                case '0': case '1': case '2': case '3': case '4':  
40.	                case '5': case '6': case '7': case '8': case '9':  
41.	                {  
42.	                    // 如果第一個數字爲0, 則使用0做填充,則用空格填充)  
43.	                    if((pcString[-1] == '0') && (ulCount == 0))  
44.	                    {  
45.	                        cFill = '0';  
46.	                    }  
47.	                    ulCount *= 10;  
48.	                    ulCount += pcString[-1] - '0';  
49.	                    goto again;  
50.	                }  
51.	                case 'c':          
52.	                {  
53.	                    ulValue = va_arg(vaArgP, unsigned long);  
54.	                    UARTwrite((unsigned char *)&ulValue, 1);  
55.	                    break;  
56.	                }  
57.	                case 'd':     
58.	                {  
59.	                    ulValue = va_arg(vaArgP, unsigned long);  
60.	                    ulPos = 0;  
61.	                      
62.	                    if((long)ulValue < 0)  
63.	                    {  
64.	                        ulValue = -(long)ulValue;  
65.	                        ulNeg = 1;  
66.	                    }  
67.	                    else  
68.	                    {  
69.	                        ulNeg = 0;  
70.	                    }  
71.	                    ulBase = 10;          
72.	                    goto convert;  
73.	                }  
74.	                case 's':  
75.	                {  
76.	                    pcStr = va_arg(vaArgP, unsigned char *);  
77.	  
78.	                    for(ulIdx = 0; pcStr[ulIdx] != '\0'; ulIdx++)  
79.	                    {  
80.	                    }  
81.	                    UARTwrite(pcStr, ulIdx);  
82.	  
83.	                    if(ulCount > ulIdx)  
84.	                    {  
85.	                        ulCount -= ulIdx;  
86.	                        while(ulCount--)  
87.	                        {  
88.	                            UARTwrite(" ", 1);  
89.	                        }  
90.	                    }  
91.	                    break;  
92.	                }  
93.	                case 'u':  
94.	                {  
95.	                    ulValue = va_arg(vaArgP, unsigned long);  
96.	                    ulPos = 0;  
97.	                    ulBase = 10;  
98.	                    ulNeg = 0;  
99.	                    goto convert;  
100.	                }  
101.	                case 'x': case 'X': case 'p':  
102.	                {  
103.	                    ulValue = va_arg(vaArgP, unsigned long);  
104.	                    ulPos = 0;  
105.	                    ulBase = 16;  
106.	                    ulNeg = 0;  
107.	         convert:   //將數值轉換成字符  
108.	                    for(ulIdx = 1; (((ulIdx * ulBase) <= ulValue) &&(((ulIdx * ulBase) / ulBase) == ulIdx)); ulIdx *= ulBase, ulCount--)       
109.	                    { }  
110.	                    if(ulNeg)  
111.	                    {  
112.	                        ulCount--;                        
113.	                    }  
114.	                    if(ulNeg && (cFill == '0'))  
115.	                    {  
116.	                        pcBuf[ulPos++] = '-';  
117.	                        ulNeg = 0;  
118.	                    }  
119.	                    if((ulCount > 1) && (ulCount < 16))  
120.	                    {  
121.	                        for(ulCount--; ulCount; ulCount--)  
122.	                        {  
123.	                            pcBuf[ulPos++] = cFill;  
124.	                        }  
125.	                    }  
126.	  
127.	                    if(ulNeg)  
128.	                    {  
129.	                        pcBuf[ulPos++] = '-';  
130.	                    }  
131.	  
132.	                    for(; ulIdx; ulIdx /= ulBase)  
133.	                    {  
134.	                        pcBuf[ulPos++] = g_pcHex[(ulValue / ulIdx) % ulBase]; 
135.	                    }  
136.	                    UARTwrite(pcBuf, ulPos);  
137.	                    break;  
138.	                }  
139.	                case '%':  
140.	                {  
141.	                    UARTwrite(pcString - 1, 1);                    
142.	                    break;  
143.	                }  
144.	                default:  
145.	                {                      
146.	                    UARTwrite("ERROR", 5);                    
147.	                    break;  
148.	                }  
149.	            }  
150.	        }  
151.	    }  
152.	    //可變參數處理結束  
153.	    va_end(vaArgP);  
154.	} 

5.2.2對調試函數進一步封裝

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

1.	#ifdef MY_DEBUG  
2.	#define MY_DEBUGF(message) do { \  
3.	                                  {UARTprintf message;} \  
4.	                               } while(0)  
5.	#else    
6.	#define MY_DEBUGF(message)    
7.	#endif /* PLC_DEBUG */

在我們編碼測試期間,定義宏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.	void lcd_redu(void)  
2.	{  
3.	    讀第一個寄存器值;  
4.	    if(第一個寄存器值==Flash存儲值)  
5.	    {  
6.	        讀第二個寄存器值;  
7.	        if(第二個寄存器值==Flash存儲值)  
8.	        {  
9.	            ...  
10.	              
11.	            讀第十個寄存器值;  
12.	            if(第十個寄存器值==Flash存儲值)  
13.	            {  
14.	                返回;  
15.	            }  
16.	            else  
17.	            {  
18.	                重新初始化LCD;  
19.	            }  
20.	        }  
21.	        else  
22.	        {  
23.	            重新初始化LCD;  
24.	        }  
25.	    }  
26.	    else  
27.	    {  
28.	        重新初始化LCD;  
29.	    }  
30.	}  

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

      我們可以先提取相同的元素,將之組織成數據結構:
1.	typedef struct {  
2.	    uint8_t  lcd_command;           //LCD寄存器  
3.	    uint8_t  lcd_get_value[8];      //初始化時寫入寄存器的值  
4.	    uint8_t  lcd_value_num;         //初始化時寫入寄存器值的數目  
5.	}lcd_redu_list_struct;  

      這裏lcd_command表示的是LCD寄存器命令號;lcd_get_value是一個數組,表示寄存器要初始化的值,這是因爲對於一個LCD寄存器,可能要初始化多個字節,這是硬件特性決定的;lcd_value_num是指一個寄存器要多少個字節的初值,這是因爲每一個寄存器的初值數目是不同的,我們用同一個方法處理數據時,是需要這個信息的。

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

1.	/*LCD部分寄存器設置值列表*/  
2.	lcd_redu_list_struct const lcd_redu_list_str[]=  
3.	{  
4.	  {SSD1963_Get_Address_Mode,{0x20}                                   ,1}, /*1*/ 
5.	  {SSD1963_Get_Pll_Mn      ,{0x3b,0x02,0x04}                         ,3}, /*2*/ 
6.	  {SSD1963_Get_Pll_Status  ,{0x04}                                   ,1}, /*3*  
7.	  {SSD1963_Get_Lcd_Mode    ,{0x24,0x20,0x01,0xdf,0x01,0x0f,0x00}     ,7}, /*4*/ 
8.	  {SSD1963_Get_Hori_Period ,{0x02,0x0c,0x00,0x2a,0x07,0x00,0x00,0x00},8}, /*5*/ 
9.	  {SSD1963_Get_Vert_Period ,{0x01,0x1d,0x00,0x0b,0x09,0x00,0x00}     ,7}, /*6*/ 
10.	  {SSD1963_Get_Power_Mode  ,{0x1c}                                   ,1}, /*7*/ 
11.	  {SSD1963_Get_Display_Mode,{0x03}                                   ,1}, /*8*/ 
12.	  {SSD1963_Get_Gpio_Conf   ,{0x0F,0x01}                              ,2}, /*9*/ 
13.	  {SSD1963_Get_Lshift_Freq ,{0x00,0xb8}                              ,2}, /*10* 
14.	}; 
      至此,我們就可以用一個處理過程來完成數十個LCD寄存器的讀取、判斷和異常處理了:
1.	/** 
2.	* lcd 顯示冗餘 
3.	* 每隔一段時間調用該程序一次 
4.	*/  
5.	void lcd_redu(void)  
6.	{  
7.	    uint8_t  tmp[8];  
8.	    uint32_t i,j;  
9.	    uint32_t lcd_init_flag;  
10.	      
11.	    lcd_init_flag =0;  
12.	    for(i=0;i<sizeof(lcd_redu_list_str)/sizeof(lcd_redu_list_str[0]);i++)  
13.	    {  
14.	        LCD_SendCommand(lcd_redu_list_str[i].lcd_command);  
15.	        uyDelay(10);  
16.	        for(j=0;j<lcd_redu_list_str[i].lcd_value_num;j++)  
17.	        {  
18.	            tmp[j]=LCD_ReadData();  
19.	            if(tmp[j]!=lcd_redu_list_str[i].lcd_get_value[j])  
20.	            {  
21.	                lcd_init_flag=0x55;  
22.	                //一些調試語句,打印出錯的具體信息
23.	                goto handle_lcd_init;  
24.	            }  
25.	        }  
26.	    }  
27.	      
28.	    handle_lcd_init:  
29.	    if(lcd_init_flag==0x55)  
30.	    {  
31.	        //重新初始化LCD  
32.	        //一些必要的恢復措施  
33.	    }     
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 著 李琳驍、吳詠煒、張菁《編程大師訪談錄》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章