天天看點

程式設計精粹--編寫高品質C語言代碼(4):為子系統設防(一)

通常,子系統都要對其實作細節進行隐藏,在進行細節隐藏的同時,子系統為使用者提供了一些關鍵入口點。程式員通過調用這些關鍵的入口點來實作與子系統的通信。是以如果在程式中使用這樣的子系統并且在其調用點加上了調試檢查,那麼不需要花費多少力氣就可以進行許多錯誤檢查。

當子系統編寫完成後,要問自己:“程式員什麼情況下會錯誤地使用這個子系統,在這個子系統中怎樣才能自動檢查出這些問題?”在這篇文章中,将講述一些用來肅清子系統中錯誤的技術。使用這些技術,可以免除許多麻煩。本章将以C的記憶體管理程式為例,但所得到的結論同樣适用于其它子系統。

通常,我們可以直接在子系統中加入相應的測試代碼,但是有時我們無法得到子系統的源代碼。是以這裡我們将利用所謂的“外殼”函數把記憶體管理程式包裝起來,并在這層包裝的内部加上相應的測試代碼。

首先以malloc的外殼函數fNewMomory為例:

從fNewMemory的定義我們可以看出,以前我們需要這樣調用malloc: pbBlock=(byte*)malloc(32);而現在如果使用fNewMemory,就需要這樣調用,fNewMemory(&pbBlock,32)。同時,malloc通過判斷pbBlock是否為NULL指針來判斷配置設定記憶體是否成功,而fNewMemory直接通過函數的傳回值來進行判斷。這樣設計是有原因的,筆者将會在後面的文章詳細說明。

中講過,對于未定義的特性,要麼将其從程式設計裡去掉,要麼利用斷言來驗證其不會被用到。ANSI

C中的malloc的未定義特性有兩點:1,當配置設定記憶體塊的大小為0時,其結果未定義;2,當記憶體塊配置設定成功後,記憶體塊的初始内容未定義。對于第一點,我們可以使用斷言來進行檢查,但是對于第二點,我們無法用斷言來進行驗證。那如果我們人為地利用一個正常值(例如0)來填充這個記憶體塊,這樣就可以消去這個未定義的特性。但是這樣至少帶來兩點影響:1,對記憶體塊填充一個正常值有可能會影響程式的結果。2,有可能會隐瞞錯誤(例如程式員在配置設定記憶體後未初始化,但是由于事先對記憶體塊填充了一個值,是以程式可能正常運作,進而隐瞞錯誤)。

但是,無論如何我們還是不希望記憶體塊的初始内容未定義,因為這樣意味着錯誤難以再現。因為有可能程式隻有在某個特定的初始值時才出錯。這樣程式大部分時間都發現不了錯誤,但總是不明原因地失敗。暴露錯誤的關鍵就是消除錯誤發生的随機性。是以對于malloc來說,隻有對其配置設定的記憶體塊進行填充,才能消除其随機性。但是又要避免填充值對程式造成影響或者隐瞞程式中的錯誤,是以填充值應該離奇地看起來像無用資訊。而且這種填充應該在程式的調試版本中,這樣既可以解決問題,又不影響程式的發行版。在基于Intel

80x86的機器上,作者推薦這個值為OxCC。

是以新版本的fNewMemory的代碼如下:

fNewMemory不僅可以有助于錯誤的再現,而且常常使錯誤被很容易的發現出來。例如當你調試跟蹤時,發現某個值是0xCC,是不是讓你瞬間想到這是個未初始化的資料。是以要檢視子系統,确定子系統中引起随機錯誤的設計之處。一旦發現了這些地方,就可以通過改變相應的設計方法來把它們排除,或者在他們周圍加上調試代碼,最大限度地減少錯誤行為的随機性。

要消除錯誤的随機性--使錯誤可再現

接下來是記憶體釋放函數free的外殼函數FreeMemory,在ANSI C中,如果傳遞給free函數的指針是個無效指針,那麼free函數的結果是未定義的。是以對于未定義的特性,我們要麼改變設計以消除未定義的特性,要麼使用斷言檢查未定義的特性不會被使用。同時,還有一點需要注意:即使我們把記憶體釋放了,但是如果還有其他指針指向這塊記憶體,而且繼續對這塊記憶體進行通路,得到的似乎還是有效資料。是以已經釋放了的無用記憶體仍然包含着好像有效的資料,這将讓我們程式錯誤,并且難以發現。

FreeMemory 中首先檢查pv是否為空指針,作者不贊成為了實作友善,就把無意義的空指針傳給FreeMemory函數,是以用斷言檢查pv不能為空指針,接着加入調試代碼,把即将被釋放的記憶體用垃圾填充。這樣當我們對已經被釋放的記憶體塊進行通路時,得到的就是垃圾資訊。這樣有助于我們發現錯誤。這裡用到的sizeofBlock函數是需要我們自己編寫的調試函數,用來擷取指針所指向記憶體塊的大小。

再來看realloc的外殼函數fResizeMemory,fResizeMemory函數用來改變記憶體塊的大小。fResizeMemory可以是縮小記憶體,也可以是擴大記憶體。基于上面的分析,我們可以寫出這樣的代碼:

代碼中有一點需要說明,就是sizeOld這個用于調試的局部變量。用#ifdef來保證sizeOld隻有在程式調試時才可以使用,當程式傳遞版本中不小心使用了這個變量,就會獲得一個編譯錯誤。上面的程式代碼盡管看上去有些複雜,但是調試版本本來就不必短小精悍。一般可以在程式中加上你認為有必要的任何調試代碼,以增強程式的查錯能力。

          沖掉無用資訊,以免被錯誤地使用。

但是上述程式還有一個隐藏的非常深的錯誤。ANSI C中說明了realloc擴大記憶體時有可能會讓原有的記憶體塊進行移動,也就是說擴大後的記憶體塊有可能被分到新的位址處,該塊原有的内容被拷貝到新的位置。這會導緻什麼後果呢?想象一下,如果有兩個指針p,q,它們都指向同一塊記憶體,然後realloc把指針p作為參數,對這塊記憶體進行擴大,而此時記憶體塊發生了移動,p指向了新的記憶體塊位置,而q仍然指向的是原來的記憶體塊位置,而原來的記憶體塊位置其實已經被釋放了,但是資料可能看起來仍然有效。更要命的是,realloc的這個特性可能很少發生,是以你的程式是震蕩的,時而正确,時而出錯。

你可能給出一種解決方案:在fResizeMemory中加入調試代碼,如果記憶體塊發生移動時,就把原來的記憶體塊用無用資訊填充,當我們對原來的記憶體塊進行通路時,得到無用資訊,就會發現這個錯誤。很遺憾,這種方案是不行的,因為原來的記憶體塊是記憶體管理程式自己釋放的,我們不知道記憶體管理程式會對其釋放了的記憶體空間如何處理。一旦我們動了這部分記憶體空間,就會有破壞整個系統的危險。

盡管上面描述的realloc的這個特性可能很少發生,但是我們編寫無錯代碼的一個準則就是:“不要讓事情很少發生”。是以我們需要确定子系統可能發生哪些事情,并且使他們經常發生和一定發生。如果确實發現子系統中極罕見的行為,要千方百計地使其重制。

對于realloc的這個特性,我們無法控制讓realloc經常移動記憶體塊,但是我們可以在調試代碼中模仿realloc的這個特性,我們在realloc擴大記憶體塊時,通過先建立一個新的記憶體塊,然後把原來記憶體塊的内容拷貝到這個新的記憶體塊,最後釋放掉原有的記憶體塊,就可以準确的模仿出realloc的全部動作。

上面的程式代碼不僅使相應的記憶體發生了移動,而且還充掉了原有記憶體塊的内容,因為它調用了FreeMemory釋放原有記憶體塊的同時,該記憶體塊的内容也會被垃圾資訊填充。還有一點需要說明,即使我們通過移動記憶體塊的位置模仿了realloc的行為,但是我們還是調用了realloc函數,因為調試代碼隻是多餘的代碼,而不是不同的代碼,除非有非常值得考慮的理由,否則永遠執行原有的非調試代碼。畢竟查出代碼錯誤的最好方法是執行代碼,是以我們盡可能執行原有的非調試代碼。

可能你還是對上述做法的原因不是很清楚,筆者的了解是:realloc擴大記憶體塊可能讓記憶體塊的位置發生移動,但是realloc的這個特性很少發生,是以你的程式有可能長時間都是正确的,但是一旦realloc的這個特性發生了,有可能你的程式就會發生錯誤。那為了我們的程式能夠在這種情況下仍然成功,那我們在程式的調試版本中,通過模拟realloc這個特性,檢查我們程式中是否存在錯誤。如果程式能夠正常運作,那我們就不用擔心程式的傳遞版本中realloc的這個特性了,因為我們已經在調試版本中考慮過了。是以如果某件事情很少發生,這并沒有什麼問題,隻要在程式的調試版本中不少發生就行了。

          如果某件事甚少發生的話,設法使其經常發生。

總結:

1,考察所編寫的子系統,問自己:“在什麼樣的情況下,程式員在使用這些子系統時會犯錯誤。”在系統中加上相應的斷言和确認檢查代碼,以捕捉難以發現的錯誤和常見的錯誤”。

2,找出程式中可能引起随機行為的因素,将它們從程式的調試版本中清除。這樣至少每次程式出錯時,都會得到同樣的錯誤結果。

3,如果編寫的子系統釋放了記憶體(或其他資源),并是以産生了“無用資訊”,那麼要把它攪亂,使它真的像無用資訊。否則,這些被釋放了的資料就有可能仍被引用,而又不會引起注意。

4,如果編寫的子系統中某些事情可能發生,那麼要為子系統加上相應的調試代碼,使這些事情一定發生。這樣對于那些通常得不到執行的代碼,可以提供檢查出錯誤的可能性。

最後依舊以一句話結束這篇文章:

錯誤處理程式之是以往往容易出錯,正是因為它們很少被執行到。