文章目录
- 3.7 过程
- 3.7.1 栈帧
- 3.7.2 转移控制
- 3.7.3 寄存器使用惯例
- 3.7.4 过程示例
- 3.7.5 递归过程
3.7 过程
一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。大多数机器,包括 IA32,只提供简单的转移控制到过程和成过程中转移出控制的指令。数据传递、局部变量的分配和释放是通过操纵程序栈来实现的。
3.7.1 栈帧
IA32 程序用程序栈来支持过程调用。栈用来传递过程参数、存储返回信息、保存寄存器以供以后恢复之用,以及用于本地存储。
为单个过程分配的那部分栈称为栈帧(stack frame)。
栈帧的通用结构:
栈帧的最顶端是以两个指针定界的,寄存器 %ebp 作为 帧指针,而寄存器 %esp 作为 栈指针。当程序执行时,栈指针是可以移动的,因此大多数信息的访问都是相对于帧指针的。
假设过程 P(调用者)调用过程 Q(被调用者)。Q的参数放在 P 的栈帧中。另外,当 P 调用 Q 时,P 中的返回地址被压入栈中,形成 P 的栈帧的末尾,返回地址就是当程序从 Q 返回时应该继续执行的地方。Q 的栈帧从保存的帧指针的值(如 %ebp)开始,后面是保存的其他寄存器的值。
过程 Q 也用栈来保存其他不能存放在寄存器中的局部变量。这样做是因为:
- 寄存器不够存放所有的变量。
- 有些局部变量是数组或结构,因此必须通过数组或结构引用来访问。
- 要对一个局部变量使用地址操作符 “&”,因此我们必须能够为它产生一个地址。
最后,Q 会用栈帧来存放它调用其他过程的参数。
如前文所讲,栈向低地址方向增长,而栈指针 %esp 指向栈顶元素。 可以通过 pushl
和 popl
指令将数据存入栈中和从栈中取出。可以通过将栈指针的值减小适当的值来分配没有指定初始值的数据的空间。类似地,可以通过增加栈指针来释放空间。
3.7.2 转移控制
下表是支持过程调用和返回的指令:
指令 | 描述 |
---|---|
call Label | 过程调用 |
call *Operand | 过程调用 |
leave | 为返回准备栈 |
ret | 从过程调用中返回 |
call
指令有一个目标,指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是*
后面跟一个操作数指示符,其语法与movl
指令的操作数的语法相同。call
指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是紧跟在程序中 call 后面的那条指令的地址,这样当被调用过程返回时,执行会从此继续。ret
指令从栈中弹出地址,并跳转到那个位置。要正确使用这条指令,就要使栈准备好,栈指针要指向前面 call 指令存储返回地址的位置。leave
指令可以用来使栈做好返回的准备。它等价于下面的代码序列:
另外,这种准备工作也可以通过直接使用传送和弹出操作来完成。
寄存器 %eax 可以用来返回值,如果函数要返回整数或指针的话。
练习:
下面的代码片段常常出现在库函数的编译版本中:
call next
next:
popl %eax
Question:
- 寄存器 %eax 设置成了什么值?
- 解释为什么这个调用没有匹配的 ret 指令。
- 这段代码完成了什么功能?
Answer:
这是一个汇编代码的习惯用法。刚开始,它看起来很奇怪——call 指令没有与之匹配的 ret。然后就意识到它根本就不是一个真正的过程调用。
- %eax 被设置成
popl
指令的地址。 - 这不是一个真正的子过程调用,因为控制是按照与指令相同的顺序进行的,而返回值是从栈中弹出的。
- 这是 IA32 中将程序计数器的值放到整数寄存器中的唯一方法。
3.7.3 寄存器使用惯例
程序寄存器组是唯一一个被所有过程共享的资源。 虽然在给定时刻只能有一个过程是活动的,但是必须保证当一个过程(调用者)调用另一个(被调用者)时,被调用者不会覆盖某个调用者稍后会使用的寄存器的值。为此,IA32 采用了一组统一的寄存器使用惯例,所有的过程都必须遵守,包括程序库中的过程。
根据惯例,寄存器 %eax、%edx 和 %ecx 被划分为调用者保存(caller save)寄存器。当过程 P 调用 Q 时,Q 可以覆盖这些寄存器,而不会破坏任何 P 所需要的数据。
另外,寄存器 %ebx、%esi 和 %edi 被划分为 被调用者保存(callee save)寄存器。这意味着 Q 必须在覆盖它们之前,将这些寄存器的值保存到栈中,并在返回前恢复它们,因为 P (或某个更高层次的过程)可能会在今后的计算中需要这些值。
此外,根据这里描述的惯例,必须保持寄存器 %ebp 和 %esp。
为什么叫做 “被调用者保存” 和 “调用者保存”?
考虑如下场景:
int P()
{
int x = f(); /* Some computation */
Q();
return x;
}
过程 P 希望它计算出来的 x x x 的值在调用了 Q 之后仍然有效。
如果 x x x 放在一个调用者保存寄存器中,而 P (调用者)必须在调用 Q 之前保存这个值,并在 Q 返回后恢复该值。
如果 x x x 在一个被调用者保存寄存器中,Q(被调用者)想使用这个寄存器,那么 Q 在使用这个寄存器之前,必须保存这个值,并在返回前恢复它。
在这两种情况中,保存就是将寄存器值压入栈中,而恢复是指从栈中弹出到寄存器中。
示例:
int P()
{
int y = x * x;
int z = Q(y);
return y + z;
}
过程 P 在调用 Q 之前计算 y y y,但是它必须保证 y y y 的值在 Q 返回后是可用的。有两种方式可以做到:
- 它可以在调用 Q 之前,将 y y y 的值存放在自己的栈帧中;当 Q 返回时,它可以从栈中取出 y y y 的值。
- 它可以将 y y y 的值保存在被调用者保存寄存器中。如果 Q,或其他任何 Q 调用的程序,想使用这个寄存器,它必须将这个寄存器的值保存在栈帧中,并在返回前恢复该值。因此,当 Q 返回到 P 时, y y y 的值会在被调用者保存寄存器中,或者是因为寄存器根本就没有改变,或者是因为它被保存并恢复了。
最常见的是,GCC 使用后一种方法,因为它会尽量减少写和读栈的次数。
3.7.4 过程示例
示例,考虑如下定义的C过程。
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;
}
下图是这两个过程的栈帧:
swap_add
从 caller 的栈帧中取出它的参数。这些参数的位置的访问都是相对于寄存器 %ebp 中的帧指针的。帧左边的数字表示相对于帧指针的地址偏移。
caller 的栈帧包括局部变量 arg1
和 arg2
的存储,其位置相对于帧指针是-8 和 -4。这些变量必须存在栈中,因为必须为它产生地址。
如下这段来自 caller 编译过的汇编代码显示出它是如何调用 swap_add 的。
注意,这段代码计算的是局部变量 arg2
和 arg1
的地址(用 leal
指令),并将它们压入栈中。然后再调用 swap_add
。
swap_add
编译过的代码有三个部分:
- “建立”部分,初始化栈帧;
- “主体”部分,执行过程的实际计算;
- “结尾”部分,恢复栈的状态和过程返回。
swap_add 建立部分
如下是 swap_add 的建立代码。回想一下,call 指令已经将返回地址压入栈中。
过程 swap_add 需要用寄存器 %ebx 作为临时存储。因为这是一个被调用保存的寄存器,它会将旧值作为栈帧建立的一部分压入栈中。
swap_add 的主体部分
如下是 swap_add 的主体代码:
这段代码从 caller 的栈帧中取出它的参数。因为帧指针已经移动了,这些参数的位置已经从相对于 %ebp 的旧值的位置-12 和 -6 移到了相对于 %ebp 的新值的位置 +12 和 +8。注意,变量
x
x
x 和
y
y
y 的和是存放在寄存器 %eax 中作为返回值传递的。
swap_add的结尾部分
如下是 swap_add 的结尾代码:
这段代码就是恢复三个寄存器 %ebx、%esp 和 %ebp 的值,然后执行 ret 指令。注意,可以用一个条 leave 指令代替指令 13 和 14。不同版本的 GCC 对此可能有不同的习惯。
caller 中跟在 swap_add 指令后的指令
下面的 caller 中的代码紧跟在调用 swap_add 的指令后面:
从 swap_add 返回时,过程 caller 会从这条指令开始继续执行。注意,这条指令将返回值从 %eax 拷贝到另一个寄存器。
3.7.5 递归过程
上一节中描述的栈和寄存器惯例使得过程能够递归地调用它们自身。因为每个调用在栈中都有它自己的私有空间,多个未完成调用的局部变量不会相互影响。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储(storage),当返回时释放存储。
下面是递归的 Fibonacci 函数的 C 代码。(效率很低)
int fib_rec(int n)
{
int prev_val, val;
if (n <= 2)
return 1;
prev_val = fib_rec(n - 2);
val = fib_rec(n - 1);
return prev_val + val;
}
完整的汇编代码如下:
- 建立代码(第 2 ~ 6 行)创建一个栈帧,其中包含 %ebp 的旧值、未使用的 16 个字节、保存的被调用者保存寄存器 %esi 和 %ebx 的值,如下图左边所示。然后它用寄存器 %ebx 来保存过程参数 n n n (第 7 行)。一旦满足中止条件,代码会跳转到第 22 行,在此将返回值设为1。
- 对于不满足中止条件的情况,指令 10 ~ 12 会进行第一次递归调用。这包括在栈中分配不会被使用的 12 个字节,然后将计算出来的值 n − 2 n-2 n−2 压入栈中。此时,栈帧如下图右边所示。然后,它会进行递归调用,引起一连串的调用、分配栈帧、对局部存储进行操作,等等。每次调用返回时,它都会释放栈空间,恢复所有被修改过的被调用者保存寄存器。因此,当我们返回到当前调用时(第14行),我们可以假设寄存器 %eax 包含着递归调用返回的值,而寄存器 %ebx 包含函数参数 n n n 的值。返回值(C代码中的局部变量 prev_val)存放在寄存器 %esi 中(第14行)。通过使用被调用者保存寄存器,能保证在第二次调用调用后这个值仍然是可用的。
- 指令 15 ~ 17 进行第二次递归调用。它会再次分配不会被使用的 12 个字节,并将值 n − 1 n-1 n−1 压入栈中。在这个调用之后(第18行),计算出来的结果会放在寄存器 %eax 中,而假设前一次调用的结果放在寄存器 %esi 中。两者相加得到返回值(第19行)。
- 完成代码恢复寄存器和释放栈帧。它首先将栈帧设置为保存的 %ebx 值的位置。注意,通过计算相对于 %ebp 值的栈的位置,无论是否满足中止条件,计算都会是正确的。