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

來源:互聯網
上載者:User

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

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

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

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

flag fNewMemory(void** ppv,size_t size){byte** ppb=(byte**)ppv;*ppb=(byte*)malloc(size);return (*ppb!=NULL); }

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

<<編程精粹--編寫高品質的C語言代碼(2):自己設計並使用斷言(一)>>中講過,對於未定義的特性,要麼將其從程式設計裡去掉,要麼利用斷言來驗證其不會被用到。ANSI C中的malloc的未定義特性有兩點:1,當分配記憶體塊的大小為0時,其結果未定義;2,當記憶體塊分配成功後,記憶體塊的初始內容未定義。對於第一點,我們可以使用斷言來進行檢查,但是對於第二點,我們無法用斷言來進行驗證。那如果我們人為地利用一個常規值(例如0)來填充這個記憶體塊,這樣就可以消去這個未定義的特性。但是這樣至少帶來兩點影響:1,對記憶體塊填充一個常規值有可能會影響程式的結果。2,有可能會隱瞞錯誤(例如程式員在分配記憶體後未初始化,但是由於事先對記憶體塊填充了一個值,所以程式可能正常運行,從而隱瞞錯誤)。

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

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

#define bGarbage 0xCCflag fNewMemory(void** ppv,size_t size){byte** ppb=(byte**)ppv;ASSERT(ppv!=NULL&&size!=0)*ppb=(byte*)malloc(size);#ifdef DEBUG       {     if(*ppb!=NULL)        memset(*ppb,bGarbage,size);         }        #endifreturn (*ppb!=NULL); } 

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

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

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

void FreeMemory(void* pv){ASSERT(pv!=NULL);#ifdef DEBUG    {    memset(pv,bGarbage,sizeofBlock(pv));    }     #endif    free(pv);} 


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

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

flag fResizeMemory(void** ppv,size_t sizeNew){byte** ppb=(byte**) ppv;byte* pbResize;#ifdef DEBUG    size_t sizeOld;    #endif    ASSERT(ppb!=NULL&&sizeNew!=0);    #ifdef DEBUG    {    sizeOld=sizeof(*ppb);    /** 如果縮小,衝掉尾部無用的記憶體 */     if(sizeNew<sizeOld)    {        memset((*ppb)+sizeNew,bGarbage,sizeOld-sizeNew);    }    }    #endif    pbResize=(byte*)realloc(*ppb,sizeNew);    if(pbResize!=NULL)    {    #ifdef DEBUG    {    /** 如果擴大,對尾部增加的內容用無用資訊填充 */     if(sizeNew>sizeOld)    {           memset((*ppb)+sizeOld,bGarbage,sizeNew-sizeOld);    }         }    #endif    *ppb=pbResize;    }    return (pbResize!=NULL);       }

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

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

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

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

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

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

flag fResizeMemory(void** ppv,size_t sizeNew){byte** ppb=(byte**) ppv;byte* pbResize;#ifdef DEBUGsize_t sizeOld;#endif;ASSERT(ppv!=NULL&&sizeNew!=0);#ifdef DEBUG{sizeOld=sizeofBlock(*ppb);if(sizeOld>sizeNew){memset(ppb+sizeNew,bGarbage,sizeOld-sizeNew);} else if(sizeOld<sizeNew){            byte* pbNew;            /** 類比realloc的記憶體塊移動 */ if(fMemoryNew(&pbNew,sizeNew)){memcpy(pbNew,*ppb,sizeOld);FreeMemory(*ppb);*ppb=pbNew;}  }}#endifpbResize=(byte*)realloc(*ppb,sizeNew);    /** 後面代碼省略 */}


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

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

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

總結:

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

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

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

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


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


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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.