C语言拾遗(六):分析C语言的函数调用过程
函数可以把大的计算任务分解成若干个较小的任务,我们可以基于函数进一步构造程序,而不需要重新编写一些重复代码。
C语言程序一般都由许多小的函数组成。今天一起看一下函数是怎么调用的。
用术语来讲,这叫做“过程调用”。
一个过程调用包括将数据和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。
实现过程调用,编译器使用了一个基于栈的方法。栈,先进后出。机器用栈来传递过程的参数,存储返回信息,保存寄存器等等。
为单个过程分配的部分叫做一个“栈帧”。
如下图所示:(ubuntu的Pinta画的,唉,实在是简陋)
栈帧的边界由两个指针界定,帧指针EBP指向一帧开始的地方,栈指针指向栈的顶端。汇编代码经常看到基于EBP和ESP的偏移值。
讲这些理论的总是很无聊啊,来干货,上个最最简单的代码。
1 int add(int x, int y) 2 { 3 return x + y; 4 } 5 6 int main() 7 { 8 int arg1 = 0x222; 9 int arg2 = 0x666; 10 int sum = add(arg1, arg2); 11 12 return sum; 13 }
C代码就不用解释了吧,一个简单的求和函数add( )。
来看汇编。去掉了我认为无用,影响大家视觉的代码。还是逐行解释。
gcc -S call.c
1 add: 2 pushl %ebp /压EBP,这里就是保存调用者main的 3 movl %esp, %ebp /同main 4 movl 12(%ebp), %eax /取x放eax 5 movl 8(%ebp), %edx /取y放edx 6 addl %edx, %eax /相加放eax 7 popl %ebp /弹出ebp 8 ret 返回 9 main: 10 pushl %ebp /压旧的EBP 11 movl %esp, %ebp /ESP的值赋给EBP,这样main函数的栈帧开始的地方就放在EBP了 12 subl $24, %esp /ESP减去24,由于我们的栈是向地址方向增长,所以这样相当与分配了24字节的栈空间 13 movl $546, -12(%ebp) /arg1 0x222放在EBP后12字节处 14 movl $1638, -8(%ebp) /arg2 0x666放在EBP后8字节处 15 movl -8(%ebp), %eax /把arg2放eax 16 movl %eax, 4(%esp) /把eax放esp+4,arg2->esp+4 17 movl -12(%ebp), %eax /arg1放eax 18 movl %eax, (%esp) /eax放esp,arg1->esp指向处 19 call add /调用add函数 20 movl %eax, -4(%ebp) /eax给ebp-4 21 movl -4(%ebp), %eax /ebp-4再给eax,好废话啊,其实就是结果保存在eax了,gcc没加优化-O的结果 22 leave 23 ret
对着注释看,你是不是发现汇编的代码有点罗嗦,本来想gcc -O1优化一下的。谁知道一优化把我认为最能体现函数调用过程的
pushl %ebp
都优化没了。直接使用寄存器操作了,唉,谁让我的示例代码太简单了?鱼和熊掌不可兼得啊!
再解释关键的三个转移控制语句:
1. call function:将返回地址入栈,并跳转到function的起始处。返回地址是call后面那条语句的地址,我们这里就是汇编中的20行。
2. leave:使栈做好返回的准备。相当于如下语句:
movl %ebp, %esp //esp移动到ebp处
pop %ebp //ebp弹出,这样esp就指向返回地址了,以此达到“返回准备”
3. ret:返回,从栈中弹出地址,并跳转到这个位置。程序就从被调函数返回继续执行了。
好了,这样对照汇编,在纸上把栈画画,观察EBP和ESP值的变化,应该就能理解函数调用过程了吧。
最后,再让大家看一下栈中的数据,从而验证我的陈述,看看参数,局部变量,返回地址等是如何压栈的。
我是用gdb工具调试的时候,读出x的地址,因为这个地址肯定是栈上的,所以我就在这个地址附近打了一下内存的内容。
如果你有更好的,更简单的查看函数栈内容的方法,类似直接调dump_stack()函数的,请一定告诉我,先行谢过。
留言或mail:randyxw@gmail.com
(gdb) display &x
1: &x = (int *) 0xbffff2c0
(gdb) x/40xw 0xbffff280
0xbffff280: 0xbffff4f9 0x0000002f 0xbffff2dc 0xb7fc6ff4
0xbffff290: 0x080483f0 0x08049ff4 0x00000001 0x080482bd
0xbffff2a0: 0xb7fc73e4 0x0000000a 0x08049ff4 0x08048411
0xbffff2b0: 0xffffffff 0xb7e54196 0xbffff2d8 0x080483e7
0xbffff2c0: 0x00000222 0x00000666 0x080483f9 0x00000222
0xbffff2d0: 0x00000666 0x00000000 0x00000000 0xb7e3a4d3
0xbffff2e0: 0x00000001 0xbffff374 0xbffff37c 0xb7fdc858
0xbffff2f0: 0x00000000 0xbffff31c 0xbffff37c 0x00000000
0xbffff300: 0x0804820c 0xb7fc6ff4 0x00000000 0x00000000
0xbffff310: 0x00000000 0x17a069f6 0x2f0c8de6 0x00000000
main()的函数的栈帧用棕色表示,add()的用绿色表示。
可以看到,main函数的保存ebp是0,arg2 0x666存在bffff2d0处,arg1 0x222存在bffff2c0+12处。
0x080483e7是调add的返回地址,0xbffff2d8是压的EBP。
我们还看到,GCC开始为main分配的栈空间一共是32字节(棕色部分),但是实际使用的并没有这么多。也就是说,这里有明显的浪费。
这是因为,GCC坚持一个x86编程指导方针,一个函数使用的所有栈空间必须是16字节的整数倍。采用这个规则是为了保证访问数据的严格对齐(alignment)
关于对齐规则,我下次有机会再写个博客,跟大家分享。
---End---