大家都知道函數(shù)調(diào)用是通過棧來實現(xiàn)的,而且知道在棧中存放著該函數(shù)的局部變量。但是對于棧的實現(xiàn)細(xì)節(jié)可能不一定清楚。本文將介紹一下在Linux平臺下函數(shù)棧是如何實現(xiàn)的。
棧幀的結(jié)構(gòu)
函數(shù)在調(diào)用的時候都是在??臻g上開辟一段空間以供函數(shù)使用,所以,我們先來了解一下通用棧幀的結(jié)構(gòu)。
如圖所示,棧是由高地址向地地址的方向生長的,而且棧有其棧頂和棧底,入棧出棧的地方就叫做棧頂。
在x86系統(tǒng)的CPU中,rsp是棧指針寄存器,這個寄存器中存儲著棧頂?shù)牡刂?。rbp中存儲著棧底的地址。函數(shù)??臻g主要是由這兩個寄存器來確定的。
當(dāng)程序運(yùn)行時,棧指針rsp可以移動,棧指針和幀指針rbp一次只能存儲一個地址,所以,任何時候,這一對指針指向的是同一個函數(shù)的棧幀結(jié)構(gòu)。
而幀指針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("%drn",z);
return 0;
}
反匯編如下,下面我們就對照匯編代碼一步一步分析下函數(shù)調(diào)用過程中棧的變化。
0000000000000000 <sum>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d ec mov %edi,-0x14(%rbp) # 參數(shù)傳遞
7: 89 75 e8 mov %esi,-0x18(%rbp) # 參數(shù)傳遞
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 # 存儲結(jié)果
18: 5d pop %rbp
19: c3 retq
000000000000001a <main>:
1a: 55 push %rbp # 保存%rbp。rbp,棧底的地址
1b: 48 89 e5 mov %rsp,%rbp # 設(shè)置新的棧指針。rsp 棧指針,指向棧頂?shù)牡刂?
1e: 48 83 ec 10 sub $0x10,%rsp # 分配 16字節(jié)??臻g。%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 # 參數(shù)傳遞 ,從右向左
3f: 89 c7 mov %eax,%edi # 參數(shù)傳遞
41: e8 00 00 00 00 callq 46 <main+0x2c> # 調(diào)用sum
46: 89 45 fc mov %eax,-0x4(%rbp)
49: 8b 45 fc mov -0x4(%rbp),%eax # 存儲計算結(jié)果
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
函數(shù)調(diào)用前
在函數(shù)被調(diào)用之前,調(diào)用者會為調(diào)用函數(shù)做準(zhǔn)備。首先,函數(shù)棧上開辟了16字節(jié)的空間,存儲定義的3個int型變量,建立了main函數(shù)的棧。
接著,會給三個變量進(jìn)行賦值。
以下4行代碼是進(jìn)行參數(shù)傳遞。我們可以看到是函數(shù)參數(shù)是倒序傳入的:先傳入第N個參數(shù),再傳入第N-1個參數(shù)(CDECL約定)。
mov -0x8(%rbp),%edx
mov -0xc(%rbp),%eax
mov %edx,%esi # 參數(shù)傳遞 ,從右向左
mov %eax,%edi # 參數(shù)傳遞
最后,會執(zhí)行到call指令處,調(diào)用sum函數(shù)。
callq 46 <main+0x2c> # 調(diào)用sum
CALL指令內(nèi)部其實還暗含了一個將返回地址(即CALL指令下一條指令的地址)壓棧的動作(由硬件完成)。
具體來說,call指令執(zhí)行時,先把下一條指令的地址入棧,再跳轉(zhuǎn)到對應(yīng)函數(shù)執(zhí)行的起始處。
函數(shù)調(diào)用時
進(jìn)入sum函數(shù)后,我們看到函數(shù)的前兩行:
push %rbp
mov %rsp,%rbp
這兩條匯編指令的含義是:首先將rbp寄存器入棧,然后將棧頂指針rsp賦值給rbp。
“mov rbp rsp”這條指令表面上看是用rsp覆蓋rbp原來的值,其實不然。
因為給rbp賦值之前,原rbp值已經(jīng)被壓棧(位于棧頂),而新的rbp又恰恰指向棧頂。此時rbp寄存器就已經(jīng)處于一個非常重要的地位。
該寄存器中存儲著棧中的一個地址(原rbp入棧后的棧頂),從該地址為基準(zhǔn),向上(棧底方向)能獲取返回地址、參數(shù)值,向下(棧頂方向)能獲取函數(shù)局部變量值,而該地址處又存儲著上一層函數(shù)調(diào)用時的rbp值。
一般而言,%rbp+4處為返回地址,%rbp+8處為第一個參數(shù)值(最后一個入棧的參數(shù)值,此處假設(shè)其占用4字節(jié)內(nèi)存),%rbp-4處為第一個局部變量,%rbp處為上一層rbp值。
由于rbp中的地址處總是“上一層函數(shù)調(diào)用時的rbp值”,而在每一層函數(shù)調(diào)用中,都能通過當(dāng)時的%rbp值“向上(棧底方向)”能獲取返回地址、參數(shù)值,“向下(棧頂方向)”能獲取函數(shù)局部變量值。
緊接著執(zhí)行的四條指令。
mov %edi,-0x14(%rbp) # 參數(shù)傳遞
mov %esi,-0x18(%rbp) # 參數(shù)傳遞
mov -0x14(%rbp),%edx
mov -0x18(%rbp),%eax
add %edx,%eax
mov %eax,-0x4(%rbp)
上述指令通過rbp加偏移量的方式將main傳遞給sum的兩個參數(shù)保存在當(dāng)前棧幀的合適位置,然后又取出來放入寄存器,看著有點(diǎn)兒多此一舉,這是因為在編譯時未給gcc指定優(yōu)化級別,而gcc編譯程序時,默認(rèn)不做任何優(yōu)化,所以看起來比較啰嗦。
需要說明的是,sum的兩個參數(shù)和返回值都是int,在內(nèi)存中只占4個字節(jié),而圖中每個棧內(nèi)存單元按8字節(jié)地址邊界進(jìn)行了對齊,所以才是下圖中這個樣子。
再來看緊接著的三條指令。
add %edx,%eax
mov %eax,-0x4(%rbp) # 局部變量
mov -0x4(%rbp),%eax # 存儲結(jié)果
上述第一條指令負(fù)責(zé)執(zhí)行加法運(yùn)算并將并將結(jié)果存入eax中,第二條指令將eax中的值存入局部變量c所在的內(nèi)存,第三條指令將局部變量c的值讀取到eax中,可以看到,局部變量c被編譯器安排到了%rbp -0x4這個地址對應(yīng)的內(nèi)存中。
接下來繼續(xù)執(zhí)行
pop %rbp
retq
這兩條指令的功能相當(dāng)于下面的指令:
mov %rbp,%rsp
pop %rbp
pop %rip
即在操作上面兩條指令的時候,首先把rsp賦值,它的值是存儲調(diào)用函數(shù)rbp的值的地址,所以可以通過出棧操作,來給rbp賦值,來找回調(diào)用函數(shù)的rbp。
通過棧的結(jié)構(gòu),可以知道,rbp上面就是調(diào)用函數(shù)調(diào)用被調(diào)用函數(shù)的下一條指令的執(zhí)行地址,所以需要賦值給rip,來找回調(diào)用函數(shù)里的指令執(zhí)行地址。
整個函數(shù)跳轉(zhuǎn)回main的時候,他的rsp,rbp都會變回原來的main函數(shù)的棧指針,C語言程序就是用這種方式來確保函數(shù)的調(diào)用之后,還能繼續(xù)執(zhí)行原來的程序。
函數(shù)調(diào)用后
函數(shù)最后返回的時候,繼續(xù)執(zhí)行下面這條指令:
mov %eax,-0x4(%rbp) # 把sum函數(shù)的返回值賦給變量z
上述指令將eax中的結(jié)果放入rbp -0x4所指的內(nèi)存中,這里也是main的局部變量z所在位置。
再往后的指令如下:
mov %eax,-0x4(%rbp)
mov -0x4(%rbp),%eax # 計算結(jié)果
mov %eax,%esi
mov %eax,%esi
lea 0x0(%rip),%rdi
mov $0x0,%eax
callq 5f <main+0x45>
上述指令首先為printf準(zhǔn)備參數(shù),然后調(diào)用printf,具體過程和調(diào)用sum的過程相似,讓CPU直接執(zhí)行到main倒數(shù)第二條leave指令處。
mov $0x0,%eax
指令作用是將main返回值0放到寄存器eax,等main返回后調(diào)用main可拿到這個值。
執(zhí)行l(wèi)eave指令相當(dāng)于執(zhí)行如下兩條指令:
mov %rbp, %rsp
pop %rbp
leave指令首先將rbp的值復(fù)制給rsp,rsp就指向rbp所指的棧單元。之后leave指令將該棧單元的值pop給rbp,如此,rsp和rbp就恢復(fù)成剛進(jìn)入main時的狀態(tài)。