为什么gcc会产生额外的返回地址?

时间:2016-08-05 04:27:14

标签: assembly x86 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.

让我感到困惑的部分是它正在尝试使用堆栈。 根据我的理解,这就是它的作用:

首先它需要引用堆栈中高出4个字节的内存地址,据我所知,应该是传递给main的变量,因为esp当前指向内存中的返回地址。

其次,出于性能原因,它将堆栈对齐到0边界。

第三,它推进了新的堆栈区域ecx + 4,它应该转换为推送我们想要返回堆栈的地址。

第四,它将旧帧指针推入堆栈并设置新帧。

第五,它将ecx(仍指向应该是main的参数)推入堆栈。

该程序完成了应有的工作,并开始了返回的过程。

首先,它通过在ebp上使用-0x4偏移来恢复ecx,它应该访问第一个局部变量。

其次它执行leave指令,它实际上只是将esp设置为ebp,然后从堆栈中弹出ebp。

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

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

这很好用,但提出了为什么在第3步中将返回地址放到堆栈上的问题,因为它在实际从函数返回之前将堆栈返回到最后的原始位置。

2 个答案:

答案 0 :(得分:4)

gcc在函数中对齐堆栈时会产生一些笨重的代码,即使启用了优化也是如此。我有一个可能的理论(见下文),关于为什么gcc可能会将返回地址复制到保存ebp以形成堆栈帧的上方(是的,我同意&#39) ;是什么gcc正在做的事情)。在这个函数中看起来没什么必要,并且clang没有做那样的事情。

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

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

ABI遵守gcc的方式并不需要,因为clang做的事情要简单得多。

我添加了一个对齐变量(使用rcx作为一种简单的方法来强制编译器在堆栈上为它实际保留对齐的空间,而不是优化它)。我将代码on the Godbolt compiler explorer放在volatile处查看asm。我在gcc 4.9,5.3和6.1中看到了相同的行为,但是在clang中有不同的行为。

-O3

Clang3.8&#39; int main(){ __attribute__((aligned(32))) volatile int v = 1; return 0; } 输出在功能上与其-O3 -m32输出完全相同。请注意-m64启用-O3,但有些函数无论如何都会生成堆栈帧。

-fomit-frame-pointer

gcc的输出在 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 -m32之间几乎相同,但它会将-m64放入v -m64,所以{ {1}}输出有两条额外的说明:

-m32

似乎gcc想要在对齐堆栈后制作其堆栈帧(使用 # 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 。我想这是有道理的,所以它可以引用相对于push ebp的本地人。否则,它必须使用ebp - 相对寻址,如果它想要对齐的本地人。

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

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

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

在函数内部需要esp的原始值来引用堆栈上的函数args。我认为gcc不知道如何优化其对齐堆栈方法中不需要的部分。 (例如,esp以外没有看到它的args(并宣布不接受任何))

这种代码是您在需要对齐堆栈的函数中看到的典型代码;由于使用main自动存储,这并不奇怪。

答案 1 :(得分:1)

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

通常,在未经优化(或使用-fno-omit-frame-pointer选项进行编译的情况下),编译器会使用调用者保存的帧指针值创建一个堆栈帧,该堆栈帧包括一个指向上一个堆栈帧的链接。通常,编译器将先前的帧指针值保存为返回地址之后的堆栈中的第一位内容,然后将帧指针设置为指向堆栈上的该位置。当程序中的所有函数都执行此操作时,帧指针寄存器将成为指向堆栈帧链接列表的指针,该列表可以一直追溯到程序的启动代码。每帧中的返回地址表明该帧属于哪个功能。

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

基本上,两个编译器所做的都是创建一个拆分堆栈帧,通过为对齐堆栈而创建的对齐填充一分为二。填充上方的顶部是存储区域变量的位置。在填充下方的底部是可以找到传入参数的位置。 Clang使用ESP访问顶部,使用EBP访问底部。 GCC使用EBP访问底部,并使用堆栈序言中保存的ECX值访问顶部。在这两种情况下,尽管只有GCC的EBP可以像正常框架一样用于访问函数的局部变量,但EBP都指向看起来像正常的堆栈框架。

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

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

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

GCC的策略似乎基于对拥有一组样板程序序言和结尾代码序列的需求,这些序列可用于需要堆栈对齐的所有功能。它还可以避免在函数的生命周期内分配任何寄存器,尽管如果尚未清除保存的ECX值,则可以直接从ECX使用它。考虑到GCC如何生成函数序言和结语代码,我怀疑生成像Clang这样的更灵活的代码确实很困难。

(但是,gcc 8和更高版本的确为需要过度对齐栈的功能使用了更简单的序言,更像是clang的策略。)