當一個C函數被調用時,函數的參數如何傳遞、堆棧指針如何變化、棧幀是如何被建立以及如何被消除的,一直缺乏系統性的理解,因此決定花時間學習下函數調用時整個調用機制并總結成文,以便加深理解。本文將從匯編的角度講解函數調用時,堆棧的變化,參數的傳遞方式、以及棧幀的建立和消除等方面知識。
這些細節跟操作系統平臺及編譯器的實現有關,下面的描述是針對運行在 Intel 至強處理器芯片上 Linux 的 gcc 編譯器而言。C語言的標準并沒有描述實現的方式,所以,不同的編譯器,處理器,操作系統都可能有自己的建立棧幀的方式。
堆棧指針及相關寄存器
堆棧是操作系統中,最為常見的一種數據結構。嚴謹的說,堆棧是包括堆和棧兩種數據結構的,但是我們通常說堆棧,其實就是指棧。在棧中,最重要的兩個指針是 SP(棧指針) 和 BP(基址指針)。
SP(Stack Pointer) ,棧指針,在 32 位系統中,ESP(Extended SP) 寄存器存放的就是棧指針。在 64 位系統中,表現為 RSP 寄存器。SP 永遠指向系統棧最上面一個棧幀的棧頂。所以 SP 是棧頂指針。(SP與ESP共用相同寄存器,SP是ESP的低16位,ESP是32位) BP(Base Pointer) ,基址指針,在 32 位系統中,EBP(Extended BP)寄存器存放的就是基址指針。在 64 位系統中,表現為 RBP 寄存器。BP 指向棧幀的底部,一般稱之為棧底指針。(BP與EBP共用相同寄存器,BP是ESP的低16位,EBP是32位)
注:由于當下主要使用32位及以上寄存器,因此本文以32位寄存器講解為主,下文亦主要使用ESP,EBP為例進行介紹。
這些指針及寄存器的作用到底是什么呢?ESP,指針即地址,存放棧頂指針,目的就是,下一次對棧操作的時候,系統可以及時找到棧的當前位置。舉個例子來說,push 壓入一個操作數,會在 esp - 4 的地址的內存空間,存入一個2個字長的操作數。EBP 的作用,會在下文講述。
因本文后續可能會用到很多通用寄存器,為防止讀者不懂寄存器的含義,這里小編整理了通用寄存器的功能和名稱對應關系表,如忘記了可返回查看該表,表格如下圖:
函數調用匯編指令
一個函數調用另外一個函數,堆棧到底是怎么樣變化的呢?ESP和EBP是如何變更的?函數形參又是如何傳遞的呢?后面我們會寫一個簡單的Demo程序,加深對堆棧相關寄存器的理解。在一個函數中調用另外一個函數,匯編指令往往有以下幾個步驟:
匯編指令 | 指令歸屬函數 | ESP 變化 | 作用 |
---|---|---|---|
push arg3 | 主調函數 | esp-4 | 將被調函數參數3壓入棧中,供被調函數執行時使用。 |
push arg2 | 主調函數 | esp-4 | 將被調函數參數2壓入棧中,供被調函數執行時使用。 |
push arg1 | 主調函數 | esp-4 | 將被調函數參數3壓入棧中,供被調函數執行時使用。 |
call function | 主調函數 | esp-4 | 開始調用被調函數,同時保存返回地址。 |
push ebp | 被調函數 | esp-4 | 將主調函數的ebp中的基址值壓入棧中,以便被調函數執行完畢后,恢復主調函數的基址到ebp。 |
mov ebp, esp | 被調函數 | 無變化 | 將當前esp(esp此時指向本函數棧幀的棧底)的值存入ebp寄存器,目的是讓被調函數的基址指針指向本函數的棧幀的棧底,后續可通過ebp來定位函數參數。 |
sub esp, #num | 被調函數 | esp-num | 為被調函數分配棧空間 |
... | 被調函數 | ... | 被調函數的具體實現邏輯 |
pop ebp | 被調函數 | esp+4 | 將棧中保存的主調函數的基址地址彈出棧,保存到ebp寄存器。 |
ret | 被調函數 | sp+4 | 將棧中保存的被調函數調用處的下一條指令的地址彈出棧并保存至eip寄存器,同時esp+4 |
說明
- push arg 在調用一個函數之前,需要把傳遞的參數壓入棧。每次 push 之后,棧多了2個字長(32 位系統 --> 4 字節),因此棧頂需要往上移動 4 字節,該指令暗含 sub esp, #4
- call call 指令用來調用某個函數,該指令含有兩個操作(1)將返回地址壓入棧;(2)esp = esp - 4
- push ebp; mov ebp, esp 這樣的操作,會經常出現在各個函數反匯編的開頭,保存上一個函數棧的基址,并更新本函數的基址
- ret,即 return,此時 esp 應該指向 call 指令壓入的返回地址;執行 ret 其實就是將此時棧中的數據彈出,存至 eip 寄存器。eip 存放的是當前被調用函數被調用位置處的下一條即將執行的指令的地址(即返回地址)。同時 esp = esp + 4
ret 指令相當于 pop eip; esp = esp + 4
call 指令相當于 push eip; esp = esp - 4
通過以上匯編代碼及解釋說明,你可能還是不能完全了解函數調用過程堆棧的變化,沒關系,我們看下一個典型的棧幀是如何構成的,見下圖。
圖1
綠色表示調用函數的匯編指令和棧空間, 藍色表示被調用函數的匯編指令和相應的棧空間。紅色箭頭表示通過被調用函數的ebp訪問被調用函數的參數以及局部變量。
上圖棧頂在下,棧底在上,棧空間由高地址向低地址增長。
如下函數的調用時堆棧變化即可用圖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函數有兩個局部int型局部變量(每個變量4字節)。在這個簡化的場景中,main調用func,而程序的控制仍在func中。此處,main是調用函數(caller),func是被調用函數(callee)。
esp被func函數使用來表示棧頂。ebp相當于一個“基準指針”。從main傳遞到func的參數以及func函數本身的局部變量都可以以這個基準指針為參考,加上偏移量找到。
由于被調用函數也允許使用EAX,ECX和EDX寄存器,所以如果調用函數希望保存這些寄存器的值,就必須在調用被調用函數之前顯式地將這些寄存器的值保存在棧中。另外,除了上面提到的幾個寄存器,被調用函數還想使用其他別的寄存器,比如EBX,ESI和EDI,那么被調用函數就必須在棧中保存這些被使用的額外的寄存器,并且需要在調用返回前恢復他它們。換一句話說,即如果被調用函數只使用約定的EAX,ECX和EDX寄存器,它們則由調用函數負責保存并恢復;如果被調用函數還額外使用了別的寄存器,則必須由被調用函數自己保存并恢復這些寄存器的值。
傳遞給func的參數被壓到棧中的順序為最后一個參數先進棧,第二個參數其次進棧,第一個參數最后進棧。因此圖1中,arg3比arg1先入棧。func函數中聲明的局部變量以及函數執行過程中需要用到的一些臨時變量也都在保存在棧中。
注意:在被調用函數返回時, 小于以及等于4個字節的返回值會被保存在EAX中 ,如果返回值大于4字節,小于8字節,那么返回值則會被保存在EDX中。但是如果返回值占用的空間大于8個字節,則調用函數會向被調用函數傳遞一個額外的參數,這個額外的參數指向將要保存返回值的空間的地址。用C語言的話來說,就是函數調用:
x = func(i,j,k);
被轉化為
func(&x,i,j,k);
上述情況僅僅在返回值占用空間超過8個字節時才會發生。有的編譯器不用EDX保存返回值,所以當返回值大于4個字節時,就會用這種轉換。
當然,不是所有的函數調用都是將返回值直接賦值給一個變量,還有可能是直接參與到某個表達式的計算中,如:
n = func(i,j,k) + func(x,y,z);
又或者是作為另外的函數的參數,如:
func(func(i,j,k),4);
這種情況下,func的返回值會被保存在一個臨時的變量中參加后續的運算,所以func(i,j,k)還是可以被轉化成func(&tmp,i,j,k)。
接下來,讓我們一起看下在c函數的調用中,一個棧幀的建立以及消除過程。
函數調用前調用函數的動作
我們仍以上面例子為例,調用函數是main,它準備調用被調函數func。在函數調用前,main函數正在用esp和ebp寄存器指示它自己的棧幀。
首先,main函數把傳遞給func的參數壓入棧中。不過,該步驟是可選的,只在這三個寄存器內容需要保留的時候執行此步驟。
緊接著,main函數會把傳遞給func的參數一一壓入棧中,最后的參數最先進棧,第一個參數最后進棧。假如,我們的函數調用是:
x = func(5,6,7);
則對應的匯編語言指令如下:
push 0x7
push 0x6
push 0x5
最后,main函數用call指令調用被調函數
call func
如前面所說,call指令含有兩個操作,首先是先將eip指令寄存器中的返回地址(即被調函數在被調用處的下一條指令的地址)壓入棧中;其次是棧頂指針esp的值減4,即esp=esp-4。此時返回地址就在棧頂了。在call指令執行完畢以后,下一個執行周期將從名為func的標記處開始。
圖2展示了call指令執行完以后棧的內容。圖2以及后續圖中的綠色粗虛線表示了被調用函數在被調用之前棧頂的位置。當整個func函數調用過程結束以后,棧頂將又會回到該位置。
圖2
函數調用發生后被調用函數的動作
當函數func,即被調用函數取得程序的控制權,它必須做三件事:
- 建立它自己的棧幀
- 為局部變量分配空間
- 如果有必要,保存寄存器EBX,ESI和EDI的值
首先,func函數必須建立它自己的棧幀。ebp寄存器現在正在指向main函數的棧幀中的某個位置,這個值必須被保留,因此,ebp保存的值需要進棧,即push ebp。之后,就可以隨意操作ebp寄存器了(因為ebp內保存的main的基址已入棧),此時,將esp的內容賦值給ebp,即mov ebp, esp;由圖2我們可知,在調用func函數的過程中,原本指向main函數的棧頂指針,會隨著EAX、ECX和EDX寄存器以及實際參數的入棧而不斷發生變化,在call指令執行之后,此時esp棧頂指針已指向返回地址(被調用函數在被調用處的下一條指令的地址)位置,此時,func的ebp即和esp棧頂指針一樣指向返回地址處。
當func函數建立它自己棧幀時,保留ebp寄存器內容(即main函數的ebp)的位置所對應的地址,即為func函數的基址,也就是func函數的ebp指向的地址。換一句話說,就是func函數的ebp指向的位置保存了main函數的ebp。
func的ebp寄存器在被esp賦值后,func函數的參數就可以通過對ebp附加一個偏移量得到,而棧頂寄存器就可以空出來做其它事情。如此一來,幾乎所有的c函數都由如下兩個指令開始:
push ebp
mov ebp,esp
此時,堆棧分布如圖3所示。在該場景中,第一個參數的地址是ebp+8,因為main的ebp和返回地址各在棧中占了4個字節。
圖3
接下來,func必須為它的局部變量分配棧空間,與此同時,也必須為它可能會用到的一些臨時變量分配棧空間。比如func中可能包括一些復雜的表達式,其子表達式的中間值就必須得有地方存放。這些存放中間值的地方統稱為臨時的,因為它們可以被下一個復雜的表達式所使用。為方便說明,我們假設func中有兩個int類型(每個4字節)的局部變量,同時,需要額外的2字節的臨時存儲空間,則可以簡單地把棧頂指針減去10便為這10個字節分配了棧空間,匯編指令如下:
sub esp,10
此時,局部變量以及臨時變量都可以通過基址指針ebp加上偏移量來找到了。
最后,如果func函數用到EBX,ESI和EDI寄存器,則它必須在自己的棧幀里保存它們。如下圖4所示:
圖4
func函數的函數體現在可以執行了。這其中可能包含進棧、出棧的動作,棧指針esp會上下移動,但ebp則是保持不變的。這也就表示我們可以一直使用[esp+8]找到第一個參數,而不需要管函數中有多少進出棧的動作。
函數func執行過程中也許還會調用其它函數,甚至遞歸地調用func自身。然而只要ebp寄存器在這些子調用返回時被恢復,就可以繼續用ebp加上偏移量的方式訪問實際參數、局部變量以及臨時變量。
被調用函數返回前的動作
func函數把程序控制權返回給調用函數之前,被調用函數func必須先把返回值保存在EAX寄存器中。正如前面所討論過的,當返回值占用多于4個或8個字節時,接收返回值的變量地址會作為一個額外的指針參數被傳到函數func中,而函數func本身就不需要返回值了。這種情況下,被調用函數直接通過內存拷貝把返回值直接拷貝到接收地址,從而省去了一次通過棧的中轉拷貝。
其次,func必須恢復EBX,ESI和EDI寄存器的值。如果這些寄存器被修改,正如前面所說,我們會在func執行開始時把它們的原始值壓入棧中。如果esp寄存器指向如圖4所示的正確位置,寄存器的原始值即可出棧并恢復。由此可見,func函數執行過程中正確地跟蹤esp是多么重要,也即進棧和出棧的次數必須保持平衡。
上面兩步之后,我們便不再需要func函數的局部變量和臨時變量了,我們可以通過下面的指令消除棧幀:
mov esp,ebp
pop ebp
上面執行后的結果就是棧里面的內容跟圖2中所示的棧完全一樣。
現在可以執行返回指令了。從棧里彈出返回地址,賦值給eip寄存器。棧如圖5所示:
圖5
i386指令集有一條"leave"指令,它與上面提到的mov和pop指令所做的動作完全相同。所以c函數通常以這樣的指令結束:
leave
ret
這樣被調用函數執行完畢,下一步將繼續執行被調用函數調用處的下一條的指令。但是此時, esp 指向原先的 arg1,并沒有指向原先主函數的棧頂 。如果原先棧中還有其他數據,esp 沒有歸位會導致調用函數引用棧中數據出錯。
堆棧平衡
在這種背景下,出現了堆棧平衡的概念。即,還需對esp進行單獨操作,才能將esp指向調用函數的棧頂。以常見的c語言,函數有好幾種調用規則。比如 cdecl 方式和 stdcall 方式。
cdecl 方式中,由調用函數執行 add esp, n 指令調整 esp,達到堆棧平衡。在 stdcall 方式中,由被調用函數在返回時,執行 ret n 平衡堆棧。n 其實就是函數的參數所占的空間大小。
在程序控制權又返回到調用函數(即我們例子中的main函數)后,棧如圖5所示。這時傳遞給func的參數通常已經不需要了。我們可以把3個參數一起彈出棧,這可以通過把棧頂指針加0xc(即3個4字節)實現:
add esp,0xc
如果在函數調用前,EAX,ECX和EDX寄存器的值被保存在棧中,調用函數main現在則可以把它們彈出棧。在這個動作以后,棧頂就回到了我們開始整個函數調用前的位置,也就是圖5中綠色粗線的位置。
實例演示
C函數源碼和前面一樣,如下:
#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;
}
執行編譯指令如下:
#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編譯參數, -m32表示生成32位的代碼,如果沒有-m32,則會生成跟操作系統位數一致的代碼。
經過檢索得知,64位機器由于缺少32位兼容包,所以在編譯32代碼時,會報錯,通過如下指令安裝開發包即可解決:
#sudo yum -y install glibc-devel.i686
注意小編是用的CentOS機器,如果是Ubuntu機器,則可通過如下命令安裝:
#sudo apt-get install libc6-dev-i386
安裝完開發包以后,則編譯不會再報錯,編譯成功后,使用 objdump 或者 ida 查看匯編代碼,可以看出,默認使用 cdecl
方式平衡堆棧。經過反匯編得到部分截圖如下:
部分匯編源碼如下:
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,