为啥编译器保留一点堆栈空间而不是整个数组大小?

Posted

技术标签:

【中文标题】为啥编译器保留一点堆栈空间而不是整个数组大小?【英文标题】:Why does the compiler reserve a little stack space but not the whole array size?为什么编译器保留一点堆栈空间而不是整个数组大小? 【发布时间】:2019-08-08 00:40:41 【问题描述】:

以下代码

int main() 
  int arr[120];
  return arr[0];

编译成这样:

  sub     rsp, 360
  mov     eax, DWORD PTR [rsp-480]
  add     rsp, 360
  ret

知道整数是 4 字节,数组大小为 120,数组应该占用 480 字节,但从 ESP 中只减去 360 字节...这是为什么呢?

【问题讨论】:

确定编译器从[rsp-480]读取数据吗?我无法重现它,而且它在红色区域之外,所以只能因为读取未初始化数组元素的未定义行为而发生。 相关:Why is there no "sub rsp" instruction in this function prologue and why are function parameters stored at negative rbp offsets? 是非数组问题的更好的规范副本。 【参考方案1】:

在函数使用的堆栈区域下方,有一个128-byte red zone 保留供程序使用。由于main 没有调用其他函数,因此它不需要将堆栈指针移动超过它需要的量,尽管在这种情况下并不重要。它只从rsp 中减去足够的内容,以确保数组受到红色区域的保护。

您可以通过向main添加函数调用来查看差异

int test() 
  int arr[120];
  return arr[0]+arr[119];


int main() 
  int arr[120];
  test();
  return arr[0]+arr[119];

This gives:

test:
  push rbp
  mov rbp, rsp
  sub rsp, 360
  mov edx, DWORD PTR [rbp-480]
  mov eax, DWORD PTR [rbp-4]
  add eax, edx
  leave
  ret
main:
  push rbp
  mov rbp, rsp
  sub rsp, 480
  mov eax, 0
  call test
  mov edx, DWORD PTR [rbp-480]
  mov eax, DWORD PTR [rbp-4]
  add eax, edx
  leave
  ret

您可以看到main 函数减去了 480,因为它需要数组在其堆栈空间中,但 test 不需要,因为它不调用任何函数。

数组元素的额外使用不会显着改变输出,但添加它是为了清楚表明它不是假装这些元素不存在。

【讨论】:

您可以使用内联 asm(或者可能是 volatile)在叶函数中获取实际的数组访问,而无需像您在这里所做的那样禁用优化。但是访问arr[119] 以显示顶部的位置是个好主意。使用-fno-omit-frame-pointer 作为-O0 的一部分使所有内容都与RBP 相关,但与OP 的代码不同。 任何不调用其他函数的函数可以使用这个红色区域,还是只使用一个?【参考方案2】:

您使用的是 x86-64 Linux,其中 ABI 包含一个红色区域(低于 RSP 128 字节)。 https://***.com/tags/red-zone/info.

所以数组从红色区域的底部到 gcc 保留的顶部附近。使用-mno-red-zone 编译以查看不同的代码生成。

另外,您的编译器使用的是 RSP,而不是 ESP。 ESP 是 RSP 的低 32 位,x86-64 通常在低 32 位之外有 RSP,所以如果你将 RSP 截断为 32 位,它会崩溃。


在Godbolt compiler explorer 上,我从gcc -O3 得到这个(使用 gcc 6.3、7.3 和 8.1):

main:
    sub     rsp, 368
    mov     eax, DWORD PTR [rsp-120]   # -128, not -480 which would be outside the red-zone
    add     rsp, 368
    ret

您是否伪造了您的 asm 输出,或者其他版本的 gcc 或其他编译器是否真的从红色区域之外加载了这种未定义的行为(读取未初始化的数组元素)? clang 只是将其编译为ret,而 ICC 只是返回 0 而不加载任何内容。 (未定义的行为是不是很有趣?)


int ext(int*);
int foo() 
  int arr[120];     // can't use the red-zone because of later non-inline function call
  ext(arr);
  return arr[0];

   # gcc.  clang and ICC are similar.
    sub     rsp, 488
    mov     rdi, rsp
    call    ext
    mov     eax, DWORD PTR [rsp]
    add     rsp, 488
    ret

但我们可以在叶函数中避免 UB,而无需让编译器优化存储/重新加载。 (我们可以只使用volatile 而不是内联asm)。

int bar() 
  int arr[120];
  asm("nop # operand was %0" :"=m" (arr[0]) );   // tell the compiler we write arr[0]
  return arr[0];


# gcc output
bar:
    sub     rsp, 368
    nop # operand was DWORD PTR [rsp-120]
    mov     eax, DWORD PTR [rsp-120]
    add     rsp, 368
    ret

请注意,编译器假定我们编写的是 arr[0],而不是 arr[1..119] 中的任何一个。

但无论如何,gcc/clang/ICC 都将数组的底部放在了红色区域中。请参阅 Godbolt 链接。

这通常是一件好事:更多的数组位于 RSP 的 disp8 范围内,因此对 arr[0]arr[63 的引用可以使用 [rsp+disp8] 而不是 [rsp+disp32] 寻址模式。对于一个大数组来说不是超级有用,但作为一种在堆栈上分配局部变量的通用算法,它完全有意义。 (gcc 不会一直到 arr 的红色区域的底部,但 clang 会,使用 sub rsp, 360 而不是 368,因此数组仍然是 16 字节对齐的。(IIRC,x86-64 System V ABI 至少建议对大小 >= 16 字节的自动存储数组使用此方法。)

【讨论】:

以上是关于为啥编译器保留一点堆栈空间而不是整个数组大小?的主要内容,如果未能解决你的问题,请参考以下文章

为啥我的编译器保留的空间比函数堆栈帧所需的空间多?

为啥要为局部变量保留堆栈空间?

堆栈和堆栈大小

为啥编译器选择字符串而不是隐式字符数组的扩展方法?

某个程序的堆栈内存有多大,是不是有任何编译器标志可以设置它?

c++堆栈的各自大小,堆和栈的各自定义