stack overflow 就是棧溢出 函數間的互相調用,在計算機指令層面是怎麼實現的,以及生命狀況下會產生棧溢出這個錯誤編程
和前面幾講同樣,咱們仍是從一個很是簡單的C程序function_example.c 看起。數組
// function_example.c #include <stdio.h> int static add(int a, int b) { return a+b; } int main() { int x = 5; int y = 10; int u = add(x, y); }
一、這個程序定義了一個簡單的函數 add,接受兩個參數 a 和 b,sass
二、返回值就是 a+b性能優化
三、main函數裏則定義了兩個變量 x 和 y,而後經過調用這個 add函數,來計算 u=x+ybash
四、最後把 u 的數值打印出來。數據結構
gcc -g -c function_example.c $ objdump -d -M intel -S function_example.o
[root@luoahong c]# objdump -d -M intel -S function_example.o function_example.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <add>: #include <stdio.h> int static add(int a, int b) { 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: 89 7d fc mov DWORD PTR [rbp-0x4],edi 7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi return a+b; a: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] d: 8b 55 fc mov edx,DWORD PTR [rbp-0x4] 10: 01 d0 add eax,edx } 12: 5d pop rbp 13: c3 ret 0000000000000014 <main>: int main() { 14: 55 push rbp 15: 48 89 e5 mov rbp,rsp 18: 48 83 ec 10 sub rsp,0x10 int x = 5; 1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5 int y = 10; 23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa int u = add(x, y); 2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8] 2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 30: 89 d6 mov esi,edx 32: 89 c7 mov edi,eax 34: e8 c7 ff ff ff call 0 <add> 39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax } 3c: c9 leave 3d: c3 ret
能夠看出來、在這段代碼裏,main 函數和上一節咱們講的的程序執行區別並不大,它主要是把 jump 指令換成了函數調用的 call 指令。call 指令後面跟着的,仍然是跳轉後的程序地址。函數
他們兩個都是在原來順序執行的指令過程裏,執行一個內存地址的跳轉指令、讓指令從原來順序執行的過程裏跳開。重新的跳轉後的位置開始執行佈局
if…else 和 for/while 的跳轉,是跳轉走了就再也不回來,就在跳轉後的新地址開始順序地執行指令,就像徐志摩在《再別康橋》裏寫的「我揮一揮衣袖,不帶走一片雲彩」繼續進行新的生活性能
函數調用的跳轉,在對應函數的指令執行問了以後,還要再回到函數調用的地方,繼續執行call以後的指令,就好像賀知章在《回鄉偶書》裏面寫的那樣:「少小離家老大回,鄉音未改鬢毛衰」,無論走多遠,最終仍是要回來測試
那麼有沒有一個能夠不跳轉回到原來開始的地方,替換掉對應的call指令,而後在編譯器編譯代碼的時候,直接把函數調用編程對應的指令替換掉
若是函數 A 調用了函數 B,而後函數 B 再調用函數 A,咱們就得面臨在 A 裏面插入 B 的指令,而後在在 B 裏面插入 A 的指令,這樣就會產生無窮無盡地替換
就好像兩面鏡子面對面放在一起,任何一面鏡子裏面都會看到無窮多面鏡子。
能不能把後面要調回來執行的指令地址給記錄下來呢?就像前面PC寄存器同樣,咱們能夠專門設立一個「程序調用寄存器」,來存儲接下來要跳轉回來執行的
指令地址,等到函數調用結束,從這個寄存器裏取出地址,再跳轉到這個記錄的地址、繼續執行就行了
一、簡單隻記錄一個地址是不夠的
A-B-C 這一層一層的調用並無數量上的限制,在全部函數調用返回以前,每一次調用的返回地址都要記錄下來,可是咱們的CPU寄存器數量並很少,想咱們呢使用的Intel i7 CPU 只有 16 個 64 位寄存器,調用的層數一多存不下了
最終,計算機科學家們想到了一個比單獨記錄跳轉回來的地址更完善的辦法,咱們在內存裏面開闢一段空間,用棧這個後進先出的數據結構,棧就像一個乒乓球的桶後進先出的數據結構
一、什麼是壓棧
每次程序調用函數以前,咱們都把調用返回後的地址寫在一個乒乓球上,而後塞進這個球桶。這個操做其實就是咱們常說的壓棧
二、什麼是出棧
若是函數執行完了,咱們就從球桶裏取出最上面的那個乒乓球,很顯然,這就是出棧
三、什麼是棧底
拿到出棧的乒乓球,找到上面的地址,把程序跳轉過去,就返回到了函數調用後的下一條指令了。若是函數 A 在執行完成以前又調用了函數 B,那麼在取出乒乓球以前,咱們須要往球桶裏塞一個乒乓球。
而咱們從球桶最上面拿乒乓球的時候,拿的也必定是最近一次的,也就是最下面一層的函數調用完成後的地址。乒乓球桶的底部,就是棧底
四、什麼是棧頂
最上面的乒乓球所在的位置,就是棧頂。
五、什麼是棧幀
在真實的程序裏,壓棧的不僅有函數調用完成後的返回地址,好比函數A在調用B的時候,須要傳輸一些參數數據,這些參數數據在寄存器不夠用的時候也會壓入棧中。整個函數A所佔用的全部空間,就是函數A的棧幀
而實際的程序棧佈局,頂和底與咱們的乒乓球桶相比是倒過來的,底在最上面,頂在最下面,這樣的佈局是由於棧底的內存地址是一開始就固定的,而一層層壓棧以後,棧頂的內存地址是在逐漸變小而不斷變大
一、對應上面函數 add 的彙編代碼,咱們來仔細看看,main函數調用 add 函數時,add 函數入口在 0~1 行,add 函數結束以後在 12~13 行。
二、咱們在調用第 34 行的 call 指令時,會把當前的 PC寄存器裏的下一條指令的地址壓棧,保留函數調用結束後要執行的指令地址。而 add 函數的第 0 行,push rbp 這個指令,
就是在進行壓棧。這裏的 rbp又叫棧幀指針(Frame Pointer),是一個存放了當前棧幀位置的寄存器。push rbp 就把以前調用函數,main 函數的棧幀的棧底地址,壓到棧頂。
三、接着,第 1 行的一條命令 mov rbp, rsp 裏,則是把 rsp 這個棧指針(Stack Pointer)的值複製到 rbp 裏,而 rsp 始終會指向棧頂。這個命令意味着,rbp 這個棧幀指針指向的地址,變成當前
最新的棧頂,也就是 add 函數的棧幀的棧底地址了。
四、而在函數 add 執行完成以後,又會分別調用第 12 行的 pop rbp 來將當前的棧頂出棧,這部分操做維護好了咱們整個棧幀。而後,咱們能夠調用第 13 行的 行的 ret 指令,
這時候同時要把 call 調用的時候壓入的 PC 寄存器裏的下一條指令出棧,更新到 PC 寄存器中,將程序的控制權返回到出棧後的棧頂。
經過引入棧,咱們能夠看到,不管有多少層的函數調用,或者在函數A 裏調用函數 B,再在函數B 裏調用 A,這樣的遞歸調用,咱們都只須要經過維持 和 rsp,這兩個維護棧頂所在地址的寄存器,
就能管理好不一樣函數之間的跳轉。不過,棧的大小也是有限的。若是函數調用層數太多,咱們往棧裏壓入它存不下的內容,程序在執行的過程當中就會遇到棧溢出的錯誤,這就是大名鼎鼎的「stack overflow」。
要構造一個棧溢出的錯誤並不困難,最簡單的辦法,就是咱們上面說的 Infiinite Mirror Effect的方式,讓函數 A 調用本身,而且不設任何終止條件。這樣一個無限遞歸的程序,在不斷地壓棧過程當中,將整個棧空間填滿並最終趕上 stack overflow。
int a() { return a(); } int main() { a(); return 0; }
一、無線遞歸
二、遞歸層數過深
三、巨大的數組(在棧空間裏面建立很是佔內存的變量)
咱們只要在GCC編譯的時候,加上對應的一個讓編譯器自動化的參數-O,編譯器就會再可行的狀況下、進行這樣的指令替換
#include <stdio.h> #include <time.h> #include <stdlib.h> int static add(int a, int b) { return a+b; } int main() { srand(time(NULL)); int x = rand() % 5 int y = rand() % 10; int u = add(x, y) printf("u = %d\n", u) }
爲了不編譯器優化掉太多的代碼,我小小修改了一下function_example.c,讓參數x和y都變成了,經過隨機函數生成,並在代碼的最後機上講u經過printf打印出來的語句
gcc -g -c -O function_example_inline.c $ objdump -d -M intel -S function_example_inline.o
上面的 function_example_inline.c 的編譯出來的彙編代碼,沒有把 add 函數單獨編譯成一段指令順序,而是在調用 u = add(x, y) 的時候,直接替換成了一個 add 指令。
return a+b; 4c: 01 de add esi,ebx
除了依靠編譯器的自動優化,還以在定義函數的地方,加上inline的關鍵字,來提示編譯器對函數進行內聯
內聯帶來的優化是,CPU須要執行的指令數變少了,根據地址跳轉的過程不須要了,壓棧和出棧的過程也不用了
不過內聯並非沒有代價,內聯意味着,咱們把能夠服用的程序指令在調用它的地方徹底展開了,若是一個函數在不少地方被調用了,那麼久會展開不少次,整個程序佔用的空間就會打了
這樣沒有調用其餘函數,只會被調用的函數,咱們通常稱之爲葉子函數(或葉子過程)
這一節,咱們講了一個程序的函數調用,在CPU指令層面是怎麼執行的,其中必定須要牢記的就是程序棧這個新概念
咱們能夠方便地經過壓棧和出棧操做,使得程序在不一樣的函數調用過程當中進行轉移,而函數內聯和溢出,
一個是咱們經常能夠選擇優化方案,另外一個則是咱們會唱遇到的成Bug
經過加入程序棧,咱們至關於在指令跳轉的過程當中,加入一個「記憶「的功能,能在跳轉去運行新的指令以後,再回到跳出去的位置,
可以實現更加豐富和靈活的指令執行流程。這個也爲咱們在程序開發過程當中,提供了「函數」這樣一個抽象,使得咱們在軟件開發的過程當中,
能夠複用代碼和指令,而不是隻是簡單粗暴地複製、粘貼和指令