軟件編程的過程中,程序員所犯的并不一定是重大錯誤,反而一些常見的錯誤屢見不鮮。這些錯誤嚴(yán)重影響到編程中測試和調(diào)試的時間。這一部分總結(jié)一下,時時提醒自己,告誡自己避免這些錯誤。 常見的錯誤有: 1、內(nèi)存泄露 在c/c++中,內(nèi)存管理器不會幫助你自動回收不再使用的內(nèi)存,不管在什么情況下,采取謹(jǐn)慎的態(tài)度,杜絕內(nèi)存泄露的出現(xiàn),都是上策。盡管一些工具可以幫助我們檢查內(nèi)存泄露問題,但是編程時還是應(yīng)該仔細(xì)一點,盡早排除這類錯誤,工具只是用作驗證的手段。 2、內(nèi)存越界訪問 1)讀越界,即讀了不屬于自己的數(shù)據(jù),如果所讀的內(nèi)存地址是無效的,程度立刻就崩潰了。如果所讀內(nèi)存地址是有效的,雖然讀的時候不會出問題,但由于讀到的數(shù)據(jù)是隨機的,它會產(chǎn)生不可預(yù)料的后果。 2)寫越界,也叫緩沖區(qū)溢出,所寫入的數(shù)據(jù)對別人來說是隨機的,它也會產(chǎn)生不可預(yù)料的后果。 內(nèi)存越界訪問造成的后果非常嚴(yán)重,是程序穩(wěn)定性的致命威脅之一。更麻煩的是,它造成的后果是隨機的,表現(xiàn)出來的癥狀和時機也是隨機的,讓BUG的現(xiàn)象和本質(zhì)看似沒有什么聯(lián)系,這給BUG的定位帶來極大的困難。一些工具可以夠幫助檢查內(nèi)存越界訪問的問題,但也不能太依賴于工具。內(nèi)存越界訪問通常是動態(tài)出現(xiàn)的,即依賴于測試數(shù)據(jù),在極端的情況下才會出現(xiàn),除非精心設(shè)計測試數(shù)據(jù),工具也無能為力。工具本身也有一些限制,甚至在一些大型項目中,工具變得完全不可用。比較保險的方法還是在編程是就小心,特別是對于外部傳入的參數(shù)要仔細(xì)檢查。 c/c++中,數(shù)組中的越界問題尤其常見。例如: --------------------------------------------------------------------------------------- #include <stdio.h> #include <stdlib.h> #include <string.h> int main(int argc, char* argv[]) { char str[10]; int array[10] = {0,1,2,3,4,5,6,7,8,9}; int data = array[10];// read error: array[10] array[10] = data; if(argc == 2) { strcpy(str, argv[1]);// write error: argv[1]的字符串可能字符個數(shù)超過10 } return 0; } ---------------------------------------------------------------------------------------- 3、野指針 野指針是指已經(jīng)釋放了的內(nèi)存指針。當(dāng)調(diào)用free(p);p本身沒有變化,它指向的內(nèi)存仍然是有效的。釋放掉的內(nèi)存會被內(nèi)存管理器重新分配,此時,野指針指向的內(nèi)存已經(jīng)被賦予新的意義。對野指針指向內(nèi)存的訪問,無論是有意還是無意的,都為此會付出巨大代價,因為它造成的后果,如同越界訪問一樣是不可預(yù)料的。 較好的對策是:釋放內(nèi)存后立即把對應(yīng)指針置為空值,這是避免野指針常用的方法。這個方法簡單有效,只是要注意,當(dāng)然指針是從函數(shù)外層傳入的時,在函數(shù)內(nèi)把指針置為空值,對外層的指針沒有影響。比如,你在析構(gòu)函數(shù)里把this指針置為空值,沒有任何效果,這時應(yīng)該在函數(shù)外層把指針置為空值。 4、訪問空指針 空指針在C/C++中占有特殊的地址,通常用來判斷一個指針的有效性??罩羔樢话愣x為0。現(xiàn)代操作系統(tǒng)都會保留從0開始的一塊內(nèi)存,至于這塊內(nèi)存有多大,視不同的操作系統(tǒng)而定。一旦程序試圖訪問這塊內(nèi)存,系統(tǒng)就會觸發(fā)一個異常/信號。 操作系統(tǒng)為什么要保留一塊內(nèi)存,而不是僅僅保留一個字節(jié)的內(nèi)存呢?原因是:一般內(nèi)存管理都是按頁進(jìn)行管理的,無法單純保留一個字節(jié),至少要保留一個頁面。保留一塊內(nèi)存也有額外的好處,可以檢查諸如p==NULL; p[1]之類的內(nèi)存錯誤。 在一些嵌入式系統(tǒng)(如arm7)中,從0開始的一塊內(nèi)存是用來安裝中斷向量的,沒有MMU的保護(hù),直接訪問這塊內(nèi)存好像不會引發(fā)異常。不過這塊內(nèi)存是代碼段的,不是程序中有效的變量地址,所以用空指針來判斷指針的有效性仍然可行。 5、引用未初始化的變量 未初始化變量的內(nèi)容是隨機的(有的編譯器會在調(diào)試版本中把它們初始化為固定值,如0xcc),使用這些數(shù)據(jù)會造成不可預(yù)料的后果,調(diào)試這樣的BUG也是非常困難的。 較好的習(xí)慣是:在聲明變量時就對它進(jìn)行初始化。另外也要重視編譯器的警告信息,發(fā)現(xiàn)有引用未初始化的變量,立即修改過來。 6、指針運算 依照指針類型進(jìn)行指針運算,指針偏移值。 7、結(jié)構(gòu)的成員順序變化引起的錯誤 如: ------------------------------------------------------------------------------------- Struct s { int l; char* p; }; int main(int argc, char* argv[]) { struct s s1 = {4, "abcd"}; return 0; } ------------------------------------------------------------------------------------- 以上這種方式是非常危險的,原因在于你對結(jié)構(gòu)的內(nèi)存布局作了假設(shè)。如果這個結(jié)構(gòu)是第三方提供的,他很可能調(diào)整結(jié)構(gòu)中成員的相對位置。而這樣的調(diào)整往往不會在文檔中說明,你自然很少去關(guān)注。如果調(diào)整的兩個成員具有相同數(shù)據(jù)類型,編譯時不會有任何警告,而程序的邏輯可能相距十萬八千里了。 正確的初始化方法應(yīng)該是(當(dāng)然,一個成員一個成員的初始化也行): ------------------------------------------------------------------------------------- struct s { int l; char* p; }; int main(int argc, char* argv[]) { struct s s1 = {.l=4, .p = "abcd"}; return 0; } ------------------------------------------------------------------------------------- 8、結(jié)構(gòu)的大小變化引發(fā)的錯誤 我們看看下面這個例子: ------------------------------------------------------------------------------------- struct base { int n; }; struct s { struct base b; int m; }; ------------------------------------------------------------------------------------- 在OOP中,我們可以認(rèn)為第二個結(jié)構(gòu)繼承了第一結(jié)構(gòu),這有什么問題嗎?當(dāng)然沒有,這是C語言中實現(xiàn)繼承的基本手法。 現(xiàn)在假設(shè)第一個結(jié)構(gòu)是第三方提供的,第二個結(jié)構(gòu)是你自己的。第三方提供的庫是以DLL方式分發(fā)的,DLL最大好處在于可以獨立替換。但隨著軟件的進(jìn)化,問題可能就來了。 當(dāng)?shù)谌皆诘谝粋€結(jié)構(gòu)中增加了一個新的成員int k;,編譯好后把DLL給你,你直接把它給了客戶了,讓他們替換掉老版本。程序加載時不會有任何問題,在運行邏輯可能完全改變!原因是兩個結(jié)構(gòu)的內(nèi)存布局重疊了。 解決這類錯誤的唯一辦法就是重新編譯全部代碼。由此看來,動態(tài)庫并不見得可以動態(tài)替換,如果你想了解更多相關(guān)內(nèi)容,建議你閱讀《COM本質(zhì)論》。 9、分配/釋放不配對 大家都知道m(xù)alloc要和free配對使用,new要和delete/delete[]配對使用,重載了類new操作,應(yīng)該同時重載類的delete/delete[]操作。這些都是書上反復(fù)強調(diào)過的,除非當(dāng)時暈了頭,一般不會犯這樣的低級錯誤。 而有時候我們卻被蒙在鼓里,兩個代碼看起來都是調(diào)用的free函數(shù),實際上卻調(diào)用了不同的實現(xiàn)。比如在Win32下,調(diào)試版與發(fā)布版,單線程與多線程是不同的運行時庫,不同的運行時庫使用的是不同的內(nèi)存管理器。一不小心鏈接錯了庫,那你就麻煩了。程序可能動則崩潰,原因在于在一個內(nèi)存管理器中分配的內(nèi)存,在另外一個內(nèi)存管理器中釋放時就會出現(xiàn)問題。 10、返回指向臨時變量的指針 大家都知道,棧里面的變量都是臨時的。當(dāng)前函數(shù)執(zhí)行完成時,相關(guān)的臨時變量和參數(shù)都被清除了。不能把指向這些臨時變量的指針返回給調(diào)用者,這樣的指針指向的數(shù)據(jù)是隨機的,會給程序造成不可預(yù)料的后果。 下面是個錯誤的例子: ------------------------------------------------------------------------------------- char* get_str(void) { char str[] = {"abcd"}; return str; } int main(int argc, char* argv[]) { char* p = get_str(); printf("%s\n", p); return 0; } ------------------------------------------------------------------------------------- 下面這個例子沒有問題,大家知道為什么嗎? ------------------------------------------------------------------------------------- char* get_str(void) { char* str = {"abcd"}; return str; } int main(int argc, char* argv[]) { char* p = get_str(); printf("%s\n", p); return 0; } ------------------------------------------------------------------------------------- 11、試圖修改常量 在函數(shù)參數(shù)前加上const修飾符,只是給編譯器做類型檢查用的,編譯器禁止修改這樣的變量。但這并不是強制的,你完全可以用強制類型轉(zhuǎn)換繞過去,一般也不會出什么錯。 而全局常量和字符串,用強制類型轉(zhuǎn)換繞過去,運行時仍然會出錯。原因在于它們是放在.rodata里面的,而.rodata內(nèi)存頁面是不能修改的。試圖對它們修改,會引發(fā)內(nèi)存錯誤。 下面這個程序在運行時會出錯: ------------------------------------------------------------------------------------- int main(int argc, char* argv[]) { char* p = "abcd"; *p = '1'; return 0; } ------------------------------------------------------------------------------------- 12、誤解傳值與傳引用(函數(shù)參數(shù)單向傳值問題) 在C/C++中,參數(shù)默認(rèn)傳遞方式是傳值的,即在參數(shù)入棧時被拷貝一份。在函數(shù)里修改這些參數(shù),不會影響外面的調(diào)用者。如: ------------------------------------------------------------------------------------- #include <stdlib.h> #include <stdio.h> void get_str(char* p) { p = malloc(sizeof("abcd")); strcpy(p, "abcd"); return; } int main(int argc, char* argv[]) { char* p = NULL; get_str(p); printf("p=%p\n", p); return 0; } ------------------------------------------------------------------------------------- 在main函數(shù)里,p的值仍然是空值。當(dāng)然在函數(shù)里修改指針指向的內(nèi)容是可以的。 13、重名符號(變量的作用域問題) 無論是函數(shù)名還是變量名,如果在不同的作用范圍內(nèi)重名,自然沒有問題。但如果兩個符號的作用域有交集,如全局變量和局部變量,全局變量與全局變量之間,重名的現(xiàn)象一定要堅決避免。gcc有一些隱式規(guī)則來決定處理同名變量的方式,編譯時可能沒有任何警告和錯誤,但結(jié)果通常并非你所期望的。 下面例子編譯時就沒有警告: ------------------------------------------------------------------------------------- t.c #include <stdlib.h> #include <stdio.h> int count = 0; int get_count(void) { return count; } main.c #include <stdio.h> extern int get_count(void); int count; int main(int argc, char* argv[]) { count = 10; printf("get_count=%d\n", get_count()); return 0; } ------------------------------------------------------------------------------------- 如果把main.c中的int count;修改為int count = 0;,gcc就會編輯出錯,說multiple definition of `count’。它的隱式規(guī)則比較奇妙吧,所以還是不要依賴它為好。 14、棧溢出 我們在前面關(guān)于堆棧的一節(jié)講過,在PC上,普通線程的??臻g也有十幾M,通常夠用了,定義大一點的臨時變量不會有什么問題。 而在一些嵌入式中,線程的??臻g可能只5K大小,甚至小到只有256個字節(jié)。在這樣的平臺中,棧溢出是最常用的錯誤之一。在編程時應(yīng)該清楚自己平臺的限制,避免棧溢出的可能。 15、誤用sizeof。 盡管C/C++通常是按值傳遞參數(shù),而數(shù)組則是例外,在傳遞數(shù)組參數(shù)時,數(shù)組退化為指針(即按引用傳遞),用sizeof是無法取得數(shù)組的大小的。 從下面這個例子可以看出: ------------------------------------------------------------------------------------- void test(char str[20]) { printf("%s:size=%d\n", __func__, sizeof(str)); } int main(int argc, char* argv[]) { char str[20] = {0}; test(str); printf("%s:size=%d\n", __func__, sizeof(str)); return 0; } [root@localhost mm]# ./t.exe test:size=4 main:size=20 ------------------------------------------------------------------------------------- 16、字節(jié)對齊 字節(jié)對齊主要目的是提高內(nèi)存訪問的效率。但在有的平臺(如arm7)上,就不光是效率問題了,如果不對齊,得到的數(shù)據(jù)是錯誤的。 所幸的是,大多數(shù)情況下,編譯會保證全局變量和臨時變量按正確的方式對齊。內(nèi)存管理器會保證動態(tài)內(nèi)存按正確的方式對齊。要注意的是,在不同類型的變量之間轉(zhuǎn)換時要小心,如把char*強制轉(zhuǎn)換為int*時,要格外小心。 另外,字節(jié)對齊也會造成結(jié)構(gòu)大小的變化,在程序內(nèi)部用sizeof來取得結(jié)構(gòu)的大小,這就足夠了。若數(shù)據(jù)要在不同的機器間傳遞時,在通信協(xié)議中要規(guī)定對齊的方式,避免對齊方式不一致引發(fā)的問題。 17、大小端問題(字節(jié)順序) 字節(jié)順序歷來是設(shè)計跨平臺軟件時頭疼的問題。字節(jié)順序是關(guān)于數(shù)據(jù)在物理內(nèi)存中的布局的問題,最常見的字節(jié)順序有兩種:大端模式與小端模式。 大端模式是高位字節(jié)數(shù)據(jù)存放在低地址處,低位字節(jié)數(shù)據(jù)存放在高地址處。 小端模式指低位字節(jié)數(shù)據(jù)存放在內(nèi)存低地址處,高位字節(jié)數(shù)據(jù)存放在內(nèi)存高地址處; 在普通軟件中,字節(jié)順序問題并不引人注目。而在開發(fā)與網(wǎng)絡(luò)通信和數(shù)據(jù)交換有關(guān)的軟件時,字節(jié)順序問題就要特殊注意了。 18、多線程共享變量沒有用valotile修飾 關(guān)鍵字valotile的作用是告訴編譯器,不要把變量優(yōu)化到寄存器里。在開發(fā)多線程并發(fā)的軟件時,如果這些線程共享一些全局變量,這些全局變量最好用valotile修飾。這樣可以避免因為編譯器優(yōu)化而引起的錯誤,這樣的錯誤非常難查。 19、忘記函數(shù)的返回值 函數(shù)需要返回值,如果你忘記return語句,它仍然會返回一個值,因為在i386上,EAX用來保存返回值,如果沒有明確返回,EAX最后的內(nèi)容被返回,所以EAX的內(nèi)容是隨機的。 第三部分編寫程序和檢查程序的良好習(xí)慣 第一階段分析設(shè)計 充分理解需求,設(shè)計軟件框架時數(shù)據(jù)流,控制流盡可能完整;形成盡可能詳盡的流程圖;涉及到算法類的設(shè)計時,要將涉及到的數(shù)學(xué)公式或思想深入理解,如果需要采用快速算法的,可以參考經(jīng)典的算法。參考網(wǎng)上的幾篇博客和《高質(zhì)量》 第二階段編寫代碼的習(xí)慣 按照設(shè)計文檔編,遵照良好的規(guī)范書寫代碼(文獻(xiàn)【1】),時時刻刻牢記上面提到的二十種常見錯誤,爭取第一次書寫時就避免這樣的錯誤。 程序完成后,不急著編譯,而是先仔細(xì)閱讀: 1、第一遍閱讀:重點關(guān)注語法錯誤、代碼排版和命名規(guī)則等等問題,只要看不順眼就修改它們。讀完之后,你的代碼很少有低級錯誤,看起來也比較干凈清爽。 2、第二遍閱讀:重點關(guān)注第二部分的常見編程錯誤。 3、模擬計算機執(zhí)行:常見錯誤是比較死的東西,按照檢查列表一條一條的做就行了。有些邏輯通常不是這么直觀的,這時可以自己模擬計算機去執(zhí)行,假想你自己是計算機,讀入這些代碼時你會怎么處理。這種方法能有效的完善我們的思路,考慮不同的輸入數(shù)據(jù),各種邊界值,這能幫助我們想到一些沒有處理的情況,讓程序的邏輯更嚴(yán)謹(jǐn)。 至于讀幾遍合適,要根據(jù)情況而定,通常三遍是最佳的投資。 其他幾項良好習(xí)慣: 1. 一組良好的接口,實現(xiàn)構(gòu)造和析構(gòu)函數(shù)。 2. 隱藏內(nèi)部細(xì)節(jié):內(nèi)部函數(shù)盡量使用static聲明,最好結(jié)構(gòu)聲明都不要放在頭文件里,除非確實需要提供給外部。 3. 良好的編程風(fēng)格:排版,布局,注釋,命名需要規(guī)范。 4. 盡量滿足同時供c/c++調(diào)用,條件聲明成 extern“c” --------------------------------------------------------------- #if defined(__cplusplus) extern "C" { #endif #if defined(__cplusplus) } #endif --------------------------------------------------------------- 5. 需要帶有自動測試程序,Demo程序。 6. 加上#ifndef/#define/#endif:防止重復(fù)include相同的頭文件 |
|