重學計算機組成原理(六)- 函式呼叫怎麼突然Stack Overflow了!
用Google搜異常資訊,肯定都訪問過Stack Overflow網站
全球最大的程式設計師問答網站,名字來自於一個常見的報錯,就是棧溢位(stack overflow)
從函式呼叫開始,在計算機指令層面函式間的相互呼叫是怎麼實現的,以及什麼情況下會發生棧溢位
1 棧的意義
先看一個簡單的C程式
- function.c
- 直接在Linux中使用GCC編譯執行
[hadoop@JavaEdge Documents]$ vim function.c [hadoop@JavaEdge Documents]$ gcc -g -c function.c [hadoop@JavaEdge Documents]$ objdump -d -M intel -S function.o function.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 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>: return a+b; } 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 return 0; 3c: b8 00 00 00 00 mov eax,0x0 } 41: c9 leave 42: c3 ret
main函式和上一節我們講的的程式執行區別不大,主要是把jump指令換成了函式呼叫的call指令,call指令後面跟著的,仍然是跳轉後的程式地址
看看add函式
add函式編譯後,程式碼先執行了一條push指令和一條mov指令
在函式執行結束的時候,又執行了一條pop和一條ret指令
這四條指令的執行,其實就是在進行我們接下來要講壓棧(Push)和出棧(Pop)
函式呼叫和上一節我們講的if…else和for/while迴圈有點像
都是在原來順序執行的指令過程裡,執行了一個記憶體地址的跳轉指令,讓指令從原來順序執行的過程裡跳開,從新的跳轉後的位置開始執行。
但是,這兩個跳轉有個區別
- if…else和for/while的跳轉,是跳轉走了就不再回來了,就在跳轉後的新地址開始順序地執行指令,後會無期
- 函式呼叫的跳轉,在對應函式的指令執行完了之後,還要再回到函式呼叫的地方,繼續執行call之後的指令,地球畢竟是圓的
有沒有一個可以不跳回原來開始的地方,從而實現函式的呼叫呢
似乎有.可以把呼叫的函式指令,直接插入在呼叫函式的地方,替換掉對應的call指令,然後在編譯器編譯程式碼的時候,直接就把函式呼叫變成對應的指令替換掉。
不過思考一下,你會發現漏洞
如果函式A呼叫了函式B,然後函式B再呼叫函式A,我們就得面臨在A裡面插入B的指令,然後在B裡面插入A的指令,這樣就會產生無窮無盡地替換。
就好像兩面鏡子面對面放在一塊兒,任何一面鏡子裡面都會看到無窮多面鏡子
Infinite Mirror Effect
如果函式A呼叫B,B再呼叫A,那麼程式碼會無限展開
那就換一個思路,能不能把後面要跳回來執行的指令地址給記錄下來呢?
就像PC暫存器一樣,可以專門設立一個“程式呼叫暫存器”,儲存接下來要跳轉回來執行的指令地址
等到函式呼叫結束,從這個暫存器裡取出地址,再跳轉到這個記錄的地址,繼續執行就好了。
但在多層函式呼叫裡,只記錄一個地址是不夠的
在呼叫函式A之後,A還可以呼叫函式B,B還能呼叫函式C
這一層又一層的呼叫並沒有數量上的限制
在所有函式呼叫返回之前,每一次呼叫的返回地址都要記錄下來,但是我們CPU裡的暫存器數量並不多
像我們一般使用的Intel i7 CPU只有16個64位暫存器,呼叫的層數一多就存不下了。
最終,CSer們想到了一個比單獨記錄跳轉回來的地址更完善的辦法
在記憶體裡面開闢一段空間,用棧這個後進先出(LIFO,Last In First Out)的資料結構
棧就像一個乒乓球桶,每次程式呼叫函式之前,我們都把呼叫返回後的地址寫在一個乒乓球上,然後塞進這個球桶
這個操作其實就是我們常說的壓棧。如果函式執行完了,我們就從球桶裡取出最上面的那個乒乓球,很顯然,這就是出棧。
拿到出棧的乒乓球,找到上面的地址,把程式跳轉過去,就返回到了函式呼叫後的下一條指令了
如果函式A在執行完成之前又呼叫了函式B,那麼在取出乒乓球之前,我們需要往球桶裡塞一個乒乓球。而我們從球桶最上面拿乒乓球的時候,拿的也一定是最近一次的,也就是最下面一層的函式呼叫完成後的地址
乒乓球桶的底部,就是棧底,最上面的乒乓球所在的位置,就是棧頂
壓棧的不只有函式呼叫完成後的返回地址
比如函式A在呼叫B的時候,需要傳輸一些引數資料,這些引數資料在暫存器不夠用的時候也會被壓入棧中
整個函式A所佔用的所有記憶體空間,就是函式A的棧幀(Stack Frame)
Frame在中文裡也有“相框”的意思,所以,每次到這裡,都有種感覺,整個函式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暫存器中,將程式的控制權返回到出棧後的棧頂。
2 構造Stack Overflow
通過引入棧,我們可以看到,無論有多少層的函式呼叫,或者在函式A裡呼叫函式B,再在函式B裡呼叫A
這樣的遞迴呼叫,我們都只需要通過維持rbp和rsp,這兩個維護棧頂所在地址的暫存器,就能管理好不同函式之間的跳轉
不過,棧的大小也是有限的。如果函式呼叫層數太多,我們往棧裡壓入它存不下的內容,程式在執行的過程中就會遇到棧溢位的錯誤,這就是stack overflow
構造一個棧溢位的錯誤
並不困難,最簡單的辦法,就是我們上面說的Infiinite Mirror Effect的方式,讓函式A呼叫自己,並且不設任何終止條件
這樣一個無限遞迴的程式,在不斷地壓棧過程中,將整個棧空間填滿,並最終遇上stack overflow。
int a()
{
return a();
}
int main()
{
a();
return 0;
}
除了無限遞迴,遞迴層數過深,在棧空間裡面建立非常佔記憶體的變數(比如一個巨大的陣列),這些情況都很可能給你帶來stack overflow
相信你理解了棧在程式執行的過程裡面是怎麼回事,未來在遇到stackoverflow這個錯誤的時候,不會完全沒有方向了。
3 利用函式內聯實現效能優化
上面我們提到一個方法,把一個實際呼叫的函式產生的指令,直接插入到的位置,來替換對應的函式呼叫指令。儘管這個通用的函式呼叫方案,被我們否決了,但是如果被呼叫的函式裡,沒有呼叫其他函式,這個方法還是可以行得通的。
事實上,這就是一個常見的編譯器進行自動優化的場景,我們通常叫函式內聯(Inline)
只要在GCC編譯的時候,加上對應的一個讓編譯器自動優化的引數-O,編譯器就會在可行的情況下,進行這樣的指令替換。
- 案例
為了避免編譯器優化掉太多程式碼,小小修改了一下function.c,讓引數x和y都變成了,通過隨機數生成,並在程式碼的最後加上將u通過printf列印
[hadoop@JavaEdge Documents]$ vim function.c
[hadoop@JavaEdge Documents]$ gcc -g -c -O function.c
[hadoop@JavaEdge Documents]$ objdump -d -M intel -S function.o
function.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
{
return a+b;
}
int main()
{
0: 53 push rbx
1: bf 00 00 00 00 mov edi,0x0
6: e8 00 00 00 00 call b <main+0xb>
b: 89 c7 mov edi,eax
d: e8 00 00 00 00 call 12 <main+0x12>
12: e8 00 00 00 00 call 17 <main+0x17>
17: 89 c3 mov ebx,eax
19: e8 00 00 00 00 call 1e <main+0x1e>
1e: 89 c1 mov ecx,eax
20: bf 67 66 66 66 mov edi,0x66666667
25: 89 d8 mov eax,ebx
27: f7 ef imul edi
29: d1 fa sar edx,1
2b: 89 d8 mov eax,ebx
2d: c1 f8 1f sar eax,0x1f
30: 29 c2 sub edx,eax
32: 8d 04 92 lea eax,[rdx+rdx*4]
35: 29 c3 sub ebx,eax
37: 89 c8 mov eax,ecx
39: f7 ef imul edi
3b: c1 fa 02 sar edx,0x2
3e: 89 d7 mov edi,edx
40: 89 c8 mov eax,ecx
42: c1 f8 1f sar eax,0x1f
45: 29 c7 sub edi,eax
47: 8d 04 bf lea eax,[rdi+rdi*4]
4a: 01 c0 add eax,eax
4c: 29 c1 sub ecx,eax
#include <time.h>
#include <stdlib.h>
int static add(int a, int b)
{
return a+b;
4e: 8d 34 0b lea esi,[rbx+rcx*1]
{
srand(time(NULL));
int x = rand() % 5;
int y = rand() % 10;
int u = add(x, y);
printf("u = %d\n", u);
51: bf 00 00 00 00 mov edi,0x0
56: b8 00 00 00 00 mov eax,0x0
5b: e8 00 00 00 00 call 60 <main+0x60>
60: b8 00 00 00 00 mov eax,0x0
65: 5b pop rbx
66: c3 ret
上面的function.c的編譯出來的彙編程式碼,沒有把add函式單獨編譯成一段指令順序,而是在呼叫u = add(x, y)的時候,直接替換成了一個add指令。
除了依靠編譯器的自動優化,你還可以在定義函式的地方,加上inline的關鍵字,來提示編譯器對函式進行內聯。
內聯帶來的優化是,CPU需要執行的指令數變少了,根據地址跳轉的過程不需要了,壓棧和出棧的過程也不用了。
不過內聯並不是沒有代價,內聯意味著,我們把可以複用的程式指令在呼叫它的地方完全展開了。如果一個函式在很多地方都被呼叫了,那麼就會展開很多次,整個程式佔用的空間就會變大了。
這樣沒有呼叫其他函式,只會被呼叫的函式,我們一般稱之為葉子函式(或葉子過程)
3 總結
這一節,我們講了一個程式的函式間呼叫,在CPU指令層面是怎麼執行的。其中一定需要你牢記的,就是程式棧這個新概念。
我們可以方便地通過壓棧和出棧操作,使得程式在不同的函式呼叫過程中進行轉移。而函式內聯和棧溢位,一個是我們常常可以選擇的優化方案,另一個則是我們會常遇到的程式Bug。
通過加入了程式棧,我們相當於在指令跳轉的過程種,加入了一個“記憶”的功能,能在跳轉去執行新的指令之後,再回到跳出去的位置,能夠實現更加豐富和靈活的指令執行流程。這個也為我們在程式開發的過程中,提供了“函式”這樣一個抽象,使得我們在軟體開發的過程中,可以複用程式碼和指令,而不是隻能簡單粗暴地複製、貼上程式碼和指令。
4 推薦閱讀
可以仔細讀一下《深入理解計算機系統(第三版)》的3.7小節《過程》,進一步瞭解函式呼叫是怎麼回事。
另外,我推薦你花一點時間,通過搜尋引擎搞清楚function.c每一行彙編程式碼的含義,這個能夠幫你進一步深入瞭解程式棧、棧幀、暫存器以及Intel CPU的指令集。
參考
深入淺出計算機組成原