6 調用棧實例分析
本節通過代碼實例分析函數調用過程中棧幀的布局、形成和消亡。
6.1 棧幀的布局
示例代碼如下:

1 //StackReg.c 2 #include <stdio.h> 3 4 //獲取函數運行時寄存器%ebp和%esp的值 5 #define FETCH_SREG(_ebp, _esp) do{\ 6 asm volatile( \ 7 "movl %%ebp, %0 \n" \ 8 "movl %%esp, %1 \n" \ 9 : "=r" (_ebp), "=r" (_esp) \ 10 ); \ 11 }while(0) 12 //也可使用gcc擴展register void *pvEbp __asm__ ("%ebp"); register void *pvEsp __asm__ ("%esp");獲取, 13 // pvEbp和pvEsp指針變量的值就是FETCH_SREG(_ebp, _esp)中_ebp和_esp的值 14 15 #define PRINT_ADDR(x) printf("[%s]: &"#x" = %p\n", __FUNCTION__, &x) 16 #define PRINT_SREG(_ebp, _esp) do{\ 17 printf("[%s]: EBP = 0x%08x\n", __FUNCTION__, _ebp); \ 18 printf("[%s]: ESP = 0x%08x\n", __FUNCTION__, _esp); \ 19 printf("[%s]: (EBP) = 0x%08x\n", __FUNCTION__, *(int *)_ebp); \ 20 printf("[%s]: (EIP) = 0x%08x\n", __FUNCTION__, *((int *)_ebp + 1)); \ 21 printf("[%s]: &"#_esp" = %p\n", __FUNCTION__, &_esp); \ 22 printf("[%s]: &"#_ebp" = %p\n", __FUNCTION__, &_ebp); \ 23 }while(0) 24 25 void tail(int paraTail){ 26 int locTail = 0; 27 int ebpReg, espReg; 28 29 FETCH_SREG(ebpReg, espReg); 30 PRINT_SREG(ebpReg, espReg); 31 PRINT_ADDR(paraTail); 32 PRINT_ADDR(locTail); 33 } 34 int middle(int paraMid1, int paraMid2, int paraMid3){ 35 int ebpReg, espReg; 36 tail(paraMid1); 37 38 FETCH_SREG(ebpReg, espReg); 39 PRINT_SREG(ebpReg, espReg); 40 PRINT_ADDR(paraMid1); 41 PRINT_ADDR(paraMid2); 42 PRINT_ADDR(paraMid3); 43 return 1; 44 } 45 int main(void){ 46 int ebpReg, espReg; 47 int locMain = middle(1, 2, 3); 48 49 FETCH_SREG(ebpReg, espReg); 50 PRINT_SREG(ebpReg, espReg); 51 PRINT_ADDR(locMain); 52 return 0; 53 }
該程序每個函數都嵌入匯編代碼,以獲取各函數運行時刻EBP和ESP寄存器的值。每個函數都打印出EBP寄存器所指向內存地址處的值,以及位於其后的函數返回地址。圖7給出程序的編譯和運行結果。
圖7 StackReg運行結果
為便於理解輸出結果中數據間的關系,將其轉化為圖8所示。圖左還示出棧的增長方向和棧的內存地址。黑色箭頭和寄存器名表示當前棧幀,否則用灰色表示。圖中表示tail函數內所看到的棧布局,其中完整示出tail和middle函數的棧幀結構,以及main函數的部分。注意,形參1、2、3(常量)不在棧內。
圖8 StackReg棧幀布局
通常每個函數都有自己的棧幀。各棧幀中存放前一個調用函數的棧幀基址,通過該地址域將所有主調函數與被調函數的棧幀以鏈表形式連在一起。函數調用級數越多,占用的棧空間也越大,因此應小心使用遞歸函數。
6.2 棧幀的形成
為方便講解,獲取StackReg示例程序所對應的匯編代碼片段,如圖9所示。在匯編代碼中,最左列為指令在內存中的地址,棧幀中的返回地址(return address)即指此類地址。最右列為待執行的匯編指令語句,中間列為該指令在代碼段中的16進制表示,可見push %ebp指令僅占一個字節(0x55)。每次CPU執行都要先讀取%eip寄存器值,然后定位到%eip指向的匯編指令內存地址,讀取該指令並執行。讀取指令會使%eip寄存器值增加相應指令的長度(字節數),執行指令后%eip值為下條待執行指令的跳轉地址。
圖9 StackReg匯編片段
假設程序運行在main剛調用middle函數時,觀察棧幀布局如何變化。程序進入middle函數所運行的第一條指令位於內存地址0x804847c處,在運行該指令之前的棧幀結構如圖10所示。此時EBP指向main函數棧幀的頭部,而ESP所指向的內存中存放程序返回到main函數的指令位置(0x080485c5)。
圖10 StackReg運行中棧幀結構-1
被調函數在調用后獲得程序的控制權,接着需完成3項工作:建立自己的棧幀,為局部變量分配空間,按需保存寄存器%ebx、%esi和%edi的值。
內存地址0x804847c~0x804847f的指令用於形成middle函數的棧幀。第一條指令(位於地址0x804847c處,簡稱<指令804847c>)將主調函數main的棧幀基址保存到棧上(壓棧操作),該地址用於從被調函數堆棧返回到主調函數main中。正是各函數內的這一操作,使得所有棧幀連在一起成為一條鏈。
<指令804847d>將%esp寄存器的值賦值給%ebp寄存器,此時%ebp寄存器中存放當前函數的棧幀基址,以便根據偏移量訪問堆棧中的參數或變量。這樣便可騰出%esp寄存器以作他用,並在需要時根據%ebp值從當前函數棧頂直接返回棧底。
<指令804847f>對%esp進行減操作,即將%esp向低地址處移動40(0x28)個字節,以便在棧上騰出空間來存放局部變量和臨時變量。
運行完上述三條指令后,middle函數的棧幀就已形成,如圖11所示。圖中還示出該函數內的局部變量ebpReg和espReg在棧幀中的位置。
圖11 StackReg運行中棧幀結構-2
隨后,將執行middle函數體。執行過程中幀基指針EBP保持不變,通過該指針加偏移量即可訪問函數實參、局部變量和臨時存儲內容。即使middle函數內調用其他函數(如tail),甚至遞歸調用middle自身,只要在這些子調用返回時恢復EBP,就可繼續用EBP加偏移量的方式訪問實參等信息。
<指令804848d>和<指令804848f>是middle函數中內嵌的匯編代碼,用於獲取此時%ebp和%esp寄存器的值。<指令8048491>將%ebp寄存器值放入局部變量ebpReg中,<指令8048494>則將%esp寄存器值放入局部變量espReg中。其中,0xfffffffc(%ebp)等於(%ebp - 4),表示在幀基指針向低地址偏移四字節的地址處存儲的內容(偏移量用補碼表示,負值表示向低地址偏移)。
<指令8048482>和<指令8048485>將main函數中傳遞來的第一個變量paraMid1值拷貝到%esp寄存器所指向的內存中,為調用tail函數准備實參。此時棧空間如圖12所示。
圖12 StackReg運行中棧幀結構-3
<指令8048488>調用tail函數,該調用將返回地址(EIP指令指針寄存器的內容)壓入棧中,調用該指令后的棧空間如圖13所示。壓棧的返回地址是0x804848d,從圖9中可看出該地址指向middle函數內調用tail函數的后一條指令,當tail函數返回時將從該地址處繼續運行程序。調用<指令8048488>也意味着進入tail函數的棧幀,tail函數采用與middle函數相同方式的建立自己的棧幀。前面圖8所示正是tail函數建立棧幀時的內存布局。
圖13 StackReg運行中棧幀結構-4
通過以上運行時分析,可看到函數調用過程中堆棧擴展與恢復的動態過程。%esp和%ebp兩個寄存器之間的賦值時機,正是主調函數和被調函數職責交替之時。也正是該時機的正確,才能保證堆棧的恢復。
6.3 棧幀的消亡
在把程序控制權返還給主調函數前,被調函數若有返回值,則先將其保存在相應寄存器(通常是%eax)中,然后按需恢復%ebx、%esi和%edi寄存器的值,最后從棧里彈出返回地址。
下面觀察tail函數內進行函數返回時棧空間如何變化。<指令804847a>為leave指令,將%esp寄存器的值設置為%ebp寄存器值並做一次彈棧操作,將彈棧操作的內容放入%ebp寄存器中。該指令的功能等價於"mov %ebp, %esp"加"pop %ebp",可將tail函數所建立的棧幀清除。該指令執行后的棧布局與圖13完全相同。<指令804847b>用於將棧上的返回地址彈出到%eip寄存器中,執行該指令后程序返回到middle函數的0x804848d地址處。該指令執行后的棧結構與圖12相同。
6.4 返回結構體
分析以下示例程序:

1 //StackStrt.c 2 #include <stdio.h> 3 4 typedef struct{ 5 int member1; 6 int member2; 7 int member3; 8 }T_RET_STRT; 9 10 //FETCH_SREG/PRINT_SREG/PRINT_ADDR宏定義,略(詳見StackReg.c) 11 T_RET_STRT func(int paraFunc){ 12 T_RET_STRT locStrtFunc = {.member1=1, .member2=2, .member3=3}; 13 int ebpReg, espReg; 14 15 FETCH_SREG(ebpReg, espReg); 16 PRINT_SREG(ebpReg, espReg); 17 PRINT_ADDR(paraFunc); 18 printf("[%s]: (BelowPara) = 0x%08x\n", __FUNCTION__, *((int *)¶Func - 1)); 19 PRINT_ADDR(locStrtFunc.member1); 20 PRINT_ADDR(locStrtFunc.member2); 21 PRINT_ADDR(locStrtFunc.member3); 22 return locStrtFunc; 23 } 24 int main(void){ 25 int ebpReg, espReg; 26 T_RET_STRT locStrtMain = func(100); 27 28 FETCH_SREG(ebpReg, espReg); 29 PRINT_SREG(ebpReg, espReg); 30 PRINT_ADDR(locStrtMain.member1); 31 PRINT_ADDR(locStrtMain.member2); 32 PRINT_ADDR(locStrtMain.member3); 33 return 0; 34 }
該示例中,main和func函數內均定義類型為T_RET_STRT的局部變量,且func函數的返回值類型也是T_RET_STRT。變量locStrtMain和locStrtFunc的內存將分配在各自函數的棧幀中,那么func函數的locStrtFunc變量值如何通過函數返回值傳遞到main函數的locStrtMain變量中?編譯該程序並運行以觀察結果,如圖14所示。圖15示出func函數內所看到的棧布局。
圖14 StackStrt運行結果
圖15 StackStrt棧幀布局
從圖中可看出,main函數調用func函數時除將后者所需的參數壓入棧中外,還將局部變量locStrtMain地址也壓入棧中;func函數返回時將locStrtFunc變量的值通過該地址直接拷貝到main函數的locStrtMain變量中,從而省去一次通過棧的中轉拷貝。
刪除打印等無關語句后,查看StackStrt.c源文件匯編代碼如下圖所示(略有刪減):
圖16 StackStrt匯編片段
<指令804839a>將局部變量locStrtMain結構體在棧中的地址存入%eax寄存器。<指令804839d>將標量參數(100)入棧,因<指令8048397>已預留好存儲空間,故此處等效於"pushl $0x64"。<指令8048397>將%eax中保存的結構體地址(&locStrtMain)入棧,此處等效於"pushl %eax"。
<指令804835a>將8(%ebp)處所存儲的主調函數locStrtMain結構體地址存入%edx寄存器。<指令804835d>至<指令804836b>對被調函數棧內的局部變量locStrtFunc結構體賦值。<指令8048372>至<指令8048380>將locStrtFunc結構體的各個成員變量值依次存入%edx寄存器所指向的內存地址處(&locStrtMain)。<指令8048383>將暫存的%edx寄存器內容存入%eax寄存器,此時%eax內存放主調函數結構體locStrtMain的地址。
根據匯編結果,可知func函數被“改編”為以下實現:

1 void func(T_RET_STRT *pStrtMain, int paraFunc){ 2 T_RET_STRT locStrtFunc = {.member1=1, .member2=2, .member3=3}; 3 pStrtMain->member1 = locStrtFunc.member1; 4 pStrtMain->member2 = locStrtFunc.member2; 5 pStrtMain->member3 = locStrtFunc.member3; 6 return; //此句可有可無 7 }
若顯式聲明結構體指針參數,則可編寫更高效的func函數代碼:

1 void func(T_RET_STRT *pStrtMain, int paraFunc){ 2 pStrtMain->member1 = 1; 3 pStrtMain->member2 = 2; 4 pStrtMain->member3 = 3; 5 }
注意,若T_RET_STRT locStrtMain = func(100)改為func(100),主調函數棧上仍會預留一個結構體變量的空間,然后將該變量地址存入%eax寄存器。<指令8048397>和<指令804839a>分別變為sub $0x1c, %esp和lea 0xffffffe8(%ebp), %eax。
從以上分析亦知,當函數以結構體或聯合體作為返回值時,函數第一個參數存放在棧幀12(%ebp)位置處,而8(%ebp)位置處存放返回值的地址。