函数调用的本质
次访问
这篇文章从汇编的角度来探讨下函数调用的本质是什么。
前面文章介绍了代码指令的顺序执行、分支控制以及循环中,底层汇编代码到底发生了什么。
这一篇从底层的角度来看我们在执行函数调用的时候会发生什么。
函数调用也称为过程调用(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.《深入理解计算机系统(第二版)》