# 3.10.5 支持变长栈帧

到目前为止,我们已经检查了各种函数的机器级代码,但它们有一个共同点,即编译器能够预先确定需要为栈帧分配多少空间。但是有些函数,需要的局部存储是变长的。例如,当函数调用 alloca 时就会发生这种情况。alloca 是一个标准库函数,可以在栈上分配任意字节数量的存储。当代码声明一个局部变长数组时,也会发生这种情况。

虽然本节介绍的内容实际上是如何实现过程的一部分,但我们还是把它推迟到现在才讲,因为它需要理解数组和对齐。

图 3-43a 的代码给出了一个包含变长数组的例子。该函数声明了 n 个指针的局部数组 p,这里 n 由第一个参数给出。这要求在栈上分配 8n 个字节,这里 n 的值每次调用该函数时都会不同。因此编译器无法确定要给该函数的栈帧分配多少空间。此外,该程序还产生一个对局部变量 i 的地址引用,因此该变量必须存储在栈中。在执行过程中,程序必须能够访问局部变量 i 和数组 p 中的元素。返回时,该函数必须释放这个栈帧,并将栈指针设置为存储返回地址的位置。

long vframe(long n, long idx, long *q) {
    long i;
    long *p[n];
    p[0] = &i;
    for (i = 1; i < n; i++)
        p[i] = q;
    return *p[idx];
}

a)C 代码

long vframe(long n, long idx, long *q)
n in %rdi, idx in %rsi, q in %rdx
Only portions of code shown

1  vframe:
2      pushq %rbp                   Save old %rbp
3      movq %rsp, %rbp              Set frame pointer
4      subq $16, %rsp               Allocate space for i (%rsp = s₁)
5      leaq 22(,%rdi,8), %rax
6      andq $-16, %rax
7      subq %rax, %rsp              Allocate space for array p (%rsp = s₂)
8      leaq 7(%rsp), %rax
9      shrq $3, %rax
10     leaq 0(,%rax,8), %r8         Set %r8 to &amp;p[0]
11     movq %r8, %rcx               Set %rcx to &amp;p[0] (%rcx = p)
       ...
       Code for initialization loop
       i in %rax and on stack, n in %rdi, p in %rcx, q in %rdx
12 .L3:                             loop:
13     movq %rdx, (%rcx,%rax,8)     Set p[i] to q
14     addq $1, %rax                Increment i
15     movq %rax, -8(%rbp)          Store on stack
16 .L2:
17     movq -8(%rbp), %rax          Retrieve i from stack
18     cmpq %rdi, %rax              Compare i:n
19     jl .L3                       If &lt;, goto loop
       ...
       Code for function exit
20     leave                        Restore %rbp and %rsp
21     ret                          Return

b)生成的部分汇编代码

图 3-43 需要使用帧指针的函数。变长数组意味着在编译时无法确定栈帧的大小

为了管理变长栈帧,x86-64 代码使用寄存器 %rbp 作为帧指针(frame pointer)(有时称为基指针(base pointer),这也是 %rbp 中 bp 两个字母的由来)。当使用帧指针时,栈帧的组织结构与图 3-44 中函数 vframe 的情况一样。可以看到代码必须把 %rbp 之前的值保存到栈中,因为它是一个被调用者保存寄存器。然后在函数的整个执行过程中,都使得 %rbp 指向那个时刻栈的位置,然后用固定长度的局部变量(例如 i)相对于 %rbp 的偏移量来引用它们。

图 3-44 函数 vframe 的栈帧结构

图 3-43b 是 GCC 为函数 vframe 生成的部分代码。在函数的开始,代码建立栈帧,并为数组 p 分配空间。首先把 %rbp 的当前值压入栈中,将 %rbp 设置为指向当前的栈位置(第 2~3 行)。然后,在栈上分配 16 个字节,其中前 8 个字节用于存储局部变量 i,而后 8 个字节是未被使用的。接着,为数组 p 分配空间(第 5~11 行)。练习题 3.49 探讨了分配多少空间以及将 p 放在这段空间的什么位置。当程序到第 11 行的时候,已经(1)在栈上分配了 8n 字节,并(2)在已分配的区域内放置好数组 p,至少有 8n 字节可供其使用。

初始化循环的代码展示了如何引用局部变量 ip 的例子。第 13 行表明数组元素 p[i] 被设置为 q。该指令用寄存器 %rcx 中的值作为 p 的起始地址。我们可以看到修改局部变量 i(第 15 行)和读局部变量(第 17 行)的例子。i 的地址是引用 -8(%rbp),也就是相对于帧指针偏移量为 -8 的地方。

在函数的结尾,leave 指令将帧指针恢复到它之前的值(第 20 行)。这条指令不需要参数,等价于执行下面两条指令:

movq %rbp, %rsp    Set stack pointer to beginning of frame
popq %rbp          Restore saved %rbp and set stack ptr
                   to end of caller's frame

也就是,首先把栈指针设置为保存 %rbp 值的位置,然后把该值从栈中弹出到 %rbp,这个指令组合具有释放整个栈帧的效果。

在较早版本的 x86 代码中,每个函数调用都使用了帧指针。而现在,只在栈帧长可变的情况下才使用,就像函数 vframe 的情况一样。历史上,大多数编译器在生成 IA32 代码时会使用帧指针。最近的 GCC 版本放弃了这个惯例。可以看到把使用帧指针的代码和不使用帧指针的代码混在一起是可以的,只要所有的函数都把 %rbp 当做被调用者保存寄存器来处理即可。

练习题 3.49 在这道题中,我们要探究图 3-43b 第 5~11 行代码背后的逻辑,它分配了变长大小的数组 p。正如代码的注释表明的,s₁ 表示执行第 4 行的 subq 指令之后栈指针的地址。这条指令为局部变量 i 分配空间。s₂ 表示执行第 7 行的 subq 指令之后栈指针的值。这条指令为局部数组 p 分配存储。最后,p 表示第 10~11 行的指令赋给寄存器 %r8%rcx 的值。这两个寄存器都用来引用数组 p

图 3-44 的右边画出了 s₁、s₂ 和 p 指示的位置。图中还画出了 s₂ 和 p 的值之间可能有一个偏移量为 e₂ 字节的位置,该空间是未被使用的。数组 p 的结尾和 s₁ 指示的位置之间还可能有一个偏移量为 e₁ 字节的地方。

A. 用数学语言解释第 5~7 行中计算 s₂ 的逻辑。提示:想想 -16 的位级表示以及它在第 6 行 andq 指令中的作用。

B. 用数学语言解释第 8~10 行中计算 p 的逻辑。提示:可以参考 2.3.7 节中有关除以 2 的幂的讨论。

C. 对于下面 n 和 s₁ 的值,跟踪代码的执行,确定 s₂、p、e₁ 和 e₂ 的结果值。

n s₁ s₂ p e₁ e₂
5 2065
6 2064

D. 这段代码为 s₂ 和 p 的值提供了什么样的对齐属性?