對C/C++程序員來說,管理和使用虛擬存儲器可能是個困難的, 容易出錯的任務。與存儲器有關的錯誤屬于那些令人驚恐的錯誤, 因為它們在時間和空間上, 經常是在距錯誤源一段距離之后才表現出來。 將錯誤的數據寫到錯誤的位置, 你的程序可能在最終失敗之前運行了好幾個小時,且使程序中止的位置距離錯誤的位置已經很遠啦。而避免這種噩夢的最好方法就是防范于未然。
幸好《深入理解計算機系統》中有一段講: C程序中常見的內存操作有關的10種典型編程錯誤,十分經典, 因此抄寫在此, 以便以后隨時查看,復習。
把優秀變成習慣, 雖不能至,心向往之。
1. 間接引用無效指針
進程虛擬地址空間的某些地址范圍可能沒有映射到任何有意義的數據,如果我們試圖間接引用一個指向這些地址的指針,則操作系統會以Segment Fault終止進程。而且,虛擬存儲器的某些區域是只讀的(如.text或.rodata),試圖寫這些區域會以保護異常中止當前進程。
如從stdin讀取一個int變量時,scanf("%d", &val)是正確用法,若誤寫為scanf("%d",
val)時,val的值會被解釋為一個地址,并試圖向該地址寫數據。在最好的情況下,進程立即異常中止。在最壞的情況下,val的值恰好對應于虛擬存儲器
的某個合法的具有讀/寫權限的內存區域,于是該內存單元會被改寫,而這通常會在相當長的一段時間后造成災難性的、令人困惑的后果。我們學習C/C++中的指針時, 指針未初始化錯誤也屬于這類錯誤。
2. 讀未初始化的存儲器(Reading Uninitialized Memory)
C語言的malloc并不負責初始化申請到的內存區域(在C/C++中未初始化的全局變量會被初始化為0),因此,常見的錯誤是假設堆存儲器被初始化為0,例如:
這個程序是計算一個 n*n的矩陣(**A) 乘以 一個 n*1(*x) 的矩陣, 并返回計算結果(*y)。
// Return y = Ax
int *matvec(int **A, int *x, int n)
{
int i, j;
int *y = (int *)malloc(n * sizeof(int));
for ( i = 0; i < n; i++)
for (j = 0; j < n; j++)
y[i] += A[i][j] * x[j];
return y;
}
上述代碼中,錯誤地假設了y被初始化為0。正確的實現方式是顯式地依次將y[i]置為0或者使用calloc分配內存。
3. 棧緩沖區溢出(Allowing Stack Buffer Overflows)
這個是我們熟悉的緩沖區溢出錯誤(buffer overflow bug)
void bufoverflow()
{
char buf[64];
//Here is the stack buffer overflow bug
gets(buf);
return;
}
如果輸入超過64個字符, 上面的代碼將導致棧緩沖區溢出。 可以使用 fgets 函數代替 gets函數, fget函數有第二個參數, 以限制輸入串的大小。
4. 誤以為指針和它們指向的對象是相同大小的。(Assuming that Pointers and the Objects They Point to Are the Same Size)
例如: 申請一個二維 n*m 的int數組空間。
1 // Create an nxm array
2 int **makeArray1(int n, int m)
3 {
4 int i;
5 int **A = (int **)malloc(n * sizeof(int)); // Wrang way
6 // right way
7 //int **A = (int **)malloc(n * sizeof(int *));
8
9 for (i = 0; i < n; i++)
10 A[i] = (int *)malloc(m * sizeof(int));
11 return A;
12 }
上述代碼目的是創建一個由n個指針構成的數組,每個指針均指向一個包含m個int的數組,但是第五行誤將sizeof(int
*)寫成sizeof(int)。這段代碼只有在int和int *的size相同的機器上運行良好。如果在像Core
i7這樣的機器上運行這段代碼,由于指針變量的size大于sizeof(int),則會引發代碼中的for循環寫越界。因為這些字中的一個很可能是已分
配塊的邊界標記腳部,所以我們可能不會立即發現這個錯誤,直到進程運行很久釋放這個內存塊時,此時,分配器中的合并代碼會戲劇性地失敗,而沒有任何明顯的
原因。這是"在遠處起作用"(action at distance)的一個隱秘示例,這類"在遠處起作用"是與存儲器有關的編程錯誤的典型情況。
5. 造成錯位錯誤(Making Off-by-One Errors)
錯位(Off-by-one)錯誤是另一種常見的覆蓋錯誤來源:
1 // Create an nxm array
2 int **makeArray2(int n, int m)
3 {
4 int i;
5 int **A = (int **)malloc(n * sizeof(int *));
6
7 for (i = 0; i <= n; i++)
8 A[i] = (int *)malloc(m * sizeof(int));
9 return A;
10 }
很明顯,for循環次數不合預期,導致寫越界。幸運的話,進程會立即崩潰;不幸的話,運行很長時間才拋出各種詭異問題。
6. 引用指針,而不是它所指向的對象(Referencing a Pointer Instead of the Object It Points to)
如果不注意C操作符的優先級和結合性,就會錯誤地操作指針,而不是指針所指向的對象。
比如下面的函數,其目的是刪除一個有*size項的二叉堆里的第一項,然后對剩下的*size-1項重建堆:
1 int *binheapDelete(int **binheap, int *size)
2 {
3 int *packet = binheap[0];
4
5 binheap[0] = binheap[*size - 1];
6 *size--; // This should be (*size)--
7 heapify(binheap, *size, 0);
8 return (packet);
9 }
上述代碼中,由于--和*優先級相同,從右向左結合,所以*size--其實減少的是指針自己的值,而非其指向的整數的值。因此,謹記:當你對優先級和結合性有疑問時,就應該使用括號。
7. 誤解指針運算(Misunderstanding Pointer Arithmetic)
在C/C++中,指針的算術操作是以它們指向的對象的大小為單位來進行的。例如下面函數的功能是掃描一個int的數組,并返回一個指針,指向val的首次出現:
1 int *search(int *p, int val)
2 {
3 while (*p && *p != val)
4 p += sizeof(int); // Should be p++
5 return p;
6 }
8. 引用不存在的變量(Referenceing Nonexistent Variables)
C/C++新手不理解棧的規則時,可能會引用不再合法的本地變量,例如:
1 int *stackref()
2 {
3 int val;
4
5 return &val;
6 }
函數返回的指針(假設為p)指向棧中的局部變量,但該變量在函數返回后隨著stackref棧幀的銷毀已經不再有效。也即:盡管函數返回的指針p仍然指向
一個合法的存儲器地址,但它已經不再指向一個合法的變量了。當程序后續調用其它函數時,存儲器將重用剛才銷毀棧幀處的存儲器區域。再后來,如果程序分配某
個值給*p,那么它可能實際上正在修改另一個函數棧幀中的數據,從而潛在地帶來災難性的、令人困惑的后果。
9. 引用空閑堆塊中的數據(Referencing Data in Free Heap Blocks)
典型的錯誤為:引用已經被釋放了的堆塊中的數據,例如:
1 int *heapref(int n, int m)
2 {
3 int i;
4 int *x, *y;
5
6 x = (int *)malloc(n * sizeof(int));
7
8 /* ... */ /* Other calls to malloc and free go here */
9
10 free(x);
11
12 y = (int *)malloc(m * sizeof(int));
13 for (i = 0; i < m; i++)
14 y[i] = x[i]++; // Oops! x[i] is a word in a free block
15
16 return y;
17 }
10. 引起內存泄露(Introducing Memory leaks)
內存泄露是緩慢、隱性的殺手,當程序員忘記釋放已分配塊時會發生這種問題,例如:
1 void leak(int n)
2 {
3 int *x = (int *)malloc(n * sizeof(int));
4
5 return; // x is garbage at this point
6 }
如果leak在程序整個生命周期內只調用數次,則問題還不是很嚴重(但還是會浪費存儲器空間),因為隨著進程結束,操作系統會回收這些內存空間。但如果
leak()被經常調用,那就會發生嚴重的內存泄露,最壞的情況下,會占用整個虛擬地址空間。對于像守護進程和服務器這樣的程序來說,內存泄露是嚴重的
bug,必須加以重視。
【參考資料】
深入理解計算機系統. Bryant & O`Hallaron.
文章列表