荔园在线

荔园之美,在春之萌芽,在夏之绽放,在秋之收获,在冬之沉淀

[回到开始] [上一篇][下一篇]


发信人: jek (Super Like Cat), 信区: Program
标  题: 完美程式設計指南--程式碼的追蹤除錯
发信站: 荔园晨风BBS站 (Thu Apr  4 06:59:11 2002), 转信

我之前說過,找尋程式錯誤最好的方法就是執行程式,然後盯著它跑,無論是用眼
睛或是用些自動化測試工具,如除錯檢查巨集跟子系統整合度檢查。不過除錯檢查
巨集與子系統檢查雖然都有用,它們卻不能幫你避開你沒想到的錯誤;在這方面,
它們跟家裡的保全系統沒兩樣。

如果你在家裡的門窗都裝上了鐵條,而小偷還是能從樓頂或地下室進來,那保全系
統的警報當然就不會響了。如果你在錄影機、音響跟其他你認為小偷會偷的東西上
裝了警報器,而小偷卻搬走了貴重珠寶組合,那當他偷你東西時,你根本不會察覺
到。相似的,如果你對函式的參數使用除錯檢查巨集進行核對,而臭蟲是來自你的
邏輯錯誤上的,那你設下的除錯檢查巨集對這樣的錯誤是根本不會有反應的。

理論上,你可以在程式中設下非常多除錯檢查巨集跟除錯程式碼,讓臭蟲很快就會
被這些警報系統逮個正著。理論,只是理論。現實中,在大部分程式專案裡加入很
多除錯程式碼可能只是浪費時間,而且你還是得預先判斷好程式的哪邊可能有錯誤


與其加入一大堆除錯檢查巨集跟除錯檢查,一個更好的辦法是主動在錯誤可能發生
時主動去找出錯誤來。不過,怎麼做呢?你不是已經改好程式了嗎?如果在你改過
程式後,錯誤才出現,那你要明白,程式不會自己出錯的-一定是你改了某些東西
,才讓它出現錯誤。

寫出零錯誤程式碼的最好辦法就是主動追蹤所有新的或改過的程式碼,檢查它們的
執行狀態,看看是不是每個動作都如你預期的般執行。

在本章中,我要談的不只是為何追蹤程式執行是件重要的事,還會談到如何有效的
做好這件工作。

增進對程式的自信
最近,我在替微軟內部的麥金塔程式開發系統發展一個新功能。當我開始測試這個
新功能時,我找到一隻臭蟲,追著它,跑到了另一名程式員寫的新程式碼中。這隻
蟲讓我迷惑的地方是它跟其他程式員寫的功能如此相關,我不知道別人寫的功能大
概怎樣子才算是正常運作的狀態。我去找那位先生談了一下。

"我想我在你剛完成的程式中找到一個錯誤",我說。"你有時間簡單看一下嗎?"

他將程式載入編輯器中,然後我告訴他我認為錯誤在哪裡。當他看了那邊的程式,
他嚇了一跳。

"你說對了;這程式是錯的。我不知道為什麼測試時沒抓到這個問題。"

我也想到同個問題。"你是怎樣測試這段程式的?"

他解釋了一下,那樣作應該能抓到這隻蟲才對,這讓我們都搞迷糊了。"在這函式
中放個中斷點,追蹤一下程式的運作,看看到底發生了什麼事吧",我建議。

我們試了一下,卻發現當我們設好了中斷點並按下執行鍵,整個測試跑得好好的;
完全沒碰到我們設下的中斷點。這就是為何那位先生沒找到錯誤的原因。沒花多久
,我們就找到為何測試動作沒碰到那個中斷點-在整個呼叫鏈中,一個上頭好幾層
處的函式裡頭被最佳化編譯器跳掉了些東西,使一些非必要的呼叫完全不會被執行
到,所以也跳過了後來加上的新程式碼。

你還記得我在第一章提到過黑箱測試的問題嗎?我說測試人員對程式丟入輸入資料
,再從輸出結果判斷程式有沒有正確運作-如果輸出正確,就當作程式正確執行。
這樣做法帶來的問題是,你沒辦法判斷在輸入與輸出之間發生了什麼事。上頭那位
程式員漏掉了那個錯誤,只因為他將程式當成黑箱子來測試;他放入一些輸入資料
,得到了正確結果,然後就判斷程式是正確的。身為一名程式寫作者,他沒使用他
能用到的額外工具來進行測試。

程式寫作者們,不同於大部分測試者,有著在程式中設定中斷點的能力,他們可以
追蹤程式的執行,觀看輸入資料如何產生輸出結果。奇怪的是,只有少部分程式員
有測試程式時追蹤程式執行過程的習慣;許多人根本不會在程式中設定中斷點來看
看程式有沒有正確執行。

回到我在本章開頭所提到的:捕捉錯誤最好的辦法就是在你寫程式或改程式時就把
它們找出來。而程式員測試程式最好的辦法是什麼?就是追蹤程式執行,拿個工具
來看看中間過程的結果。我不認識太多寫出來的程式都沒錯誤的程式員,但是我認
識的那些人都有從頭到尾追蹤自己程式執行結果的習慣。

作為一個專案領導人,我要許多程式員在測試時追蹤他們自己寫的程式。幾乎每個
人都嚇到了-不是因為他們不同意這個做法,而是因為這個做法聽起來很耗時。他
們已經很難跟得上程式發展的時間表了-他們要去哪裡找時間來追蹤自己的程式?


幸運的,他們的直覺反應是錯的。是的,追蹤程式需要花時間,可是只佔寫程式時
間中的一小部份。想想看,當你寫了個新功能,你必須設計這功能被使用的方式,
想出其中的演算法,而且實際將它寫成原始碼。那在你第一次執行這程式時,設一
下中斷點,在每一行原始碼上按一下追蹤執行鍵要花多少時間?不多,特別當你已
經將它養成習慣時。就好像學開一部手排車-剛開始似乎不可能,但在幾天的練習
後,你甚至不會注意到排檔的切換過程;你自然而然就會切換排檔了。同樣的,只
要你養成了追蹤程式執行的習慣,你自然而然就會設下中斷點並追蹤程式執行過程
。然後你就抓到以前抓不到的錯誤了。


------------------------------------------------------------------------
--------

不要等到有錯誤發生了才來追蹤程式碼的執行。


------------------------------------------------------------------------
--------
程式中的分岔
當然有些技巧可以讓你更有效的追蹤程式的執行。畢竟虎頭蛇尾的追蹤程式沒什麼
好處。例如,每個程式員都知道錯誤處理程式經常會出錯,因為那部分很少被測試
到,而錯誤就會留在那裡,除非你努力測試那些程式。你可以製造一些產生錯誤狀
況的測試碼,也可以在你追蹤程式執行時模擬錯誤的發生。模擬錯誤發生通常需要
花更少時間,看看下面這段程式段落的例子:

pbBlock = (byte *)malloc(32);
if (pbBlock == NULL)
{
    處理錯誤狀況;
}
一般說來,當你追蹤執行這段程式時,malloc會配置一塊32位元組大小的記憶體,
並傳回一個非NULL的指標,讓這程式跳過錯誤處理碼。所以我們可以在第二次追蹤
執行這段程式時,利用除錯程式在執行過底下這行後,將pbBlock的值設成NULL指
標:

pbBlock = (byte a)malloc(32);
malloc會配置記憶體,但如果我們把pbBlock設成了NULL指標,對你的程式來說,
就好像記憶體配置失敗了一樣,讓你能夠追蹤錯誤處理程式的執行狀態。(給追根
究底的讀者們:是的,你改變pbBlock的內容時會失去指向malloc配置的記憶體的
指標,不過這裡只是在進行一個測試而已。)

除了追蹤錯誤狀況的處理情形,你也應該看看程式中每一種狀況的執行流程。會出
現多個執行流程的常見例子就是if跟switch敘述,不過還有別種:&&,||跟?:運算
子也都有兩條執行路線。

這麼做的想法是追蹤程式中每一條執行路線至少一次,以確保程式的正確無誤。在
你完成這項工作後,你可以更加確信自己的程式是沒有錯誤的-至少你知道這個程
式在某些輸入狀態下會正常運作。而如果你挑選的測試樣本夠好,追蹤執行程式運
作所能得到的經驗將會讓你難以忘懷。


------------------------------------------------------------------------
--------

追蹤執行每一條執行路線


------------------------------------------------------------------------
--------
資料流-程式的命脈
在 第二章 中,我寫了個快速的memset副程式,底下是那副程式的第一版(略掉了
除錯檢查巨集):

void *memset(void *pv, byte b, size_t size)
{
    byte *pb = (byte a)pv;

    if (size >= sizeThreshold)
    {
        unsigned long l;
        /* 以四個位元組拼組成一個常整數。*/
        l = (b<<24) ? (b<<16) ? (b<<8) ? b;

        pb = (byte a)longfill((long a)pb, l, size / 4);
        size = size % 4;
    }

    while (size- > 0)
        *pb++ = b;

    return (pv);
}
這程式看起來正確,但是有個潛伏的問題。在我寫好這程式後,我把它在一個現成
的程式中使用。沒碰到任何問題-這程式運作得好好的。但為了確定這副程式真的
動作了,我在副程式中設了中斷點,然後重新執行那個程式。當除錯程式把控制權
交給我時,我看了一下函式參數:*pv指標看起來是合法的,size大小參數也對,
而要填入的位元組b是0. 我討厭用0測試程式碼,因為這樣子不好找出不同類型的
錯誤,所以我立刻將參數b改成了一個奇怪的值,0x4E。

我這次的追蹤狀態是size小於sizeThreshold的情形,這路線執行得好好的。接下
來我讓size大於或等於sizeThreshold. 我不預期會碰到什麼問題,可是當我追蹤
到這一行時,

l = (b<<24) | (b<<16) | (b<<8) | b;
我看到 | 被設成了0x00004E4E,而不是我要的0x4E4E4E4E. 在查看這段程式的組
合語言列表後,我找到了問題所在-這解釋了為何出現了這樣的錯誤,程式還是動
作得好好的。

如你所見,我用的編譯器的整數寬度是16位元的,如果你用16位元寬度的整數進行
如b<<24的運算,會得到什麼結果?你會得到0. 那b<<16呢?也是出現0. 這副程式
的邏輯沒什麼不對的,但是實作上有缺陷。這副程式在那個現成的程式中沒出錯,
只是因為那程式使用memset將記憶體填成0,而0<<24還是0,所以得到的結果沒出
錯-雖然過程錯了。

我能立刻逮到這個錯誤,因為我花了額外的時間來追蹤這個程式碼的執行,而不是
寫好它就丟著它不管了。是的,這問題足夠嚴重到最後總有人會注意到他,不過記
住我們的目標是盡可能愈早抓出錯誤來。追蹤程式執行有助於達到這個目標。

追蹤程式執行的真正威力在於你可以看到資料在你的函式中流通。只要你注意著資
料流的去向,你想你能逮到底下多少種錯誤呢?

溢位跟借位錯誤

資料轉換錯誤

錯字

NULL指標錯誤

使用不正確記憶體的錯誤

把 == 打成 = 的指派錯誤

運算子順序錯誤

邏輯錯誤

你會抓不到這些錯誤嗎?經由注意資料流的去向,你可以從另一個不一樣角度來看
待你的程式。你也許不會注意到程式中的這個指派錯誤:

if (ch = t')
    ExpandTab();
可是當你循著資料流追蹤到這裡時,你可以輕易看到ch被亂改了。


------------------------------------------------------------------------
--------

追蹤程式執行時,注意資料流的去向。


------------------------------------------------------------------------
--------
你錯過了什麼?
使用原始碼除錯器的一個問題是逐行追蹤原始碼可能會跳過很重要的東西。假設在
底下的程式中,我們把&&錯打成了&:

/*  如果符號存在,而且有個文字名稱,
 *  就釋放那名稱佔用的記憶體。
 */
if (psym != NULL  &  psym->strName != NULL)
{
    FreeMemory(psym->strName);
    psym->strName = NULL;
}
程式碼合乎語法,可是寫錯了。那個if敘述的用途是防止psym的內容為NULL指標時
被用來指向一個symbol結構的strName欄位,但這程式完全沒作到這點。它永遠會
拿psym指向一個symbol結構的strName欄位,不管psym是不是NULL.

如果你用原始碼除錯器來追蹤程式,並在追蹤到if敘述時按下逐行追蹤鍵,除錯器
將會把整個if的條件測試當成一個完整的動作來看待。可是要找到問題核心的話,
你得注意到條件式的右邊即使左邊結果是FALSE也會被執行到。(如果你夠幸運,
你的電腦會在你使用NULL指標時當掉,不過許多桌上型電腦都不會,至少現在還不
會。)


------------------------------------------------------------------------
--------
譯按:
現在的情況好多囉,不管是Mac OS或Windows 3.1/95/98/NT,使用NULL指標進行記
憶體存取會立刻造成記憶體保護例外的觸發的。注意到原作者寫這本書時,大部分
桌上型電腦的程式員都還在用不支援記憶體保護的作業系統跟十六位元的程式編譯
器。事實上,今天的三十二位元編譯器已經不存在如前述的b<<24的溢位問題了,
理由很簡單,整數資料型態的預設寬度已經變成三十二位元了。不過如果讀者試著
以短整數或字元進行類似的位元位移運算,或者進行超出三十二位元的位移運算,
結果還是會變成0的。


------------------------------------------------------------------------
--------

記住我稍早說過的:&& , || 跟 ?: 運算子都有兩條執行路線,要抓錯誤,你得
兩邊都追蹤一遍。使用原始碼除錯器的問題在於逐行追蹤容易一次跳過這些運算子
的兩個執行路線。有兩種辦法可以克服這個問題。

首先,你要追蹤有著&&跟||運算子的複合敘述時,檢查一下要被執行過去的那個敘
述寫得對不對。接下來,用除錯器的計算評估功能檢查一下運算式每一邊的結果是
不是你要的。這樣可以幫你找到整個計算式評估結果正確而過程不對的情形。像是
,如果你認為一個||運算式左邊的結果是TRUE而右邊是FALSE,卻出現相反的結果
,那這運算式一定算錯了,雖然結果是對的。檢查一下運算式的各個部分,將能讓
你警覺這類問題的存在。

另一個更徹底的做法是將複合敘述跟?:運算子列成組合語言來看。是的,這會花更
多功夫,但是對於關鍵的程式碼來說,真正追蹤過一遍來看看程式跑得對不對,是
重要的。當你在C語言的程式開發環境中追蹤程式時,只要你習慣了這麼作,將程
式攤開成組合語言來追蹤是很快的;多練習幾次就會了。


------------------------------------------------------------------------
--------

原始碼除錯器可能隱藏執行細節。

將關鍵的程式碼攤開成組合語言來追蹤吧。


------------------------------------------------------------------------
--------
試試看-你會喜歡它
我希望我有辦法說服程式員們去追蹤自己的程式,或至少讓他們試著追蹤一個月看
看。不過我發現程式員們通常都沒辦法丟棄追蹤程式要花很多時間的觀念。這時作
為一名專案領導者的好處之一就派得上用處了;你可以專制一點,堅持參予專案的
程式員們都要追蹤他們寫的程式,直到他們真的了解這麼作不但不花時間,而且值
得為止。

如果你還沒追蹤過自己的程式,你會開始這麼作嗎?只有你自己知道會不會。不過
我想當你拿起這本書開始看是因為你對於減少程式中的錯誤非常在意,不管這些程
式是你自己寫的,或是你帶的程式員們寫的。最後你真的得面對這樣的抉擇:要花
少部分時間追蹤程式的執行來確定程式執行無誤呢,還是你希望讓臭蟲跑進原始碼
正本中,再來冀望測試人員能抓到這些蟲,然後你才來修正這些錯誤呢?如何選擇
完全都看你自己。


------------------------------------------------------------------------
--------
快速回顧
臭蟲不會自己從程式中長出來;它們是從程式員新寫的程式或改過的老程式中冒出
來的。如果你要在程式中找尋錯誤,除了逐步追蹤編譯好了的程式碼的每一行,沒
有更好的方法。

雖然你的直覺反應會認為追蹤程式碼很花時間,你的直覺可能是錯的。是的,一開
始會花更多時間-一旦你習慣了,就不會了。當你習慣之後,你追蹤程式碼的速度
可快了。
?
小心追蹤每一條程式執行路線-特別錯誤處理程式裡頭的東西-至少一次。不要忘
了&&, || 跟 ?: 運算子都有兩條執行路線要測試。
?
在某些狀況中,你得把程式攤開成組合語言來追蹤。雖然你往往不用這麼作,但是
該作的時候就要作。


------------------------------------------------------------------------
--------

------------------------------------------------------------------------
--------
學習計劃:
如果你回顧一下 第一章 中的內容,那邊談的是編譯器能幫你自動抓到的常見錯誤
。複習一下那些內容,再回頭問問你自己,追蹤程式執行時會不會漏掉那些錯誤。



------------------------------------------------------------------------
--------

------------------------------------------------------------------------
--------
學習計劃:
看一下過去六個月裡,你程式中已知的那些臭蟲。如果你當初寫程式時就有追蹤過
你的程式碼,你可以抓到裡頭幾隻?

--
 === I love Puss forever ===

※ 来源:·荔园晨风BBS站 bbs.szu.edu.cn·[FROM: 192.168.1.241]


[回到开始] [上一篇][下一篇]

荔园在线首页 友情链接:深圳大学 深大招生 荔园晨风BBS S-Term软件 网络书店