原創作品轉載請注明出處
參考材料 《Linux內核分析》 MOOC課程http://mooc.study.163.com/course/USTC-1000029000 ”
作者:Casualet
我們在這里從匯編代碼的角度, 給出一段簡單的C語言程序運行過程中機器狀態的變化情況. 我們的實驗環境是Ubuntu 64位, 編譯器gcc的版本是4.8.4.
我們使用的c程序如下:
int g(int x){
return x + 3;
}
int f(int x){
return g(x);
}
int main(void){
return f(8) + 1;
}
這個簡單的c程序有一個main函數, 在main函數里調用了f函數, 然后f函數調用了g函數. 我們把其編譯成32位的匯編代碼, 使用的命令是: gcc -S -o main.s main.c -m32. 這樣,我們獲得了匯編代碼文件main.s, 打開以后可以看到這種效果:
.file "test.c"
.text
.globl g
.type g, @function
g:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
movl 8(%ebp), %eax
addl $3, %eax
popl %ebp
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE0:
.size g, .-g
.globl f
.type f, @function
f:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $4, %esp
movl 8(%ebp), %eax
movl %eax, (%esp)
call g
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE1:
.size f, .-f
.globl main
.type main, @function
main:
.LFB2:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $4, %esp
movl $8, (%esp)
call f
addl $1, %eax
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE2:
.size main, .-main
.ident "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4"
.section .note.GNU-stack,"",@progbits
由於以點開頭的都是鏈接時候用到的信息, 跟實際的代碼執行邏輯沒有關系, 為了方便分析,我們給出刪除了以點開頭的行以后的代碼版本:
g:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
addl $3, %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 $8, (%esp)
call f
addl $1, %eax
leave
ret
在這里,我們可以清晰地看到匯編代碼和三個3函數之間的對應關系.我們補充兩張代碼的圖例:
接下來我們從main函數開始分析:
首先是
pushl %ebp
movl %esp, %ebp
subl $4, %esp
圖1
圖2

圖3
這樣, 從ebp開始,到esp 就是屬於main函數的棧. main函數執行完, 需要清空這個棧, 返回原來的狀態, 但是怎么返回呢? 因為我們保存了100這個信息, 所以我們知道, 在調用main函數以前,ebp的值是100, esp的值是88, 所以我們可以返回. 這也就是為什么要做上面這三個步驟. 然后我們繼續執行指令, 把數字8放在esp指向的位置, 得到如下的結果:
圖4
接下來,調用函數f, 這一步會把eip壓棧. eip指向的是call的下一條指令, addl $1, %eax. 進入f函數以后, 又進行以下三步:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
這個的效果和前面講的是一樣的, 結果圖如下:

圖5
然后,movl 8(%ebp), %eax 表示把ebp+8地址所在位置的值放到eax中, 在這里,這個值是正好是8. (對應c語言,我們發現原來要做的事情是int x參數傳遞.所以說, 在32位的x86情況下, 函數的參數傳遞是通過棧來實現的, 我們在使用call 指令調用函數前, 先把函數需要的參數壓棧, 然后再使用call指令導致eip壓棧, 然后進入新的函數后, 舊的ebp壓棧, 新的ebp指向的位置存了這個剛壓棧的舊的ebp. 所以, 我們通過新的ebp指向的位置, 可以通過計算的方法, 得到函數需要的參數). 接下來, movl %eax, (%esp) 會把eax的值放到esp指向的內存的位置, 然后調用 g函數, 又可以壓棧call指令的下一條指令的地址, 得到的結果圖是:

圖6
然后,我們進入了g函數, 執行了前兩條指令,得到的結果是:

圖7
第三條指令, 和前面說過的用法相同, 是把8這個數字放在%eax中.下一個指令把數字+3,所以現在eax中的數字是11. 接下來的popl %ebp, ebp的值變成了72,因為這個時候esp執行的位置存放的值就是72,這個值正好就是之前存放的上一個函數的ebp的值, 所以得到如下的圖:
圖8
然后, ret執行,會把leave的地址彈到eip中, 就可以執行leave 指令了.得到的圖是:
圖9
leave 指令類似一條宏指令, 等價於
movl %ebp, %esp
popl %ebp
我們知道,ebp=72指向的位置存了82這個數,正好是上一次存的舊的ebp的值, 所以經過這步得到如下的圖.
圖10
這樣, 又遇到了一次ret, 開始執行main 函數中的addl $1, %eax, 由於eax 的值是11, 所以現在變成了12. 然后又碰到leave 指令, 達到清棧的目的, 效果圖如下:
圖11
於是, 棧恢復了初始的狀態. 我們可以看到, 在main函數之后, 有一個ret指令. 由於我們之前進入main函數的時候沒有考慮地址壓棧, 那部分是操作系統來管理的, 所以這里不考慮這條指令的執行.
總結:
一個函數的執行過程, 會有自己的一段從ebp 到esp的棧空間. 對於一個函數, ebp指向的位置的值是調用這個函數的上一個函數的棧空間的ebp的值. 這種機制使得leave指令可以清空一個函數的棧, 達到調用之前的狀態. 由於在這個棧設置之前, 有一個eip壓棧的過程, 所以leave 以后的ret正好對應了上一個函數的返回地址, 也就是返回上一個函數時要執行的指令的地址. 另外,由於對於一個函數的棧空間來說, ebp指向的位置存了上一個ebp的值, 再往上是上一個函數的返回地址, 再往上是上一個函數壓棧傳參數的值, 所以我們知道了自己的當前ebp, 就可以通過棧的機制來獲得參數.