编译器如何为C++中有条件声明的自动变量分配内存?

12
说我有一个函数,根据某些运行时条件创建一个昂贵的自动对象或一个廉价的自动对象:
void foo() {
   if (runtimeCondition) {
       int x = 0;
   } else {
       SuperLargeObject y;
   }
}

当编译器为此函数分配堆栈帧的内存时,它是否只会分配足够存储SuperLargeObject的内存,如果导致int的条件为真,则多余的内存将不会被使用?还是会以其他方式分配内存?


10
这取决于您的编译器,可能还取决于优化设置。在调试版本中,大多数 C++ 编译器将为对象分配堆栈内存,并根据采用哪个分支使用其中一个。在优化版本中,情况变得更加复杂。 - chrysante
4
我认为在实践中,他们会为最大的分支分配内存。 - user253751
3
栈被认为是一种有限的资源,那么为什么要首先将“超大对象”放在其中呢? - Richard Critten
2
@ThomasMatthews 当它遇到它们时,它只会构造它们,但它可以在进入时调整sp寄存器,以便为最大空间需求留出足够的空间。 - Richard Critten
2
@RichardCritten 我的技巧是添加一个 extern void consume(void*); 并使用任何你不想被优化掉的东西的地址来调用它。https://godbolt.org/z/9Gr5549vq - Raymond Chen
显示剩余4条评论
2个回答

12

这取决于您的编译器和优化设置。在未经优化的构建中,大多数C++编译器可能会为两个对象分配堆栈内存,并根据采取哪个分支来使用其中一个。在经过优化的构建中,情况变得更加有趣:

如果两个对象(intSuperLargeObject)都没有被使用,并且编译器可以证明构造SuperLargeObject没有副作用,则两个分配都将被省略。

如果对象逃逸函数,即它们的地址被传递到另一个函数中,编译器必须为它们提供内存。但由于它们的生命周期不重叠,它们可以存储在重叠的内存区域中。实际上是否发生这种情况取决于编译器。

正如您在这里看到的那样, 不同的编译器为这两个函数生成不同的汇编代码:(修改自原始帖子和参考文献,均编译为x86-64)

void escape(void const*);

struct SuperLargeObject {
    char data[104];
};

void f(bool cond) {
    if (cond) {
        int x;
        escape(&x);
    }
    else {
        SuperLargeObject y;
        escape(&y);
    }
}

void g() {
    SuperLargeObject y;
    escape(&y);
}

请注意,所有堆栈分配都是8的奇数倍,因为x86-64 ABI规定堆栈指针必须是16字节对齐的,并且call指令会为返回地址推送8个字节(感谢@PeterCordes在另一篇文章中向我解释了这一点)。

ICC

f(bool):
        sub       rsp, 120
        test      dil, dil
        lea       rax, QWORD PTR [104+rsp]
        lea       rdx, QWORD PTR [rsp]
        cmovne    rdx, rax
        mov       rdi, rdx
        call      escape(void const*)
        add       rsp, 120
        ret
g():
        sub       rsp, 104
        lea       rdi, QWORD PTR [rsp]
        call      escape(void const*)
        add       rsp, 104
        ret

ICC 似乎分配了足够的内存以存储两个对象,然后根据运行时条件(使用 cmov )在两个不重叠的区域之间进行选择,并将所选指针传递给逃逸函数。

在参考函数 g 中,它仅分配了104个字节,正好是 SuperBigObject 的大小。

GCC

f(bool):
        sub     rsp, 120
        mov     rdi, rsp
        call    escape(void const*)
        add     rsp, 120
        ret
g():
        sub     rsp, 120
        mov     rdi, rsp
        call    escape(void const*)
        add     rsp, 120
        ret

GCC 也分配了 120 字节,但它将两个对象放置在同一个地址上,因此不会发出 cmov 指令。

Clang

f(bool):
        sub     rsp, 104
        test    edi, edi
        mov     rdi, rsp
        call    escape(void const*)@PLT
        add     rsp, 104
        ret
g():
        sub     rsp, 104
        mov     rdi, rsp
        call    escape(void const*)@PLT
        add     rsp, 104
        ret

Clang 还将两个分配合并,并将分配大小减少到必要的 104 字节。

不幸的是,我不理解为什么它在函数 f 中测试条件。


你还应该注意,当编译器可以将变量中的一个或两个放置在寄存器中时,即使它们在整个函数中被使用和重新赋值,也不会分配任何内存。对于int和long以及其他小对象来说,这通常是最常见的情况,只要它们的地址没有逃逸出函数。

4
有趣的是,GCC合并了分配,仍然分配了120个字节,但没有使用额外的空间来使数组在16字节边界上对齐。如果有人能解释这种行为,我会很高兴。 - chrysante

4

在函数中声明的所有内存都可以假定在函数进入时一次性分配完毕。良好的 C 编译器会合并具有非重叠生命周期的对象的存储。

如果您在某些特定代码路径中有一个大型对象,而希望避免在不采用该路径时分配它,则必须执行以下操作之一:

  • 在该代码路径中动态分配并释放它。

  • 使用 C99 变长数组进行分配。

  • 使用 alloca 函数/运算符进行分配,这是许多编译器中存在的传统扩展。

  • 将代码移动到单独的辅助函数中。但是,如果该函数被内联,则不会有任何区别!由内联函数产生的栈分配会合并为一个大栈帧,就像代码是内联编写的一样。请确保使用特定于编译器的魔术代码声明此函数不被内联。

#ifdef __GNUC__
#define NOINLINE __attribute__((noinline))
#else
#error port me
#endif

NOINLINE void foo_LargeObjectCase()
{
   SuperLargeObject y;
}

void foo() {
   if (runtimeCondition) {
       int x = 0;
   } else {
       foo_SuperLargeObjectCase();
   }
}

我在TXR Lisp虚拟机中使用了上述最后一种方法。该虚拟机为各种指令分派函数。其中一些函数具有更多的堆栈存储,而另一些则较少。我已将其中许多函数声明为notinline,这对观察到的堆栈帧大小产生了巨大影响。

当然,将代码移入函数可能会不方便;您可能需要传递所有参数,甚至还需要传递一些额外的参数,如果代码需要访问foo的某些局部变量。

如果您关心函数的堆栈使用情况,gcc提供了有用的诊断工具。您可以使用-fstack-usage获取有关函数堆栈使用情况的信息,或者使用-Wstack-usage=N警告,如果某个函数的堆栈使用超过N字节,则会发出此警告。

真实故事:-fstack-usage帮助我发现,使用GNU C库中的crypt_r函数的一个函数具有超过128千字节的堆栈帧。这就是struct crypt_data上下文缓冲区的大小!我将该代码切换到mallocfree结构。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接