一段C語言和彙編的對應分析,揭示函數調用的本質

最近網易雲課堂開放了一節叫 Linux內核分析 的課程。一直對操做系統和計算機本質很感興趣,因而進去看了下,才第一堂課,老師就要求學生寫一篇關於課時1的博客做爲做業。對於這種新穎的做業形式,筆 者至關驚訝。好吧,做爲任務,仍是完成一下吧,恰好須要消化一下。本文將會按照要求,將一段C語言代碼編譯成彙編,並給予分析和本身的思考。html

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

  • 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 表示把當即數存到 eax 中
  • () 用於內存間接尋址,好比 movl $10, (%esp) 表示將當即數保存到 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

分析

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

首先,咱們看到3個C函數對應生成了3個部分的彙編代碼,分別用函數名做爲標號隔開了ip

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 函數,函數 f 和 g 的開頭也是這兩個指令。分析一下,不可貴出,這兩條指令是指 將當前棧基地址壓棧後,從新將基地址定位到棧頂 ,這個含義實際上是保存好當前的基地址,從新開始一個新的棧。因爲函數能夠調函數, 這裏的當前基地址,其實是上一個函數的棧基地址 。例如,在 f 函數中的這兩句指令,實際上保存的是 main 函數的棧基地址。

接着來分析兩句:

subl    $4, %esp
movl    $10, (%esp)

對照C代碼不難發現,這是 參數進棧 ,將當即數,保存到棧頂(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 下一條指令的地址被壓棧了,這佔用率個字節
  2. 進入 f 函數後,當即將 main 函數的棧基地址進棧了,並且將 ebp 靠向了棧頂 esp ,這又佔用了個字節

因而經過 8(%ebp) 能夠找到前一個函數的第一個整型參數的值。

一張圖告訴你怎麼回事:

看過了進入函數,調用函數的過程,再看一下函數是如何退出的。觀察 main 和 f 不難發現,退出函數使用的是以下指令

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 退回到上一個函數即將要執行的那條語句的地址上
相關文章
相關標籤/搜索