【圖文】函式呼叫過程中棧的變化

嵌入式與Linux那些事發表於2021-12-30

大家都知道函式呼叫是通過棧來實現的,而且知道在棧中存放著該函式的區域性變數。但是對於棧的實現細節可能不一定清楚。本文將介紹一下在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時的狀態。

相關文章