alloca() 如何与其他堆栈分配交互?

Pet*_* Wu 0 c x86 alloca stack-frame

让我们从一个简单的堆栈分配示例开始:

void f() {
    int a, b;
    ...
}
Run Code Online (Sandbox Code Playgroud)

如果我理解正确的话。a那么和的地址b与栈基址(即寄存器 )有固定的偏移量ebp。如果我们以后需要它们,这就是编译器找到它们的方式。

但请考虑以下代码。

void f(int n) {
    int a;
    alloca(n);
    int b;
    ...
}
Run Code Online (Sandbox Code Playgroud)

如果编译器不做任何优化,堆栈将为a->n->b. 现在 的偏移量b取决于n。那么编译器做了什么?

模仿alloca() 在内存级别如何工作?。我尝试了以下代码:

#include <stdio.h>
#include <alloca.h>

void foo(int n)
{
    int a;
    int *b = alloca(n * sizeof(int));
    int c;
    printf("&a=%p, b=%p, &c=%p\n", (void *)&a, (void *)b, (void *)&c);
}

int main()
{
    foo(5);
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

输出是&a=0x7fffbab59d68, b=0x7fffbab59d30, &c=0x7fffbab59d6c. 这次的地址ac看起来很相邻。编译器是否做了一些重新排序?如果我们不允许编译器重新排序,编译器将如何找到 的地址c

------------一些更新------------

好吧,只要你相信编译器真的很重要,那就试试 x86-64 gcc 13.2,我稍微修改了代码。

#include <alloca.h>
void alloca_test(int n) {
    int a;
    int* ptr = (int *) alloca(n);
    int b;
    a++;
    b++;
    ptr[0]++;
}
Run Code Online (Sandbox Code Playgroud)

这是程序集:

alloca_test(int):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48
        mov     DWORD PTR [rbp-36], edi
        mov     DWORD PTR [rbp-4], 0
        mov     eax, DWORD PTR [rbp-36]
        cdqe
        lea     rdx, [rax+8]
        mov     eax, 16
        sub     rax, 1
        add     rax, rdx
        mov     ecx, 16
        mov     edx, 0
        div     rcx
        imul    rax, rax, 16
        sub     rsp, rax
        mov     rax, rsp
        add     rax, 15
        shr     rax, 4
        sal     rax, 4
        mov     QWORD PTR [rbp-16], rax
        mov     DWORD PTR [rbp-20], 0
        add     DWORD PTR [rbp-4], 1    <--a++
        add     DWORD PTR [rbp-20], 1   <--b++
        mov     rax, QWORD PTR [rbp-16]
        mov     eax, DWORD PTR [rax]
        lea     edx, [rax+1]
        mov     rax, QWORD PTR [rbp-16]
        mov     DWORD PTR [rax], edx
        nop
        leave
        ret
Run Code Online (Sandbox Code Playgroud)

这里b有地址[rbp-20],不会改变n

Nat*_*dge 6

实际上,对于一个真正非优化的编译器,情况恰恰相反。

让我们想象一个简单的 20 世纪 70 年代技术的编译器,因为 C 语言最初被设计为由这样的编译器实现。我们alloca暂时忽略这个电话。当编译器解析函数时,每次遇到局部变量 [*] 的定义时,它都会为其分配一个相对于帧指针的堆栈槽ebp:so aat [ebp-4]bat[ebp-8]等等,并使用它来寻址变量。当它完成解析时,它已经看到了所有局部变量并且可以计算所需的堆栈总量,因此在调整堆栈指针的序言代码中插入适当的常量(例如sub esp, 8)。即使是一个非常简单的逐行发出代码的一次性编译器也可以做到这一点,通过发出类似于sub esp, 0序言中的内容,然后稍后进行反向修补以0用正确的值替换立即数。

现在,至于alloca,它并不是原始 C 语言的一部分。相反,它基本上是某人发现的一个“酷黑客”,可以以与上述过程完全正交的方式实现。我们可以用 x86 术语来描述这个想法。(最初的实现是针对 PDP-11 或 VAX 或类似的东西,但想法是相同的。)由于所有局部变量都是相对于 进行寻址的,因此在执行期间堆栈指针是否进一步递减ebp并不重要。esp函数的执行;编译器从不引用esp. 并且函数尾声中的堆栈清理通常是作为而mov esp, ebp不是实现的add esp, 8,因此它也将继续正常工作。

所以事实上编译器甚至不必知道它对alloca(n)堆栈做了什么特殊的事情。它可以是一个扩展为内联汇编的宏sub esp, [ebp+8] / mov eax, esp,例如 ,它对于编译器来说也是不透明的,除了填充寻址模式n(在本例中为第一个堆栈参数,假设[ebp+8]传统使用 EBP 作为帧指针) 。它在为局部变量分配堆栈槽的过程中根本不起任何作用,因为这个假设的编译器只会访问相对于帧指针 (EBP) 的局部变量,而不是 ESP。

事实上,如果您愿意做更多的堆栈杂耍,alloca甚至可以是外部库函数,编译器就像任何其他函数调用一样对待。这就是为什么alloca有函数调用的语法,而不是更深入地集成到语言中——最初的实现只是一个函数调用。

因此,通过此实现,我们将在堆栈帧的顶部拥有aand (在函数序言中同时分配),并在其下方拥有 ed 缓冲区(在调用点分配)。如果我们有更多的调用,那么它们将按照执行的顺序逐渐返回较低的地址,每次都从堆栈指针中减去。ballocaallocaalloca

如果alloca在推送参数以调用另一个函数的过程中完成编译器不理解的操作,则可能会破坏事情,因此bar(1, 2, alloca(n), 3)对于早期的 hacky 实现来说可能是不安全的。


现在,正如您可能想象的那样,一旦编译器变得更聪明并且希望能够在整个函数执行过程中实际控制堆栈指针,这种“酷黑客”就不再那么有效了。那么,在编译器背后更改堆栈指针就会变得灾难性的,因此alloca必须给予特殊的编译器支持,这给编译器编写者带来了很多痛苦。当 C99 引入可变长度数组时,这个想法alloca最终被完全采用到语言中,但许多观察家认为这个决定是一个错误。

因此,对于现代编译器的作用,不一定有任何单一的答案。它确切地知道需要提供什么语义alloca,并且可以自行决定如何实现它。它不一定限于ebp对所有局部变量使用相对寻址;它可以使用esp-relative,或者以其他方式计算地址,或者只是将变量优化到寄存器中,以便它们根本不占用堆栈槽。所以我们无法轻易预测堆栈布局会是什么样子。


[*]顺便注意,在分配堆栈槽和计算堆栈使用情况时,函数的块结构通常会被展平,并且不会考虑变量的范围。所以即使在像这样的代码中

int a;
if (...) {
    int b;
    ...
}
while (...) {
    int c;
    ...
}
Run Code Online (Sandbox Code Playgroud)

这里a,b,c每个人都会得到自己的堆栈槽,分别是[ebp-4][ebp-8][ebp-12]。序言中堆栈指针将调整12。特别是,编译器不会发出重新调整每个块的开头和结尾处的堆栈指针的指令{ }b然而,它可以通过注意到和的范围c不重叠来进行优化,因此它可以分配[ebp-8]给它们两个,并且仅使用 8 个字节的堆栈而不是 12 个字节。