我们用下面的代码来研究函数调用的过程。
例 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);
,打印结果又是多少?