引言
最近在項目中使用了靜態程序分析工具PC-Lint,體會到它在項目實施中帶給開發人員的方便。PC-Lint是一款針對C/C++語言、windows平臺的靜態分析工具,FlexeLint是針對其他平臺的PC-Lint版本。由於PC-Lint/FlexeLint是商業的程序分析工具,不便於大家對其進行學習和使用,因而下面我將介紹一個針對C語言的開源程序靜態分析工具——splint。
靜態程序分析
先來說說什麼是“靜態程序分析(Static program analysis)”,靜態程序分析是指使用自動化工具軟件對程序源代碼進行檢查,以分析程序行爲的技術,應用於程序的正確性檢查、安全缺陷檢測、程序優化等。它的特點就是不執行程序,相反,通過在真實或模擬環境中執行程序進行分析的方法稱爲“動態程序分析(Dynamic program analysis)”。
那在什麼情況下需要進行靜態程序分析呢?靜態程序分析往往作爲一個多人蔘與的項目中代碼審查過程的一個階段,因編寫完一部分代碼之後就可以進行靜態分析,分析過程不需要執行整個程序,這有助於在項目早期發現以下問題:變量聲明瞭但未使用、變量類型不匹配、變量在使用前未定義、不可達代碼、死循環、數組越界、內存泄漏等。下圖說明了靜態程序分析在進行項目編碼過程中所處的位置:
從上圖可以知道,靜態分析工具在代碼通過編譯之後再對代碼進行分析。我們會問:靜態分析工具與編譯器相比,所做的工作有什麼不同?靜態分析工具相比編譯器,對代碼進行了更加嚴格的檢查,像數組越界訪問、內存泄漏、使用不當的類型轉換等問題,都可以通過靜態分析工具檢查出來,我們甚至可以在分析工具的分析標準裏定義代碼的編寫規範,在檢測到不符合編寫規範的代碼時拋出告警,這些功能都是編譯器沒有的。
既然靜態分析工具發揮了不小的作用,何不在編譯器裏兼備靜態分析的功能?對於這個問題,S. C. Johnson(他是最古老的靜態分析工具Lint的作者)在其1978年發表的論文《Lint, a C Program Checker》中給出了他的答案:“Lint與C編譯器在功能上的分離既有歷史原因,也有現實的意義。編譯器負責把C源程序快速、高效地轉變爲可執行文件,不對代碼做類型檢查(特別是對分別編譯的程序),有益於做到快速與高效。而Lint沒有“高效”的要求,可以花更多時間對代碼進行更深入、仔細的檢查。”
針對空指針提取、未定義變量使用、類型轉換、內存管理、函數接口定義等,我們可以在靜態分析工具裏制定不同的檢測標準,以下曲線圖說明了在使用splint進行分析時,檢測標準與splint運行的開銷所對應的關係,從另一個角度看,也說明了靜態分析工具與編譯器的關係:
splint
掌握了“靜態分析”等概念之後,我們再來看splint。
在Linux命令行下,splint的使用很簡單,檢測文件*.c,只要這樣使用就可以了:
splint *.c
1.splint消息
我們通過以下例子來認識典型的splint告警信息:
1 //splint_msg.c 2 int func_splint_msg1(void) 3 { 4 int a; 5 return0; 6 } 7 int func_splint_msg2(void) 8 { 9 int* a = (int*)malloc(sizeof(int)); 10 a = NULL; 11 return0; 12 }
運行splint splint_msg.c之後,我們來看輸出的告警信息:
splint_msg.c: (in function func_splint_msg1) splint_msg.c:4:6: Variable a declared but not used A variable is declared but never used. Use /*@unused@*/ in front of declaration to suppress message. (Use -varuse to inhibit warning) splint_msg.c: (in function func_splint_msg2) splint_msg.c:10:2: Fresh storage a (type int *) not released before assignment: a = NULL A memory leak has been detected. Storage allocated locally is not released before the last reference to it is lost. (Use -mustfreefresh to inhibit warning) splint_msg.c:9:37: Fresh storage a created Finished checking --- 2 code warnings
藍色字體部分:給出告警所在函數名,在函數的第一個警告消息報告前打印;
紅色字體部分:消息的正文,文件名、行號、列號顯示在的警告的正文前;
黑色字體部分:是有關該可疑錯誤的詳細信息,包含一些怎樣去掉這個消息的信息;
綠色字體部分:給出格外的位置信息,這裏消息給出了是在哪裏申請了這個可能泄露的內存。
2.檢查控制
splint提供了三種方式可進行檢查的控制,分別是.splintrc配置文件、flags標誌和格式化註釋。
flags:splint支持幾百個標誌用來控制檢查和消息報告,使用時標誌前加’+‘或’-’,'+'標誌開啓這個標誌,'-'表示關閉此標誌,下面例子展示了flags標誌的用法:
splint -showcol a.c //在檢測a.c時,告警消息中列數不被打印 splint -varuse a.c //在檢測a.c時,告警消息中未使用變量告警不被打印
.splintrc配置文件:在使用源碼安裝splint之後,.splintrc文件將被安裝在主目錄下,.splintrc文件中對一些標誌作了默認的設定,命令行中指定的flags標誌會覆蓋.splintrc文件中的標誌。
格式化註釋:格式化註釋提供一個類型、變量或函數的格外的信息,可以控制標誌設置,增加檢查效果,所有格式化註釋都以/*@開始,@*/結束,比如在函數參數前加/*@null@*/,表示該參數可能是NULL,做檢測時,splint會加強對該參數的值的檢測。
3.檢測分析內容
1.解引用空指針(Null Dereferences)
在Unix操作系統中,解引用空指針將導致我們在程序運行時產生段錯誤(Segmentation fault),一個簡單的解引用空指針例子如下:
1 //null_dereferences.c 2 int func_null_dereferences(void) 3 { 4 int* a = NULL; 5 return*a; 6 }
執行splint null_dereference.c命令,將產生以下告警消息:
null_dereference.c: (in function func_null_dereferences) null_dereference.c:5:10: Dereference of null pointer a: *a A possibly null pointer is dereferenced. Value is either the result of a function which may return null (in which case, code should check it is not null), or a global, parameter or structure field declared with the null qualifier. (Use -nullderef to inhibit warning) null_dereference.c:4:11: Storage a becomes null Finished checking --- 1 code warnin
2.類型(Types)
我們在編程中經常用到強制類型轉換,將有符號值轉換爲無符號值、大範圍類型值賦值給小範圍類型,程序運行的結果會出無我們的預料。
1 //types.c 2 void splint_types(void) 3 { 4 short a =0; 5 long b =32768; 6 a = b; 7 return; 8 }
執行splint types.c命令,將產生以下告警消息:
types.c: (in function splint_types) types.c:6:2: Assignment of longint to shortint: a = b To ignore type qualifiers in type comparisons use +ignorequals. Finished checking ---1 code warning
3.內存管理(Memory Management)
C語言程序中,將近半數的bug歸功於內存管理問題,關乎內存的bug難以發現並且會給程序帶來致命的破壞。由內存釋放所產生的問題,我們可以將其分爲兩種:
- 當尚有其他指針引用的時候,釋放一塊空間
1 //memory_management1.c 2 void memory_management1(void) 3 { 4 int* a = (int*)malloc(sizeof(int)); 5 int* b = a; 6 free(a); 7 *b =0; 8 return; 9 }
在上面這個例子中,指針a與b指向同一塊內存,但在內存釋放之後仍對b指向的內容進行賦值操作,我們來看splint
memory_management1.c的結果:
memory_management1.c: (in function memory_management1) memory_management1.c:7:3: Variable b used after being released Memory is used after it has been released (either by passing as an only param or assigning to an only global). (Use -usereleased to inhibit warning) memory_management1.c:6:7: Storage b released memory_management1.c:7:3: Dereference of possibly null pointer b: *b A possibly null pointer is dereferenced. Value is either the result of a function which may returnnull (in which case, code should check it is not null), or a global, parameter or structure field declared with the null qualifier. (Use -nullderef to inhibit warning) memory_management1.c:5:11: Storage b may become null Finished checking ---2 code warnings
檢查結果中包含了兩個告警,第一個指出我們使用了b指針,而它所指向的內存已被釋放;第二個是對解引用空指針的告警。
- 當最後一個指針引用丟失的時候,其指向的空間尚未釋放
1 //memory_management2.c 2 void memory_management2(void) 3 { 4 int* a = (int*)malloc(sizeof(int)); 5 a = NULL; 6 return; 7 }
這個例子中內存尚未釋放,就將指向它的唯一指針賦值爲NULL,我們來看splint memory_management2.c的檢測結果:
memory_management2.c: (in function memory_management2) memory_management2.c:5:2: Fresh storage a (type int*) not released before assignment: a = NULL A memory leak has been detected. Storage allocated locally is not released before the last reference to it is lost. (Use -mustfreefresh to inhibit warning) memory_management2.c:4:37: Fresh storage a created Finished checking ---1 code warning
splint拋出一個告警:類型爲int*的a在進行a = NULL賦值前沒有釋放新分配的空間。
4.緩存邊界(Buffer Sizes)
splint會對數組邊界、字符串邊界作檢測,使用時需要加上+bounds的標誌,我們來看下面的例子:
1 //bounds1.c 2 void bounds1(void) 3 { 4 int a[10]; 5 a[10] =0; 6 return; 7 }
使用splint +bounds bounds1.c命令對其進行檢測,結果如下:
bounds1.c: (in function bounds1) bounds1.c:5:2: Likely out-of-bounds store: a[10] Unable to resolve constraint: requires 9>=10 needed to satisfy precondition: requires maxSet(a @ bounds1.c:5:2) >=10 A memory write may write to an address beyond the allocated buffer. (Use -likelyboundswrite to inhibit warning) Finished checking ---1 code warning
告警消息提示數組越界,訪問超出我們申請的buffer大小範圍。再看一個例子:
1 //bounds2.c 2 void bounds2(char* str) 3 { 4 char* tmp = getenv("HOME"); 5 if(tmp != NULL) 6 { 7 strcpy(str, tmp); 8 } 9 return; 10 }
不對這個例子進行詳細檢查,可能我們不能發現其中隱含的問題,執行splint +bounds bounds2.c之後,會拋出如下告警:
bounds2.c: (in function bounds2) bounds2.c:7:3: Possible out-of-bounds store: strcpy(str, tmp) Unable to resolve constraint: requires maxSet(str @ bounds2.c:7:10) >= maxRead(getenv("HOME") @ bounds2.c:4:14) needed to satisfy precondition: requires maxSet(str @ bounds2.c:7:10) >= maxRead(tmp @ bounds2.c:7:15) derived from strcpy precondition: requires maxSet(<parameter 1>) >= maxRead(<parameter 2>) A memory write may write to an address beyond the allocated buffer. (Use -boundswrite to inhibit warning) Finished checking ---1 code warning
告警消息提示我們:在使用strcpy(str, tmp)進行字符串複製時,可能出現越界錯誤,因爲str的大小可能不足以容納環境變量“HOME”對應的字符串。綠色字體的內容指示瞭如何消除告警消息。
小結
這裏僅給出了splint檢查的4種檢測:解引用空指針、類型、內存管理、緩存邊界,除此之外,splint還對宏(Macros)、函數接口(Function Interfaces)、控制流(Control Flow)等內容作檢測,很多檢測標誌和格式化註釋都未在本文中提到,更詳細的內容請查看splint使用手冊。
不管pc-lint、splint等靜態程序分析工具的功能多麼強大,它們對程序的檢查也有疏漏的地方,工具的使用並不能提高我們的編程能力,我們更應該通過它們學習各種編碼錯誤和代碼隱患,憑積累的編碼知識把程序隱患扼殺在搖籃裏。
你在項目實施中是否遇到過隱藏的bug,導致返工呢?在你的項目中是否使用了靜態程序分析工具,它起到多大的作用?說出來與大家一塊分享吧~
Reference:《Splint Manual》