天天看點

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(上)1 棧的意義

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(上)1 棧的意義

用Google搜異常資訊,肯定都通路過

Stack Overflow網站
全球最大的程式員問答網站,名字來自于一個常見的報錯,就是棧溢出(stack overflow)

從函數調用開始,在計算機指令層面函數間的互相調用是怎麼實作的,以及什麼情況下會發生棧溢出

1 棧的意義

先看一個簡單的C程式

  • function.c
重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(上)1 棧的意義

直接在

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的指令,這樣就會産生無窮無盡地替換。

就好像兩面鏡子面對面放在一塊兒,任何一面鏡子裡面都會看到無窮多面鏡子

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(上)1 棧的意義

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,那麼在取出乒乓球之前,我們需要往球桶裡塞一個乒乓球。而我們從球桶最上面拿乒乓球的時候,拿的也一定是最近一次的,也就是最下面一層的函數調用完成後的位址

乒乓球桶的底部,就是棧底,最上面的乒乓球所在的位置,就是棧頂

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(上)1 棧的意義

壓棧的不隻有函數調用完成後的傳回位址

比如函數A在調用B的時候,需要傳輸一些參數資料,這些參數資料在寄存器不夠用的時候也會被壓入棧中

整個函數A所占用的所有記憶體空間,就是函數A的棧幀(Stack Frame)

Frame在中文裡也有“相框”的意思,是以,每次到這裡,都有種感覺,整個函數A所需要的記憶體空間就像是被這麼一個“相框”給框了起來,放在了棧裡面。

而實際的程式棧布局,頂和底與我們的乒乓球桶相比是倒過來的

底在最上面,頂在最下面,這樣的布局是因為棧底的記憶體位址是在一開始就固定的。而一層層壓棧之後,棧頂的記憶體位址是在逐漸變小而不是變大

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(上)1 棧的意義

對應上面函數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寄存器中,将程式的控制權傳回到出棧後的棧頂。

繼續閱讀