程式碼動態檢測實踐分享
摘要: 程式碼動態檢測是對執行在實際或虛擬處理器上的程式進行的計算機軟體分析,可以檢測到程式中存在的緩衝區溢位、資源洩露、程序執行緒異常等問題。程式碼覆蓋是度量軟體測試的一種方式,描述程式中原始碼被測試的比例和程度,所得比例稱為程式碼覆蓋率。程式碼覆蓋率的度量方式包括行覆蓋率、函式覆蓋和分支覆蓋率。本文實踐在程式碼動態檢測中引入程式碼覆蓋測試,目的是通過分析覆蓋率補充測試場景和資料,以保證程式碼動態檢測的充分性。
一、 概述
隨著計算機應用需求的日益增加,應用程式的設計與開發也日趨複雜。在程式實現的過程中,處理數以千計變數的記憶體分配和釋放,以及大量對計算機記憶體和儲存的併發讀寫指令在所難免,一旦疏忽就難免引入錯誤,導致程式執行出錯。在實際中經常會遇到伺服器長時間執行出現效能下降甚至系統崩潰問題,以及當訪問量增大時出現業務處理錯誤問題等。只依靠黑盒測試和程式碼靜態檢測手段很難發現這些問題,即使發現問題還需要花費很大成本進行定位和修復。通過引入程式碼動態檢測,在黑盒測試同時從程式碼和指令層級對程式進行監控,發現並鎖定引發記憶體錯誤和執行緒錯誤的程式碼進行修復,極大減少測試成本。再結合程式碼覆蓋測試,從程式碼行覆蓋率、函式覆蓋率和分支覆蓋率三個角度度量和提高程式碼動態檢測的充分性,增強管理者對軟體產品質量的置信水平。
(一) 程式碼動態檢測
程式碼動態檢測通過工具監控執行時程式發現潛在的記憶體錯誤和程序執行緒錯誤。為了有效地發現和定位程式錯誤,監控工具通常會在被測程式中插入程式碼監控程式的執行。這種在被測程式中插入監控程式碼的技術被稱為程式碼插樁。按照程式碼插樁或替換的時間不同,主要分為三類插樁技術:
ECI(Executable Code Interception):執行碼替換技術,在連結或執行時,直接替換相應的系統呼叫,代表工具如valgrind。
OCI(Object Code Insertion):目標碼插樁技術,直接對目標碼進行分析,並插入相應的彙編程式碼,代表工具如Purify。
SCI(Source Code Instrumentation):原始碼插樁技術,對源程式進行掃描分析,收集所有必要的檢測資訊,插入相應的檢測程式碼,代表工具如Insure++。
這些插樁技術通常以監控執行時程式使用的資源如記憶體、檔案控制代碼、組數、執行緒共享資源等為手段,發現由於資源使用不當導致的記憶體洩露、檔案控制代碼未關閉、陣列訪問越界、訪問共享資源衝突等問題。這些插樁技術一般不會影響程式的功能,但會使程式的執行速度變慢、佔用的記憶體空間變大。
(二) 程式碼覆蓋測試
在軟體測試的世界裡,程式碼覆蓋測試是衡量測試完整性的重要指標。對於被測系統是否經過了充分的測試,這是一個相當有效的手段,一方面衡量測試工作本身的有效性,另一方面增強管理者對軟體產品質量的置信水平。程式碼覆蓋測試通常從程式碼行覆蓋率、函式覆蓋率和分支覆蓋率三個方面衡量測試的充分性。在本文程式碼動態檢測實踐中,通過分析三個覆蓋率資料來補充測試場景和資料以提高測試的充分性。在其他測試領域如功能測試、可靠性測試中也可以加入程式碼覆蓋測試以評估和提高測試的充分性。
二、 實踐分享
在測試大型金融類專案時,考慮金融軟體對執行有很高的安全性、實時性和穩定性要求。然而,錯誤地使用記憶體資源和執行緒資源極易引發系統的安全問題和穩定性問題。通過程式碼動態檢測對記憶體資源和執行緒資源的使用進行監控,發現並鎖定問題,再結合程式碼覆蓋測試保證測試的充分性。
為了做好程式碼動態檢測,在測試前期對測試工具進行了深入調研,從工具發現問題的能力、與被測系統結合的難易程度以及工具的使用成本等多方面綜合考慮,最終選擇使用Valgrind以及Gcov/Lcov工具開展程式碼動態檢測工作。Valgrind是執行在Linux上一套開源的基於模擬技術的程式除錯和分析工具套件,用於C++專案的程式碼動態檢測。通過Valgrind可以發現記憶體洩露、記憶體使用越界、使用未初始化記憶體、重複釋放記憶體、執行緒死鎖、執行緒資料訪問衝突等問題。Gcov/Lcov是Linux上用於測試C++程式碼覆蓋率的開源工具,支援獲取程式碼行覆蓋率、函式覆蓋率和分支覆蓋率。
(一) 具體方案
圖一: 程式碼動態檢測流程圖
程式碼動態檢測的實施主要分三個階段:
第一階段:測試準備,包括圖中的第1、2步。
第1步,編譯被測系統,在編譯時增加編譯引數在被測系統中加入程式碼覆蓋率收集功能;
第2步,部署被測系統,在部署環境時需要正確配置GCOV_PREFIX和GCOV_PREFIX_STRIP環境變數以保證程式碼覆蓋測試正常進行。
第二階段:測試執行,包括圖中的第3、4、5步。
第3步,配置工具引數,依據檢測內容選取Valgrind/Lcov工具引數;
第4步,執行動態檢測,為提高檢測效率需要在功能測試或自動化測試配合下完成程式碼動態檢測;
第5步,補充測試場景和資料,分析程式碼覆蓋測試結果(行覆蓋率、函式覆蓋率、分支覆蓋率)補充測試場景和資料,提高程式碼動態檢測的充分性。
第三階段:結果分析,包括圖中的第6步。
第6步,分析程式碼動態檢測結果,包括程式碼動態檢測結果和程式碼覆蓋測試結果。
(二) 難點&解決
難點問題:以後臺服務方式持續執行的被測系統,無法生成覆蓋率資料檔案。
解決辦法:這類被測系統的退出方式通常為kill程序,可在kill程序前通過gdb主動呼叫__gcov_flush函式解決該問題,如下:
gdb --quiet --pid=$pid > /dev/null << EOF
p __gcov_flush()
EOF
Kill -9 $pid
難點問題:如果被測系統部署在多臺不同的機器上,為了收集完整的覆蓋率需要對各機器的覆蓋率進行合併。
解決辦法:lcov工具本身支援覆蓋率生成和覆蓋率合併功能,如下:
覆蓋率生成命令及引數:
lcov -c -d gcda_dir -rc lcov_branch_coverage=1 --no-external -o cov.info
覆蓋率合併命令及引數:
lcov -a cov1.info -a cov2.info … -rc lcov_branch_coverage=1 -o cov_all.info
(三) 發現問題舉例
1、多執行緒資料爭用問題,指兩個或更多執行緒同時讀/寫共享資料。一旦發生資料爭用,共享資料的值是不可知的,使用這些值會導致程式執行結果完全不可預測,甚至直接崩潰。
問題程式碼舉例:
4 void *write_data(void *arg)
5 {
6 char *buffer = (char*)arg;
7
8 sprintf(buffer, “I’am thread %ld”, pthread_self());
9 pthread_exit(0);
10 return NULL;
11 }
12
13 int main(void)
14 {
15 char data_buffer[20] = { 0 };
16 pthread a, b;
17
18 pthread_create(&a, NULL, write_data, data_buffer);
19 pthread_create(&b, NULL, write_data, data_buffer);
20 pthread_join(a, NULL);
21 pthread_join(a, NULL);
22
23 return 0;
24 }
Valgrind檢測結果:
==2431== Thread #3 was created
……
==2431== by 0x4011F1: main (thread.c:19)
……
==2431== Thread #2 was created
……
==2431== by 0x4011C2: main (thread.c:18)
……
==2431== Possible data race during read of size 8 at 0x6042E0 by thread #3
==2431== Locks held: none
==2431== at 0x401112: write_data(void*) (thread.c:5)
……
==2431== This conflicts with a previous write of size 8 by thread #2
==2431== Locks held: none
==2431== at 0x40111D: write_data(void*) (thread.c:5)
……
2、記憶體洩露問題,指程式動態申請記憶體資源使用後未歸還。一旦發生記憶體洩露,在程式長時間執行中會不斷堆積最終導致記憶體耗盡,程式崩潰。
問題程式碼舉例:
40 void memory_leak(void)
41 {
42 char *pc = (char*)malloc(1);
43 *pc = ‘T’;
44
45 char c = *pc;
46
47 printf(“c = [%c]\n”, c);
48 }
Valgrind檢測結果:
3、使用未初始化記憶體問題,指區域性變數或動態申請的變數,其初始值是隨機的。一旦使用這些隨機值,會使程式的行為變得不可預期,甚至由於訪問非法記憶體而導致程式崩潰。
程式原始碼:
6 void use_uninit_memory(void)
7 {
8 char *pc;
9 char c = *pc;
10 printf(“c = [%c]\n”, c);
11 }
檢測結果:
==3908== Use of uninitialized values of size 8
==3908== at 0x400FE4: use_uninit_memory() (memory.c:9)
==3908== by 0x4014A8: main (memory.c:121)
4、使用記憶體越界問題,指程式使用已申請或已分配範圍外的記憶體。一旦程式中存在使用記憶體越界問題,可能被惡意程式碼攻擊獲取整個程式的控制權,造成嚴重的安全性問題。
問題程式碼舉例:
28 void use_out_memory(void)
29 {
30 char *pc = (char*)malloc(1);
31 *pc = ‘T’;
32
33 char c = *(pc + 1);
34 printf(“c = [%c]\n”, c);
35
36 free(pc);
37 }
Valgrind檢測結果:
==3914== Invalid read of size 1
==3914== at 0x4010E7: use_out_memory() (memory.c:33)
==3914== by 0x4014F3: main (memory.c:127)
==3914== Address 0x5ab6c81 is 0 bytes after a block of size 1 alloc’d
==3914== at 0x4C2DB8F: malloc (in /usr/lib/Valgrind/
vgpreload_memcheck-amd64-linux.so)
==3914== by 0x4010D7: use_out_memory() (memory.c:30)
==3914== by 0x4014F3: main (memory.c:127)
(四) 覆蓋率應用
在程式碼動態檢測中加入程式碼覆蓋測試主要有兩個目的:
-
通過已發現缺陷數和已覆蓋程式碼行數計算被測試程式缺陷密度,用於評估被測系統質量。
-
通過分析函式覆蓋補充測試場景,分析分支覆蓋補充測試資料,用於提高程式碼動態檢測的充分性。
下圖是程式碼覆蓋測試報告:其中程式碼行覆蓋率為97.9%,函式覆蓋率為100%,分支覆蓋率為66.7%。

三、結束語
雖然程式碼動態檢測主要依賴檢測工具完成測試,但在實施過程中,仍然要求測 試人員具備一定的C++程式碼分析能力和Linux系統操作基礎。本文只介紹了程式碼動態檢測整體實施過程,關於實施的具體細節及工具引數的選取未詳細闡述,需要讀者結合具體專案特點並參考網上相關資料完成。