隨著諸如代碼重構和單元測試等方法引入實踐,調試技能漸漸弱化了,甚至有人主張廢
除調試器。這是有道理的,原因在於調試的代價往往太大了,特別是調試系統整合之後的
BUG,一個BUG花了幾天甚至數周時間並非罕見。
而這些難以定位的BUG基本上可以歸為兩類:記憶體錯誤和並發問題。而又以記憶體錯誤最
為普遍,即使是久經沙場的老手,也有時也難免落入陷阱。前事不忘,後世之師,瞭解這些
常見的錯誤,在編程時就加以注意,把出錯的機率降到最低,可以節省不少時間。
這些列舉一些常見的記憶體錯誤,供新手參考。
1. 記憶體泄露
大家都知道,在堆上分配的記憶體,如果不再使用了,應該把它釋放掉,以便後面其 它
地方可以重用。在C/C++中,記憶體管理器不會幫你自動回收不再使用的記憶體。如果你忘了釋
放不再使用的記憶體,這些記憶體就不能被重用,就造成了所謂的記憶體泄露。
把記憶體泄露列為首位,倒並不是因為它有多麼嚴重的後果,而因為它是最為常見的一類
錯誤。一兩處記憶體泄露通常不至於讓程式崩潰,也不會出現邏輯上的錯誤,加上進程退出時
,系統會自動釋放該進程所有相關的記憶體,所以記憶體泄露的後果相對來說還是比較溫和的。
當然了,量變會產生質變,一旦記憶體泄露過多以致於耗盡記憶體,後續記憶體配置將會失敗,程
序可能因此而崩潰。
現在的PC機記憶體夠大了,加上進程有獨立的記憶體空間,對於一些小程式來說,記憶體泄露
已經不是太大的威脅。但對於大型軟體,特別是長時間啟動並執行軟體,或者嵌入式系統來說,
記憶體泄露仍然是致命的因素之一。
不管在什麼情況下,採取比較謹慎的態度,杜絕記憶體泄露的出現,都是可取的。相反,
認為記憶體有的是,對記憶體泄露放任自流都不是負責的。儘管一些工具可以協助我們檢查記憶體
泄露問題,我認為還是應該在編程時就仔細一點,及早排除這類錯誤,工具只是用作驗證的
手段。
2. 記憶體越界訪問
記憶體越界訪問有兩種:一種是讀越界,即讀了不屬於自己的資料,如果所讀的記憶體位址
是無效的,程度立刻就崩潰了。如果所讀記憶體位址是有效,在讀的時候不會出問題,但由
於讀到的資料是隨機的,它會產生不可預料的後果。另外一種是寫越界,又叫緩衝區溢位。
所寫入的資料對別人來說是隨機的,它也會產生不可預料的後果。
記憶體越界訪問造成的後果非常嚴重,是程式穩定性的致命威脅之一。更麻煩的是,它造
成的後果是隨機的,表現出來的癥狀和時機也是隨機的,讓BUG的現象和本質看似沒有什麼
聯絡,這給BUG的定位帶來極大的困難。
一些工具可以夠協助檢查記憶體越界訪問的問題,但也不能太依賴於工具。記憶體越界訪問
通常是動態出現的,即依賴於測試資料,在極端的情況下才會出現,除非精心設計測試資料
,工具也無能為力。工具本身也有一些限制,甚至在一些大型項目中,工具變得完全不可用
。比較保險的方法還是在編程是就小心,特別是對於外部傳入的參數要仔細檢查。
3. 野指標
野指標是指那些你已經釋放掉的記憶體指標。當你調用free(p)時,你真正清楚這個動作
背後的內容嗎?你會說p指向的記憶體被釋放了。沒錯,p本身有變化嗎?答案是p本身沒有變
化。它指向的記憶體仍然是有效,你繼續讀寫p指向的記憶體,沒有人能攔得住你。
釋放掉的記憶體會被記憶體管理器重新分配,此時,野指標指向的記憶體已經被賦予新的意義
。對野指標指向記憶體的訪問,無論是有意還是無意的,都為此會付出巨大代價,因為它造成
的後果,如同越界訪問一樣是不可預料的。
釋放記憶體後立即把對應指標置為空白值,這是避免野指標常用的方法。這個方法簡單有效
,只是要注意,當然指標是從函數外層傳入的時,在函數內把指標置為空白值,對外層的指標
沒有影響。比如,你在解構函式裡把this指標置為空白值,沒有任何效果,這時應該在函數外
層把指標置為空白值。
4. 訪問null 指標
null 指標在C/C++中佔有特殊的地址,通常用來判斷一個指標的有效性。null 指標一般定義
為0。現代作業系統都會保留從0開始的一塊記憶體,至於這塊記憶體有多大,視不同的作業系統
而定。一旦程式試圖訪問這塊記憶體,系統就會觸發一個異常。
作業系統為什麼要保留一塊記憶體,而不是僅僅保留一個位元組的記憶體呢?原因是:一般內
存管理都是按頁進行管理的,無法單純保留一個位元組,至少要保留一個頁面。保留一塊記憶體
也有額外的好處,可以檢查諸如p=NULL; p[1]之類的記憶體錯誤。
在一些嵌入式系統(如arm7)中,從0開始的一塊記憶體是用來安裝中斷向量的,沒有MMU的
保護,直接存取這塊記憶體好像不會引發異常。不過這塊記憶體是程式碼片段的,不是程式中有效
變數地址,所以用null 指標來判斷指標的有效性仍然可行。
在訪問指標指向的記憶體時,確保指標不是null 指標。訪問null 指標指向的記憶體,通常會導致
程度崩潰,或者不可預料的錯誤。
5. 引用未初始化的變數
未初始設定變數的內容是隨機的,使用這些資料會造成不可預料的後果,調試這樣的BUG
也是非常困難的。
對於態度嚴謹的程度員來說,防止這類BUG非常容易。在聲明變數時就對它進行初始化
,是一個編程的好習慣。另外也要重視編譯器的警告資訊,發現有引用未初始化的變數,立
即修改過來。
6. 不清楚指標運算
對於一些新手來說,指標常常讓他們犯糊塗。比如:
int *p = …; p+1; 等於 (size_t)p + 1; 嗎?
老手自然清楚,新手可能就搞不清了。事實上:
p+n; 等於 (size_t)p + n * sizeof(*p);
指標是C/C++中最有力的武器,功能非常強大,無論是變數指標還是函數指標,都應該
掌握都非常熟練。只要有不確定的地方,馬上寫個小程式驗證一下。對每一個細節都瞭然於
胸,在編程時會省下不少時間。
7. 結構的成員順序變化引發的錯誤
在初始化一個結構時,老手可能很少像新手那樣老老實實的,一個成員一個成員的為結
構初始化,而是採用捷徑,如:
Struct s
{
int l;
char* p;
};
int main(int argc, char* argv[])
{
struct s s1 = {4, "abcd"};
return 0;
}
以上這種方式是非常危險的,原因在於你對結構的記憶體布局作了假設。如果這個結構是
第三方提供的,他很可能調整結構中成員的相對位置。而這樣的調整往往不會在 文檔中說
明,你自然很少去關注。如果調整的兩個成員具有相同資料類型,編譯時間不會有任何警告,
而程式的邏輯上可能相距十萬八千裡了。
正確的初始化方法應該是(當然,一個成員一個成員的初始化也行):
struct s
{
int l;
char* p;
};
int main(int argc, char* argv[])
{
struct s s1 = {.l=4, .p = "abcd"};
struct s s2 = {l:4, p:"abcd"};
return 0;
}
8. 結構的大小變化引發的錯誤
我們看看下面這個例子:
struct base
{
int n;
};
struct s
{
struct base b;
int m;
};
在OOP中,我們可以認為第二個結構繼承了第一結構,這有什麼問題嗎?當然沒有,這
是C語言中實現繼承的基本手法。
現在假設第一個結構是第三方提供的,第二個結構是你自己的。第三方提供的庫是以
DLL方式分發的,DLL最大好處在於可以獨立替換。但隨著軟體的進化,問題可能就來了。
當第三方在第一個結構中增加了一個新的成員int k;,編譯好後把DLL給你,你直接給
了客戶了。程式載入時不會有任何問題,在運行邏輯可能完全改變!原因是兩個結構的記憶體
布局重疊了。解決這類錯誤的唯一辦法就是全部重新相關的代碼。
解決這類錯誤的唯一辦法就是重新編譯全部代碼。由此看來,DLL並不見得可以動態替
換,如果你想瞭解更多相關內容,建議閱讀《COM本質論》。
9. 分配/釋放不配對
大家都知道malloc要和free配對使用,new要和delete/delete[]配對使用,重載了類
new操作,應該同時重載類的delete/delete[]操作。這些都是書上反覆強調過的,除非當時
暈了頭,一般不會犯這樣的低級錯誤。
而有時候我們卻被蒙在鼓裡,兩個代碼看起來都是調用的free函數,實際上卻調用了不
同的實現。比如在Win32下,調試版與發布版,單線程與多線程是不同的執行階段程式庫,不同的
執行階段程式庫使用的是不同的記憶體管理器。一不小心連結錯了庫,那你就麻煩了。程式可能動則
崩潰,原因在於在一個記憶體管理器中分配的記憶體,在另外一個記憶體管理器中釋放時出現了問
題。
10. 返回指向臨時變數的指標
大家都知道,棧裡面的變數都是臨時的。當前函數執行完成時,相關的臨時變數和參數
都被清除了。不能把指向這些臨時變數的指標返回給調用者,這樣的指標指向的資料是隨機
的,會給程式造成不可預料的後果。
下面是個錯誤的例子:
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. 試圖修改常量
在函數參數前加上const修飾符,只是給編譯器做類型檢查用的,編譯器禁止修改這樣
的變數。但這並不是強制的,你完全可以用強制類型轉換繞過去,一般也不會出什麼錯。
而全域常量和字串,用強制類型轉換繞過去,運行時仍然會出錯。原因在於它們是是
放在常量區裡面的,而常量區記憶體頁面是不能修改的。試圖對它們修改,會引發記憶體錯誤。
下面這個程式在運行時會出錯:
int main(int argc, char* argv[])
{
char* p = "abcd";
*p = '1';
return 0;
}
12. 誤解傳值與傳引用
在C/C++中,參數預設傳遞方式是傳值的,即在參數入棧時被拷貝一份。在函數裡修改
這些參數,不會影響外面的調用者。如:
#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函數裡,p的值仍然是空值。
13. 重名符號
無論是函數名還是變數名,如果在不同的作用範圍內重名,自然沒有問題。但如果兩個
符號的範圍有交集,如全域變數和局部變數,全域變數與全域變數之間,重名的現象一定
要堅決避免。gcc有一些隱式規則來決定處理同名變數的方式,編譯時間可能沒有任何警告和
錯誤,但結果通常並非你所期望的。
14. 棧溢出
我們在前面關於堆棧的一節講過,在PC上,普通線程的棧空間也有十幾M,通常夠用了
,定義大一點的臨時變數不會有什麼問題。
而在一些嵌入式中,線程的棧空間可能只5K大小,甚至小到只有256個位元組。在這樣的
平台中,棧溢出是最常用的錯誤之一。在編程時應該清楚自己平台的限制,避免棧溢出的可
能。
15. 誤用sizeof。
儘管C/C++通常是按值傳遞參數,而數組則是例外,在傳遞數組參數時,數組退化為指
針(即按引用傳遞),用sizeof是無法取得數組的大小的。
從下面這個例子可以看出:
void test(char str[20])
{
printf("test:size=%d/n", sizeof(str));
}
int main(int argc, char* argv[])
{
char str[20];
test(str);
printf("main:size=%d/n",sizeof(str));
return 0;
}
test:size=4
main:size=20
16. 位元組對齊
位元組對齊主要目的是提高記憶體訪問的效率。但在有的平台(如arm7)上,就不光是效率問
題了,如果不對齊,得到的資料是錯誤的。
所幸的是,大多數情況下,編譯會保證全域變數和臨時變數按正確的方式對齊。記憶體管
理器會保證動態記憶體按正確的方式對齊。要注意的是,在不同類型的變數之間轉換時要小心
,如把char*強制轉換為int*時,要格外小心。
另外,位元組對齊也會造成結構大小的變化,在程式內部用sizeof來取得結構的大小,這
就足夠了。若資料要在不同的機器間傳遞時,在通訊協定中要規定對齊的方式,避免對齊方
式不一致引發的問題。
17. 位元組順序
位元組順序曆來是設計跨平台軟體時頭疼的問題。位元組順序是關於資料在實體記憶體中的布
局的問題,最常見的位元組順序有兩種:大端模式與小端模式。
大端模式是高位位元組資料存放在低地址處,低位位元組資料存放在高地址處。
小端模式指低位位元組資料存放在記憶體低地址處,高位位元組資料存放在記憶體高地址處;
比如long n = 0x11223344
模式 第1位元組 第2位元組 第3位元組 第4位元組
大端模式 0x11 0x22 0x33 0x44
小端模式 0x44 0x33 0x22 0x11
在普通軟體中,位元組順序問題並不引人注目。而在開發與網路通訊和資料交換有關的軟體時
,位元組順序問題就要特殊注意了。
18. 多線程共用變數沒有用valotile修飾
在關於全域記憶體的一節中,我們講了valotile的作用,它告訴編譯器,不要把變數最佳化
到寄存器中。在開發多線程並發的軟體時,如果這些線程共用一些全域變數,這些全域變數