我們用下面的代碼來研究函數調用的過程。
例 19.1. 研究函數的調用過程
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
選項(在第 10 章 gdb講過-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>: int 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
,而不生成二進制的目標檔案。
整個程序的執行過程是main
調用foo
,foo
調用bar
,我們用gdb
跟蹤程序的執行,直到bar
函數中的int e = c + d;
語句執行完畢準備返回時,這時在gdb
中打印函數棧幀。
(gdb) start ... main () at main.c:14 14 foo(2, 3); (gdb) s foo (a=2, b=3) at main.c:9 9 return bar(a, b); (gdb) s bar (c=2, d=3) at main.c:3 3 int e = c + d; (gdb) disassemble Dump of assembler code for function bar: 0x08048394 <bar+0>: push %ebp 0x08048395 <bar+1>: mov %esp,%ebp 0x08048397 <bar+3>: sub $0x10,%esp 0x0804839a <bar+6>: mov 0xc(%ebp),%edx 0x0804839d <bar+9>: mov 0x8(%ebp),%eax 0x080483a0 <bar+12>: add %edx,%eax 0x080483a2 <bar+14>: mov %eax,-0x4(%ebp) 0x080483a5 <bar+17>: mov -0x4(%ebp),%eax 0x080483a8 <bar+20>: leave 0x080483a9 <bar+21>: ret End of assembler dump. (gdb) si 0x0804839d 3 int e = c + d; (gdb) si 0x080483a0 3 int e = c + d; (gdb) si 0x080483a2 3 int e = c + d; (gdb) si 4 return e; (gdb) si 5 } (gdb) bt #0 bar (c=2, d=3) at main.c:5 #1 0x080483c2 in foo (a=2, b=3) at main.c:9 #2 0x080483e9 in main () at main.c:14 (gdb) info registers eax 0x5 5 ecx 0xbff1c440 -1074674624 edx 0x3 3 ebx 0xb7fe6ff4 -1208061964 esp 0xbff1c3f4 0xbff1c3f4 ebp 0xbff1c404 0xbff1c404 esi 0x8048410 134513680 edi 0x80482e0 134513376 eip 0x80483a8 0x80483a8 <bar+20> eflags 0x200206 [ PF IF ID ] cs 0x73 115 ss 0x7b 123 ds 0x7b 123 es 0x7b 123 fs 0x0 0 gs 0x33 51 (gdb) x/20 $esp 0xbff1c3f4: 0x00000000 0xbff1c6f7 0xb7efbdae 0x00000005 0xbff1c404: 0xbff1c414 0x080483c2 0x00000002 0x00000003 0xbff1c414: 0xbff1c428 0x080483e9 0x00000002 0x00000003 0xbff1c424: 0xbff1c440 0xbff1c498 0xb7ea3685 0x08048410 0xbff1c434: 0x080482e0 0xbff1c498 0xb7ea3685 0x00000001 (gdb)
這裡又用到幾個新的gdb
命令。disassemble
可以反彙編當前函數或者指定的函數,單獨用disassemble
命令是反彙編當前函數,如果disassemble
命令後面跟函數名或地址則反彙編指定的函數。以前我們講過step
命令可以一行代碼一行代碼地單步調試,而這裡用到的si
命令可以一條指令一條指令地單步調試。info registers
可以顯示所有寄存器的當前值。在gdb
中表示寄存器名時前面要加個$
,例如p $esp
可以打印esp
寄存器的值,在上例中esp
寄存器的值是0xbff1c3f4,所以x/20 $esp
命令查看內存中從0xbff1c3f4地址開始的20個32位數。在執行程序時,操作系統為進程分配一塊棧空間來保存函數棧幀,esp
寄存器總是指向棧頂,在x86平台上這個棧是從高地址向低地址增長的,我們知道每次調用一個函數都要分配一個棧幀來保存參數和局部變數,現在我們詳細分析這些數據在棧空間的佈局,根據gdb
的輸出結果圖示如下[29]:
圖中每個小方格表示4個位元組的內存單元,例如b: 3
這個小方格占的內存地址是0xbf822d20~0xbf822d23,我把地址寫在每個小方格的下邊界線上,是為了強調該地址是內存單元的起始地址。我們從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
指令,這個指令有兩個作用:
foo
函數調用完之後要返回到call
的下一條指令繼續執行,所以把call
的下一條指令的地址0x80483e9壓棧,同時把esp
的值減4,esp
的值現在是0xbf822d18。
修改程序計數器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
的值現在是0xbf822d14,下一條指令把這個值傳送給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
的逆操作:
把ebp
的值賦給esp
,現在esp
的值是0xbf822d04。
現在esp
所指向的棧頂保存着foo
函數棧幀的ebp
,把這個值恢復給ebp
,同時esp
增加4,esp
的值變成0xbf822d08。
最後是ret
指令,它是call
指令的逆操作:
現在esp
所指向的棧頂保存着返回地址,把這個值恢復給eip
,同時esp
增加4,esp
的值變成0xbf822d0c。
修改了程序計數器eip
,因此跳轉到返回地址0x80483c2繼續執行。
地址0x80483c2處是foo
函數的返回指令:
80483c2: c9 leave 80483c3: c3 ret
重複同樣的過程,又返回到了main
函數。注意涵數調用和返回過程中的這些規則:
參數壓棧傳遞,並且是從右向左依次壓棧。
ebp
總是指向當前棧幀的棧底。
返回值通過eax
寄存器傳遞。
這些規則並不是體繫結構所強加的,ebp
寄存器並不是必須這麼用,函數的參數和返回值也不是必須這麼傳,只是操作系統和編譯器選擇了以這樣的方式實現C代碼中的函數調用,這稱為Calling Convention,Calling Convention是操作系統二進制介面規範(ABI,Application Binary Interface)的一部分。
1、在第 2 節 “自定義函數”講過,Old Style C風格的函數聲明可以不指定參數個數和類型,這樣編譯器不會對函數調用做檢查,那麼如果調用時的參數類型不對或者參數個數不對會怎麼樣呢?比如把本節的例子改成這樣:
int foo(); int bar(); int main(void) { foo(2, 3, 4); return 0; } int foo(int a, int b) { return bar(a); } int bar(int c, int d) { int e = c + d; return e; }
main
函數調用foo
時多傳了一個參數,那麼參數a
和b
分別取什麼值?多的參數怎麼辦?foo
調用bar
時少傳了一個參數,那麼參數d
的值從哪裡取得?請讀者利用反彙編和gdb
自己分析一下。我們再看一個參數類型不符的例子:
#include <stdio.h> int main(void) { void foo(); char c = 60; foo(c); return 0; } void foo(double d) { printf("%f\n", d); }
打印結果是多少?如果把聲明void foo();
改成void foo(double);
,打印結果又是多少?