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---

 

 

posted on 2013-04-30 17:29  Randy Xu  阅读(1637)  评论(1编辑  收藏  举报

导航