程序的執行過程可看作連續的函數調用。當一個函數執行完畢時,程序要回到調用指令的下一條指令(緊接call指令)處繼續執行。函數調用過程通常使用堆棧實現,每個用戶態進程對應一個調用棧結構(call stack)。編譯器使用堆棧傳遞函數參數、保存返回地址、臨時保存寄存器原有值(即函數調用的上下文)以備恢復以及存儲本地局部變量。 不同處理器和編譯器的堆棧布局、函數調用方法都可能不同,但堆棧的基本概念是一樣的。 1 寄存器分配 寄存器是處理器加工數據或運行程序的重要載體,用於存放程序執行中用到的數據和指令。因此函數調用棧的實現與處理器寄存器組密切相關。 Intel 32位體系結構(簡稱IA32)處理器包含8個四字節寄存器,如下圖所示: 圖1 IA32處理器寄存器 最初的8086中寄存器是16位,每個都有特殊用途,寄存器名城反映其不同用途。由於IA32平台采用平面尋址模式,對特殊寄存器的需求大大降低,但由於歷史原因,這些寄存器名稱被保留下來。在大多數情況下,上圖所示的前6個寄存器均可作為通用寄存器使用。某些指令可能以固定的寄存器作為源寄存器或目的寄存器,如一些特殊的算術操作指令imull/mull/cltd/idivl/divl要求一個參數必須在%eax中,其運算結果存放在%edx(higher 32-bit)和%eax (lower32-bit)中;又如函數返回值通常保存在%eax中,等等。為避免兼容性問題,ABI規范對這組通用寄存器的具體作用加以定義(如圖中所示)。 對於寄存器%eax、%ebx、%ecx和%edx,各自可作為兩個獨立的16位寄存器使用,而低16位寄存器還可繼續分為兩個獨立的8位寄存器使用。編譯器會根據操作數大小選擇合適的寄存器來生成匯編代碼。在匯編語言層面,這組通用寄存器以%e(AT&T語法)或直接以e(Intel語法)開頭來引用,例如mov $5, %eax或mov eax, 5表示將立即數5賦值給寄存器%eax。 在x86處理器中,EIP(Instruction Pointer)是指令寄存器,指向處理器下條等待執行的指令地址(代碼段內的偏移量),每次執行完相應匯編指令EIP值就會增加。ESP(Stack Pointer)是堆棧指針寄存器,存放執行函數對應棧幀的棧頂地址(也是系統棧的頂部),且始終指向棧頂;EBP(Base Pointer)是棧幀基址指針寄存器,存放執行函數對應棧幀的棧底地址,用於C運行庫訪問棧中的局部變量和參數。 注意,EIP是個特殊寄存器,不能像訪問通用寄存器那樣訪問它,即找不到可用來尋址EIP並對其進行讀寫的操作碼(OpCode)。EIP可被jmp、call和ret等指令隱含地改變(事實上它一直都在改變)。 不同架構的CPU,寄存器名稱被添加不同前綴以指示寄存器的大小。例如x86架構用字母“e(extended)”作名稱前綴,指示寄存器大小為32位;x86_64架構用字母“r”作名稱前綴,指示各寄存器大小為64位。 編譯器在將C程序編譯成匯編程序時,應遵循ABI所規定的寄存器功能定義。同樣地,編寫匯編程序時也應遵循,否則所編寫的匯編程序可能無法與C程序協同工作。 【擴展閱讀】棧幀指針寄存器 為了訪問函數局部變量,必須能定位每個變量。局部變量相對於堆棧指針ESP的位置在進入函數時就已確定,理論上變量可用ESP加偏移量來引用,但ESP會在函數執行期隨變量的壓棧和出棧而變動。盡管某些情況下編譯器能跟蹤棧中的變量操作以修正偏移量,但要引入可觀的管理開銷。而且在有些機器上(如Intel處理器),用ESP加偏移量來訪問一個變量需要多條指令才能實現。 因此,許多編譯器使用幀指針寄存器FP(Frame Pointer)記錄棧幀基地址。局部變量和函數參數都可通過幀指針引用,因為它們到FP的距離不會受到壓棧和出棧操作的影響。有些資料將幀指針稱作局部基指針(LB-local base pointer)。 在Intel CPU中,寄存器BP(EBP)用作幀指針。在Motorola CPU中,除A7(堆棧指針SP)外的任何地址寄存器都可用作FP。當堆棧向下(低地址)增長時,以FP地址為基准,函數參數的偏移量是正值,而局部變量的偏移量是負值。 2 寄存器使用約定 程序寄存器組是唯一能被所有函數共享的資源。雖然某一時刻只有一個函數在執行,但需保證當某個函數調用其他函數時,被調函數不會修改或覆蓋主調函數稍後會使用到的寄存器值。因此,IA32采用一套統一的寄存器使用約定,所有函數(包括庫函數)調用都必須遵守該約定。 根據慣例,寄存器%eax、%edx和%ecx為主調函數保存寄存器(caller-saved registers),當函數調用時,若主調函數希望保持這些寄存器的值,則必須在調用前顯式地將其保存在棧中;被調函數可以覆蓋這些寄存器,而不會破壞主調函數所需的數據。寄存器%ebx、%esi和%edi為被調函數保存寄存器(callee-saved registers),即被調函數在覆蓋這些寄存器的值時,必須先將寄存器原值壓入棧中保存起來,並在函數返回前從棧中恢復其原值,因為主調函數可能也在使用這些寄存器。此外,被調函數必須保持寄存器%ebp和%esp,並在函數返回後將其恢復到調用前的值,亦即必須恢復主調函數的棧幀。 當然,這些工作都由編譯器在幕後進行。不過在編寫匯編程序時應注意遵守上述慣例。 3 棧幀結構 函數調用經常是嵌套的,在同一時刻,堆棧中會有多個函數的信息。每個未完成運行的函數占用一個獨立的連續區域,稱作棧幀(Stack Frame)。棧幀是堆棧的邏輯片段,當調用函數時邏輯棧幀被壓入堆棧, 當函數返回時邏輯棧幀被從堆棧中彈出。棧幀存放著函數參數,局部變量及恢復前一棧幀所需要的數據等。 編譯器利用棧幀,使得函數參數和函數中局部變量的分配與釋放對程序員透明。編譯器將控制權移交函數本身之前,插入特定代碼將函數參數壓入棧幀中,並分配足夠的內存空間用於存放函數中的局部變量。使用棧幀的一個好處是使得遞歸變為可能,因為對函數的每次遞歸調用,都會分配給該函數一個新的棧幀,這樣就巧妙地隔離當前調用與上次調用。 棧幀的邊界由棧幀基地址指針EBP和堆棧指針ESP界定(指針存放在相應寄存器中)。EBP指向當前棧幀底部(高地址),在當前棧幀內位置固定;ESP指向當前棧幀頂部(低地址),當程序執行時ESP會隨著數據的入棧和出棧而移動。因此函數中對大部分數據的訪問都基於EBP進行。 為更具描述性,以下稱EBP為幀基指針, ESP為棧頂指針,並在引用匯編代碼時分別記為%ebp和%esp。 函數調用棧的典型內存布局如下圖所示: 圖2 函數調用棧的典型內存布局 圖中給出主調函數(caller)和被調函數(callee)的棧幀布局,"m(%ebp)"表示以EBP為基地址、偏移量為m字節的內存空間(中的內容)。該圖基於兩個假設:第一,函數返回值不是結構體或聯合體,否則第一個參數將位於"12(%ebp)" 處;第二,每個參數都是4字節大小(棧的粒度為4字節)。在本文後續章節將就參數的傳遞和大小問題做進一步的探討。 此外,函數可以沒有參數和局部變量,故圖中“Argument(參數)”和“Local Variable(局部變量)”不是函數棧幀結構的必需部分。 從圖中可以看出,函數調用時入棧順序為 實參N~1→主調函數返回地址→主調函數幀基指針EBP→被調函數局部變量1~N 其中,主調函數將參數按照調用約定依次入棧(圖中為從右到左),然後將指令指針EIP入棧以保存主調函數的返回地址(下一條待執行指令的地址)。進入被調函數時,被調函數將主調函數的幀基指針EBP入棧,並將主調函數的棧頂指針ESP值賦給被調函數的EBP(作為被調函數的棧底),接著改變ESP值來為函數局部變量預留空間。此時被調函數幀基指針指向被調函數的棧底。以該地址為基准,向上(棧底方向)可獲取主調函數的返回地址、參數值,向下(棧頂方向)能獲取被調函數的局部變量值,而該地址處又存放著上一層主調函數的幀基指針值。本級調用結束後,將EBP指針值賦給ESP,使ESP再次指向被調函數棧底以釋放局部變量;再將已壓棧的主調函數幀基指針彈出到EBP,並彈出返回地址到EIP。ESP繼續上移越過參數,最終回到函數調用前的狀態,即恢復原來主調函數的棧幀。如此遞歸便形成函數調用棧。 EBP指針在當前函數運行過程中(未調用其他函數時)保持不變。在函數調用前,ESP指針指向棧頂地址,也是棧底地址。在函數完成現場保護之類的初始化工作後,ESP會始終指向當前函數棧幀的棧頂,此時,若當前函數又調用另一個函數,則會將此時的EBP視為舊EBP壓棧,而與新調用函數有關的內容會從當前ESP所指向位置開始壓棧。 若需在函數中保存被調函數保存寄存器(如ESI、EDI),則編譯器在保存EBP值時進行保存,或延遲保存直到局部變量空間被分配。在棧幀中並未為被調函數保存寄存器的空間指定標准的存儲位置。包含寄存器和臨時變量的函數調用棧布局可能如下圖所示: 圖3 函數調用棧的可能內存布局 在多線程(任務)環境,棧頂指針指向的存儲器區域就是當前使用的堆棧。切換線程的一個重要工作,就是將棧頂指針設為當前線程的堆棧棧頂地址。 以下代碼用於函數棧布局示例: StackFrame 編譯鏈接並執行後,輸出打印如下: 圖4 StackFrame輸出 函數棧布局示例如下圖所示。為直觀起見,低於起始高地址0xbfc75a58的其他地址采用點記法,如0x.54表示0xbfc75a54,以此類推。 圖5 StackFrame棧幀 內存地址從棧底到棧頂遞減,壓棧就是把ESP指針逐漸往地低址移動的過程。而結構體tStrt中的成員變量memberX地址=tStrt首地址+(memberX偏移量),即越靠近tStrt首地址的成員變量其內存地址越小。因此,結構體成員變量的入棧順序與其在結構體中聲明的順序相反。 函數調用以值傳遞時,傳入的實參(locMain1~3)與被調函數內操作的形參(para1~3)兩者存儲地址不同,因此被調函數無法直接修改主調函數實參值(對形參的操作相當於修改實參的副本)。為達到修改目的,需要向被調函數傳遞實參變量的指針(即變量的地址)。 此外,"[locMain1,2,3] = [0, 0, 3]"是因為對四字節參數locMain2調用memset函數時,會從低地址向高地址連續清零8個字節,從而誤將位於高地址locMain1清零。 注意,局部變量的布局依賴於編譯器實現等因素。因此,當StackFrameContent函數中刪除打印語句時,變量locVar3、locVar2和locVar1可能按照從高到低的順序依次存儲!而且,局部變量並不總在棧中,有時出於性能(速度)考慮會存放在寄存器中。數組/結構體型的局部變量通常分配在棧內存中。 【擴展閱讀】函數局部變量布局方式 與函數調用約定規定參數如何傳入不同,局部變量以何種方式布局並未規定。編譯器計算函數局部變量所需要的空間總數,並確定這些變量存儲在寄存器上還是分配在程序棧上(甚至被優化掉)——某些處理器並沒有堆棧。局部變量的空間分配與主調函數和被調函數無關,僅僅從函數源代碼上無法確定該函數的局部變量分布情況。 基於不同的編譯器版本(gcc3.4中局部變量按照定義順序依次入棧,gcc4及以上版本則不定)、優化級別、目標處理器架構、棧安全性等,相鄰定義的兩個變量在內存位置上可能相鄰,也可能不相鄰,前後關系也不固定。若要確保兩個對象在內存上相鄰且前後關系固定,可使用結構體或數組定義。 4 堆棧操作 函數調用時的具體步驟如下: 1) 主調函數將被調函數所要求的參數,根據相應的函數調用約定,保存在運行時棧中。該操作會改變程序的棧指針。 注:x86平台將參數壓入調用棧中。而x86_64平台具有16個通用64位寄存器,故調用函數時前6個參數通常由寄存器傳遞,其余參數才通過棧傳遞。 2) 主調函數將控制權移交給被調函數(使用call指令)。函數的返回地址(待執行的下條指令地址)保存在程序棧中(壓棧操作隱含在call指令中)。 3) 若有必要,被調函數會設置幀基指針,並保存被調函數希望保持不變的寄存器值。 4) 被調函數通過修改棧頂指針的值,為自己的局部變量在運行時棧中分配內存空間,並從幀基指針的位置處向低地址方向存放被調函數的局部變量和臨時變量。 5) 被調函數執行自己任務,此時可能需要訪問由主調函數傳入的參數。若被調函數返回一個值,該值通常保存在一個指定寄存器中(如EAX)。 6) 一旦被調函數完成操作,為該函數局部變量分配的棧空間將被釋放。這通常是步驟4的逆向執行。 7) 恢復步驟3中保存的寄存器值,包含主調函數的幀基指針寄存器。 8) 被調函數將控制權交還主調函數(使用ret指令)。根據使用的函數調用約定,該操作也可能從程序棧上清除先前傳入的參數。 9) 主調函數再次獲得控制權後,可能需要將先前的參數從棧上清除。在這種情況下,對棧的修改需要將幀基指針值恢復到步驟1之前的值。 步驟3與步驟4在函數調用之初常一同出現,統稱為函數序(prologue);步驟6到步驟8在函數調用的最後常一同出現,統稱為函數跋(epilogue)。函數序和函數跋是編譯器自動添加的開始和結束匯編代碼,其實現與CPU架構和編譯器相關。除步驟5代表函數實體外,其它所有操作組成函數調用。 以下介紹函數調用過程中的主要指令。 壓棧(push):棧頂指針ESP減小4個字節;以字節為單位將寄存器數據(四字節,不足補零)壓入堆棧,從高到低按字節依次將數據存入ESP-1、ESP-2、ESP-3、ESP-4指向的地址單元。 出棧(pop):棧頂指針ESP指向的棧中數據被取回到寄存器;棧頂指針ESP增加4個字節。 圖6 出棧入棧操作示意 可見,壓棧操作將寄存器內容存入棧內存中(寄存器原內容不變),棧頂地址減小;出棧操作從棧內存中取回寄存器內容(棧內已存數據不會自動清零),棧頂地址增大。棧頂指針ESP總是指向棧中下一個可用數據。 調用(call):將當前的指令指針EIP(該指針指向緊接在call指令後的下條指令)壓入堆棧,以備返回時能恢復執行下條指令;然後設置EIP指向被調函數代碼開始處,以跳轉到被調函數的入口地址執行。 離開(leave): 恢復主調函數的棧幀以准備返回。等價於指令序列movl %ebp, %esp(恢復原ESP值,指向被調函數棧幀開始處)和popl %ebp(恢復原ebp的值,即主調函數幀基指針)。 返回(ret):與call指令配合,用於從函數或過程返回。從棧頂彈出返回地址(之前call指令保存的下條指令地址)到EIP寄存器中,程序轉到該地址處繼續執行(此時ESP指向進入函數時的第一個參數)。若帶立即數,ESP再加立即數(丟棄一些在執行call前入棧的參數)。使用該指令前,應使當前棧頂指針所指向位置的內容正好是先前call指令保存的返回地址。 基於以上指令,使用C調用約定的被調函數典型的函數序和函數跋實現如下: 指令序列 含義 函數序 (prologue) push %ebp 將主調函數的幀基指針%ebp壓棧,即保存舊棧幀中的幀基指針以便函數返回時恢復舊棧幀 mov %esp, %ebp 將主調函數的棧頂指針%esp賦給被調函數幀基指針%ebp。此時,%ebp指向被調函數新棧幀的起始地址(棧底),亦即舊%ebp入棧後的棧頂 sub <n>, %esp 將棧頂指針%esp減去指定字節數(棧頂下移),即為被調函數局部變量開辟棧空間。<n>為立即數且通常為16的整數倍(可能大於局部變量字節總數而稍顯浪費,但gcc采用該規則保證數據的嚴格對齊以有效運用各種優化編譯技術) push <r> 可選。如有必要,被調函數負責保存某些寄存器(%edi/%esi/%ebx)值 函數跋 (epilogue) pop <r> 可選。如有必要,被調函數負責恢復某些寄存器(%edi/%esi/%ebx)值 mov %ebp, %esp* 恢復主調函數的棧頂指針%esp,將其指向被調函數棧底。此時,局部變量占用的棧空間被釋放,但變量內容未被清除(跳過該處理) pop %ebp* 主調函數的幀基指針%ebp出棧,即恢復主調函數棧底。此時,棧頂指針%esp指向主調函數棧頂(espßesp-4),亦即返回地址存放處 ret 從棧頂彈出主調函數壓在棧中的返回地址到指令指針寄存器%eip中,跳回主調函數該位置處繼續執行。再由主調函數恢復到調用前的棧 *:這兩條指令序列也可由leave指令實現,具體用哪種方式由編譯器決定。 若主調函數和調函數均未使用局部變量寄存器EDI、ESI和EBX,則編譯器無須在函數序中對其壓棧,以便提高程序的執行效率。 參數壓棧指令因編譯器而異,如下兩種壓棧方式基本等效: extern CdeclDemo(int w, int x, int y, intz); //調用CdeclDemo函數 CdeclDemo(1, 2, 3, 4); //調用CdeclDemo函數 壓棧方式一 壓棧方式二 pushl 4 //壓入參數z pushl 3 //壓入參數y pushl 2 //壓入參數x pushl 1 //壓入參數w call CdeclDemo //調用函數 addl $16, %esp //恢復ESP原值,使其指向調用前保存的返回地址 subl $16, %esp //多次調用僅執行一遍 movl $4, 12(%esp) //傳送參數z至堆棧第四個位置 movl $3, 8(%esp) //傳送參數y至堆棧第三個位置 movl $2, 4(%esp) //傳送參數x至堆棧第二個位置 movl $1, (%esp) //傳送參數w至堆棧棧頂 call CdeclDemo //調用函數 兩種壓棧方式均遵循C調用約定,但方式二中主調函數在調用返回後並未顯式清理堆棧空間。因為在被調函數序階段,編譯器在棧頂為函數參數預先分配內存空間(sub指令)。函數參數被復制到棧中(而非壓入棧中),並未修改棧頂指針,故調用返回時主調函數也無需修改棧頂指針。gcc3.4(或更高版本)編譯器采用該技術將函數參數傳遞至棧上,相比棧頂指針隨每次參數壓棧而多次下移,一次性設置好棧頂指針更為高效。設想連續調用多個函數時,方式二僅需預先分配一次參數內存(大小足夠容納參數尺寸和最大的函數即可),後續調用無需每次都恢復棧頂指針。注意,函數被調用時,兩種方式均使棧頂指針指向函數最左邊的參數。本文不再區分兩種壓棧方式,"壓棧"或"入棧"所提之處均按相應匯編代碼理解,若無匯編則指方式二。 某些情況下,編譯器生成的函數調用進入/退出指令序列並不按照以上方式進行。例如,若C函數聲明為static(只在本編譯單元內可見)且函數在編譯單元內被直接調用,未被顯示或隱式取地址(即沒有任何函數指針指向該函數),此時編譯器確信該函數不會被其它編譯單元調用,因此可隨意修改其進/出指令序列以達到優化目的。 盡管使用的寄存器名字和指令在不同處理器架構上有所不同,但創建棧幀的基本過程一致。 注意,棧幀是運行時概念,若程序不運行,就不存在棧和棧幀。但通過分析目標文件中建立函數棧幀的匯編代碼(尤其是函數序和函數跋過程),即使函數沒有運行,也能了解函數的棧幀結構。通過分析可確定分配在函數棧幀上的局部變量空間准確值,函數中是否使用幀基指針,以及識別函數棧幀中對變量的所有內存引用。