第十章 調試
所有的軟體都會存在缺陷,通常每100行代碼就會存在2到5個缺陷。這些錯誤通常會使得程式和庫并不會預期的表現,通常會使得一個程式的行為并不會如預想的那樣。Bug跟蹤,辨別以及修複會占用程式軟體開發過程中的大量時間。
在這一章,我們讨論軟體缺陷,并且會考慮一些工具與技術用于跟蹤特定的錯誤行為。這不同于測試(在各種條件下驗證程式行為的任務),盡管測試與調試是相關聯的,而且許多bug就是在測試過程中發現的。
我們會讨論下列主題:
錯誤類型
通常的調試技術
使用GDB與其他工具進行調試
斷言
記憶體使用調試
bug通常是由下列一些原因引起的,而其中的每一個都指出一個檢測與修複的方法:
規範錯誤:如果一個程式沒有進行正确的規範,毫無疑問,這個程式并不會表現出預期的行為。即使是世界上時優秀的程式員有時也會編寫出錯誤的程式。在我們開始程式設計(或是設計)之前,要保證我們清楚的知道與了解我們的程式需要做什麼。我們可以通過檢視需求和與使用程式的使用者所達成的協定來檢測與修複許多(如果不是所有)的規範錯誤。
設計錯誤:任何規模的程式都需要建立之前進行設計。通常坐在電腦前,直接輸入源碼,并且希望程式第一次就正确工作,這樣是不夠的。我們需要花些時間來考慮如何組織我們的程式,我們需要使用哪些資料結構,以及如何使用他們。試着進行詳細的設計,因為這樣以後就可以省去許多重新編寫的痛苦。
編碼錯誤:當然,每個人都會出輸入錯誤。由我們的設計建立源代碼的過程是一個不完美的過程。這也是許多bug滋生的地方。當我們在程式中遇到一個bug時,不要忽視簡單重讀源代碼或是請其他人來閱讀源代碼進而修複bug的可能性。令人驚奇的一件事是就通守與其他人讨論實作我們可以檢測并修複許多bug。
試着在紙上執行程式核心,這個過程被稱之為幹運作(dry running)。對于許多重要的例程,一步步寫下輸入的值并且計算輸出。我們并不必須使用計算機進行調試,而且有時就是計算機引起的問題。即使是那些編寫庫,編譯器,以及作業系統的人也會出錯。另一方面,不要急于責備工具;很有可能是在一個新的程式中存在bug,而不是存在于編譯器中。
有許多不的方法可以用來調試與測試一個通常的Linux程式。我們通常運作程式并且檢視發生了什麼。如果程式不能工作,我們需要決定對其做些什麼。我們可以修改程式并且再次運作,我們可以嘗試獲得程式内部運作的更多資訊,或是我們可以直接監視程式的運作。調試的五個步驟為:
測試:發現存在哪些缺陷或是bug
穩定化:使得bug重新出現
本地化:辨別相關的代碼行
修正:修正代碼
驗證:保證修正正常工作
一個帶有bug的程式
下面我們來看一下帶有bug的程式。在本章的讨論中,我們将會嘗試對其進行調試。這個程式是在一個大型軟體系統的開發過程中編寫的。其目的就是測試一個函數,sort,其作用是在一個item類型的結構數組上實作一個冒泡排序算法。這些項目以其成員key升序的順序進行排列。這個程式在一個例子數組上調用sort進行測試。在實際的工作中我們絕不會使用這種排序算法,因為其效率實在是太低了。我們在這裡使用他是因為他很短小,了解相對簡單,而且很容易出錯。事實上,标準C庫具有一個名為qsort的函數可以實作所要求的任務。
不幸的是,代碼很難閱讀,沒有注釋,而且原始程式也不可得了。我們不得不自己與其掙紮,我們由基本的例程debug1.c開始。
/* 1 */ typedef struct {
/* 2 */ char *data;
/* 3 */ int key;
/* 4 */ } item;
/* 5 */
/* 6 */ item array[] = {
/* 7 */ {“bill”, 3},
/* 8 */ {“neil”, 4},
/* 9 */ {“john”, 2},
/* 10 */ {“rick”, 5},
/* 11 */ {“alex”, 1},
/* 12 */ };
/* 13 */
/* 14 */ sort(a,n)
/* 15 */ item *a;
/* 16 */ {
/* 17 */ int i = 0, j = 0;
/* 18 */ int s = 1;
/* 19 */
/* 20 */ for(; i < n && s != 0; i++) {
/* 21 */ s = 0;
/* 22 */ for(j = 0; j < n; j++) {
/* 23 */ if(a[j].key > a[j+1].key) {
/* 24 */ item t = a[j];
/* 25 */ a[j] = a[j+1];
/* 26 */ a[j+1] = t;
/* 27 */ s++;
/* 28 */ }
/* 29 */ }
/* 30 */ n--;
/* 31 */ }
/* 32 */ }
/* 33 */
/* 34 */ main()
/* 35 */ {
/* 36 */ sort(array,5);
/* 37 */ }
我們試着編譯這個程式:
$ cc -o debug1 debug1.c
編譯成功,沒有錯誤或是警告報告。
在我們運作這個程式之前,我們需要添加一些代碼來輸出結果。否則,我們就不知道程式是否進行了工作。我們會添加一些額外的代碼在排序結束之後顯示數組。我們稱這個新版本為debug2.c。
/* 36 */ int i;
/* 37 */ sort(array,5);
/* 38 */ for(i = 0; i < 5; i++)
/* 39 */ printf(“array[%d] = {%s, %d}/n”,
/* 40 */ i, array[i].data, array[i].key);
/* 41 */ }
嚴格來說這些額外的代碼并不算是程式修正的一部分。我們添加這些代碼僅是為測試。我們必須非常小心不要在我們的測試代碼中引入額外的bug。現在再次編譯并且運作程式。
$ cc -o debug2 debug2.c
$ ./debug2
當我們這樣做時發生了什麼依賴于我們的Linux平台以及我們所進行的設定。在作者的系統上,我們會得到下面的輸出資訊:
array[0] = {john, 2}
array[1] = {alex, 1}
array[2] = {(null), -1}
array[3] = {bill, 3}
array[4] = {neil, 4}
但是在另一個作者的系統(運作一個不同的核心),我們會得到下面的資訊:
Segmentation fault
在我們的Linux系統上,我們會看到其中的一個資訊或是另一上不同的結果。我們希望得看到下面的資訊:
array[0] = {alex, 1}
array[1] = {john, 2}
array[2] = {bill, 3}
array[3] = {neil, 4}
array[4] = {rick, 5}
很明顯,在代碼中存在一個嚴重的問題。如果這個程式可以運作,那麼他就不能對數組進行正确的排序,而如果程式結束并提示記憶體錯誤,那麼是系統向程式發送了一個信号表明系統已經檢測到一個非法的記憶體通路并且提前結束了程式的運作以防止記憶體被破壞。
作業系統檢測非法記憶體通路的能力依賴于其硬體的配置以及記憶體管理系統的精巧實作。在大多數系統上,由作業系統配置設定給程式的記憶體遠大于實際正在使用的記憶體。如果非法記憶體通路發生了這塊記憶體區域,硬體也許就不能檢測非法通路。這就是為什麼并不是所有的Linux版本以及Unix産生記憶體錯誤的原因。
注:一些庫函數,例如printf,也會阻止某些條件下的非法通路,例如使用一個空指針。
當我們跟蹤數組通路問題時,通常增加數組元素的數量是一個好主意,因為這會增加錯誤數。如果我們讀取超過數組位元組結束處一個位元組,我們也許就會消耗掉這些記憶體,因為配置設定給程式的記憶體将會達到作業系統特定的邊界,通常為8K。
如果我們增加數組元素的數量,在這個例子中可以通過修改item成員data為一個4096字元的數組來做到,對于不存在的數組元素的通路也許就會是超出已配置設定的記憶體位址。每一個數組元素為4K大小,是以我們非正常使用的記憶體可以為0到4K。
如果我們這樣修改,并将其結果稱之為debug3.c,我們就會在兩個作者的Linux版本上得到記憶體錯誤的資訊。
/* 2 */ char data[4096];
$ cc -o debug3 debug3.c
$ ./debug3
Segmentation fault (core dumped)
也有可能某些Linux或是Unix版本仍然不會産生記憶體錯誤資訊。當ANSI C标準檢測到未定義行為時,他會允許程式執行任何動作。當然看上去似乎是我們編寫了一個非正常的C程式,而一個非正常的C程式可以執行任何奇怪的行為。正如我們将會看到的,錯誤類型就落入了未定義行為的類别。