堆棧溢出一般都是由堆棧越界訪問導致的。例如函數內局部變量數組越界訪問,或者函數內局部變量使用過多,超出了操作系統為該進程分配的棧的大小也會導致堆棧溢出。深度解析:
首先要區分清楚堆、棧、堆棧這幾個名詞。堆(heap)和棧(stack)是兩種不同的內存管理機制:
1.堆
堆被稱為動態內存,由堆管理器(系統里的大人物,山高皇帝遠不用去管它)管理,程序中可以使用malloc函數來(向堆管理器)申請分配堆內存,使用完后使用free函數釋放(給堆管理器回收)。堆內存的特點是:在程序運行過程中才申請分配,在程序運行中即釋放(因此稱為動態內存分配技術)。
2.棧
棧是C語言使用的一種內存自動分配技術(注意是自動,不是動態,這是兩個概念),自動指的是棧內存操作不用C程序員干預,而是自動分配自動回收的。C語言中局部變量就分配在棧上,進入函數時局部變量需要的內存自動分配,函數結束退出時局部變量對應的內存自動釋放,整個過程中程序員不需要人為干預。
堆棧這個詞純粹是用來坑人的。堆就是堆(heap),棧就是棧(stack),根本沒有另外一種內存管理機制叫堆棧。大多數時候有人說起堆棧,其實他想說的是棧,以前早些的時候,這方面的命名并不是特別準確。(別人說堆棧的時候,大家知道他其實想說的是棧就行了,自己就不要再用這個不準確的詞了)。既然堆和棧都是用來管理內存的機制,使用時就有一定的規則。無視規則的錯誤使用(C語言設計時賦予了程序員很大的自由度,所以有些錯誤語言本身是不會檢查的,全憑程序員自己把握。)就可以導致一些內存錯誤,如內存泄漏、溢出錯誤等。
3.存泄漏
內存泄漏主要發生在堆內存使用中。譬如我們使用malloc申請了內存,使用過后并未釋放而丟棄了指向該內存的指針(這個指針是這段內存的唯一記錄,程序中釋放該段內存都靠這個指針了),那么這段堆內存就泄漏掉了(堆管理器以為程序還在使用,所以不會將這段內存再次分配給別的程序)。必須等到這個程序徹底退出后,系統回收該程序所使用的所有資源(申請的內存,使用的文件描述符等)時這些泄漏的內存才重新回到堆管理器的懷抱。
內存溢出在堆和棧中都有可能發生。參見章節示例1_2_stack_overflow.c中的8個示例函數,其中前三個函數與堆溢出有關,后五個函數與棧溢出有關。
4.堆溢出
函數heap_overflow中使用malloc申請了16字節動態內存,然后嘗試去讀寫這16個內存之中的第n個。三個測試分別給n賦值9,99和9999999,得到的結果很有意思(見程序后面的注釋,大家也可以自己編譯運行測試),現在我們來探討其中的原理。
n等于9的時候沒什么好說的,本該正確運行,這個相信大家沒有異議。n等于99的時候······竟然也可以正確運行,這個相信很多人就有點想不通了。我們申請的空間只有16字節啊,怎么竟然還可以訪問第99個字節空間呢(這就是所謂的堆溢出訪問)?這時候實際已經堆溢出了,但是為什么結果沒有出錯呢?原因在操作系統的內存分配策略中。譬如linux中內存是按照頁(Page,一般是4K字節一個頁)來管理的,操作系統給進程分配內存本質上都是以頁為單位進行的。也就是說你雖然只要求了16個字節,但是實際分配給你這個進程的可能是一個頁(4K字節)。這個頁中只有這16個字節是你自己的“合法財產”,其他部分你不該去訪問(一訪問就堆越界)。但是因為操作系統對內存的訪問權限管理是以頁為單位的,因此本頁內16字節之外的內存你(非法)訪問時系統仍然不會報錯,并且確實能夠達成目的(示例中n等于99時讀寫仍然正確)。那是不是說堆越界是無害的,完全不用擔心呢?顯然不是。因為堆越界最大的傷害不是對自己,而是對“別人”。因為除了你申請的16字節外本頁面內其他內存可能會被堆管理器分配給其他變量,你越界訪問時意味著你可能踐踏了其他變量的有效區域(譬如我們給第99個字節賦值為g時,很可能把別處動態分配的一個變量的一部分給無意識的修改了)。因此其他變量會“莫名其妙”的出錯,而且最可怕的是這種出錯編譯器無法幫你發現,大多數時候隱藏的很深,極難發現,往往令調試者抓狂、痛不欲生。因此訪問堆內存時應該極為小心,一定要檢驗訪問范圍,謹防堆訪問越界。
最后一個示例中n等于9999999,這是我隨便寫的一個很大的數,執行結果為:段錯誤(Segmentation fault)。熟悉C語言的同學都知道,一般段錯誤都是因為程序訪問了不該訪問的區域(譬如試圖寫代碼段),這里也不例外。什么原因?考慮下上文中提到的以頁為單位的內存管理策略。給你分配了一個頁(一般是4KB),你訪問時索引值太大已經超出了這個頁(跑到下個頁甚至更后面的頁面去了),那邊的內存頁面根本不歸你使用,你試圖讀寫的時候操作系統的內存管理部分就會一巴掌把你扇回來,給你個Segmentation fault。那個數字式我隨便寫的,你也可以自己試試先給個小數字,然后逐漸加大,總會有個臨界點,過了那個點就開始段錯誤了。
5.棧溢出
func1到func5這五個示例用來演示棧溢出。
func1是典型的數組越界造成的棧溢出,壓棧越界導致沖毀了函數調用堆棧結構,致使整個程序崩潰。由此可見,在C語言中數組訪問時一定要小心檢查,保證不越界。C語言為了追求最高的效率,并未提供任何數組訪問動態檢查(實際上也沒有提供編譯時數組訪問是否越界的靜態檢查,其原因是C語言愿意相信程序員,而將檢查的重任交給了程序員自己······果然是權力越大、責任越大。。,因此“保衛世界和平的重任就靠你了”。
func2和func3是一對對比測試。其中調用了一個遞歸函數factorial,該函數用來求一個正整數n的階乘。func2中n等于10,計算結果為3628800,是正確的(大家可以用計算器自己驗證)。func3中n等于10000000,運行結果為段錯誤(其實即使不段錯誤,factorial函數本身也無法計算很大數字的階乘,原因在于函數中使用unsigned int類型來存階乘值,這個類型的取值范圍非常有限,n稍微大一點就會溢出。但溢出只會導致計算結果不對,不會造成段錯誤的)。
怎么會段錯誤呢?因為遞歸次數太多,棧終于被撐爆了。遞歸函數運行時,實際上相當于不停在執行子函數調用,因此棧一直在分配而沒有釋放。若在棧使用完之前遞歸仍然沒有結束返回(此時會逐層釋放棧)就會發生段錯誤。這是棧溢出的另一個典型情況,請大家以后使用遞歸算法解決問題時注意這個限制。
func4和func5是一對對比測試。其中均定義了一個局部變量數組a,不同的是a的大小。func4中數組大小為1M(注意a的類型是int,因此這里單位是4字節),運行成功。而func5中數組大小為4M,運行時則發生段錯誤。相信有了上面上面的講解,大家能夠很容易想明白,局部變量分配太多把棧用完了,所以就段錯誤了,就這么簡單。
以上,通過5個示例程序為大家演示了棧溢出的三種情況。一般來說,第一種情況是明顯的錯誤,且每次執行都確定會發生錯誤。而后兩種錯誤則稍微復雜一些,原因在于這兩種錯誤都依賴于棧的大小。而棧的大小在操作系統中不是固定的,是可以人為設置的(譬如linux中使用ulimit –s來查看和設置用戶進程棧大。。這就會帶來一些很“神奇”的bug,如程序在你的計算機中運行良好,調試通過。結果發給客戶,10個客戶中8個運行良好,另外兩個會報錯、死機······
這時候只要重新設置一個更大的用戶棧容量就可以解決問題。所以大家在寫代碼時一定要注意,考慮到你的代碼有可能潛在的問題。這樣一旦問題暴露即可迅速定位,并最快的找到解決方案。不過更高級的做法是:在寫代碼時盡量減少可能存在的問題,讓你的程序盡量更加健壯(robust)。
代碼如下:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// function prototype declaration
int heap_overflow(unsigned int n, char c);
void func1(void);
void func2(void);
void func3(void);
void func4(void);
void func5(void);
// 注意:每個函數需要單獨執行測試,因此在測試每個函數時,需要將其他函數屏蔽。
int main(void)
{
// 堆溢出訪問演示
//heap_overflow(9, *t*); // The 9th element = t.
//heap_overflow(99, *g*); // The 99th element = g.
heap_overflow(9999999, *g*); // Segmentation fault
// 棧溢出訪問演示
//func1(); // stack smashing detected
//func2(); // factorial(10) = 3628800.
//func3(); // Segmentation fault
//func4(); // a[1048576-1] = 5.
//func5(); // Segmentation fault
return 0;
}
int heap_overflow(unsigned int n, char c)
{
char *p = NULL;
p = (char *)malloc(16);
if (NULL == p)
{
printf("fail to get dynamic memory from heap.\n");
return -1;
}
memset(p, 0, 16);
*(p + n) = c;
printf("The %dth element = %c.\n", n, *(p + n));
free(p);
p = NULL;
return 0;
}
void func1(void)
{
char name[8];
strcpy(name, "linus tovards.");
printf("Hello, %s!", name);
}
static unsigned int factorial(unsigned int n)
{
if (n == 1)
return 1;
else
return n * factorial(n - 1);
}
void func2(void)
{
printf("factorial(10) = %d.\n", factorial(10));
}
void func3(void)
{
printf("factorial(10000000) = %d.\n", factorial(10000000));
}
#define M (1 * 1024 * 1024)
#define N (4 * 1024 * 1024)
void func4(void)
{
int a[M];
a[M-1] = 5;
printf("a[%d-1] = %d.\n", M, a[M-1]);
}
void func5(void)
{
int a[N];
a[N-1] = 5;
printf("a[%d-1] = %d.\n", N, a[N-1]);
}
6.堆和棧溢出總結
答:1.函數調用層次太深。函數遞歸調用時,系統要在棧中不斷保存函數調用時的現場和產生的變量,如果遞歸調用太深,就會造成棧溢出,這時遞歸無法返回。再有,當函數調用層次過深時也可能導致棧無法容納這些調用的返回地址而造成棧溢出。
2.動態申請空間使用之后沒有釋放。由于C語言中沒有垃圾資源自動回收機制,因此,需要程序主動釋放已經不再使用的動態地址空間。申請的動態空間使用的是堆空間,動態空間使用不會造成堆溢出。
3.數組訪問越界。C語言沒有提供數組下標越界檢查,如果在程序中出現數組下標訪問超出數組范圍,在運行過程中可能會內存訪問錯誤。
4.指針非法訪問。指針保存了一個非法的地址,通過這樣的指針訪問所指向的地址時會產生內存訪問錯誤。