假设我有一个函数,其中根据某些运行时条件创建了一个昂贵的自动对象或创建了一个廉价的自动对象:
void foo() {
if (runtimeCondition) {
int x = 0;
} else {
SuperLargeObject y;
}
}
当编译器为这个函数的堆栈帧分配内存时,它会不会只分配足够的内存来存储SuperLargeObject
,如果导致int
的条件成立,额外的内存就会被闲置?还是会以其他方式分配内存?
这取决于您的编译器和优化设置。在未优化的构建中,大多数C编译器可能会为这两个对象分配堆栈内存,并根据采用的分支使用其中一个。在优化的构建中,事情变得更有趣:
如果两个对象(int
和SuperLargeObject
都没有使用,并且编译器可以证明构造SuperLargeObject
没有副作用,则两个分配都将被省略。
如果对象转义了函数,即它们的地址被传递给另一个函数,编译器必须为它们提供内存。但是由于它们的生命周期不重叠,它们可以存储在重叠的内存区域中。这取决于编译器是否真的发生了。
正如你在这里看到的,不同的编译器为这两个函数生成不同的汇编:(修改了OP和参考的示例,都是为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-64ABI要求堆栈指针与16字节对齐,并且8字节由返回地址的call
指令推送(感谢@PeterCordes在另一篇文章中向我解释了这一点)。
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
的大小。
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
指令。
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
以及其他最常见的小对象,如果它们的地址不转义函数。
您应该假设函数中任何位置声明的所有内存在进入函数时立即分配。体面的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虚拟机中使用了上面的最后一种方法。VM为各种指令分派函数。有些函数的堆栈存储更多,有些更少。我已经声明了许多这样的函数notinline
,这对观察到的堆栈帧大小产生了巨大的影响。
当然,将代码移动到函数中可能会很不方便;如果代码需要访问foo
的一些局部变量,您可能必须传递所有参数,甚至一些额外的参数。
如果您担心函数的堆栈使用情况,gcc
对此提供了有用的诊断方法。您可以使用-fstack-用法
来获取有关函数堆栈使用情况的信息,和/或-Wstack-用法=N
警告,如果某些函数的堆栈使用超过N
字节,则会发出警告。
真实故事:-fstack-用法
帮助我发现一个在GNU C库中使用crypt_r
函数的函数有一个超过128 KB的堆栈帧。这就是结构crypt_data
crypt_r
的上下文缓冲区有多大!我将代码切换到malloc
和释放
结构。