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