軟體除錯及單元測試
對於很多程式設計師朋友來說,編寫程式碼要比除錯程式碼快樂的多。似乎創造軟體比維護軟體更能給人帶來成就感。然而,在企業裡面維護前人留下的程式碼也是工作中不可缺少的一項內容。所以,如何除錯軟體,更快更好地尋找軟體中的bug,就成了我們必須學習的一門功課。當然,有人查詢故障很快,而有的人卻要慢一點,這中間的原因很多,比如說對業務的熟悉程度,對除錯工具的使用程度。這也從一方面說明了,掌握軟體除錯的技巧是十分重要的。這裡討論的內容,不是指怎麼用visual studio或者是gdb、kgdb、systemtap除錯,而是說說除錯軟體的基本原理是什麼。說到底,除錯軟體也是軟體,它需要晶片、作業系統、編譯軟體、堆疊格式的支援。
(1)晶片的支援
很多朋友都喜歡在軟體執行的過程中設定斷點,比如說在程式碼中插入一個__asm__ ("int $3" ::)就可以達到這樣的效果。關鍵是為什麼插入這個程式碼就會有這樣的效果。原來在x86晶片中,上面的int 3會被翻譯成0xCC。當cpu遇到這樣一行指令的時候,自身就會產生異常,進而會查詢相應的異常函式進行處理。在x86中是存在專門的除錯指令的,但是在某些cpu中這樣的指令卻未必存在,比如說powerpc,那這個時候怎麼辦呢?其實也簡單,只需要把對應的指令替換成cpu不認識的指令即可,這樣同樣可以產生異常的效果。至於當前異常是不是除錯異常,那麼就需要作業系統來判斷了。
(2)作業系統的支援
晶片本身只是負責產生異常,尋找到異常處理函式,至於這樣的函式還要做些什麼,那就是作業系統要負責的事情了。比如說,現在我們要除錯的程式已經執行到斷點了,那麼作業系統就要通知gdb或者visual studio當前的程式已經執行到節點了,接下來要做什麼。在一般的軟體除錯中,功能其實都大同小異,比如說檢視暫存器、檢視記憶體、設定斷點、取消斷點、檢視執行緒號、繼續執行等等。這些都需要作業系統本身的支援,否則作為使用者側的gdb怎麼知道當前的除錯程式執行到什麼地方了,它的基本資訊在哪裡等等。畢竟,gdb本身也是一個軟體,它關於除錯程式的具體資訊都是別人告訴它的,它又不是神。
(3)編譯軟體的支援
有了晶片和作業系統的支援,其實就可以除錯軟體了,比如說wingdb就是這麼幹的。但是,我們還不是很滿足,為什麼?因為有的時候,我們還需要知道函式的引數值、全域性變數是多少,有沒有發生改變,C語言程式碼有沒有對應的彙編程式碼,能否實現彙編級的除錯等等。當然,這些資訊對於執行檔案本身的執行其實是無關緊要的,只是我們為了除錯軟體的時候使用的。所以,在visual studio編譯的軟體版本當中有Debug版本,有release版本之分;有普通的軟體版本,有優化的軟體版本。在linux上,人們為了除錯的需要,也會在gcc除錯的選項中新增-g選項,獲得額外的除錯資訊。
(4)堆疊的支援
在除錯軟體中,有一項非常棒的內容,那就是函式堆疊檢視功能。堆疊會根據臨時變數的新增、減少進行浮動處理。可以說掌握了堆疊就掌握了cpu、掌握了程式設計、掌握了軟體除錯。在x86中,ebp是比較神奇的暫存器。在堆疊中,ebp[0]儲存了上一個ebp的地址,ebp[1]儲存了返回函式的地址。通過迭代,就可以的得到所有的函式指標了。當然,通過編譯器可以生成軟體的systemp map檔案,也就記錄所有函式的空間地址。把返回地址和system map聯絡在一起,我們就可以知道當前程式碼的函式堆疊了。除此之外,我們還可以利用堆疊中的返回地址設定斷點,這樣可以在當前函式執行到結束的時候斷住,使用起來也是十分方便。
- void print_function_addr(int ebp, int level)
- {
- int* start;
- int index;
- if(0 == ebp || 0 == level)
- return;
- start = (int*)ebp;
- index = 0;
- while(index < level){
- printf("[%d] 0x%08x\n", index, start[1]);
- index ++;
- start = (int*)start[0];
- }
- }
(5)日誌和計數器
依靠系統本身的除錯軟體當然是不夠的,所以為了看清資料的執行流程,我們還需要獲得一些額外的除錯資訊了。所以,對於業務而言,我們需要按照告警、錯誤、資料格式分別進行日誌儲存。當然,有時候我們還要對業務效能、頻率進行衡量和比較,所以很多時候計數器也是必不可少的。這些資訊都是正常程式碼之外的額外資訊,所以處理好他們和普通程式碼之間的關係也是一門大學問。同時,日誌有時還要受到多執行緒、效率、異常等因素的影響,所以思考和執行的時候一定要顧慮周全。
(6)除錯原則
這些除錯原則只是我個人的一些經驗總結,談不上真知灼見,僅供大家參考。1)先排除硬體,後軟體。特別高頻訊號要注意時序和訊號完整性;2)軟體設計大於除錯;3)軟體早除錯早得益;4)複雜的功能除錯可以在模擬軟體上進行,比如說全域性地址越界等,使用gdb的條件斷點除錯就十分方便,在嵌入式裝置上查詢卻十分困難;5)儘量自己編寫除錯函式,不斷改進和優化處理,用得也順手;6)日誌模組要健壯,尤其要適合多執行緒處理;7)定位故障一定要尋找到根本原因,否則極易生成新的故障。
很長時間以來,自己就想寫一篇關於單元測試的文章。但是由於自己在某些方面思考得不是很成熟,再加上前一段時間稍微有點忙,所以這個事情就一直這樣耽擱下來了。其實,朋友們在開發的時候都知道單元測試是個好東西,但是真正用於實踐,並且在開發中一直保持下去的卻是少數。雖然單元測試的框架很多,什麼CUnit,CxxUnit等存在很多現成的開原始碼框架,但是大家使用起來還不是很習慣。至於大家為什麼會對單元測試很抵觸,我想這主要有幾個方面的原因:(1)單元測試會在無形中增加自己的程式碼開發量;(2)程式設計師們缺少軟體質量的意識,認為保證軟體質量是軟體測試部門的事情;(3)單元測試的效果無法在短期內有所體現,不如功能開發那樣立竿見影;(4)大家習慣了開發、編譯、除錯、上機測試、修改這樣的傳統的開發方式;(5)專案至上而下缺少質量控制意識,片面追求開發速度、功能數量、入庫行數並過度依賴整合測試。
但是,這裡我想說的是每一個程式設計師都必須對自己的程式碼負責,不管這段程式碼是你設計的還是你維護的。單元測試就是一種很好的驗證你程式碼質量的方法。無論是在設計測試用例、理解程式碼設計、新功能開發、系統理解方面,單元測試都會對你有所幫助。但是,不可否認,單元測試對個人的要求還是很高的,這就需要個人一點點去適應、去改變。
a)標頭檔案模擬
在單元測試中,為了呼叫很多的底層函式,通常我們會對某些標頭檔案進行模擬。這個時候,我們引用的函式完全是自己定義和設計的。但是,我們也不能為了現在的測試修改原來的標頭檔案排布。所以,這個時候就需要對原有的標頭檔案進行模擬。現在,我們假設原來會引用到一個data_type.h檔案,中間有我們需要的函式宣告,但是現在不需要了。這時候,我們就可以自己定義一個空的data_type.h檔案,添幾行程式碼就可以了。
- #ifndef _DATA_TYPE_H
- #define _DATA_TYPE_H
- #endif
b)資料處理流程和上層介面分開
我們在安排原始檔的時候,在安排函式分佈的時候要注意一個基本的原則:資料和上層介面分開。在單元測試的時候,我們不太在乎曾經將這個資料的上層包裝形式是什麼。只有真正把資料從結構從釋放出來,形成一個獨立的處理檔案,這樣我們的測試才更方便、更有針對性。小函式、獨立函式、與介面分離的函式,這些都是我們在程式碼開發中需要特別注意的。
c)底層驅動打樁處理
在真實的軟體模組中,我們的程式碼是不可能獨立存在的,因此當前模組的程式碼常常需要引用別的模組程式碼。建立符合自己模組的樁函式,一方面可以提高程式碼的開發效率,另外一方面也方便我們對自身的程式碼進行測試。當然,底層驅動打樁函式是多種多樣的,某些配置類的函式我們可以象徵地輸出一行列印就可以了;某些函式我們可以利用測試端的一個相似函式代替即可;另外本地不存在的一些函式可能還需要我們真正編寫程式碼模擬一把。
d)測試用例應該儘量和實際環境一致
為了驗證程式碼的正確性,編寫測試用例當然是少不了的。但是,編寫測試用例並不是說越多越好。重複、低質量的測試用例只會浪費我們的測試資源。那麼,應該怎麼做呢?其實真實的執行場景才是我們所關注的。對於我們來說,最重要的就是把那些基礎功能、使用最多的功能、最容易犯錯的功能設計成測試用例,剩下的測試用例才是關於覆蓋率、效能方面的。
e)重視程式碼覆蓋率,更加重視功能覆蓋率
在開發中,很多開發者甚至領導都會把程式碼測試覆蓋率當作單元測試很重要的一個條件。誠然,高的程式碼覆蓋率固然能說明一些問題,但那不是問題的全部。我們進行單元測試的目的主要是為了驗證功能實現和設計是否一致,不是為了測試而測試。當然,在測試中我們可以模擬很多的條件,90%甚至更高的程式碼覆蓋率都是有可能的。但是,我們需要問一下自己,這些測試和最後的功能測試關係很大嗎?如果沒有這些測試,會影響最後的功能測試嗎?我們假設的這些單元測試條件在實際執行的時候是真實存在的嗎?
f)多執行緒測試需要日誌儲存,在程式崩潰的時候生成dump檔案
有些功能的開發,是需要同時執行多個執行緒的。因此對於某些關鍵的配置,由於無法保證程式碼的執行順序,我們需要對執行過程進行日誌記錄。如果程式在執行的過程中發生了崩潰,我們也需要及時對關鍵資料進行記錄,儲存系統生成的dump檔案。
g)測試用例注意生成環境和清除環境
使用過CUnit的朋友想必對××_init和××_clean非常熟悉。××_init是為了給我們的測試用例構建測試環境,而××_clean則是對當前的測試環境進行清理,這樣不至於對下面一個測試造成影響。本質上說,CUnit乾的就是這麼一件事情,在測試用例執行前,自動呼叫你的**_init函式,執行結束後自動呼叫你的**_clean函式。如果你不使用現成的測試框架也沒有關係,但是你在測試程式碼的時候也需要注意環境的生成和清理問題。
h)測試程式碼也需要儲存、重構、模組、分層設計
測試程式碼也不是一層不變的,有的時候為了適應程式碼的重構需要,我們也需要對測試程式碼進行重構處理。比如說,有些程式碼我們是用來測試函式級別的基本功能的,有些程式碼我們是測試模組功能的,有的程式碼我們是測試函式效能的,這些測試程式碼都需要分開。另外,測試也會按照函式呼叫順序不斷增加測試的難度和複雜度的,所以測試程式碼的分層設計也是十分必要的。
i)執行帶測試用例的實際版本
在版本release的時候,我們是絕不可能在實際版本中存在測試程式碼的。但是在開發的時候,我們可以自己編譯生成帶測試程式碼的版本。所以,我們需要做的就是保證我們的測試程式碼不但可以在本地單元測試通過,還需要在實際環境通過。如果在這兩方面都能通過的話,那麼才能說明我們的測試是成功的,我們測試是有保障的,否則即使在本地做好了單元測試又有什麼意義呢?