[科技]一段 C 語言和匯編的對應分析
最近網易云課堂開放了一節叫 Linux內核分析 的課程。一直對操作系統和計算機本質很感興趣,于是進去看了下,才第一堂課,老師就要求學生寫一篇關于課時1的博客作為作業。對于這種新穎的作業形式,筆者相當驚訝。好吧,作為任務,還是完成一下吧,剛好需要消化一下。本文將會按照要求,將一段C語言代碼編譯成匯編,并給予分析和自己的思考。
本文作者周平,原創作品轉載請注明出處
首先對會涉及到的一些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
程序開始執行…
我們捆綁著看,首先先看這兩條:
ushl %ebp movl %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
即把 call 指令的后一條指令進棧后,將 eip 賦值為目標函數的第一個指令地址。這樣做顯而易見:當所調用的函數結束后,需要返回當前函數繼續執行,所以必須要保存下一條指令,否則回來的時候就找不到了。
來到 f 函數,首先是保存main函數的?;刂?,然后需要調用 g 函數,于是需要參數先進棧:
subl $4, %esp movl 8(%ebp), %eax movl %eax, (%esp)
這里重點思考一下, f 函數是如何獲得main函數傳遞過來的參數的,我們看到
movl 8(%ebp), %eax
為什么參數是從 8(%ebp) 中獲得的呢?我們知道 8(%ebp) 表示的是以ebp為基準向棧底回溯8個字節得到,為什么是8個字節呢?
回想一下,在 main 函數中完成了參數進棧后做了兩件事情:
由于 call f 指令的作用, call f 下一條指令的地址被壓棧了,這占用率 4 個字節
進入 f 函數后,立即將 main 函數的?;刂愤M棧了,而且將 ebp 靠向了棧頂 esp ,這又占用了 4 個字節
于是通過 8(%ebp) 可以找到前一個函數的第一個整型參數的值。
一張圖告訴你怎么回事:
看過了進入函數,調用函數的過程,再看一下函數是如何退出的。觀察 main 和 f 不難發現,退出函數使用的是如下指令
leave ret
leave 指令相當于如下指令:
movl %ebp, %esp popl %ebp
第一條語句是將 esp 重置到 ebp ,可以理解為清空當前函數所使用的棧
第二條語句是將棧頂值賦值給 ebp ,并彈出,棧頂值是什么呢?通過上面的分析不難發現,此時的棧頂值實際上是前一個函數的?;刂?,所以第二條語句的意思就是把 ebp 恢復到前一個函數的?;刂?
接著 ret 就是相當于,恢復指令指向:
popl %eip
為什么g函數沒有leave呢?因為g函數內部沒有任何的變量聲明和函數調用棧一直都是空的,所以編譯器優化了指令
總結
最后,通過這個例子,總結一下函數調用的過程:
進入函數:
當前?;刂穳簵?當前?;刂穼嶋H上是前一個函數的?;刂?
調用其他函數:
參數從右到左進棧
下一條指令地址進棧
退出函數:
棧頂 esp 歸位,回到本函數的 ebp
基地址回退到上一個函數的基地址
eip 退回到上一個函數即將要執行的那條語句的地址上