當(dāng)一個C函數(shù)被調(diào)用時,函數(shù)的參數(shù)如何傳遞、堆棧指針如何變化、棧幀是如何被建立以及如何被消除的,一直缺乏系統(tǒng)性的理解,因此決定花時間學(xué)習(xí)下函數(shù)調(diào)用時整個調(diào)用機制并總結(jié)成文,以便加深理解。本文將從匯編的角度講解函數(shù)調(diào)用時,堆棧的變化,參數(shù)的傳遞方式、以及棧幀的建立和消除等方面知識。
這些細節(jié)跟操作系統(tǒng)平臺及編譯器的實現(xiàn)有關(guān),下面的描述是針對運行在 Intel 至強處理器芯片上 Linux 的 gcc 編譯器而言。C語言的標(biāo)準(zhǔn)并沒有描述實現(xiàn)的方式,所以,不同的編譯器,處理器,操作系統(tǒng)都可能有自己的建立棧幀的方式。
堆棧指針及相關(guān)寄存器
堆棧是操作系統(tǒng)中,最為常見的一種數(shù)據(jù)結(jié)構(gòu)。嚴(yán)謹(jǐn)?shù)恼f,堆棧是包括堆和棧兩種數(shù)據(jù)結(jié)構(gòu)的,但是我們通常說堆棧,其實就是指棧。在棧中,最重要的兩個指針是 SP(棧指針) 和 BP(基址指針)。
SP(Stack Pointer) ,棧指針,在 32 位系統(tǒng)中,ESP(Extended SP) 寄存器存放的就是棧指針。在 64 位系統(tǒng)中,表現(xiàn)為 RSP 寄存器。SP 永遠指向系統(tǒng)棧最上面一個棧幀的棧頂。所以 SP 是棧頂指針。(SP與ESP共用相同寄存器,SP是ESP的低16位,ESP是32位) BP(Base Pointer) ,基址指針,在 32 位系統(tǒng)中,EBP(Extended BP)寄存器存放的就是基址指針。在 64 位系統(tǒng)中,表現(xiàn)為 RBP 寄存器。BP 指向棧幀的底部,一般稱之為棧底指針。(BP與EBP共用相同寄存器,BP是ESP的低16位,EBP是32位)
注:由于當(dāng)下主要使用32位及以上寄存器,因此本文以32位寄存器講解為主,下文亦主要使用ESP,EBP為例進行介紹。
這些指針及寄存器的作用到底是什么呢?ESP,指針即地址,存放棧頂指針,目的就是,下一次對棧操作的時候,系統(tǒng)可以及時找到棧的當(dāng)前位置。舉個例子來說,push 壓入一個操作數(shù),會在 esp - 4 的地址的內(nèi)存空間,存入一個2個字長的操作數(shù)。EBP 的作用,會在下文講述。
因本文后續(xù)可能會用到很多通用寄存器,為防止讀者不懂寄存器的含義,這里小編整理了通用寄存器的功能和名稱對應(yīng)關(guān)系表,如忘記了可返回查看該表,表格如下圖:
函數(shù)調(diào)用匯編指令
一個函數(shù)調(diào)用另外一個函數(shù),堆棧到底是怎么樣變化的呢?ESP和EBP是如何變更的?函數(shù)形參又是如何傳遞的呢?后面我們會寫一個簡單的Demo程序,加深對堆棧相關(guān)寄存器的理解。在一個函數(shù)中調(diào)用另外一個函數(shù),匯編指令往往有以下幾個步驟:
匯編指令 | 指令歸屬函數(shù) | ESP 變化 | 作用 |
---|---|---|---|
push arg3 | 主調(diào)函數(shù) | esp-4 | 將被調(diào)函數(shù)參數(shù)3壓入棧中,供被調(diào)函數(shù)執(zhí)行時使用。 |
push arg2 | 主調(diào)函數(shù) | esp-4 | 將被調(diào)函數(shù)參數(shù)2壓入棧中,供被調(diào)函數(shù)執(zhí)行時使用。 |
push arg1 | 主調(diào)函數(shù) | esp-4 | 將被調(diào)函數(shù)參數(shù)3壓入棧中,供被調(diào)函數(shù)執(zhí)行時使用。 |
call function | 主調(diào)函數(shù) | esp-4 | 開始調(diào)用被調(diào)函數(shù),同時保存返回地址。 |
push ebp | 被調(diào)函數(shù) | esp-4 | 將主調(diào)函數(shù)的ebp中的基址值壓入棧中,以便被調(diào)函數(shù)執(zhí)行完畢后,恢復(fù)主調(diào)函數(shù)的基址到ebp。 |
mov ebp, esp | 被調(diào)函數(shù) | 無變化 | 將當(dāng)前esp(esp此時指向本函數(shù)棧幀的棧底)的值存入ebp寄存器,目的是讓被調(diào)函數(shù)的基址指針指向本函數(shù)的棧幀的棧底,后續(xù)可通過ebp來定位函數(shù)參數(shù)。 |
sub esp, #num | 被調(diào)函數(shù) | esp-num | 為被調(diào)函數(shù)分配棧空間 |
... | 被調(diào)函數(shù) | ... | 被調(diào)函數(shù)的具體實現(xiàn)邏輯 |
pop ebp | 被調(diào)函數(shù) | esp+4 | 將棧中保存的主調(diào)函數(shù)的基址地址彈出棧,保存到ebp寄存器。 |
ret | 被調(diào)函數(shù) | sp+4 | 將棧中保存的被調(diào)函數(shù)調(diào)用處的下一條指令的地址彈出棧并保存至eip寄存器,同時esp+4 |
說明
- push arg 在調(diào)用一個函數(shù)之前,需要把傳遞的參數(shù)壓入棧。每次 push 之后,棧多了2個字長(32 位系統(tǒng) --> 4 字節(jié)),因此棧頂需要往上移動 4 字節(jié),該指令暗含 sub esp, #4
- call call 指令用來調(diào)用某個函數(shù),該指令含有兩個操作(1)將返回地址壓入棧;(2)esp = esp - 4
- push ebp; mov ebp, esp 這樣的操作,會經(jīng)常出現(xiàn)在各個函數(shù)反匯編的開頭,保存上一個函數(shù)棧的基址,并更新本函數(shù)的基址
- ret,即 return,此時 esp 應(yīng)該指向 call 指令壓入的返回地址;執(zhí)行 ret 其實就是將此時棧中的數(shù)據(jù)彈出,存至 eip 寄存器。eip 存放的是當(dāng)前被調(diào)用函數(shù)被調(diào)用位置處的下一條即將執(zhí)行的指令的地址(即返回地址)。同時 esp = esp + 4
ret 指令相當(dāng)于 pop eip; esp = esp + 4
call 指令相當(dāng)于 push eip; esp = esp - 4
通過以上匯編代碼及解釋說明,你可能還是不能完全了解函數(shù)調(diào)用過程堆棧的變化,沒關(guān)系,我們看下一個典型的棧幀是如何構(gòu)成的,見下圖。
圖1
綠色表示調(diào)用函數(shù)的匯編指令和??臻g, 藍色表示被調(diào)用函數(shù)的匯編指令和相應(yīng)的??臻g。紅色箭頭表示通過被調(diào)用函數(shù)的ebp訪問被調(diào)用函數(shù)的參數(shù)以及局部變量。
上圖棧頂在下,棧底在上,棧空間由高地址向低地址增長。
如下函數(shù)的調(diào)用時堆棧變化即可用圖1近似表示:
#include< stdio.h >
int func(int arg1, int arg2, int arg3)
{
int x = 1;
int y = 2;
return (arg1 + arg2 + arg3);
}
int main()
{
func(5,6,7);
return 0;
}
func函數(shù)有兩個局部int型局部變量(每個變量4字節(jié))。在這個簡化的場景中,main調(diào)用func,而程序的控制仍在func中。此處,main是調(diào)用函數(shù)(caller),func是被調(diào)用函數(shù)(callee)。
esp被func函數(shù)使用來表示棧頂。ebp相當(dāng)于一個“基準(zhǔn)指針”。從main傳遞到func的參數(shù)以及func函數(shù)本身的局部變量都可以以這個基準(zhǔn)指針為參考,加上偏移量找到。
由于被調(diào)用函數(shù)也允許使用EAX,ECX和EDX寄存器,所以如果調(diào)用函數(shù)希望保存這些寄存器的值,就必須在調(diào)用被調(diào)用函數(shù)之前顯式地將這些寄存器的值保存在棧中。另外,除了上面提到的幾個寄存器,被調(diào)用函數(shù)還想使用其他別的寄存器,比如EBX,ESI和EDI,那么被調(diào)用函數(shù)就必須在棧中保存這些被使用的額外的寄存器,并且需要在調(diào)用返回前恢復(fù)他它們。換一句話說,即如果被調(diào)用函數(shù)只使用約定的EAX,ECX和EDX寄存器,它們則由調(diào)用函數(shù)負責(zé)保存并恢復(fù);如果被調(diào)用函數(shù)還額外使用了別的寄存器,則必須由被調(diào)用函數(shù)自己保存并恢復(fù)這些寄存器的值。
傳遞給func的參數(shù)被壓到棧中的順序為最后一個參數(shù)先進棧,第二個參數(shù)其次進棧,第一個參數(shù)最后進棧。因此圖1中,arg3比arg1先入棧。func函數(shù)中聲明的局部變量以及函數(shù)執(zhí)行過程中需要用到的一些臨時變量也都在保存在棧中。
注意:在被調(diào)用函數(shù)返回時, 小于以及等于4個字節(jié)的返回值會被保存在EAX中 ,如果返回值大于4字節(jié),小于8字節(jié),那么返回值則會被保存在EDX中。但是如果返回值占用的空間大于8個字節(jié),則調(diào)用函數(shù)會向被調(diào)用函數(shù)傳遞一個額外的參數(shù),這個額外的參數(shù)指向?qū)⒁4娣祷刂档目臻g的地址。用C語言的話來說,就是函數(shù)調(diào)用:
x = func(i,j,k);
被轉(zhuǎn)化為
func(&x,i,j,k);
上述情況僅僅在返回值占用空間超過8個字節(jié)時才會發(fā)生。有的編譯器不用EDX保存返回值,所以當(dāng)返回值大于4個字節(jié)時,就會用這種轉(zhuǎn)換。
當(dāng)然,不是所有的函數(shù)調(diào)用都是將返回值直接賦值給一個變量,還有可能是直接參與到某個表達式的計算中,如:
n = func(i,j,k) + func(x,y,z);
又或者是作為另外的函數(shù)的參數(shù),如:
func(func(i,j,k),4);
這種情況下,func的返回值會被保存在一個臨時的變量中參加后續(xù)的運算,所以func(i,j,k)還是可以被轉(zhuǎn)化成func(&tmp,i,j,k)。
接下來,讓我們一起看下在c函數(shù)的調(diào)用中,一個棧幀的建立以及消除過程。
函數(shù)調(diào)用前調(diào)用函數(shù)的動作
我們?nèi)砸陨厦胬訛槔{(diào)用函數(shù)是main,它準(zhǔn)備調(diào)用被調(diào)函數(shù)func。在函數(shù)調(diào)用前,main函數(shù)正在用esp和ebp寄存器指示它自己的棧幀。
首先,main函數(shù)把傳遞給func的參數(shù)壓入棧中。不過,該步驟是可選的,只在這三個寄存器內(nèi)容需要保留的時候執(zhí)行此步驟。
緊接著,main函數(shù)會把傳遞給func的參數(shù)一一壓入棧中,最后的參數(shù)最先進棧,第一個參數(shù)最后進棧。假如,我們的函數(shù)調(diào)用是:
x = func(5,6,7);
則對應(yīng)的匯編語言指令如下:
push 0x7
push 0x6
push 0x5
最后,main函數(shù)用call指令調(diào)用被調(diào)函數(shù)
call func
如前面所說,call指令含有兩個操作,首先是先將eip指令寄存器中的返回地址(即被調(diào)函數(shù)在被調(diào)用處的下一條指令的地址)壓入棧中;其次是棧頂指針esp的值減4,即esp=esp-4。此時返回地址就在棧頂了。在call指令執(zhí)行完畢以后,下一個執(zhí)行周期將從名為func的標(biāo)記處開始。
圖2展示了call指令執(zhí)行完以后棧的內(nèi)容。圖2以及后續(xù)圖中的綠色粗虛線表示了被調(diào)用函數(shù)在被調(diào)用之前棧頂?shù)奈恢?。?dāng)整個func函數(shù)調(diào)用過程結(jié)束以后,棧頂將又會回到該位置。
圖2
函數(shù)調(diào)用發(fā)生后被調(diào)用函數(shù)的動作
當(dāng)函數(shù)func,即被調(diào)用函數(shù)取得程序的控制權(quán),它必須做三件事:
- 建立它自己的棧幀
- 為局部變量分配空間
- 如果有必要,保存寄存器EBX,ESI和EDI的值
首先,func函數(shù)必須建立它自己的棧幀。ebp寄存器現(xiàn)在正在指向main函數(shù)的棧幀中的某個位置,這個值必須被保留,因此,ebp保存的值需要進棧,即push ebp。之后,就可以隨意操作ebp寄存器了(因為ebp內(nèi)保存的main的基址已入棧),此時,將esp的內(nèi)容賦值給ebp,即mov ebp, esp;由圖2我們可知,在調(diào)用func函數(shù)的過程中,原本指向main函數(shù)的棧頂指針,會隨著EAX、ECX和EDX寄存器以及實際參數(shù)的入棧而不斷發(fā)生變化,在call指令執(zhí)行之后,此時esp棧頂指針已指向返回地址(被調(diào)用函數(shù)在被調(diào)用處的下一條指令的地址)位置,此時,func的ebp即和esp棧頂指針一樣指向返回地址處。
當(dāng)func函數(shù)建立它自己棧幀時,保留ebp寄存器內(nèi)容(即main函數(shù)的ebp)的位置所對應(yīng)的地址,即為func函數(shù)的基址,也就是func函數(shù)的ebp指向的地址。換一句話說,就是func函數(shù)的ebp指向的位置保存了main函數(shù)的ebp。
func的ebp寄存器在被esp賦值后,func函數(shù)的參數(shù)就可以通過對ebp附加一個偏移量得到,而棧頂寄存器就可以空出來做其它事情。如此一來,幾乎所有的c函數(shù)都由如下兩個指令開始:
push ebp
mov ebp,esp
此時,堆棧分布如圖3所示。在該場景中,第一個參數(shù)的地址是ebp+8,因為main的ebp和返回地址各在棧中占了4個字節(jié)。
圖3
接下來,func必須為它的局部變量分配??臻g,與此同時,也必須為它可能會用到的一些臨時變量分配??臻g。比如func中可能包括一些復(fù)雜的表達式,其子表達式的中間值就必須得有地方存放。這些存放中間值的地方統(tǒng)稱為臨時的,因為它們可以被下一個復(fù)雜的表達式所使用。為方便說明,我們假設(shè)func中有兩個int類型(每個4字節(jié))的局部變量,同時,需要額外的2字節(jié)的臨時存儲空間,則可以簡單地把棧頂指針減去10便為這10個字節(jié)分配了棧空間,匯編指令如下:
sub esp,10
此時,局部變量以及臨時變量都可以通過基址指針ebp加上偏移量來找到了。
最后,如果func函數(shù)用到EBX,ESI和EDI寄存器,則它必須在自己的棧幀里保存它們。如下圖4所示:
圖4
func函數(shù)的函數(shù)體現(xiàn)在可以執(zhí)行了。這其中可能包含進棧、出棧的動作,棧指針esp會上下移動,但ebp則是保持不變的。這也就表示我們可以一直使用[esp+8]找到第一個參數(shù),而不需要管函數(shù)中有多少進出棧的動作。
函數(shù)func執(zhí)行過程中也許還會調(diào)用其它函數(shù),甚至遞歸地調(diào)用func自身。然而只要ebp寄存器在這些子調(diào)用返回時被恢復(fù),就可以繼續(xù)用ebp加上偏移量的方式訪問實際參數(shù)、局部變量以及臨時變量。
被調(diào)用函數(shù)返回前的動作
func函數(shù)把程序控制權(quán)返回給調(diào)用函數(shù)之前,被調(diào)用函數(shù)func必須先把返回值保存在EAX寄存器中。正如前面所討論過的,當(dāng)返回值占用多于4個或8個字節(jié)時,接收返回值的變量地址會作為一個額外的指針參數(shù)被傳到函數(shù)func中,而函數(shù)func本身就不需要返回值了。這種情況下,被調(diào)用函數(shù)直接通過內(nèi)存拷貝把返回值直接拷貝到接收地址,從而省去了一次通過棧的中轉(zhuǎn)拷貝。
其次,func必須恢復(fù)EBX,ESI和EDI寄存器的值。如果這些寄存器被修改,正如前面所說,我們會在func執(zhí)行開始時把它們的原始值壓入棧中。如果esp寄存器指向如圖4所示的正確位置,寄存器的原始值即可出棧并恢復(fù)。由此可見,func函數(shù)執(zhí)行過程中正確地跟蹤esp是多么重要,也即進棧和出棧的次數(shù)必須保持平衡。
上面兩步之后,我們便不再需要func函數(shù)的局部變量和臨時變量了,我們可以通過下面的指令消除棧幀:
mov esp,ebp
pop ebp
上面執(zhí)行后的結(jié)果就是棧里面的內(nèi)容跟圖2中所示的棧完全一樣。
現(xiàn)在可以執(zhí)行返回指令了。從棧里彈出返回地址,賦值給eip寄存器。棧如圖5所示:
圖5
i386指令集有一條"leave"指令,它與上面提到的mov和pop指令所做的動作完全相同。所以c函數(shù)通常以這樣的指令結(jié)束:
leave
ret
這樣被調(diào)用函數(shù)執(zhí)行完畢,下一步將繼續(xù)執(zhí)行被調(diào)用函數(shù)調(diào)用處的下一條的指令。但是此時, esp 指向原先的 arg1,并沒有指向原先主函數(shù)的棧頂 。如果原先棧中還有其他數(shù)據(jù),esp 沒有歸位會導(dǎo)致調(diào)用函數(shù)引用棧中數(shù)據(jù)出錯。
堆棧平衡
在這種背景下,出現(xiàn)了堆棧平衡的概念。即,還需對esp進行單獨操作,才能將esp指向調(diào)用函數(shù)的棧頂。以常見的c語言,函數(shù)有好幾種調(diào)用規(guī)則。比如 cdecl 方式和 stdcall 方式。
cdecl 方式中,由調(diào)用函數(shù)執(zhí)行 add esp, n 指令調(diào)整 esp,達到堆棧平衡。在 stdcall 方式中,由被調(diào)用函數(shù)在返回時,執(zhí)行 ret n 平衡堆棧。n 其實就是函數(shù)的參數(shù)所占的空間大小。
在程序控制權(quán)又返回到調(diào)用函數(shù)(即我們例子中的main函數(shù))后,棧如圖5所示。這時傳遞給func的參數(shù)通常已經(jīng)不需要了。我們可以把3個參數(shù)一起彈出棧,這可以通過把棧頂指針加0xc(即3個4字節(jié))實現(xiàn):
add esp,0xc
如果在函數(shù)調(diào)用前,EAX,ECX和EDX寄存器的值被保存在棧中,調(diào)用函數(shù)main現(xiàn)在則可以把它們彈出棧。在這個動作以后,棧頂就回到了我們開始整個函數(shù)調(diào)用前的位置,也就是圖5中綠色粗線的位置。
實例演示
C函數(shù)源碼和前面一樣,如下:
#include< stdio.h >
int func(int arg1, int arg2, int arg3)
{
int x = 1;
int y = 2;
return (arg1 + arg2 + arg3);
}
int main()
{
func(5,6,7);
return 0;
}
執(zhí)行編譯指令如下:
#gcc test.c -m32 -o test
In file included from /usr/include/features.h:462,
from /usr/include/bits/libc-header-start.h:33,
from /usr/include/stdio.h:27,
from test.c:2:
/usr/include/gnu/stubs.h:7:11: fatal error: gnu/stubs-32.h: No such file or directory
# include < gnu/stubs-32.h >
^~~~~~~~~~~~~~~~
compilation terminated.
我們會看到編譯報錯,讓我們先解析一下gcc編譯參數(shù), -m32表示生成32位的代碼,如果沒有-m32,則會生成跟操作系統(tǒng)位數(shù)一致的代碼。
經(jīng)過檢索得知,64位機器由于缺少32位兼容包,所以在編譯32代碼時,會報錯,通過如下指令安裝開發(fā)包即可解決:
#sudo yum -y install glibc-devel.i686
注意小編是用的CentOS機器,如果是Ubuntu機器,則可通過如下命令安裝:
#sudo apt-get install libc6-dev-i386
安裝完開發(fā)包以后,則編譯不會再報錯,編譯成功后,使用 objdump 或者 ida 查看匯編代碼,可以看出,默認(rèn)使用 cdecl
方式平衡堆棧。經(jīng)過反匯編得到部分截圖如下:
部分匯編源碼如下:
080484ad < func >:
80484ad: 55 push ebp
80484ae: 89 e5 mov ebp,esp
80484b0: 83 ec 10 sub esp,0x10
80484b3: c7 45 fc 01 00 00 00 mov DWORD PTR [ebp-0x4],0x1
80484ba: c7 45 f8 02 00 00 00 mov DWORD PTR [ebp-0x8],0x2
80484c1: 8b 55 08 mov edx,DWORD PTR [ebp+0x8]
80484c4: 8b 45 0c mov eax,DWORD PTR [ebp+0xc]
80484c7: 01 c2 add edx,eax
80484c9: 8b 45 10 mov eax,DWORD PTR [ebp+0x10]
80484cc: 01 d0 add eax,edx
80484ce: c9 leave
80484cf: c3 ret
080484d0 < main >:
80484d0: 8d 4c 24 04 lea ecx,[esp+0x4]
80484d4: 83 e4 f0 and esp,0xfffffff0
80484d7: ff 71 fc push DWORD PTR [ecx-0x4]
80484da: 55 push ebp
80484db: 89 e5 mov ebp,esp
80484dd: 51 push ecx
80484de: 83 ec 04 sub esp,0x4
80484e1: 6a 07 push 0x7
80484e3: 6a 06 push 0x6
80484e5: 6a 05 push 0x5
80484e7: e8 c1 ff ff ff call 80484ad < func >
80484ec: 83 c4 0c add esp,0xc
80484ef: 83 ec 08 sub esp,0x8
80484f2: 50 push eax
80484f3: 68 9c 85 04 08 push 0x804859c
80484f8: e8 53 fe ff ff call 8048350 < printf@plt >
80484fd: 83 c4 10 add esp,0x10
8048500: b8 00 00 00 00 mov eax,