为什么 GCC 在堆栈上压入一个额外的 return 地址?

Why is GCC pushing an extra return address on the stack?

我目前正在学习汇编的基础知识,在查看 GCC(6.1.1) 生成的指令时遇到了一些奇怪的事情。

这是来源:

#include <stdio.h>

int foo(int x, int y){
    return x*y;
}

int main(){
    int a = 5;
    int b = foo(a, 0xF00D);
    printf("0x%X\n", b);
    return 0;
}

用于编译的命令:gcc -m32 -g test.c -o test

在检查 GDB 中的函数时,我得到:

(gdb) set disassembly-flavor intel
(gdb) disas main
Dump of assembler code for function main:
   0x080483f7 <+0>:     lea    ecx,[esp+0x4]
   0x080483fb <+4>:     and    esp,0xfffffff0
   0x080483fe <+7>:     push   DWORD PTR [ecx-0x4]
   0x08048401 <+10>:    push   ebp
   0x08048402 <+11>:    mov    ebp,esp
   0x08048404 <+13>:    push   ecx
   0x08048405 <+14>:    sub    esp,0x14
   0x08048408 <+17>:    mov    DWORD PTR [ebp-0xc],0x5
   0x0804840f <+24>:    push   0xf00d
   0x08048414 <+29>:    push   DWORD PTR [ebp-0xc]
   0x08048417 <+32>:    call   0x80483eb <foo>
   0x0804841c <+37>:    add    esp,0x8
   0x0804841f <+40>:    mov    DWORD PTR [ebp-0x10],eax
   0x08048422 <+43>:    sub    esp,0x8
   0x08048425 <+46>:    push   DWORD PTR [ebp-0x10]
   0x08048428 <+49>:    push   0x80484d0
   0x0804842d <+54>:    call   0x80482c0 <printf@plt>
   0x08048432 <+59>:    add    esp,0x10
   0x08048435 <+62>:    mov    eax,0x0
   0x0804843a <+67>:    mov    ecx,DWORD PTR [ebp-0x4]
   0x0804843d <+70>:    leave  
   0x0804843e <+71>:    lea    esp,[ecx-0x4]
   0x08048441 <+74>:    ret    
End of assembler dump.
(gdb) disas foo
Dump of assembler code for function foo:
   0x080483eb <+0>:     push   ebp
   0x080483ec <+1>:     mov    ebp,esp
   0x080483ee <+3>:     mov    eax,DWORD PTR [ebp+0x8]
   0x080483f1 <+6>:     imul   eax,DWORD PTR [ebp+0xc]
   0x080483f5 <+10>:    pop    ebp
   0x080483f6 <+11>:    ret    
End of assembler dump.

让我困惑的部分是它试图对堆栈做什么。 据我了解,这就是它的作用:

  1. 它引用了堆栈中高 4 个字节的内存地址,据我所知,这应该是传递给 main 的变量,因为 esp 当前指向内存中的 return 地址。
  2. 出于性能原因,它将堆栈对齐到 0 边界。
  3. 它压入新的堆栈区域ecx+4,这应该转化为压入我们假设return进入堆栈的地址。
  4. 它将旧的帧指针压入堆栈并设置新的。
  5. 它将ecx(仍然指向应该是main的参数)压入堆栈。

然后程序会做它应该做的事情并开始 returning:

  1. 它通过在 ebp 上使用 -0x4 偏移来恢复 ecx,它应该访问第一个局部变量。
  2. 它执行离开指令,实际上只是将 esp 设置为 ebp,然后从堆栈中弹出 ebp

所以现在堆栈上的下一个东西是 return 地址,esp 和 ebp 寄存器应该回到它们需要的 return 对吗?

显然不是,因为它接下来要做的是用 ecx-0x4 加载 esp,因为 ecx 仍然指向传递给 main 的那个变量应该放在堆栈上 return 地址的地址。

这工作得很好,但提出了一个问题:为什么在第 3 步中将 return 地址放到堆栈上,因为它在最后 returned 堆栈到原始位置就在实际 return 从函数中调用之前?

更新:gcc8 至少针对正常用例简化了这一点(-fomit-frame-pointer,并且没有 alloca 或需要可变大小分配的 C99 VLA)。可能是由于 AVX 使用的增加导致更多函数需要 32 字节对齐的本地或数组。

此外,可能是 What's up with gcc weird stack manipulation when it wants extra stack alignment?

的副本

这个复杂的序言如果只运行几次(例如在 32 位代码中 main 的开头)就没问题,但它出现的次数越多,优化它的价值就越大。 GCC 有时仍然会过度对齐函数中的堆栈,其中所有 >16 字节对齐的对象都被优化到寄存器中,这已经是一个错过的优化,但当堆栈对齐更便宜时就不那么糟糕了。


gcc 在函数内对齐堆栈时会产生一些笨拙的代码,即使启用了优化。我有一个 可能的理论(见下文) 为什么 gcc 可能将 return 地址复制到它保存 ebp 的位置之上以制作堆栈框架(和是的,我同意这就是 gcc 正在做的事情)。在这个函数中看起来没有必要,而且 clang 不会做那样的事情。

除此之外,ecx 的废话可能只是 gcc 没有优化其对齐堆栈样板中不需要的部分。 (需要 esp 的预对齐值来引用堆栈上的 args,因此将第一个可能的 arg 的地址放入寄存器是有意义的。


您在 32 位代码中看到 with 优化(其中 gcc 使 main 不假定 16B 堆栈对齐,即使当前ABI 的版本要求在进程启动时,调用 main 的 CRT 代码要么对齐堆栈本身,要么保留内核提供的初始对齐,我忘记了)。您还会在将堆栈对齐到超过 16B 的函数中看到这一点(例如,使用 __m256 类型的函数,有时即使它们从不将它们溢出到堆栈。或者使用 C++11 [= 声明的数组的函数22=],或任何其他请求对齐的方式。)在 64 位代码中,gcc 似乎总是为此使用 r10,而不是 rcx

对于 ABI 合规性,gcc 的工作方式没有任何要求,因为 clang 做的事情要简单得多。

我添加了一个对齐变量(使用 volatile 作为强制编译器实际在堆栈上为其保留对齐 space 的简单方法,而不是将其优化掉)。我把你的代码 on the Godbolt compiler explorer,用 -O3 查看 asm。我在 gcc 4.9、5.3 和 6.1 中看到相同的行为,但在 clang 中看到不同的行为。

int main(){
    __attribute__((aligned(32))) volatile int v = 1;
    return 0;
}

Clang3.8 的 -O3 -m32 输出在功能上与其 -m64 输出相同。请注意 -O3 启用 -fomit-frame-pointer,但某些函数仍然会生成堆栈帧。

    push    ebp
    mov     ebp, esp                # make a stack frame *before* aligning, so ebp-relative addressing can only access stack args, not aligned locals.
    and     esp, -32
    sub     esp, 32                 # esp is 32B aligned with 32 or 48B above esp reserved (depending on incoming alignment)
    mov     dword ptr [esp], 1      # store v
    xor     eax, eax                # return 0
    mov     esp, ebp                # leave
    pop     ebp
    ret

gcc 的输出在 -m32-m64 之间几乎相同,但是它将 v 放在 -m64 中,所以 -m32 输出有两个额外的指令:

    # gcc 6.1 -m32 -O3 -fverbose-asm.  Most of gcc's comment lines are empty.  I guess that means it has no idea why it's emitting those insns :P
    lea     ecx, [esp+4]      #,   get a pointer to where the first arg would be
    and     esp, -32  #,          align
    xor     eax, eax  #           return 0
    push    DWORD PTR [ecx-4]       #  No clue WTF this is for; this looks batshit insane, but happens even in 64bit mode.
    push    ebp     #             make a stackframe, even though -fomit-frame-pointer is on by default and we can already restore the original esp from ecx (unlike clang)
    mov     ebp, esp  #,
    push    ecx     #             save the old esp value (even though this function doesn't clobber ecx...)
    sub     esp, 52   #,          reserve space for v  (not present with -m64)
    mov     DWORD PTR [ebp-56], 1     # v,
    add     esp, 52   #,          unreserve (not present with -m64)
    pop     ecx       #           restore ecx (even though nothing clobbered it)
    pop     ebp       #           at least it knows it can just pop instead of `leave`
    lea     esp, [ecx-4]      #,  restore pre-alignment esp
    ret

gcc 似乎想让它的栈帧(push ebp 对齐栈之后。我想这是有道理的,所以它可以引用相对于 ebp 的局部变量。否则它必须使用 esp-相对寻址,如果它想要对齐的局部变量。

我对 gcc 为什么这样做的理论:

return地址在alignment后pushebp前的额外副本,意思是return地址被复制到相对于保存的预期位置ebp(以及调用子函数时将在 ebp 中的值)。因此,这确实可能有助于通过跟踪堆栈帧的链表并查看 return-地址以找出所涉及的函数来展开堆栈的代码。

我不确定这是否与允许使用 -fomit-frame-pointer 进行堆栈展开(回溯/异常处理)的现代堆栈展开信息有关。 (它是 .eh_frame 部分中的元数据。这就是围绕 esp 的每次修改的 .cfi_* 指令的目的。)我应该看看当它必须对齐堆栈时 clang 做了什么一个非叶函数。


函数内部需要 esp 的原始值来引用堆栈上的函数参数。我认为 gcc 不知道如何优化其对齐堆栈方法中不需要的部分。 (例如 out main 不查看其参数(并声明不接受任何参数))

这种代码生成是您在需要对齐堆栈的函数中看到的典型代码;这并不奇怪,因为使用 volatile 和自动存储。

GCC 复制 return 地址以创建一个外观正常的堆栈帧,调试器可以通过链接保存的帧指针 (EBP) 值遍历该帧。虽然 GCC 生成这样的代码的部分原因是为了处理函数也具有可变长度堆栈分配的最坏情况,就像使用可变长度数组或 alloca() 时可能发生的那样。

通常,当代码在没有优化的情况下编译(或使用 -fno-omit-frame-pointer 选项)时,编译器会创建一个堆栈帧,其中包含一个 link 返回到使用保存的帧指针值的前一个堆栈帧呼叫者,召集者。通常,编译器将前一个帧指针值保存为堆栈中 return 地址之后的第一个内容,然后将帧指针设置为指向堆栈中的这个位置。当程序中的所有函数都执行此操作时,帧指针寄存器将成为指向堆栈帧的 linked 列表的指针,该列表可以一直追溯到程序的启动代码。每个帧中的return地址表示每个帧属于哪个函数。

然而,GCC 在需要对齐堆栈的函数中做的第一件事不是保存前一帧指针,而是执行对齐,在 return 地址后放置一个未知数的填充字节。因此,为了创建看起来像普通堆栈帧的内容,它会在这些填充字节之后复制 return 地址,然后保存前一个帧指针。的问题是,它并不是真的有必要像这样复制 return 地址,正如 Clang 所证明的那样,并显示在 Peter Cordes 的回答中。与 Clang 一样,GCC 可以立即保存前一个帧指针值 (EBP),然后对齐堆栈。

本质上,两个编译器所做的都是创建一个拆分堆栈帧,一个被创建的用于对齐堆栈的对齐填充一分为二。填充上方的顶部是存储语言环境变量的地方。填充下方的底部是可以找到传入参数的地方。 Clang 使用 ESP 访问顶部,使用 EBP 访问底部。 GCC 使用 EBP 访问底部,并使用堆栈中序言中保存的 ECX 值访问顶部。在这两种情况下,EBP 都指向看起来像普通堆栈帧的内容,尽管只有 GCC 的 EBP 可用于像普通帧一样访问函数的局部变量。

所以在正常情况下,Clang 的策略显然更好,不需要复制 return 地址,也不需要在堆栈上保存额外的值(ECX 值)。然而,在编译器需要对齐堆栈和分配可变大小的东西的情况下,额外的值确实需要存储在某处。由于变量分配意味着堆栈指针不再具有到局部变量的固定偏移量,因此不能再使用它来访问它们。需要在某处存储两个单独的值,一个指向拆分帧的顶部,一个指向底部。

如果您查看 Clang 在编译一个既需要对齐堆栈又具有可变长度分配的函数时生成的代码,您会发现它分配了一个有效地成为第二个帧指针的寄存器,该指针指向顶部拆分框架的一部分。 GCC 不需要这样做,因为它已经使用 EBP 指向顶部。 Clang 继续使用 EBP 指向底部,而 GCC 使用保存的 ECX 值。

虽然 Clang 在这里并不完美,因为它还分配了另一个寄存器以在超出范围时将堆栈恢复到可变长度分配之前的值。在许多情况下,这不是必需的,并且可以使用用作第二个帧指针的寄存器来代替恢复堆栈。

GCC 的策略似乎是基于希望拥有一组样板序言和结尾代码序列,可用于所有需要堆栈对齐的函数。它还避免在函数的生命周期内分配任何寄存器,尽管保存的 ECX 值可以直接从 ECX 使用,如果它还没有被破坏的话。考虑到 GCC 如何生成函数序言和结尾代码,我怀疑生成像 Clang 那样更灵活的代码会很困难。

(但是,当生成 64 位 x86 代码时,GCC 8 和更高版本确实对需要 over-align 堆栈的函数使用更简单的序言,如果它们不需要任何可变长度堆栈分配的话。这更像是 Clang 的策略。)