1. 函數調用

我們用下面的代碼來研究函數調用的過程。

例 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調用foofoo調用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]

圖 19.1. 函數棧幀

函數棧幀

圖中每個小方格表示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指令,這個指令有兩個作用:

  1. foo函數調用完之後要返回到call的下一條指令繼續執行,所以把call的下一條指令的地址0x80483e9壓棧,同時把esp的值減4,esp的值現在是0xbf822d18。

  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的值現在是0xbf822d14,下一條指令把這個值傳送給ebp寄存器。這兩條指令合起來是把原來ebp的值保存在棧上,然後又給ebp賦了新值。在每個函數的棧幀中,ebp指向棧底,而esp指向棧頂,在函數執行過程中esp隨着壓棧和出棧操作隨時變化,而ebp是不動的,函數的參數和局部變數都是通過ebp的值加上一個偏移量來訪問,例如foo函數的參數ab分別通過ebp+8ebp+12來訪問。所以下面的指令把參數ab再次壓棧,為調用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+8ebp+12分別可以訪問參數cdbar函數還有一個局部變數e,可以通過ebp-4來訪問。所以後面幾條指令的意思是把參數cd取出來存在寄存器中做加法,計算結果保存在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 %ebpmov %esp,%ebp的逆操作:

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

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

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

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

  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 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時多傳了一個參數,那麼參數ab分別取什麼值?多的參數怎麼辦?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);,打印結果又是多少?



[29] Linux內核為每個新進程指定的棧空間的起始地址都會有些不同,所以每次運行這個程序得到的地址都不一樣,但通常都是0xbf??????這樣一個地址。