C 語言中的 "函數調用棧" 一定要弄懂!

大家都知道函數調用是通過棧來實現的,而且知道在棧中存放着該函數的局部變量。但是對於棧的實現細節可能不一定清楚。本文將介紹一下在 Linux 平臺下函數棧是如何實現的。

棧幀的結構

函數在調用的時候都是在棧空間上開闢一段空間以供函數使用,所以,我們先來了解一下通用棧幀的結構。

如圖所示,棧是由高地址向地地址的方向生長的,而且棧有其棧頂和棧底,入棧出棧的地方就叫做棧頂。

在 x86 系統的 CPU 中,rsp 是棧指針寄存器,這個寄存器中存儲着棧頂的地址。rbp 中存儲着棧底的地址。函數棧空間主要是由這兩個寄存器來確定的。

當程序運行時,棧指針 rsp 可以移動,棧指針和幀指針 rbp 一次只能存儲一個地址,所以,任何時候,這一對指針指向的是同一個函數的棧幀結構。

而幀指針 rbp 是不移動的,訪問棧中的元素可以用 - 4(%rbp)或者 8(%rbp) 訪問 %rbp 指針下面或者上面的元素。

在明白了這些之後,下面我們來看一個具體的例子:

#include <stdio.h>

int sum (int a,int b)
{
 int c = a + b;
 return c;
}

int main()
{
 int x = 5,y = 10,z = 0;
 z = sum(x,y);
 printf("%d\r\n",z);
 return 0;
}

反彙編如下,下面我們就對照彙編代碼一步一步分析下函數調用過程中棧的變化。

0000000000000000 <sum>:
   0: 55                    push   %rbp 
   1: 48 89 e5              mov    %rsp,%rbp
   4: 89 7d ec              mov    %edi,-0x14(%rbp) # 參數傳遞
   7: 89 75 e8              mov    %esi,-0x18(%rbp) # 參數傳遞
   a: 8b 55 ec              mov    -0x14(%rbp),%edx
   d: 8b 45 e8              mov    -0x18(%rbp),%eax
  10: 01 d0                 add    %edx,%eax 
  12: 89 45 fc              mov    %eax,-0x4(%rbp) # 局部變量
  15: 8b 45 fc              mov    -0x4(%rbp),%eax # 存儲結果
  18: 5d                    pop    %rbp
  19: c3                    retq   

000000000000001a <main>:
  1a: 55                    push   %rbp # 保存%rbp。rbp,棧底的地址
  1b: 48 89 e5              mov    %rsp,%rbp # 設置新的棧指針。rsp 棧指針,指向棧頂的地址
  1e: 48 83 ec 10           sub    $0x10,%rsp # 分配 16字節棧空間。%rsp = %rsp-16
  22: c7 45 f4 05 00 00 00  movl   $0x5,-0xc(%rbp) # 賦值
  29: c7 45 f8 0a 00 00 00  movl   $0xa,-0x8(%rbp) # 賦值
  30: c7 45 fc 00 00 00 00  movl   $0x0,-0x4(%rbp) # 賦值
  37: 8b 55 f8              mov    -0x8(%rbp),%edx  
  3a: 8b 45 f4              mov    -0xc(%rbp),%eax 
  3d: 89 d6                 mov    %edx,%esi # 參數傳遞 ,從右向左
  3f: 89 c7                 mov    %eax,%edi # 參數傳遞
  41: e8 00 00 00 00        callq  46 <main+0x2c> # 調用sum
  46: 89 45 fc              mov    %eax,-0x4(%rbp) 
  49: 8b 45 fc              mov    -0x4(%rbp),%eax # 存儲計算結果
  4c: 89 c6                 mov    %eax,%esi
  4e: 48 8d 3d 00 00 00 00  lea    0x0(%rip),%rdi        # 55 <main+0x3b>
  55: b8 00 00 00 00        mov    $0x0,%eax
  5a: e8 00 00 00 00        callq  5f <main+0x45>
  5f: b8 00 00 00 00        mov    $0x0,%eax 
  64: c9                    leaveq 
  65: c3                    retq

函數調用前

在函數被調用之前,調用者會爲調用函數做準備。首先,函數棧上開闢了 16 字節的空間,存儲定義的 3 個 int 型變量,建立了 main 函數的棧。

接着,會給三個變量進行賦值。

以下 4 行代碼是進行參數傳遞。我們可以看到是函數參數是倒序傳入的:先傳入第 N 個參數,再傳入第 N-1 個參數(CDECL 約定)。

mov    -0x8(%rbp),%edx  
mov    -0xc(%rbp),%eax 
mov    %edx,%esi # 參數傳遞 ,從右向左
mov    %eax,%edi # 參數傳遞

最後,會執行到 call 指令處,調用 sum 函數。

callq  46 <main+0x2c> # 調用sum

CALL 指令內部其實還暗含了一個將返回地址(即 CALL 指令下一條指令的地址)壓棧的動作(由硬件完成)。

具體來說,call 指令執行時,先把下一條指令的地址入棧,再跳轉到對應函數執行的起始處。

函數調用時

進入 sum 函數後,我們看到函數的前兩行:

push   %rbp 
mov    %rsp,%rbp

這兩條彙編指令的含義是:首先將 rbp 寄存器入棧,然後將棧頂指針 rsp 賦值給 rbp。

“mov rbp rsp” 這條指令表面上看是用 rsp 覆蓋 rbp 原來的值,其實不然。

因爲給 rbp 賦值之前,原 rbp 值已經被壓棧(位於棧頂),而新的 rbp 又恰恰指向棧頂。此時 rbp 寄存器就已經處於一個非常重要的地位。

該寄存器中存儲着棧中的一個地址(原 rbp 入棧後的棧頂),從該地址爲基準,向上(棧底方向)能獲取返回地址、參數值,向下(棧頂方向)能獲取函數局部變量值,而該地址處又存儲着上一層函數調用時的 rbp 值。

一般而言,%rbp+4 處爲返回地址,%rbp+8 處爲第一個參數值(最後一個入棧的參數值,此處假設其佔用 4 字節內存),%rbp-4 處爲第一個局部變量,%rbp 處爲上一層 rbp 值。

由於 rbp 中的地址處總是 “上一層函數調用時的 rbp 值”,而在每一層函數調用中,都能通過當時的 %rbp 值“向上(棧底方向)” 能獲取返回地址、參數值,“向下(棧頂方向)”能獲取函數局部變量值。

緊接着執行的四條指令。

mov    %edi,-0x14(%rbp) # 參數傳遞
mov    %esi,-0x18(%rbp) # 參數傳遞
mov    -0x14(%rbp),%edx
mov    -0x18(%rbp),%eax
add    %edx,%eax
mov    %eax,-0x4(%rbp)

上述指令通過 rbp 加偏移量的方式將 main 傳遞給 sum 的兩個參數保存在當前棧幀的合適位置,然後又取出來放入寄存器,看着有點兒多此一舉,這是因爲在編譯時未給 gcc 指定優化級別,而 gcc 編譯程序時,默認不做任何優化,所以看起來比較囉嗦。

需要說明的是,sum 的兩個參數和返回值都是 int,在內存中只佔 4 個字節,而圖中每個棧內存單元按 8 字節地址邊界進行了對齊,所以纔是下圖中這個樣子。

再來看緊接着的三條指令。

add    %edx,%eax 
mov    %eax,-0x4(%rbp) # 局部變量
mov    -0x4(%rbp),%eax # 存儲結果

上述第一條指令負責執行加法運算並將並將結果存入 eax 中,第二條指令將 eax 中的值存入局部變量 c 所在的內存,第三條指令將局部變量 c 的值讀取到 eax 中,可以看到,局部變量 c 被編譯器安排到了 %rbp -0x4 這個地址對應的內存中。

接下來繼續執行

pop %rbp
retq

這兩條指令的功能相當於下面的指令:

mov %rbp,%rsp
pop %rbp
pop %rip

即在操作上面兩條指令的時候,首先把 rsp 賦值,它的值是存儲調用函數 rbp 的值的地址,所以可以通過出棧操作,來給 rbp 賦值,來找回調用函數的 rbp。

通過棧的結構,可以知道,rbp 上面就是調用函數調用被調用函數的下一條指令的執行地址,所以需要賦值給 rip,來找回調用函數里的指令執行地址。

整個函數跳轉回 main 的時候,他的 rsp,rbp 都會變回原來的 main 函數的棧指針,C 語言程序就是用這種方式來確保函數的調用之後,還能繼續執行原來的程序。

函數調用後

函數最後返回的時候,繼續執行下面這條指令:

mov    %eax,-0x4(%rbp)  # 把sum函數的返回值賦給變量z

上述指令將 eax 中的結果放入 rbp  -0x4 所指的內存中,這裏也是 main 的局部變量 z 所在位置。

再往後的指令如下:

mov    %eax,-0x4(%rbp) 
mov    -0x4(%rbp),%eax # 計算結果
mov    %eax,%esi
mov    %eax,%esi
lea    0x0(%rip),%rdi  
mov    $0x0,%eax
callq  5f <main+0x45>

上述指令首先爲 printf 準備參數,然後調用 printf,具體過程和調用 sum 的過程相似,讓 CPU 直接執行到 main 倒數第二條 leave 指令處。

mov    $0x0,%eax

指令作用是將 main 返回值 0 放到寄存器 eax,等 main 返回後調用 main 可拿到這個值。

執行 leave 指令相當於執行如下兩條指令:

mov %rbp, %rsp
pop %rbp

leave 指令首先將 rbp 的值複製給 rsp,rsp 就指向 rbp 所指的棧單元。之後 leave 指令將該棧單元的值 pop 給 rbp,如此,rsp 和 rbp 就恢復成剛進入 main 時的狀態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JkWxCxn3Ow22fxEKPF9tRg