一段C語言和彙編的對應分析,揭示函式呼叫的本質

neuyu發表於2021-09-09

最近網易雲課堂開放了一節叫的課程。一直對作業系統和計算機本質很感興趣,於是進去看了下,才第一堂課,老師就要求學生寫一篇關於課時1的部落格作為作業。對於這種新穎的作業形式,筆者相當驚訝。好吧,作為任務,還是完成一下吧,剛好需要消化一下。本文將會按照要求,將一段C語言程式碼編譯成彙編,並給予分析和自己的思考。

本文作者周平,原創作品轉載請註明出處

首先對會涉及到的一些CPU暫存器和彙編的基礎知識羅列一下:

  • 16位、32位、64位的CPU暫存器名稱有所不同,比如指令地址暫存器ip,在16位中叫ip,32位中叫eip,64位叫rip

  • 32位的彙編指令通常以l結尾,比如movl相當於mov的含義

  • ebp : 堆疊基地址 暫存器,這個暫存器儲存的是當前執行緒的棧底地址

  • esp : 堆疊棧頂 暫存器,這個暫存器儲存的是當前執行緒的棧頂地址

  • eip : 指令地址 暫存器,這個暫存器儲存的是指令所在的地址,CPU會不斷的根據eip所指向的指令去記憶體取指令並執行,並自行累加取下一條指令逐條執行。eip無法直接賦值,callretjmp等指令可以起到修改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 movl %esp, %ebp

再觀察一下整個程式碼,有沒有發現不僅僅是main函式,函式fg的開頭也是這兩個指令。分析一下,不難得出,這兩條指令是指將當前棧基地址壓棧後,重新將基地址定位到棧頂,這個含義其實是儲存好當前的基地址,重新開始一個新的棧。由於函式可以調函式,這裡的當前基地址,實際上是上一個函式的棧基地址。例如,在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函式中完成了引數進棧後做了兩件事情:

  1. 由於call f指令的作用,call f下一條指令的地址被壓棧了,這佔用率4個位元組

  2. 進入f函式後,立即將main函式的棧基地址進棧了,而且將ebp靠向了棧頂esp,這又佔用了4個位元組

於是透過8(%ebp)可以找到前一個函式的第一個整型引數的值。

一張圖告訴你怎麼回事:

圖片描述

看過了進入函式,呼叫函式的過程,再看一下函式是如何退出的。觀察mainf不難發現,退出函式使用的是如下指令

leave ret

leave指令相當於如下指令:

movl %ebp, %esp popl %ebp
  • 第一條語句是將esp重置到ebp,可以理解為清空當前函式所使用的棧

  • 第二條語句是將棧頂值賦值給ebp,並彈出,棧頂值是什麼呢?透過上面的分析不難發現,此時的棧頂值實際上是前一個函式的棧基地址,所以第二條語句的意思就是把ebp恢復到前一個函式的棧基地址

接著ret就是相當於,恢復指令指向:

popl %eip
 

為什麼g函式沒有leave呢?因為g函式內部沒有任何的變數宣告和函式呼叫棧一直都是空的,所以編譯器最佳化了指令

總結

最後,透過這個例子,總結一下函式呼叫的過程:

進入函式:

  1. 當前棧基地址壓棧(當前棧基地址實際上是前一個函式的棧基地址)

呼叫其他函式:

  1. 引數從右到左進棧

  2.  下一條指令地址進棧

退出函式:

  1. 棧頂esp歸位,回到本函式的ebp

  2.  基地址回退到上一個函式的基地址

  3.  eip退回到上一個函式即將要執行的那條語句的地址上

原文連結:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4550/viewspace-2805587/,如需轉載,請註明出處,否則將追究法律責任。

相關文章