1. 程式人生 > >C語言函式呼叫棧(一)

C語言函式呼叫棧(一)

程式的執行過程可看作連續的函式呼叫。當一個函式執行完畢時,程式要回到呼叫指令的下一條指令(緊接call指令)處繼續執行。函式呼叫過程通常使用堆疊實現,每個使用者態程序對應一個呼叫棧結構(call stack)。編譯器使用堆疊傳遞函式引數、儲存返回地址、臨時儲存暫存器原有值(即函式呼叫的上下文)以備恢復以及儲存本地區域性變數。

不同處理器和編譯器的堆疊佈局、函式呼叫方法都可能不同,但堆疊的基本概念是一樣的。

1 暫存器分配

暫存器是處理器加工資料或執行程式的重要載體,用於存放程式執行中用到的資料和指令。因此函式呼叫棧的實現與處理器暫存器組密切相關。

Intel 32位體系結構(簡稱IA32)處理器包含8個四位元組暫存器,如下圖所示:


圖1 IA32處理器暫存器
圖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 函式呼叫棧的典型記憶體佈局
圖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 函式呼叫棧的可能記憶體佈局
圖3 函式呼叫棧的可能記憶體佈局

在多執行緒(任務)環境,棧頂指標指向的儲存器區域就是當前使用的堆疊。切換執行緒的一個重要工作,就是將棧頂指標設為當前執行緒的堆疊棧頂地址。

以下程式碼用於函式棧佈局示例:

//StackFrame.c
#include <stdio.h>
#include <string.h>

struct Strt{
    int member1;
    int member2;
    int member3;
};

#define PRINT_ADDR(x)     printf("&"#x" = %p\n", &x)
int StackFrameContent(int para1, int para2, int para3){
    int locVar1 = 1;
    int locVar2 = 2;
    int locVar3 = 3;
    int arr[] = {0x11,0x22,0x33};
    struct Strt tStrt = {0};
    PRINT_ADDR(para1); //若para1為char或short型,則列印para1所對應的棧上整型臨時變數地址!
    PRINT_ADDR(para2);
    PRINT_ADDR(para3);
    PRINT_ADDR(locVar1);
    PRINT_ADDR(locVar2);
    PRINT_ADDR(locVar3);
    PRINT_ADDR(arr);
    PRINT_ADDR(arr[0]);
    PRINT_ADDR(arr[1]);
    PRINT_ADDR(arr[2]);
    PRINT_ADDR(tStrt);
    PRINT_ADDR(tStrt.member1);
    PRINT_ADDR(tStrt.member2);
    PRINT_ADDR(tStrt.member3);
    return 0;
}

int main(void){
    int locMain1 = 1, locMain2 = 2, locMain3 = 3;
    PRINT_ADDR(locMain1);
    PRINT_ADDR(locMain2);
    PRINT_ADDR(locMain3);
    StackFrameContent(locMain1, locMain2, locMain3);
    printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
    memset(&locMain2, 0, 2*sizeof(int));
    printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
    return 0;
}

編譯連結並執行後,輸出列印如下:


圖4 StackFrame輸出
圖4 StackFrame輸出

函式棧佈局示例如下圖所示。為直觀起見,低於起始高地址0xbfc75a58的其他地址採用點記法,如0x.54表示0xbfc75a54,以此類推。


圖5 StackFrame棧幀
圖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 出棧入棧操作示意
圖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(只在本編譯單元內可見)且函式在編譯單元內被直接呼叫,未被顯示或隱式取地址(即沒有任何函式指標指向該函式),此時編譯器確信該函式不會被其它編譯單元呼叫,因此可隨意修改其進/出指令序列以達到優化目的。

儘管使用的暫存器名字和指令在不同處理器架構上有所不同,但建立棧幀的基本過程一致。

注意,棧幀是執行時概念,若程式不執行,就不存在棧和棧幀。但通過分析目標檔案中建立函式棧幀的彙編程式碼(尤其是函式序和函式跋過程),即使函式沒有執行,也能瞭解函式的棧幀結構。通過分析可確定分配在函式棧幀上的區域性變數空間準確值,函式中是否使用幀基指標,以及識別函式棧幀中對變數的所有記憶體引用。