一段C語言和彙編的對應分析,揭示函式呼叫的本質
一段C語言和彙編的對應分析,揭示函式呼叫的本質
2018年09月30日 13:32:19 sdulibh 閱讀數:17
本文作者周平,原創作品轉載請註明出處
首先對會涉及到的一些CPU暫存器和彙編的基礎知識羅列一下:
- 16位、32位、64位的CPU暫存器名稱有所不同,比如指令地址暫存器
ip
,在16位中叫ip
,32位中叫eip
,64位叫rip
- 32位的彙編指令通常以
l
結尾,比如movl
相當於mov
的含義 ebp
: 堆疊基地址 暫存器,這個暫存器儲存的是當前執行緒的棧底地址
esp
: 堆疊棧頂 暫存器,這個暫存器儲存的是當前執行緒的棧頂地址
eip
: 指令地址 暫存器,這個暫存器儲存的是指令所在的地址,CPU會不斷的根據eip
所指向的指令去記憶體取指令並執行,並自行累加取下一條指令逐條執行。eip
無法直接賦值,call
、ret
、jmp
等指令可以起到修改eip
的作用%
用於直接定址暫存器,$
用於表示立即數。movl $8, %eax
表示把立即數8
存到eax
中()
用於記憶體間接定址,比如movl $10, (%esp)
表示將立即數10
儲存到esp
所指向的記憶體地址中8(%ebp)
表示先找到ebp
所指向的地址值+8
後得到的地址- 棧地址值是向下增長的,即棧頂從高地址向低地址移動
準備工作
準備一段C程式碼:
-
int g(int x)
-
{
-
return x+5;
-
}
-
int f(int x)
-
{
-
return g(x);
-
}
-
int main(void)
-
{
-
return f(10)+1;
-
}
使用實驗樓環境
編譯成彙編程式碼
使用如下命令編譯上面的c程式碼
gcc -S -o main.s main.c -m32
去掉不重要的部分後,得到:
彙編程式碼結果為:
-
g:
-
pushl %ebp
-
movl %esp, %ebp
-
movl 8(%ebp), %eax
-
addl $5, %eax
-
popl %ebp
-
ret
-
f:
-
pushl %ebp
-
movl %esp, %ebp
-
subl $4, %esp
-
movl 8(%ebp), %eax
-
movl %eax, (%esp)
-
call g
-
leave
-
ret
-
main:
-
pushl %ebp
-
movl %esp, %ebp
-
subl $4, %esp
-
movl $10, (%esp)
-
call f
-
addl $1, %eax
-
leave
-
ret
分析
具體的逐步分析,這裡就省了,老師課上講的很詳細了,這裡主要是要進行思考和歸納。
首先,我們看到3個C函式對應生成了3個部分的彙編程式碼,分別用函式名作為標號隔開了
-
int g(int x) -> g:
-
int f(int x) -> f:
-
int main(void) -> main:
我們知道程式是從main
函式開始執行的,那麼當程式被載入並執行時,上面的彙編程式碼會被載入到記憶體的某一個區域。而且,CPU中的很多暫存器都會初始化,當然其中最重要的是eip
,因為eip
是指向下一條將要執行的命令所在的記憶體地址,所以此時的eip
應該指向main
標號下的pushl %ebp
:
-
main:
-
eip -> pushl %ebp
程式開始執行…
我們捆綁著看,首先先看這兩條:
-
pushl %ebp :將基地址壓棧。(
ebp
: 堆疊基地址 暫存器,這個暫存器儲存的是當前執行緒的棧底地址)
-
movl %esp, %ebp:將棧頂地址賦值給棧的基地址。(esp
: 堆疊棧頂 暫存器,這個暫存器儲存的是當前執行緒的棧頂地址)
(檢視定義ESP是棧頂指標,EBP是存取堆疊指標)
再觀察一下整個程式碼,有沒有發現不僅僅是main
函式,函式f
和g
的開頭也是這兩個指令。分析一下,不難得出,這兩條指令是指將當前棧基地址壓棧後,重新將基地址定位到棧頂,這個含義其實是儲存好當前的基地址,重新開始一個新的棧。由於函式可以調函式,這裡的當前基地址,實際上是上一個函式的棧基地址。例如,在f
函式中的這兩句指令,實際上儲存的是main
函式的棧基地址。
接著來分析兩句:
-
subl $4, %esp
-
movl $10, (%esp)
對照C程式碼不難發現,這是引數進棧,將立即數10
,儲存到棧頂(esp所指向的記憶體地址是棧頂)。而在f
函式中也可以發現類似的語句:
-
subl $4, %esp
-
movl 8(%ebp), %eax
-
movl %eax, (%esp)
所以,我們可以得出結論是,在呼叫函式前需要把引數逐個壓棧,而壓棧的順序根據筆者的測試是從右向左的。
接著呼叫call
指令,跳轉到f
函式,我們知道call
指令等同於下面的虛擬碼:
-
pushl %eip+1
-
movl %eip f (是不是此處寫錯了,應該是:
movl f, %eip)
即把call
指令的後一條指令進棧後,將eip
賦值為目標函式的第一個指令地址。這樣做顯而易見:當所呼叫的函式結束後,需要返回當前函式繼續執行,所以必須要儲存下一條指令,否則回來的時候就找不到了。
來到f
函式,首先是儲存main函式的棧基地址,然後需要呼叫g
函式,於是需要引數先進棧:
-
subl $4, %esp
-
movl 8(%ebp), %eax
-
movl %eax, (%esp)
(檢視定義ESP是棧頂指標,EBP是存取堆疊指標)
這裡重點思考一下,f
函式是如何獲得main函式傳遞過來的引數的,我們看到
movl 8(%ebp), %eax
為什麼引數是從8(%ebp)
中獲得的呢?我們知道8(%ebp)
表示的是以ebp為基準向棧底回溯8個位元組得到,為什麼是8個位元組呢?
回想一下,在main
函式中完成了引數進棧後做了兩件事情:
- 由於
call f
指令的作用,call f
下一條指令的地址被壓棧了,這佔用率4
個位元組 - 進入
f
函式後,立即將main
函式的棧基地址進棧了,而且將ebp
靠向了棧頂esp
,這又佔用了4
個位元組
於是通過8(%ebp)
可以找到前一個函式的第一個整型引數的值。
一張圖告訴你怎麼回事:(棧地址值是向下增長的,即棧頂從高地址向低地址移動)
看過了進入函式,呼叫函式的過程,再看一下函式是如何退出的。觀察main
和f
不難發現,退出函式使用的是如下指令
-
leave
-
ret
leave
指令相當於如下指令:
-
movl %ebp, %esp
(檢視定義ESP是棧頂指標,EBP是存取堆疊指標) -
popl %ebp
- 第一條語句是將
esp
重置到ebp
,可以理解為清空當前函式所使用的棧 - 第二條語句是將棧頂值賦值給
ebp
,並彈出,棧頂值是什麼呢?通過上面的分析不難發現,此時的棧頂值實際上是前一個函式的棧基地址,所以第二條語句的意思就是把ebp
恢復到前一個函式的棧基地址
接著ret
就是相當於,恢復指令指向:
popl %eip
為什麼g函式沒有leave呢?因為g函式內部沒有任何的變數宣告和函式呼叫棧一直都是空的,所以編譯器優化了指令
總結
最後,通過這個例子,總結一下函式呼叫的過程:
進入函式:
- 當前棧基地址壓棧(當前棧基地址實際上是前一個函式的棧基地址)
呼叫其他函式:
- 引數從右到左進棧
- 下一條指令地址進棧
退出函式:
- 棧頂
esp
歸位,回到本函式的ebp
- 基地址回退到上一個函式的基地址
eip
退回到上一個函式即將要執行的那條語句的地址上