函数调用的本质
次访问
这篇文章从汇编的角度来探讨下函数调用的本质是什么。
前面文章介绍了代码指令的顺序执行、分支控制以及循环中,底层汇编代码到底发生了什么。
这一篇从底层的角度来看我们在执行函数调用的时候会发生什么。
函数调用也称为过程调用(Procedure Call)。
我们可以想想,在使用过程调用的时候,我们一般都做了些什么操作,有以下几项条件保证:
- 向过程传递参数
 - 从过程得到返回结果(如果有返回结果的话)
 - 在过程调用完成之后,我们还能继续进行调用前的逻辑。
 
比如下面这段代码:
1  | int P(int x)  | 
上面的代码在 P 过程中调用了 Q 过程,P 过程向 Q 过程传递参数 y,同时在 Q 过程执行完之后,能够将执行结果赋给 P 过程的 z 变量,而且在 Q 过程执行完后,还能继续进行 return 语句返回 y 和 z 的和。
那么这些,底层都是怎么实现的呢?
Stack Frame 结构
在 IA-32 中,使用程序栈(Program Stack,后面简称Stack)来支持过程调用。
每个过程中的局部变量存储,返回运算结果保存寄存器信息等等都是用Stack来实现的。
每个过程都会占用 Stack 的一部分,这部分叫栈帧(Stack Frame,后面简称 Frame)。
Stack 的结构如下所示:

 从上图可以看出Staeck结构的一些特点:
- 栈顶在低地址端,越往栈顶,地址越小,像一个“倒立”的栈。
 - 每一个过程都对应 Stack 的一部分,这部分称为 Frame,都有一个 Frame 指针 %ebp 指向 Frame 底,有一个 Stack 指针 %esp 指向 Stack 顶。
 
为什么不用寄存器保存这些东西,而用 Stack 呢?原因有以下几方面:
- 寄存器的数量和大小是有限的,不能存放所有的局部变量
 - 对一个局部变量使用地址操作符”&”,我们必须为它生成一个地址(只有在栈上才会有地址)
 
过程调用
汇编中使用 Call 指令来执行过程的调用。
Call 指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是什么呢?就是紧跟着 call 指令后面的那条指令的地址(调用过程执行完毕之后,继续从这个地址开始执行指令)。
用一个例子来表示:
1  | # sum 函数开始位置  | 
Stack Frame的地址变化如图:

%eip 寄存器存储当前执行指令的地址。
%esp 寄存器指向当前 Stack 的 Top。
图1是执行 call 指令时的寄存器和栈示意图,此时 %eip 中存储的是 call 指令所在的地址80483dc;
图2是执行 call 指令之后的寄存器和栈示意图,此时 %eip 中存储的是 sum 函数中第一条指令的地址8048394,同时我们可以看到 call 指令后面的 add 指令的地址80483e1入栈。栈顶寄存器 %esp 的地址减小了4,为ff9bc95c。
图3是sum函数执行完毕返回时的寄存器和栈示意图,此时%eip中存储的自然是add指令的地址80483e1,函数执行完毕之后要退栈,所以此时栈顶指针%esp的地址仍然是 ff9bc960。
过程调用示例
先看下面一段C语言代码:
1  | int swap_add(int *xp, int *yp)  | 
代码比较简单,就是在 caller 方法中调用了 swap_add 方法。
先看下汇编版本的 caller 方法体。
1  | caller:  | 
在这段代码里面,Stack Frame 里面分配了地址存放局部变量 arg1 和 arg2,并计算了 &arg1 和 &arg2 的值存放在 Frame 上,以备 swap_add 函数返回时调用执行。
其栈结构如下:

可以看到在caller 的 Frame 中,存放的是 arg1,arg2,&arg1,&arg2的值。
接下来我们看看调用 swap_add 方法后发生了什么:
1  | swap_add:  | 
执行到 方法体中时的 Stack Frame 结构如下所示:

(全文完)
参考资料
1.《深入理解计算机系统(第二版)》
