为啥 GCC 会在堆栈上推送一个额外的返回地址?

Posted

技术标签:

【中文标题】为啥 GCC 会在堆栈上推送一个额外的返回地址?【英文标题】:Why is GCC pushing an extra return address on the stack?为什么 GCC 会在堆栈上推送一个额外的返回地址? 【发布时间】:2016-12-11 09:21:50 【问题描述】:

我目前正在学习汇编的基础知识,在查看 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.

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

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

然后程序做它应该做的并开始返回的过程:

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

那么现在堆栈上的下一件事是返回地址,esp 和 ebp 寄存器应该返回到它们需要返回的位置,对吗?

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

这工作得很好,但提出了一个问题:为什么在步骤 3 中将返回地址放入堆栈,因为它在实际从函数返回之前将堆栈返回到最后的原始位置?

【问题讨论】:

您应该启用优化并使用gcc -m32 -O -Wall -S -fverbose-asm test.c 进行编译,然后查看生成的test.s 内部 这是它生成的 (pastebin.com/raw/1ZdhPLf6)。据我所知,它仍然有额外的退货地址。 阅读更多关于x86 calling conventions和ABI的信息。他们可能会决定通话的方式。 这可能只是为了让调试器可以将堆栈追溯到main @PeterCordes 您不能通过跟踪保存的 EBP 值链可靠地展开堆栈,因为它不是 ABI 的一部分,因此它只是有用的堆栈跟踪。因此,我不认为这样做是出于 ABI 的原因,只是为了调试。 【参考方案1】:

更新: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 可能会将返回地址复制到它保存 ebp 的上方以制作堆栈帧(是的,我同意这就是 gcc 正在做的事情)。在这个函数中它看起来没有必要,clang 不会做这样的事情。

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


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

对于 ABI 合规性,gcc 的执行方式没有任何要求,因为 clang 执行的操作要简单得多。

我添加了一个对齐的变量(volatile 作为一种简单的方法来强制编译器在堆栈上实际为它保留对齐的空间,而不是优化它)。我把你的代码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 放在red-zone 中,因此-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-relative 寻址,如果它想要对齐的局部变量。

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

在对齐之后但在推送ebp之前额外复制的返回地址意味着返回地址被复制到相对于保存的ebp值的预期位置(以及将调用子函数时位于ebp 中)。因此,这确实有助于代码通过跟踪堆栈帧的链接列表来展开堆栈,并查看返回地址以找出所涉及的函数。

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


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

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

【讨论】:

按照我现在看到的 GCC 方式对齐堆栈的唯一优点是它可以消除帧指针。使用正常的堆栈对齐代码,它被视为强制使用帧指针的可变长度堆栈分配。使用 GCC 的新代码(4.8 没有这样做),对齐基本上是在函数的堆栈框架之外完成的。由于 GCC 实际上并没有省略帧指针,所以我看不出这种变化的意义是什么。 感谢您的详细解答! -mpreferred-stack-boundary 将有助于消除 lea esp,[ecx-0x4] 部分。 @sudhackar:这不安全。它会使 gcc 无法保持 i386 System V ABI(几年前更改)所需的 16 字节对齐。现在 16 字节不仅仅是一个好主意,它是法律,并且如果使用未对齐的堆栈调用函数(例如,将movaps 放入堆栈而没有首先使用and esp, -16),则允许出现段错误。由于 gcc 仅在 main 中执行此操作,并且当需要过度对齐时(例如,对于 AVX2/AVX512),它仅在您实际需要对齐 + 整个程序总共需要一些额外指令的情况下才有害。 @PeterCordes 是的,但是通过这个问题,我觉得他正在尝试学习 C 如何转换为 asm。这样的人工制品只会让第一次这样做的人感到困惑。【参考方案2】:

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

通常,当代码在没有优化的情况下编译(或使用-fno-omit-frame-pointer 选项)时,编译器会创建一个堆栈帧,其中包括使用调用者保存的帧指针值返回到前一个堆栈帧的链接。通常,编译器将前一个帧指针值保存为堆栈上返回地址之后的第一个值,然后将帧指针设置为指向堆栈上的该位置。当程序中的所有函数都这样做时,帧指针寄存器就变成了一个指向堆栈帧链表的指针,可以一直追溯到程序的启动代码。每一帧的返回地址表示每一帧属于哪个函数。

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

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

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

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

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

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

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

【讨论】:

以上是关于为啥 GCC 会在堆栈上推送一个额外的返回地址?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 .NET 会在路径中已经存在的斜杠上添加一个额外的斜杠?

为啥 gcc 4.x 在调用方法时默认为 linux 上的堆栈保留 8 个字节?

为啥 GCC 以这种方式对堆栈中的整数进行排序?

为啥堆栈会增长到较低的地址? [复制]

为啥下一个 js 在我构建时会在第一次加载时加载所有页面

为啥 GCC 会产生奇怪的方式来移动堆栈指针