高質量嵌入式軟件的開發技巧
一、劍宗氣宗之爭
《笑傲江湖》中華山派的劍宗和氣宗之爭,可謂異常激烈。那么問題就來了,既然有劍宗氣宗之爭,到底應該先練劍,還是先練氣呢?引申到軟件開發行業有沒劍氣之爭呢?
二、文件結構
1、C 程序通常分為兩類文件,一種是程序的聲明稱為頭文件,以“.h”為后綴,另一種是程序的實現,以“.c”為后綴,一般每個c文件有個同名的h文件。
2、軟件的頭文件數目比較多,應將頭文件和定義文件分別保存于不同的目錄,例如將頭文件保存于 include或者inc 目錄,將定義文件保存于 source 或src目錄;如果某些頭文件是私有的,它不會被用戶的程序直接引用,則沒有必要公開其“聲明”。為了加強信息隱藏,這些私有的頭文件可以和定義文件存放于同一個目錄,即私有的h文件放在src目錄。
3、在文件頭添加版權和版本的聲明等信息,主要包括版權和功能,以及修改記錄,必要時可以為整個功能文件夾單獨新建readme說明文檔。
4、為了防止頭文件被重復引用,必須用 ifndef/define/endif 結構產生預處理塊。
5、頭文件中只存放“聲明”而不存放“定義”,更別提放變量,這是嚴重的錯誤。
6、用 #include <filename.h> 格式來引用標準庫的頭文件,用 #include “filename.h” 格式來引用非標準庫的頭文件(編譯器將從用戶的工作目錄開始搜索)。
7、文件可按層或者功能組件劃分不同的文件夾,便于其他人閱讀。
三、程序版式
版式雖然不會影響程序的功能,但會影響可讀性,程序的風格統一則是賞心悅目。
代碼排版在編碼時確實很難把握,但可以編碼完成后統一用工具格式化,不管編碼使用Keil/MDK、Qt等集成工具,或者純粹的代碼編輯工具Source Insight,一般都支持自定義運行可執行文件,如Astyle。可以客制化新菜單,一鍵執行Astyle,將代碼一鍵格式化,排版統一、層次分明。
Astyle官網 http://astyle.sourceforge.net/ 按要求下載安裝,只需要AStyle.exe即可。關于其使用和參數,可以再進入Documentation。對代碼基本風格,{}如何對齊、是否換行,switch-case如何排版,tab鍵占位寬度,運算符或變量前后的空格等等,基本上代碼排版涉及的方方面面都有參數說明。個人選擇的編碼參數是
效果如下:
也可以參考?? 代碼的保養?? 第3章。關于注釋,重要函數或段落必不可少,修改代碼同時修改相應的注釋,以保證注釋與代碼的一致性。
四、命名規則
比較著名的命名規則當推 Microsoft 公司的“匈牙利”法,該命名規則的主要思想是“在變量和函數名中加入前綴以增進人們對程序的理解”。例如所有的字符變量均以 ch 為前綴,若是指針變量則追加前綴 p。但沒有一種命名規則可以讓所有的程序員滿意,制定一種令大多數項目成員滿意的命名規則,重點是在整個團隊和項目中貫徹實施。
事實上開發大多數基于SDK,一般底層命名規則盡量與SDK風格保持一致,至于上層就按團隊標準,個人比較傾向全部小寫字母,用下劃線分割的風格,例如 set_apn、timer_start。
不要出現標識符完全相同的局部變量和全局變量,盡管兩者的作用域不同而不會發生語法錯誤,但會使人誤解,全局變量也不要過于簡短。
變量的名字應當使用“名詞”或者“形容詞+名詞”,函數的名字應當使用“動詞”或者“動詞+名詞”,用正確的反義詞組命名具有互斥意義的變量或相反動作的函數等。
五、基本語句
表達式和語句都屬于C 語法基礎,看似簡單,但使用時隱患比較多,提供一些建議。
5.1 if
if 語句是 C 語言中最簡單、最常用的語句,然而很多程序員卻用隱含錯誤的方式,僅以不同類型的變量與零值比較為例,展開討論。
1、布爾變量與零值比較
不可將布爾變量直接與 TRUE、FALSE 或者 1、0 進行比較。根據布爾類型的語義,零值為“假”(記為 FALSE),任何非零值都是“真”(記為TRUE)。TRUE 的值究竟是什么并沒有統一的標準。
假設布爾變量名字為 flag,它與零值比較的標準 if 語句如下:
其它的用法都屬于不良風格,例如:
2、整型變量與零值比較
整型變量用“==”或“!=”直接與 0 比較,假設整型變量的名字為 value,它與零值比較的標準 if 語句如下:
不可模仿布爾變量的風格而寫成
3、 浮點變量與零值比較
不可將浮點變量用“==”或“!=”與任何數字比較,無論是 float 還是 double 類型的變量,都有精度限制。不能將浮點變量用“==”或“!=”與數字比較,應該設法轉化成“>=”或“<=”形式。假設浮點變量的名字為 x,應當將
轉化為
4、指針變量與零值比較
指針變量用“==”或“!=”與 NULL 比較, 指針變量的零值是“空”(記為 NULL),盡管 NULL 的值與 0 相同,但是兩者意義不同。假設指針變量的名字為 p,它與零值比較的標準 if 語句如下:
不要寫成
5.2 for
在多重循環中,如果有可能,應當將最長的循環放在最內層,最短的循環放在最外層,以減少 CPU 切換循環層的次數。
5.3 switch
switch 是多分支選擇語句,而 if 語句只有兩個分支可供選擇;雖然可以用嵌套的if 語句來實現多分支選擇,但那樣的程序冗長難讀。這是 switch 語句存在的理由。
switch-case 即使不需要 default 處理,也應該保留語句 default : break; 這樣做并非多此一舉,而是為了防止別人誤以為你忘了 default 處理。確實不需要break的case,務必加上注釋標明。
5.4 goto
很多人建議禁止使用 goto 語句,但實事求是地說,錯誤是程序員自己造成的,不是 goto 的過錯。goto 語句至少有一處可顯神通,它能從多重循環體中一下子跳到外面,特殊場景下可以使用,在很多if嵌套的場景,比如都有同樣的錯誤處理,或者成對操作的文件開關,或者內存申請釋放,就比較適合goto統一處理。
對于內存申請釋放、文件打開關閉這種成對操作,或者各種異常處理的統一支持場景,就比較適合goto。類似的還有do-while(0)這種語句。
關于運算優先級,熟記運算符優先級是比較困難的,如果代碼行中的運算符比較多,為了防止產生歧義并提高可讀性,全部加括號明確表達式的操作順序,雖然愚笨但是可靠。
六、常量
常量是一種標識符,它的值在運行期間恒定不變。C 語言用 #define 來定義常量(稱為宏常量),但用 const 來定義常量(稱為 const 常量)其實更佳。
const 常量有數據類型,而宏常量沒有數據類型。編譯器可以對前者進行類型安全檢查,而對后者只進行字符替換,沒有類型安全檢查,并且在字符替換可能會產生意料不到的錯誤,所以復雜參數宏必須為每個參數加上()限制。
但也有特例
需要對外公開的常量放在頭文件中,不需要對外公開的常量放在定義文件的頭部。為便于管理,可以把不同模塊的常量集中存放在一個公共的頭文件中。
七、函數
函數設計的細微缺點很容易導致該函數被錯用,函數接口的兩個要素是參數和返回值,C 語言中函數的參數和返回值的傳遞方式有值傳遞(pass by value)和指針傳遞(pass by pointer)兩種。
7.1參數的規則
參數的書寫要完整,不要貪圖省事只寫參數的類型而省略參數名字,如果函數沒有參數,則用 void 填充。
參數命名要恰當,順序要合理。例如字符串拷貝函數
從名字上就可以看出應該把 src 拷貝到 dest。還有一個問題,兩個參數哪個該在前哪個該在后?參數的順序要遵循程序員的習慣。一般地,應將目的參數放在前面,源參數放在后面。
這里也說明下const的意義,如果參數僅作輸入用,則應在類型前加 const,以防止在函數體內被意外修改。
避免函數有太多的參數,參數個數盡量控制在 5 個以內,如果參數太多,在使用時容易將參數類型或順序搞錯,可以定為結構體指針,但盡量帶上參數注釋。
除了printf、sprintf標準庫或基于這類的日志輸出接口,盡量不要使用類型和數目不確定的參數。
7.2 返回值的規則
不要省略返回值的類型,默認不加類型說明的函數一律自動按整型處理。為了避免混亂,如果函數沒有返回值,應聲明為 void 類型。
不要將正常值和錯誤標志混在一起返回。正常值用輸出參數獲得,而錯誤標志用 return 語句返回。
7.3 函數內部實現的規則
不同功能的函數其內部實現各不相同,看起來似乎無法就“內部實現”達成一致的觀點。但根據經驗,我們可以在函數體的“入口處”和“出口處”從嚴把關,從而提高函數的質量。
在函數體的“入口處”,對參數的有效性進行檢查,很多程序錯誤是由非法參數引起的,我們應該充分理解并正確使用“斷言”(assert)來防止此類錯誤。
在函數體的“出口處”,對 return 語句的正確性和效率進行檢查。如果函數有返回值,那么函數的“出口處”是 return 語句。調用處應該盡量關注返回值,對異常進行處理
關于return的值,不可返回指向“棧內存”的“指針,該內存在函數體結束時被自動銷毀。例如
盡量避免函數帶有“記憶”功能,相同的輸入應當產生相同的輸出。帶有“記憶”功能的函數,其行為可能是不可預測的,因為它的行為可能取決于某種“記憶狀態”。這樣的函數既不易理解又不利于測試和維護。在 C語言中,函數 的 static 局部變量是函數的“記憶”存儲器。建議盡量少用 static 局部變量,除非必需。
7.4 斷言
程序一般分為 Debug 版本和 Release 版本,Debug 版本用于內部調試,Release 版本發行給用戶使用。斷言 assert 是僅在 Debug 版本起作用的宏,它用于檢查“不應該”發生的情況。在運行過程中,如果 assert 的參數為假,那么程序就會中止。
assert 不應該產生任何副作用。所以 assert 不是函數,而是宏??梢园補ssert 看成一個在任何系統狀態下都可以安全使用的無害測試手段。如果程序在 assert處終止了,并不是說含有該 assert 的函數有錯誤,而是調用者出了差錯,assert 有助于找到發生錯誤的原因。
軟件有必要進行防錯設計,如果“不可能發生”的事情的確發生了,則要使用斷言進行報警。
八、內存管理
C語言的內存管理既是它的優勢,也是劣勢。理解它的原理了才能更好的管理內存。
8.1 內存分配方式
內存分配方式有三種:
1、從靜態存儲區域分配。內存在程序編譯的時候就已經分配好,這塊內存在程序的整個運行期間都存在。例如全局變量,static 變量。
2、在棧上創建。在執行函數時,函數內局部變量的存儲單元都可以在棧上創建,函數執行結束時這些存儲單元自動被釋放。棧內存分配運算內置于處理器的指令集中,效率很高,但是分配的內存容量有限。
3、從堆上分配,亦稱動態內存分配。程序在運行的時候用 malloc 或 new 申請任意多少的內存,程序員自己負責在何時用 free 或 delete 釋放內存。動態內存的生存期由我們決定,使用非常靈活,但風險也大。
8.2 內存錯誤及其對策
發生內存錯誤是件非常麻煩的事情。編譯器不能自動發現這些錯誤,通常是在程序運行時才能捕捉到,而這些錯誤大多沒有明顯的癥狀,時隱時現,增加了改錯的難度。常見的內存錯誤及其對策如下:
1、內存分配未成功,卻使用了它
編程新手常犯這種錯誤,因為他們沒有意識到內存分配會不成功。常用解決辦法是,在使用內存之前檢查指針是否為 NULL。如果指針 p 是函數的參數,可在函數的入口處用 assert(p!=NULL)進行檢查,或者用 if(p==NULL) 或 if(p!=NULL)進行防錯處理。
2、內存分配雖然成功,但是尚未初始化就引用它
犯這種錯誤主要有兩個起因:一是沒有初始化的觀念;二是誤以為內存的缺省初值全為零,導致引用初值錯誤。內存的缺省初值究竟是什么并沒有統一的標準(盡管有些時候為零值),為了安全,對分配的內存都進行清零。
3、內存分配成功并且已經初始化,但操作越過了內存的邊界
數組使用時經常會發生下標“多 1”或“少 1”的操作。特別是在 for 循環語句中,循環次數很容易搞錯,導致數組操作越界。
4、忘記釋放內存,造成內存泄露
含有這種錯誤的函數每被調用一次就丟失一塊內存。剛開始時系統的內存充足,運行正常,但隨著運行時間加長,程序突然死掉,內存耗盡。動態內存的申請與釋放必須配對,程序中 malloc 與 free 的成對使用。
5、已經釋放的內存卻繼續使用它
程序中的調用關系過于復雜,邏輯順序錯誤,或者使用了指向“棧內存”的“臨時指針,使用 free 或 delete 釋放了內存后,務必將指針設置為 NULL,使用前判斷是否為NULL。
關于指針的使用建議,用 malloc 申請內存之后,應該立即檢查指針值是否為 NULL,非NULL的賦初值;使用結束后用 free 釋放,且將指針設置為 NULL,防止誤用“野指針”。對動態內存的一些防護性操作,可以參考微信公眾號【嵌入式系統】的文章??動態內存管理及防御性編程??。
8.3 指針與數組的對比
C 程序中指針和數組在不少地方可以相互替換著用,讓人產生一種錯覺,以 為兩者是等價的。
數組要么在靜態存儲區被創建(如全局數組),要么在棧上被創建。數組名對應著(而不是指向)一塊內存,其地址與容量在生命期內保持不變,只有數組的內容可以改變。
指針可以隨時指向任意類型的內存塊,它的特征是“可變”,所以我們常用指針來操作動態內存。指針遠比數組靈活,但也更危險。
下面以字符串為例比較指針與數組的特性。
1、修改內容
字符數組 a 的容量是 6 個字符,其內容為 hello\0。a 的內容可以改變,如 a[0]= ‘X’。指針 p 指向常量字符串“world”(位于靜態存儲區,內容為 world\0),常量字符串的內容是不可以被修改的。從語法上看,編譯器并不覺得語句 p[0]= ‘X’有什么不妥,但是該語句企圖修改常量字符串的內容而導致運行錯誤。
2、 內容復制與比較
不能對數組名進行直接復制與比較,若想把數組 a 的內容復制給數組 b,不能用語句 b = a ,否則將產生編譯錯誤。應該用標準庫函數 strcpy 進行復制。同理,比較 b 和 a 的內容是否相同,不能用 if(b == a) 來判斷,應該用標準庫函數 strcmp進行比較。
語句 p = a 并不能把 a 的內容復制指針 p,而是把 a 的地址賦給了 p。要想復制 a的內容,可以先用庫函數 malloc 為 p 申請一塊容量為 strlen(a)+1 個字符的內存,再用 strcpy 進行字符串復制。同理,語句 if(p==a) 比較的不是內容而是地址,應該用庫函數 strcmp 來比較。
3、計算內存容量
用運算符 sizeof 可以計算出數組的容量(字節數)。sizeof(a)的值是 12(注意別忘了’\0’)。指針 p 指向 a,但是 sizeof(p)的值卻是 4。這是因為sizeof(p)得到的是一個指針變量的字節數,相當于 sizeof(char*),而不是 p 所指的內存容量。/C 語言沒有辦法知道指針所指的內存容量,只能在申請內存時記住它。
當數組作為函數的參數進行傳遞時,該數組自動退化為同類型的指針。不論數組 a 的容量是多少,sizeof(a)始終等于 sizeof(char *)。
4、指針參數是如何傳遞內存
如果函數的參數是一個指針,不要指望用該指針去申請動態內存。
test 函數的get_memory(str, 100) 并沒有使 str 獲得期望的內存,str 依舊是 NULL,為什么?
問題出在函數 get_memory,編譯器總是要為函數的每個參數制作臨時副本,指針參數 p 的副本是 _p,編譯器使 _p = p。如果函數體內的程序修改了_p 的內容,就導致參數 p 的內容作相應的修改。這就是指針可以用作輸出參數的原因。而范例中_p 申請了新的內存,只是把_p 所指的內存地址改變了,但是 p 絲毫未變。所以函數 get_memory并不能輸出任何東西。事實上,每執行一次 get_memory就會泄露一塊內存,因為沒有用free 釋放內存。
如果非得要用指針參數去申請內存,那么應該改用“指向指針的指針”,正確范例如下:
由于“指向指針的指針”這個概念不容易理解,可以用函數返回值來傳遞動態內存,這種方法更加簡單。
用函數返回值來傳遞動態內存這種方法雖然好用,但是常常有人把 return 語句用錯,不要用 return 語句返回指向“棧內存”的指針,因為該內存在函數結束時自動消亡,錯誤范例如下:
執行str = get_string()后 str 不再是 NULL 指針,但是 str 的內容不是“hello world”而是垃圾。
函數 test5 運行雖然不會出錯,但是函數 get_string2的設計概念卻是錯誤的。因為 get_string2內的“hello world”是常量字符串,位于靜態存儲區,它在程序生命期內恒定不變。無論什么時候調用 get_string2,它返回的始終是同一個“只讀”的內存塊,也就是test5是無法修改str的。
5、 free 把指針怎么了
free 只是把指針所指的內存給釋放掉,但并沒有把指針本身干掉;指針 p 被 free 以后其地址仍然不變(非 NULL),只是該地址對應的內存是垃圾,p 成了“野指針”。如果此時不把 p 設置為 NULL,會讓人誤以為 p 是個合法的指針。
如果程序比較長,我們有時記不住 p 所指的內存是否已經被釋放,在繼續使用 p 之前,通常會用語句 if (p != NULL)進行防錯處理。很遺憾,此時 if 語句起不到防錯作用,此時 p 不是 NULL 指針,但它也不指向合法的內存塊。
6、動態內存會被自動釋放嗎
函數體內的局部變量在函數結束時自動消亡。
但是,變量p 是局部的指針變量,它消亡的時候并不會讓它所指的動態內存一起完蛋。發現指針有一些“似是而非”的特征:
(1)指針消亡了,并不表示它所指的內存會被自動釋放。
(2)內存被釋放了,并不表示指針會消亡或者成了 NULL 指針。
7、杜絕“野指針”
“野指針”不是 NULL 指針,是指向“垃圾”內存的指針。人們一般不會錯用 NULL指針,因為用 if 語句很容易判斷;但是“野指針”是很危險的,if 語句對它不起作用。“野指針”的成因主要有三種:
(1)指針變量沒有被初始化。任何指針變量剛被創建時不會自動成為 NULL 指針,它的缺省值是隨機的,所以,指針變量在創建的同時應當被初始化。
(2)指針 p 被 free 或者 delete 之后,沒有置為 NULL,讓人誤以為 p 是個合法的指針。
(3)指針操作超越了變量的作用范圍。這種情況讓人防不勝防。
8、內存耗盡怎么辦
如果在申請動態內存時找不到足夠大的內存塊,malloc 將返回 NULL 指針, 宣告內存申請失敗。判斷指針是否為 NULL,如果是則馬上用 return 語句終止本函數,或者用 exit(1)終止整個程序的運行。如果發生“內存耗盡”,一般說來應用程序已經無藥可救,嵌入式設備只能重啟了。
9、心得體會
很少有人能拍拍胸脯說通曉指針與內存管理,越是怕指針,就越要使用指針。不會正確使用指針,肯定算不上是合格的嵌入式程序員。
九、其它編程經驗
9.1 使用 const 提高函數的健壯性
const 是 constant 的縮寫,“恒定不變”的意思。被 const 修飾的東西都受到強制保護,可以預防意外的變動,能提高程序的健壯性。很多 C++程序設計書籍建議:“Use const whenever you need”。
1、用 const 修飾函數的參數 如果參數作輸出用,不論它是什么數據類型,都不能加 const 修飾,否則該參數將失去輸出功能。const 只能修飾輸入參數,如果輸入參數采用“指針傳遞”,那么加 const 修飾可以防止意外地改動該指針,起到保護作用。例如 strcpy函數:
其中 src是輸入參數,dest是輸出參數。給 src加上 const修飾后,如果函數體內的語句試圖改動 src 的內容,編譯器將指出錯誤。
2、如果輸入參數采用“值傳遞”,由于函數將自動產生臨時變量用于復制該參數,該輸入參數本來就無需保護,所以不要加 const 修飾。
3、對于非內部數據類型的參數而言,如 void func(A a) 這樣聲明的函數注定效率比較低,其中 A 為用戶自定義的數據類型,可以理解為大結構。
函數體內將產生 A 類型的臨時對象用于復制參數 a,而臨時對象的構造、 復制、析構過程都將消耗時間。為了提高效率,可以將函數聲明改為:
因為“引用傳遞”僅借用一下參數的別名而已,不需要產生臨時對象。但是函數 存在一個缺點,“引用傳遞”有可能改變參數 a,這是我們不期望的。解決這個問題很容易,加 const修飾即可,因此函數最終成為
4、用 const 修飾函數的返回值,如果給以“指針傳遞”方式的函數返回值加 const 修飾,那么函數返回值(即指針)的內容不能被修改,該返回值只能被賦給加 const 修飾的同類型指針。例如函數
9.2 提高程序的效率
程序的時間效率是指運行速度,空間效率是指程序占用內存或者外存的狀況。
不要一味地追求程序的效率,應當在滿足正確性、可靠性、健壯性、可讀性等質量因素的前提下,設法提高程序的效率。
在優化程序的效率時,應當先找出限制效率的“瓶頸”,不要在無關緊要之處優化。有時候時間效率和空間效率可能對立,此時應當分析那個更重要,作出適當的折衷。例如多花費一些內存來提高性能。
十、小結
不論劍宗、氣宗優劣,先把功能跑通再反推代碼原理和實現流程,還是先理清時序和原理再編碼實現功能,短期內劍宗效率高,加工資快,但后期發展有限;氣宗則面臨前期可能被淘汰,尤其在勢利的小公司,不注重新人培養,但前期積累,后期融會貫通,在技術方面成為權威。如果合二為一,項目緊急則拿來就用,空閑時專研總結,取長補短,則是高級程序員的素質。