當(dāng)前位置:首頁 > 嵌入式培訓(xùn) > 嵌入式學(xué)習(xí) > 講師博文 > 堆棧溢出一般是由什么原因?qū)е碌模?/p>
堆棧溢出一般是由什么原因?qū)е碌模?/span>
時(shí)間:2018-12-24 來源:華清遠(yuǎn)見
堆棧溢出一般都是由堆棧越界訪問導(dǎo)致的。例如函數(shù)內(nèi)局部變量數(shù)組越界訪問,或者函數(shù)內(nèi)局部變量使用過多,超出了操作系統(tǒng)為該進(jìn)程分配的棧的大小也會(huì)導(dǎo)致堆棧溢出。深度解析:
首先要區(qū)分清楚堆、棧、堆棧這幾個(gè)名詞。堆(heap)和棧(stack)是兩種不同的內(nèi)存管理機(jī)制:
1.堆
堆被稱為動(dòng)態(tài)內(nèi)存,由堆管理器(系統(tǒng)里的大人物,山高皇帝遠(yuǎn)不用去管它)管理,程序中可以使用malloc函數(shù)來(向堆管理器)申請(qǐng)分配堆內(nèi)存,使用完后使用free函數(shù)釋放(給堆管理器回收)。堆內(nèi)存的特點(diǎn)是:在程序運(yùn)行過程中才申請(qǐng)分配,在程序運(yùn)行中即釋放(因此稱為動(dòng)態(tài)內(nèi)存分配技術(shù))。
2.棧
棧是C語言使用的一種內(nèi)存自動(dòng)分配技術(shù)(注意是自動(dòng),不是動(dòng)態(tài),這是兩個(gè)概念),自動(dòng)指的是棧內(nèi)存操作不用C程序員干預(yù),而是自動(dòng)分配自動(dòng)回收的。C語言中局部變量就分配在棧上,進(jìn)入函數(shù)時(shí)局部變量需要的內(nèi)存自動(dòng)分配,函數(shù)結(jié)束退出時(shí)局部變量對(duì)應(yīng)的內(nèi)存自動(dòng)釋放,整個(gè)過程中程序員不需要人為干預(yù)。
堆棧這個(gè)詞純粹是用來坑人的。堆就是堆(heap),棧就是棧(stack),根本沒有另外一種內(nèi)存管理機(jī)制叫堆棧。大多數(shù)時(shí)候有人說起堆棧,其實(shí)他想說的是棧,以前早些的時(shí)候,這方面的命名并不是特別準(zhǔn)確。(別人說堆棧的時(shí)候,大家知道他其實(shí)想說的是棧就行了,自己就不要再用這個(gè)不準(zhǔn)確的詞了)。既然堆和棧都是用來管理內(nèi)存的機(jī)制,使用時(shí)就有一定的規(guī)則。無視規(guī)則的錯(cuò)誤使用(C語言設(shè)計(jì)時(shí)賦予了程序員很大的自由度,所以有些錯(cuò)誤語言本身是不會(huì)檢查的,全憑程序員自己把握。)就可以導(dǎo)致一些內(nèi)存錯(cuò)誤,如內(nèi)存泄漏、溢出錯(cuò)誤等。
3.存泄漏
內(nèi)存泄漏主要發(fā)生在堆內(nèi)存使用中。譬如我們使用malloc申請(qǐng)了內(nèi)存,使用過后并未釋放而丟棄了指向該內(nèi)存的指針(這個(gè)指針是這段內(nèi)存的唯一記錄,程序中釋放該段內(nèi)存都靠這個(gè)指針了),那么這段堆內(nèi)存就泄漏掉了(堆管理器以為程序還在使用,所以不會(huì)將這段內(nèi)存再次分配給別的程序)。必須等到這個(gè)程序徹底退出后,系統(tǒng)回收該程序所使用的所有資源(申請(qǐng)的內(nèi)存,使用的文件描述符等)時(shí)這些泄漏的內(nèi)存才重新回到堆管理器的懷抱。
內(nèi)存溢出在堆和棧中都有可能發(fā)生。參見章節(jié)示例1_2_stack_overflow.c中的8個(gè)示例函數(shù),其中前三個(gè)函數(shù)與堆溢出有關(guān),后五個(gè)函數(shù)與棧溢出有關(guān)。
4.堆溢出
函數(shù)heap_overflow中使用malloc申請(qǐng)了16字節(jié)動(dòng)態(tài)內(nèi)存,然后嘗試去讀寫這16個(gè)內(nèi)存之中的第n個(gè)。三個(gè)測(cè)試分別給n賦值9,99和9999999,得到的結(jié)果很有意思(見程序后面的注釋,大家也可以自己編譯運(yùn)行測(cè)試),現(xiàn)在我們來探討其中的原理。
n等于9的時(shí)候沒什么好說的,本該正確運(yùn)行,這個(gè)相信大家沒有異議。n等于99的時(shí)候······竟然也可以正確運(yùn)行,這個(gè)相信很多人就有點(diǎn)想不通了。我們申請(qǐng)的空間只有16字節(jié)啊,怎么竟然還可以訪問第99個(gè)字節(jié)空間呢(這就是所謂的堆溢出訪問)?這時(shí)候?qū)嶋H已經(jīng)堆溢出了,但是為什么結(jié)果沒有出錯(cuò)呢?原因在操作系統(tǒng)的內(nèi)存分配策略中。譬如linux中內(nèi)存是按照頁(Page,一般是4K字節(jié)一個(gè)頁)來管理的,操作系統(tǒng)給進(jìn)程分配內(nèi)存本質(zhì)上都是以頁為單位進(jìn)行的。也就是說你雖然只要求了16個(gè)字節(jié),但是實(shí)際分配給你這個(gè)進(jìn)程的可能是一個(gè)頁(4K字節(jié))。這個(gè)頁中只有這16個(gè)字節(jié)是你自己的“合法財(cái)產(chǎn)”,其他部分你不該去訪問(一訪問就堆越界)。但是因?yàn)椴僮飨到y(tǒng)對(duì)內(nèi)存的訪問權(quán)限管理是以頁為單位的,因此本頁內(nèi)16字節(jié)之外的內(nèi)存你(非法)訪問時(shí)系統(tǒng)仍然不會(huì)報(bào)錯(cuò),并且確實(shí)能夠達(dá)成目的(示例中n等于99時(shí)讀寫仍然正確)。那是不是說堆越界是無害的,完全不用擔(dān)心呢?顯然不是。因?yàn)槎言浇缱畲蟮膫Σ皇菍?duì)自己,而是對(duì)“別人”。因?yàn)槌四闵暾?qǐng)的16字節(jié)外本頁面內(nèi)其他內(nèi)存可能會(huì)被堆管理器分配給其他變量,你越界訪問時(shí)意味著你可能踐踏了其他變量的有效區(qū)域(譬如我們給第99個(gè)字節(jié)賦值為g時(shí),很可能把別處動(dòng)態(tài)分配的一個(gè)變量的一部分給無意識(shí)的修改了)。因此其他變量會(huì)“莫名其妙”的出錯(cuò),而且最可怕的是這種出錯(cuò)編譯器無法幫你發(fā)現(xiàn),大多數(shù)時(shí)候隱藏的很深,極難發(fā)現(xiàn),往往令調(diào)試者抓狂、痛不欲生。因此訪問堆內(nèi)存時(shí)應(yīng)該極為小心,一定要檢驗(yàn)訪問范圍,謹(jǐn)防堆訪問越界。
最后一個(gè)示例中n等于9999999,這是我隨便寫的一個(gè)很大的數(shù),執(zhí)行結(jié)果為:段錯(cuò)誤(Segmentation fault)。熟悉C語言的同學(xué)都知道,一般段錯(cuò)誤都是因?yàn)槌绦蛟L問了不該訪問的區(qū)域(譬如試圖寫代碼段),這里也不例外。什么原因?考慮下上文中提到的以頁為單位的內(nèi)存管理策略。給你分配了一個(gè)頁(一般是4KB),你訪問時(shí)索引值太大已經(jīng)超出了這個(gè)頁(跑到下個(gè)頁甚至更后面的頁面去了),那邊的內(nèi)存頁面根本不歸你使用,你試圖讀寫的時(shí)候操作系統(tǒng)的內(nèi)存管理部分就會(huì)一巴掌把你扇回來,給你個(gè)Segmentation fault。那個(gè)數(shù)字式我隨便寫的,你也可以自己試試先給個(gè)小數(shù)字,然后逐漸加大,總會(huì)有個(gè)臨界點(diǎn),過了那個(gè)點(diǎn)就開始段錯(cuò)誤了。
5.棧溢出
func1到func5這五個(gè)示例用來演示棧溢出。
func1是典型的數(shù)組越界造成的棧溢出,壓棧越界導(dǎo)致沖毀了函數(shù)調(diào)用堆棧結(jié)構(gòu),致使整個(gè)程序崩潰。由此可見,在C語言中數(shù)組訪問時(shí)一定要小心檢查,保證不越界。C語言為了追求最高的效率,并未提供任何數(shù)組訪問動(dòng)態(tài)檢查(實(shí)際上也沒有提供編譯時(shí)數(shù)組訪問是否越界的靜態(tài)檢查,其原因是C語言愿意相信程序員,而將檢查的重任交給了程序員自己······果然是權(quán)力越大、責(zé)任越大啊!),因此“保衛(wèi)世界和平的重任就靠你了”。
func2和func3是一對(duì)對(duì)比測(cè)試。其中調(diào)用了一個(gè)遞歸函數(shù)factorial,該函數(shù)用來求一個(gè)正整數(shù)n的階乘。func2中n等于10,計(jì)算結(jié)果為3628800,是正確的(大家可以用計(jì)算器自己驗(yàn)證)。func3中n等于10000000,運(yùn)行結(jié)果為段錯(cuò)誤(其實(shí)即使不段錯(cuò)誤,factorial函數(shù)本身也無法計(jì)算很大數(shù)字的階乘,原因在于函數(shù)中使用unsigned int類型來存階乘值,這個(gè)類型的取值范圍非常有限,n稍微大一點(diǎn)就會(huì)溢出。但溢出只會(huì)導(dǎo)致計(jì)算結(jié)果不對(duì),不會(huì)造成段錯(cuò)誤的)。
怎么會(huì)段錯(cuò)誤呢?因?yàn)檫f歸次數(shù)太多,棧終于被撐爆了。遞歸函數(shù)運(yùn)行時(shí),實(shí)際上相當(dāng)于不停在執(zhí)行子函數(shù)調(diào)用,因此棧一直在分配而沒有釋放。若在棧使用完之前遞歸仍然沒有結(jié)束返回(此時(shí)會(huì)逐層釋放棧)就會(huì)發(fā)生段錯(cuò)誤。這是棧溢出的另一個(gè)典型情況,請(qǐng)大家以后使用遞歸算法解決問題時(shí)注意這個(gè)限制。
func4和func5是一對(duì)對(duì)比測(cè)試。其中均定義了一個(gè)局部變量數(shù)組a,不同的是a的大小。func4中數(shù)組大小為1M(注意a的類型是int,因此這里單位是4字節(jié)),運(yùn)行成功。而func5中數(shù)組大小為4M,運(yùn)行時(shí)則發(fā)生段錯(cuò)誤。相信有了上面上面的講解,大家能夠很容易想明白,局部變量分配太多把棧用完了,所以就段錯(cuò)誤了,就這么簡(jiǎn)單。
以上,通過5個(gè)示例程序?yàn)榇蠹已菔玖藯R绯龅娜N情況。一般來說,第一種情況是明顯的錯(cuò)誤,且每次執(zhí)行都確定會(huì)發(fā)生錯(cuò)誤。而后兩種錯(cuò)誤則稍微復(fù)雜一些,原因在于這兩種錯(cuò)誤都依賴于棧的大小。而棧的大小在操作系統(tǒng)中不是固定的,是可以人為設(shè)置的(譬如linux中使用ulimit –s來查看和設(shè)置用戶進(jìn)程棧大小)。這就會(huì)帶來一些很“神奇”的bug,如程序在你的計(jì)算機(jī)中運(yùn)行良好,調(diào)試通過。結(jié)果發(fā)給客戶,10個(gè)客戶中8個(gè)運(yùn)行良好,另外兩個(gè)會(huì)報(bào)錯(cuò)、死機(jī)······
這時(shí)候只要重新設(shè)置一個(gè)更大的用戶棧容量就可以解決問題。所以大家在寫代碼時(shí)一定要注意,考慮到你的代碼有可能潛在的問題。這樣一旦問題暴露即可迅速定位,并最快的找到解決方案。不過更高級(jí)的做法是:在寫代碼時(shí)盡量減少可能存在的問題,讓你的程序盡量更加健壯(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);
// 注意:每個(gè)函數(shù)需要單獨(dú)執(zhí)行測(cè)試,因此在測(cè)試每個(gè)函數(shù)時(shí),需要將其他函數(shù)屏蔽。
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.堆和棧溢出總結(jié)
答:1.函數(shù)調(diào)用層次太深。函數(shù)遞歸調(diào)用時(shí),系統(tǒng)要在棧中不斷保存函數(shù)調(diào)用時(shí)的現(xiàn)場(chǎng)和產(chǎn)生的變量,如果遞歸調(diào)用太深,就會(huì)造成棧溢出,這時(shí)遞歸無法返回。再有,當(dāng)函數(shù)調(diào)用層次過深時(shí)也可能導(dǎo)致棧無法容納這些調(diào)用的返回地址而造成棧溢出。
2.動(dòng)態(tài)申請(qǐng)空間使用之后沒有釋放。由于C語言中沒有垃圾資源自動(dòng)回收機(jī)制,因此,需要程序主動(dòng)釋放已經(jīng)不再使用的動(dòng)態(tài)地址空間。申請(qǐng)的動(dòng)態(tài)空間使用的是堆空間,動(dòng)態(tài)空間使用不會(huì)造成堆溢出。
3.數(shù)組訪問越界。C語言沒有提供數(shù)組下標(biāo)越界檢查,如果在程序中出現(xiàn)數(shù)組下標(biāo)訪問超出數(shù)組范圍,在運(yùn)行過程中可能會(huì)內(nèi)存訪問錯(cuò)誤。
4.指針非法訪問。指針保存了一個(gè)非法的地址,通過這樣的指針訪問所指向的地址時(shí)會(huì)產(chǎn)生內(nèi)存訪問錯(cuò)誤。

