这篇文章从汇编的角度来探讨下函数调用的本质是什么。

前面文章介绍了代码指令的顺序执行、分支控制以及循环中,底层汇编代码到底发生了什么。

这一篇从底层的角度来看我们在执行函数调用的时候会发生什么。

函数调用也称为过程调用(Procedure Call)。

我们可以想想,在使用过程调用的时候,我们一般都做了些什么操作,有以下几项条件保证:

  1. 向过程传递参数
  2. 从过程得到返回结果(如果有返回结果的话)
  3. 在过程调用完成之后,我们还能继续进行调用前的逻辑。

比如下面这段代码:

1
2
3
4
5
6
int P(int x)
{
int y = x*x;
int z = Q(y);
return y + z;
}

上面的代码在 P 过程中调用了 Q 过程,P 过程向 Q 过程传递参数 y,同时在 Q 过程执行完之后,能够将执行结果赋给 P 过程的 z 变量,而且在 Q 过程执行完后,还能继续进行 return 语句返回 yz 的和。

那么这些,底层都是怎么实现的呢?

Stack Frame 结构

在 IA-32 中,使用程序栈(Program Stack,后面简称Stack)来支持过程调用。

每个过程中的局部变量存储,返回运算结果保存寄存器信息等等都是用Stack来实现的。

每个过程都会占用 Stack 的一部分,这部分叫栈帧(Stack Frame,后面简称 Frame)。

Stack 的结构如下所示:

栈帧结构

​ 从上图可以看出Staeck结构的一些特点:

  1. 栈顶在低地址端,越往栈顶,地址越小,像一个“倒立”的栈。
  2. 每一个过程都对应 Stack 的一部分,这部分称为 Frame,都有一个 Frame 指针 %ebp 指向 Frame 底,有一个 Stack 指针 %esp 指向 Stack 顶。

为什么不用寄存器保存这些东西,而用 Stack 呢?原因有以下几方面:

  1. 寄存器的数量和大小是有限的,不能存放所有的局部变量
  2. 对一个局部变量使用地址操作符”&”,我们必须为它生成一个地址(只有在栈上才会有地址)

过程调用

汇编中使用 Call 指令来执行过程的调用。

Call 指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是什么呢?就是紧跟着 call 指令后面的那条指令的地址(调用过程执行完毕之后,继续从这个地址开始执行指令)。

用一个例子来表示:

1
2
3
4
5
6
7
8
9
10
11
# sum 函数开始位置
08048394 <sum>
8048394: 55 push %ebp
..
# 从 sum 函数返回
80483a4: c3 ret
...

## main 中调用 call
80483dc: e8 b3 ff ff ff call 8048694 <sum>
80483e1: 83 c4 14 add $0x14,%esp

Stack Frame的地址变化如图:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int swap_add(int *xp, int *yp)
{
int x = *xp;
int y = *yp;

*xp = y;
*yp = x;
return x + y;
}

int caller()
{
int arg1 = 534;
int arg2 = 1057;
int sum = swap_add(&arg1,&arg2);
int diff = arg1 - arg2;

return sum*diff;
}

代码比较简单,就是在 caller 方法中调用了 swap_add 方法。

先看下汇编版本的 caller 方法体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
caller:
push %ebp
movl %esp,%ebp
## allocate space for local variables
subl $24,%esp
## store them on stack
movl $534,-4(%ebp)
movl $1057,-8(%ebp)
## compute &arg1 and store it on the stack
leal -8(%ebp),%eax
movl %eax,4(%esp)
## compute &arg2 and store it on the stack
lea -4(%ebp),%eax
movl %eax,(%esp)
## call function swap_add
call swap_add

在这段代码里面,Stack Frame 里面分配了地址存放局部变量 arg1 和 arg2,并计算了 &arg1 和 &arg2 的值存放在 Frame 上,以备 swap_add 函数返回时调用执行。

其栈结构如下:

before

可以看到在caller 的 Frame 中,存放的是 arg1,arg2,&arg1,&arg2的值。

接下来我们看看调用 swap_add 方法后发生了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
swap_add:
## save old %ebp
pushl %ebp
## update %ebp to %esp, save %ebp as frame pointer
movl %esp,%ebp
## swap_add function need to get %ebx to save tmp things,so backup it to stack
pushl %ebx

# get xp
movl 8(%ebp),%edx
# get yp
movl 12(%ebp),%ecx

movl (%edx),%ebx
movl (%ecx),%eax
movl %eax,(%edx)
movl %ebx,(%ecx)
# return value
addl %ebx,%eax

#teardown jobs
## restore %ebx
popl %ebx
## restore %ebp
popl %ebp
## return value=x+y
ret

执行到 方法体中时的 Stack Frame 结构如下所示:

swap_add

(全文完)

参考资料

1.《深入理解计算机系统(第二版)》