1. 程式人生 > >一段C語言和彙編的對應分析,揭示函式呼叫的本質

一段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無法直接賦值,callretjmp等指令可以起到修改eip的作用
  •  
  • %用於直接定址暫存器,$用於表示立即數。movl $8, %eax表示把立即數8存到eax
  •  
  • ()用於記憶體間接定址,比如movl $10, (%esp)表示將立即數10儲存到esp所指向的記憶體地址中
  •  
  • 8(%ebp)表示先找到 ebp所指向的地址值+8後得到的地址
  •  
  • 棧地址值是向下增長的,即棧頂從高地址向低地址移動

準備工作

準備一段C程式碼:

 
  1. int g(int x)

  2. {

  3.     return x+5;

  4. }

  5.  
  6. int f(int x)

  7. {

  8.     return g(x);

  9. }

  10.  
  11. int main(void)

  12. {

  13.     return f(10)+1;

  14. }

使用實驗樓環境

編譯成彙編程式碼

使用如下命令編譯上面的c程式碼

gcc -S -o main.s main.c -m32

去掉不重要的部分後,得到:

彙編程式碼結果為:

 
  1. g:

  2. pushl %ebp

  3. movl %esp, %ebp

  4. movl 8(%ebp), %eax

  5. addl $5, %eax

  6. popl %ebp

  7. ret

  8. f:

  9. pushl %ebp

  10. movl %esp, %ebp

  11. subl $4, %esp

  12. movl 8(%ebp), %eax

  13. movl %eax, (%esp)

  14. call g

  15. leave

  16. ret

  17. main:

  18. pushl %ebp

  19. movl %esp, %ebp

  20. subl $4, %esp

  21. movl $10, (%esp)

  22. call f

  23. addl $1, %eax

  24. leave

  25. ret

分析

具體的逐步分析,這裡就省了,老師課上講的很詳細了,這裡主要是要進行思考和歸納。

首先,我們看到3個C函式對應生成了3個部分的彙編程式碼,分別用函式名作為標號隔開了

 
  1. int g(int x) -> g:

  2. int f(int x) -> f:

  3. int main(void) -> main:

我們知道程式是從main函式開始執行的,那麼當程式被載入並執行時,上面的彙編程式碼會被載入到記憶體的某一個區域。而且,CPU中的很多暫存器都會初始化,當然其中最重要的是eip,因為eip是指向下一條將要執行的命令所在的記憶體地址,所以此時的eip應該指向main標號下的pushl %ebp

 
  1. main:

  2. eip ->  pushl %ebp

程式開始執行…

我們捆綁著看,首先先看這兩條:

 
  1. pushl %ebp  :將基地址壓棧。(ebp : 堆疊基地址 暫存器,這個暫存器儲存的是當前執行緒的棧底地址

  2. movl %esp, %ebp:將棧頂地址賦值給棧的基地址。(esp : 堆疊棧頂 暫存器,這個暫存器儲存的是當前執行緒的棧頂地址         (檢視定義ESP是棧頂指標,EBP是存取堆疊指標)

再觀察一下整個程式碼,有沒有發現不僅僅是main函式,函式fg的開頭也是這兩個指令。分析一下,不難得出,這兩條指令是指將當前棧基地址壓棧後,重新將基地址定位到棧頂,這個含義其實是儲存好當前的基地址,重新開始一個新的棧。由於函式可以調函式,這裡的當前基地址,實際上是上一個函式的棧基地址。例如,在f函式中的這兩句指令,實際上儲存的是main函式的棧基地址。

接著來分析兩句:

 
  1. subl $4, %esp

  2. movl $10, (%esp)

對照C程式碼不難發現,這是引數進棧,將立即數10,儲存到棧頂(esp所指向的記憶體地址是棧頂)。而在f函式中也可以發現類似的語句:

 
  1. subl $4, %esp

  2. movl 8(%ebp), %eax

  3. movl %eax, (%esp)

所以,我們可以得出結論是,在呼叫函式前需要把引數逐個壓棧,而壓棧的順序根據筆者的測試是從右向左的

接著呼叫call指令,跳轉到f函式,我們知道call指令等同於下面的虛擬碼:

 
  1. pushl %eip+1

  2. movl %eip f   (是不是此處寫錯了,應該是:movl f, %eip  

即把call指令的後一條指令進棧後,將eip賦值為目標函式的第一個指令地址。這樣做顯而易見:當所呼叫的函式結束後,需要返回當前函式繼續執行,所以必須要儲存下一條指令,否則回來的時候就找不到了。

來到f函式,首先是儲存main函式的棧基地址,然後需要呼叫g函式,於是需要引數先進棧:

 
  1. subl $4, %esp

  2. movl 8(%ebp), %eax

  3. movl %eax, (%esp) (檢視定義ESP是棧頂指標,EBP是存取堆疊指標)

這裡重點思考一下,f函式是如何獲得main函式傳遞過來的引數的,我們看到

movl	8(%ebp), %eax

為什麼引數是從8(%ebp)中獲得的呢?我們知道8(%ebp)表示的是以ebp為基準向棧底回溯8個位元組得到,為什麼是8個位元組呢?

回想一下,在main函式中完成了引數進棧後做了兩件事情:

  1.  
  2. 由於call f指令的作用,call f下一條指令的地址被壓棧了,這佔用率4個位元組
  3.  
  4. 進入f函式後,立即將main函式的棧基地址進棧了,而且將ebp靠向了棧頂esp,這又佔用了4個位元組

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

一張圖告訴你怎麼回事:(棧地址值是向下增長的,即棧頂從高地址向低地址移動

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

 
  1. leave

  2. ret

leave指令相當於如下指令:

 
  1. movl %ebp, %esp   (檢視定義ESP是棧頂指標,EBP是存取堆疊指標)

  2. popl %ebp

  •  
  • 第一條語句是將esp重置到ebp,可以理解為清空當前函式所使用的棧
  •  
  • 第二條語句是將棧頂值賦值給ebp,並彈出,棧頂值是什麼呢?通過上面的分析不難發現,此時的棧頂值實際上是前一個函式的棧基地址,所以第二條語句的意思就是把ebp恢復到前一個函式的棧基地址

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

popl %eip
 

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

總結

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

進入函式:

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

呼叫其他函式:

  1.  
  2. 引數從右到左進棧
  3.  
  4. 下一條指令地址進棧

退出函式:

  1.  
  2. 棧頂esp歸位,回到本函式的ebp
  3.  
  4. 基地址回退到上一個函式的基地址
  5.  
  6. eip退回到上一個函式即將要執行的那條語句的地址上