C語言函數調用過程的匯編分析


下面一段C程序:

int bar(int c, int d)
{
int e = c + d;
return e;
}

int foo(int a, int b)
{
return bar(a, b);
}

int main(void)
{
foo(2, 3);
return 0;
}

如果在編譯時加上-g選項,那么用objdump反匯編時可以把C代碼和匯編代碼穿插起來顯示,這樣C代碼和匯編代碼的對應關系看得更清楚。反匯編的結果很長,以下只列出我們關心的部分。

$ gcc main.c -g
$ objdump -dS a.out
...
08048394 <bar>:
int bar(int c, int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e = c + d;
804839a: 8b 55 0c mov 0xc(%ebp),%edx
804839d: 8b 45 08 mov 0x8(%ebp),%eax
80483a0: 01 d0 add %edx,%eax
80483a2: 89 45 fc mov %eax,-0x4(%ebp)
return e;
80483a5: 8b 45 fc mov -0x4(%ebp),%eax
}

80483a8: c9 leave
80483a9: c3 ret
080483aa <foo>:
294int foo(int a, int b)
{
80483aa: 55 push %ebp
80483ab: 89 e5 mov %esp,%ebp
80483ad: 83 ec 08 sub $0x8,%esp
return bar(a, b);
80483b0: 8b 45 0c mov 0xc(%ebp),%eax
80483b3: 89 44 24 04 mov %eax,0x4(%esp)
80483b7: 8b 45 08 mov 0x8(%ebp),%eax
80483ba: 89 04 24 mov %eax,(%esp)
80483bd: e8 d2 ff ff ff call 8048394 <bar>
}

80483c2: c9 leave
80483c3: c3 ret
080483c4 <main>:
int main(void)
{
80483c4: 8d 4c 24 04 lea 0x4(%esp),%ecx
80483c8: 83 e4 f0 and $0xfffffff0,%esp
80483cb: ff 71 fc pushl -0x4(%ecx)
80483ce: 55 push %ebp
80483cf: 89 e5 mov %esp,%ebp
80483d1: 51 push %ecx
80483d2: 83 ec 08 sub $0x8,%esp
foo(2, 3);
80483d5: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483dc: 00
80483dd: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483e4: e8 c1 ff ff ff call 80483aa <foo>
return 0;
80483e9: b8 00 00 00 00 mov $0x0,%eax
}

80483ee: 83 c4 08 add $0x8,%esp
80483f1: 59 pop %ecx
80483f2: 5d pop %ebp
80483f3: 8d 61 fc lea -0x4(%ecx),%esp
80483f6: c3 ret
...

要查看編譯后的匯編代碼,其實還有一種辦法是gcc -S main.c,這樣只生成匯編代碼main.s,而不生成二進制的目標文件。

disassemble可以反匯編當前函數或者指定的函數,單獨用disassemble命令是反匯編當前函數,如果disassemble命令后面跟函數名或地址則反匯編指定的函數。

在執行程序時,操作系統為進程分配一塊棧空間來保存函數棧幀, esp寄存器總是指向棧頂,在x86平台上這個棧是從高地址向低地址增長的,我們知道每次調用一個函數都要分配一個棧幀來保存參數和局部變量,現在我們詳細分析這些數據在棧空間的布局:

圖中每個小方格表示4個字節的內存單元,例如b: 3這個小方格占的內存地址是0xbff1c420~0xbff1c423。

從main函數的這里開始看起:

foo(2, 3);
80483d5: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483dc: 00
80483dd: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483e4: e8 c1 ff ff ff call 80483aa <foo>
return 0;
80483e9: b8 00 00 00 00 mov $0x0,%eax

要調用函數foo先要把參數准備好,第二個參數保存在esp+4指向的內存位置,第一個參數保存在esp指向的內存位置,可見參數是從右向左依次壓棧的。然后執行call指令,這個指令有兩個作用:

1. foo函數調用完之后要返回到call的下一條指令繼續執行,所以把call的下一條指令的地

址0x80483e9壓棧,同時把esp的值減4,esp的值現在是0xbff1c418。

2. 修改程序計數器eip,跳轉到foo函數的開頭執行。

現在看foo函數的匯編代碼:

int foo(int a, int b)
{
80483aa: 55 push %ebp
80483ab: 89 e5 mov %esp,%ebp
80483ad: 83 ec 08 sub $0x8,%esp

push %ebp指令把ebp寄存器的值壓棧,同時把esp的值減4。 esp的值現在是0xbff1c414,下一條指令把這個值傳送給ebp寄存器。這兩條指令合起來是把原來ebp的值保存在棧上,然后又給ebp賦了新值。在每個函數的棧幀中, ebp指向棧底,而esp指向棧頂,在函數執行過程中esp隨着壓棧和出棧操作隨時變化,而ebp是不動的,函數的參數和局部變量都是通過ebp的值加上一個偏移量來訪問,例如foo函數的參數a和b分別通過ebp+8和ebp+12來訪問。所以下面的指令把參數a和b再次壓

棧,為調用bar函數做准備,然后把返回地址壓棧,調用bar函數:

return bar(a, b);
80483b0: 8b 45 0c mov 0xc(%ebp),%eax
80483b3: 89 44 24 04 mov %eax,0x4(%esp)
80483b7: 8b 45 08 mov 0x8(%ebp),%eax
80483ba: 89 04 24 mov %eax,(%esp)
80483bd: e8 d2 ff ff ff call 8048394 <bar>

現在看bar函數的指令:

int bar(int c, int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e = c + d;
804839a: 8b 55 0c mov 0xc(%ebp),%edx
804839d: 8b 45 08 mov 0x8(%ebp),%eax
80483a0: 01 d0 add %edx,%eax
80483a2: 89 45 fc mov %eax,-0x4(%ebp)

這次又把foo函數的ebp壓棧保存,然后給ebp賦了新值,指向bar函數棧幀的棧底,通過ebp+8和ebp+12分別可以訪問參數c和d。 bar函數還有一個局部變量e,可以通過ebp-4來訪問。所以后面幾條指令的意思是把參數c和d取出來存在寄存器中做加法,計算結果保存在eax寄存器中,再把eax寄存器存回局部變量e的內存單元。

在gdb中可以用bt命令和frame命令查看每層棧幀上的參數和局部變量,現在可以解釋它的工作原理了:如果我當前在bar函數中,我可以通過ebp找到bar函數的參數和局部變量,也可以找到foo函數的ebp保存在棧上的值,有了foo函數的ebp,又可以找到它的參數和局部變量,也可以找到main函數的ebp保存在棧上的值,因此各層函數棧幀通過保存在棧上的ebp的值串起來了。

現在看bar函數的返回指令:

return e;
80483a5: 8b 45 fc mov -0x4(%ebp),%eax
}
80483a8: c9 leave
80483a9: c3 ret

bar函數有一個int型的返回值,這個返回值是通過eax寄存器傳遞的,所以首先把e的值讀到eax寄存器中。然后執行leave指令,這個指令是函數開頭的push %ebp和mov %esp,%ebp的逆操作:

1. 把ebp的值賦給esp,現在esp的值是0xbff1c404。

2. 現在esp所指向的棧頂保存着foo函數棧幀的ebp,把這個值恢復給ebp,同時esp增加4, esp的值變成0xbff1c408。

最后是ret指令,它是call指令的逆操作:

1. 現在esp所指向的棧頂保存着返回地址,把這個值恢復給eip,同時esp增加4, esp的值變成0xbff1c40c。

2. 修改了程序計數器eip,因此跳轉到返回地址0x80483c2繼續執行。

地址0x80483c2處是foo函數的返回指令:

80483c2: c9 leave
80483c3: c3 ret

重復同樣的過程,又返回到了main函數。注意函數調用和返回過程中的這些規則:

1. 參數壓棧傳遞,並且是從右向左依次壓棧。

2. ebp總是指向當前棧幀的棧底。

3. 返回值通過eax寄存器傳遞。

這些規則並不是體系結構所強加的, ebp寄存器並不是必須這么用,函數的參數和返回值也不是必須這么傳,只是操作系統和編譯器選擇了以這樣的方式實現C代碼中的函數調用,這稱為Calling Convention, Calling Convention是操作系統二進制接口規范(ABI, Application BinaryInterface)的一部分。 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM