每個函數呼叫,包括 main 函數和每次遞迴(recursion),都需要創建一個堆疊記憶體區域(稱為 stack frame)給函數呼叫(function calling)用來存儲函數的參數和區域變數。
堆疊記憶體(筆者以下簡稱堆疊)是用於存放自動變數(參數和區域變數)的記憶體區域。 呼叫函數時,堆疊用來存儲傳給函數的參數。 函數結束時,自動變數將取消指向其參考的記憶體(Out of Scope),並且該記憶體被釋放用於其他用途。高階語言的一個重要功能是為您管理堆疊(有時稱為執行時堆疊)。而在 Assembly Language,情況就並非如此了。
以低階的 Assembly 編程時,您將需要參與堆疊的管理。另外,了解操縱堆疊的指令也可幫助您偵錯。下面列出了幾項關於堆疊的重要事項:
• 隨著函數的呼叫和回傳,堆疊會增長和收縮。
• 堆疊隨著區域變數的「創建」和「取消參考」而增長和收縮。
• 每個進程(或稱為處理序)/執行緒的堆疊的大小限制都取決於作業系統(例如,Linux/Mac 預設為 8MB,Windows 預設為 1MB)。
• 記憶體位址隨堆疊增長而遞減。堆疊頂部朝下並向下增長。
• 堆疊的每個「memory slot」在 32 位模式下可容納 4 個位元組,在 64 位模式下可容納 8 個位元組。
• 預設情況下,值以 Little-Endian 格式存儲在堆疊內。
• 在 x86_64 中,所有函數必須對齊 16 個位元組的倍數(所有作業系統)。
至於如何管理堆疊,就不得不提及函數的呼叫慣例(calling convention)了。
呼叫慣例包含在應用二進位介面(application binary interface,縮寫為ABI)內,慣例定義了在低階層面如何實作函數呼叫,包括如何傳遞參數到堆疊、參數傳遞的順序、如何保存寄存器、如何接收傳回值、以及函數呼叫前的堆疊準備工作和在呼叫後的還原工作如何分工等。 在討論呼叫慣例時,筆者會使用術語 caller 和 callee。
函數的呼叫慣例有很多種,主要以 Caller clean-up 和 Callee clean-up 分類。Clean-up 就是清理的意思,以是 caller 還是 callee 負責在函數呼叫後清理堆疊作劃分。Caller clean-up 的呼叫慣例有 cdecl, syscall, optlink 等,Callee clean-up 的呼叫慣例有 stdcall, fastcall, vectorcall, safecall 等,混合式的有 thiscall。函數的呼叫慣例其實還可以根據傳遞參數的形式分類:使用堆疊傳遞和使用寄存器傳遞。
cdecl(C declaration,即 C 聲明)呼叫慣例在非 Windows 的 32 位平台上最為常見,所以我們會先深入探討 cdecl。因為它基於 C 標準,cdecl 通常是 GCC,Clang 和 Visual Studio 的 C 編譯器等編譯器中的預設慣例。某些開發環境(例如 Visual Studio)允許您在專案的屬性中修改預設慣例。
cdecl 具有四個主要特徵:
1. 參數通過堆疊以相反的順序(從右到左)傳遞給函數。
2. eax,ecx 和 edx(volatile registers)由 caller 負責保存,顧名思義,這些通用寄存器通常保存臨時(易失)信息,這些信息可以被任何函數覆蓋。因此,caller 有責任在呼叫函數前將這些寄存器中的每一個想要保存的值推入堆疊。而其餘的通用寄存器(non-volatile registers)則由 callee 負責保存,它們用於保存長期值(非易失性),callee 有責任保存想要動用的寄存器並在返回給 caller 之前恢復它們,當 caller 進行函數呼叫時,可以期望這些寄存器在 callee 返回後將保持相同的值。
3. 在大多數情況下,eax 用作接收回傳值,ST0 用於浮點回傳值。
4. caller 負責清理堆疊。 C 支援 variadic/varargs functions(參數數目可變),這意味著 callee 不知道它已經接收了多少個參數。因此,caller 必須負責清理堆疊,並且每次呼叫函數時都需要清理。可變參數 C 函數的主要例子有 printf()。
5. 呼叫函數時,浮點寄存器 ST0 至 ST7 必須為空,並且退出函數時 ST1 至 ST7 必須為空。不用回傳值時,ST0也必須為空。
好!現在我們把話題拉回 stack frame。 stack frame 的作用類似於在堆疊上劃出分區給函數,使函數可以獨立地只操縱自己的分區。
我們來看看一個簡單例子(使用 NASM)如何創建 stack frame。
使用 call 指令呼叫 _somefunc 函數前,假設是使用 cdecl 呼叫慣例,需要把傳遞的參數反方向 push 入堆疊,由於每個 memory slot 是 4 個位元組,每次推入的長度必須是 4 個位元組(DWORD)。
使用 call 指令呼叫 _somefunc 函數時,return address (_main 函數中 call 語句的下一行的地址)會被自動隱式 push 入堆疊,並轉跳到 _somefunc 的第一行執行。在 _somefunc 函數裡, 先備份了 ebp,然後複製指向堆疊頂部的 esp 給 ebp ,此時新 epb 指向舊 ebp 的備份,然後以 ebp 作為存取整個 stack frame 的基點, 用加/減法運算來參考「相對於基點」若干個位元組的參數/區域變數的位置。
由於堆疊內每個項目都是 4 個位元組, 所以 [ebp + 0] 是舊 ebp 的備份,[ebp + 4] 是 return address, [ebp + 8] 是 num1, [ebp + 12] 是 char1。最先推入的參數會離 ebp 最遠。
定義函數內的區域變數也是依靠 stack frame 的,_somefunct 內的 sub esp, 8 就是為了分配兩個 4 位元組的空間給兩個區域變數。之後就可用 [ebp - 4] 和 [ebp - 8] 來參考該兩個區域變數。
我們可以比較一下高階語言和 assembly language 處理區域變數的分別。
函數開首(使用高階語言):
void somefunc()
{
int a, b;
...
函數開首(使用 Assembly),這指令序稱為 Function Prologue 或 Standard Entry Sequence:
_somefunc:
push ebp
mov ebp, esp
sub esp, 8
如果不需要在函數創建區域變數,我們也會加一句 sub esp, 0 ,大多數組譯器也會忽略它,不會佔用記憶體,但卻可增加可讀性。
區域變數賦值(使用高階語言):
a = 9;
b = 'Z';
區域變數賦值(使用 Assembly):
mov DWORD[ebp - 4], 9
mov DWORD[ebp - 8], 'Z'
在 x86 中,不應使用 esp 來參考任何自動變數,因為 esp 的目的是始終指向堆疊的頂部,隨著 push 和 pop 而改變。 ebp 給我們一個恆定的參考點來參考 stack frame 內所有項目 (因此 ebp 有時被稱為 frame pointer),即使在函數中再有其他 push 和 pop 也不會影響 ebp,我們亦不應在整個函數中對其進行更改。
同樣地,函數只應操縱自己的 stack frame 或其之後的堆疊, 不應更改 stack frame 前的數據,因其屬於其他函數,這樣做是非常危險的。
在這個例子裡, _main 函數呼叫了 _somefunc 函數, 因此 _main 是 caller 以及 _somefunc 是 callee。 在呼叫 _somefunc 函數前在 caller 保存了 eax,在 _somefunc 函數內保存了 ebx。
在函數回傳前,必須:
1. 如果有定義過區域變數,用 ebp 的值來把 esp 還原到 stack frame 的基準點,
用以消除區域變數
2. 還原舊 ebp
3. 用 ret 指令自動隱式 pop 出 return address 到 eip 並跳轉到其所指向的行。
以上這指令序稱為 Function Epilogue 或 Standard Exit Sequence。
假設是使用 cdecl 呼叫慣例,_main 函數負責在呼叫 _somefunc 函數後清理呼叫前推到堆疊的參數(加 8 個位元組到 esp)。
另一個頗為常用的呼叫慣例是 stdcall,它以廣泛使用於呼叫 Windows API 而聞名。通常,stdcall 遵循與 cdecl 相同的規則,但有一個區別:callee (而不是 caller)負責清理堆棧。
stdcall 的優點在於,清理堆疊的程式碼僅在 callee 中寫入一次,而不是在每次呼叫特定函數時都要編寫。因為 stdcall 不允許可變參數功能,因此 callee 可以知道它接收了多少個參數,所以可以由 callee 清理堆疊。而且 callee 可以把執行堆疊清理整合到 ret 指令,這意味著呼叫函數中的程式碼行數更少。把上述示例更改為使用 stdcall 只需作兩項更改便完成。
1. 在 _somefunc 函數回傳時用 ret 指令的另一種形式,其中包括要從堆疊中刪除的位元組數目:
ret 8
2. 刪除 _main 函數中的清理動作:
add esp, 8