棧基礎和函數調用約定
本節我們將講述程序中函數調用在棧上的細節,希望大家認真閱讀,不能放過文中的任何一個細節。大家還是需要明確一個基礎棧是從高地址到底地址增長的。這里的內容是非常基礎也是非常重要的,一開始接受可能略微困難,需要大家在后續的學習過程中不斷回顧,當然,能動手調試觀察是再好不過的了。
棧基礎
棧是一種典型的先進后出( First in Last Out )的數據結構,其操作主要有壓棧(push)與出棧(pop)兩種操作,如下圖所示(維基百科)。兩種操作都操作棧頂,當然,它也有棧底。
高級語言在運行時都會被轉換為匯編程序,在匯編程序運行過程中,充分利用了這一數據結構。每個程序在運行時都有虛擬地址空間,其中某一部分就是該程序對應的棧,用于保存函數調用信息和局部變量。此外,常見的操作也是壓棧與出棧。需要注意的是,程序的棧是從進程地址空間的高地址向低地址增長的。
函數調用棧(調用約定)
程序的執行過程可看作連續的函數調用。當一個函數執行完畢時,程序要回到調用指令的下一條指令(緊接call指令)處繼續執行。函數調用過程通常使用堆棧實現,每個用戶態進程對應一個調用棧結構(call stack)。編譯器使用堆棧傳遞函數參數、保存返回地址、臨時保存寄存器原有值(即函數調用的上下文)以備恢復以及存儲本地局部變量。
不同處理器和編譯器的堆棧布局、函數調用方法都可能不同,但堆棧的基本概念是一樣的。
寄存器分配x86

IA32處理器寄存器
前四個寄存器可以進行高地位的拆分,其中EAX通常同來保存函數的返回值,EBX用來保存局部變量,ECX用于循環計數器,EDX用作數學除法。
在x86處理器中,EIP(Instruction Pointer)是指令寄存器,指向處理器下條等待執行的指令地址(代碼段內的偏移量),每次執行完相應匯編指令EIP值就會增加。ESP(Stack Pointer)是堆棧指針寄存器,存放執行函數對應棧幀的棧頂地址(也是系統棧的頂部) 低地址,且始終指向棧頂;EBP(Base Pointer)是棧幀基址指針寄存器,存放執行函數對應棧幀的棧底地址 高地址,用于C運行庫訪問棧中的局部變量和參數。
注意,EIP是個特殊寄存器,不能像訪問通用寄存器那樣訪問它,即找不到可用來尋址EIP并對其進行讀寫的操作碼(OpCode)。EIP可被jmp、call和ret等指令隱含地改變(事實上它一直都在改變)。
棧幀結構
函數調用經常是嵌套的,在同一時刻,堆棧中會有多個函數的信息。每個未完成運行的函數占用一個獨立的連續區域,稱作棧幀(Stack Frame)。棧幀是堆棧的邏輯片段,當調用函數時邏輯棧幀被壓入堆棧, 當函數返回時邏輯棧幀被從堆棧中彈出。棧幀存放著函數參數,局部變量及恢復前一棧幀所需要的數據等。
編譯器利用棧幀,使得函數參數和函數中局部變量的分配與釋放對程序員透明。編譯器將控制權移交函數本身之前,插入特定代碼將函數參數壓入棧幀中,并分配足夠的內存空間用于存放函數中的局部變量。使用棧幀的一個好處是使得遞歸變為可能,因為對函數的每次遞歸調用,都會分配給該函數一個新的棧幀,這樣就巧妙地隔離當前調用與上次調用。
棧幀的邊界由棧幀基地址指針EBP和堆棧指針ESP界定(指針存放在相應寄存器中)。EBP指向當前棧幀底部(高地址),在當前棧幀內位置固定;ESP指向當前棧幀頂部(低地址),當程序執行時ESP會隨著數據的入棧和出棧而移動。因此函數中對大部分數據的訪問都基于EBP進行。

函數調用棧的典型內存布局
以下稱EBP為幀基指針, ESP為棧頂指針
圖中給出主調函數(caller)和被調函數(callee)的棧幀布局,“m(%ebp)”表示以EBP為基地址、偏移量為m字節的內存空間(中的內容)。該圖基于兩個假設:第一,函數返回值不是結構體或聯合體,否則第一個參數將位于”12(%ebp)” 處;第二,每個參數都是4字節大小(棧的粒度為4字節)。在本文后續章節將就參數的傳遞和大小問題做進一步的探討。 此外,函數可以沒有參數和局部變量,故圖中“Argument(參數)”和“Local Variable(局部變量)”不是函數棧幀結構的必需部分。
從圖中可以看出,函數調用時入棧順序為
實參N~1 ---> 主調函數返回地址 ---> 主調函數幀基指針EBP ---> 被調函數局部變量1~N
1. 主調函數將參數按照調用約定依次入棧(圖中為從右到左)[call]
2. 然后將指令指針EIP入棧以保存主調函數的返回地址(下一條待執行指令的地址)
3. 進入被調函數時,被調函數將主調函數的幀基指針EBP入棧 [push ebp],并將主調函數的棧頂指針ESP值賦給被調函數的EBP(作為被調函數的棧底) [mov ebp, esp],接著改變ESP值[sub esp (N)]來為函數局部變量預留空間。
4. 此時被調函數幀基指針(ebp of callee)指向被調函數的棧底。以該地址為基準,向上(棧底方向)可獲取主調函數的返回地址、參數值,向下(棧頂方向)能獲取被調函數的局部變量值,而該地址處又存放著上一層主調函數的幀基指針值。
5. 本級調用結束后,將EBP指針值賦給ESP[mov esp, ebp],使ESP再次指向被調函數棧底以釋放局部變量[pop (r)];再將已壓棧的主調函數幀基指針彈出到EBP[pop ebp],并彈出返回地址到EIP[ret]。ESP繼續上移越過參數,最終回到函數調用前的狀態,即恢復原來主調函數的棧幀。如此遞歸便形成函數調用棧。
整個過程中,ESP始終指向棧頂,EBP始終指向棧底。
調用(call):將當前的指令指針EIP(該指針指向緊接在call指令后的下條指令)壓入堆棧,以備返回時能恢復執行下條指令;然后設置EIP指向被調函數代碼開始處,以跳轉到被調函數的入口地址執行。
離開(leave): 恢復主調函數的棧幀以準備返回。等價于指令序列movl %ebp, %esp(恢復原ESP值,指向被調函數棧幀開始處)和popl %ebp(恢復原ebp的值,即主調函數幀基指針)。
返回(ret):與call指令配合,用于從函數或過程返回。從棧頂彈出返回地址(之前call指令保存的下條指令地址)到EIP寄存器中,程序轉到該地址處繼續執行(此時ESP指向進入函數時的第一個參數)。若帶立即數,ESP再加立即數(丟棄一些在執行call前入棧的參數)。使用該指令前,應使當前棧頂指針所指向位置的內容正好是先前call指令保存的返回地址。
基于以上指令,使用C調用約定的被調函數典型的函數序和函數跋實現如下:

舉個例子
上述可能有點枯燥不太好理解,下面來一個具體的實例幫助大家理解。
int callee(int a, int b, int c)
{
return a + b + c;
}
int caller(void)
{
int ret;
ret = callee(1, 2, 3);
ret = ret + 4;
return ret;
}
void main()
{
/* code */
caller();
}
caller主調函數調用callee被調函數并進行傳參數,進行運算后返回結果再進一步處理。
首先查看caller函數和callee函數的匯編代碼

首先將上一個函數的ebp壓入棧中push ebp,接著將當前棧頂賦值給棧底mov ebp, esp,接著為當前函數的棧開辟空間sub esp, 0x10,這里要進行內存對齊,所以開辟了16字節的空間。然后在棧上進行參數的傳遞push 0x1 0x2 0x3。到此時,進行call調用callee被調函數。

可以看到,一開始與caller主調函數一樣,將ebp壓棧,棧頂指針賦值給棧底指針。框中的指令為進行加法操作return a + b + c這里通過ebp來進行變量查找。我們可以設置斷點并查看一下在當前指令下的棧結構。break *0x080484fa && run

可以看到[ebp+8]為0x1,同理可以依次獲取到其他參數的值。運算完之后將結果存入eaxadd eax,edx。我們首先觀察此時棧上的布局,ebp和esp指向同一位置,是因為該函數callee沒有開辟棧空間的必要,在寄存器中即可實現函數功能,所以不需要涉及到內存。接下來運行pop ebp將caller主調函數的ebp恢復,這里不需要考慮esp的問題,因為他自己會根據棧的變化而變化。
最后進行ret指令同時我們在前面提到過,ret指令的作用為從棧頂彈出主調函數壓在棧中的返回地址到指令指針寄存器eip中,跳回主調函數該位置處繼續執行。再由主調函數恢復到調用前的棧。如下圖所示
從棧頂獲取eip,接著下一步就是執行主調函數的指令了。以上我們介紹的是32位的函數調用約定和棧幀變化的知識。
需要注意的是,32 位和 64 位程序有以下簡單的區別
- x86
- 函數參數在函數返回地址的上方
- x64
- System V AMD64 ABI (Linux、FreeBSD、macOS 等采用)中前六個整型或指針參數依次保存在RDI, RSI, RDX, RCX, R8 和 R9 寄存器中,如果還有更多的參數的話才會保存在棧上。
- 內存地址不能大于 0x00007FFFFFFFFFFF,6 個字節長度,否則會拋出異常。
總結
- 什么是調用約定
- 實現層面 (底層) 的規范
- 約定了函數之間如何傳遞參數
- 約定了函數如何傳遞返回值
- 常見x86調用約定
- 調用者負責清理棧上的參數 (Caller Clean-up)
- cdecl (我們舉的例子用的就是)
- oplink
- 被調者負責清理棧上的參數 (Callee Clean-ip)
- stdcall
- fastcall
- 調用者負責清理棧上的參數 (Caller Clean-up)
- x86 (32位) cdecl調用約定
- 用棧來傳遞參數
- 用寄存器eax來保存返回值
- amd64 (64位) cdecl調用約定
- 使用寄存器rdi, rsi, rdx, rcx, r8, r9來傳遞前6個參數
- 第7個及以上的參數通過棧(內存)來傳遞
- 棧幀指針ebp(rbp)的用途
- 索引棧上的參數 (例如x86下,ebp + 8指向第一個參數)
- 保存棧頂位置 esp (rsp)
參考