为什么gcc会产生额外的寄信人地址?

Dex*_*rig 2 x86 assembly 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;
}
Run Code Online (Sandbox Code Playgroud)

用于编译的命令: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.
Run Code Online (Sandbox Code Playgroud)

令我感到困惑的部分是它正在尝试使用堆栈。据我了解,这是做什么的:

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

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

第三,它压入新的堆栈区域ecx + 4,这将转换为压入我们假定要返回到堆栈上的地址。

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

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

程序将执行应做的事情,并开始返回过程。

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

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

因此,现在堆栈上的下一个内容是返回地址,并且esp和ebp寄存器应该返回到返回所需的状态了吗?

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

这工作得很好,但是引起了一个问题,为什么它会在第3步中麻烦将返回地址放到堆栈上,因为它在实际上从函数返回之前,将堆栈返回到末尾的原始位置。

Pet*_*des 5

更新:gcc8至少在正常用例(-fomit-frame-pointer,并且不需要alloca或不需要可变大小分配的C99 VLA)中简化了此操作。可能是由于AVX使用量的增加导致更多功能需要32字节对齐的本地或数组而引起的。

如果仅运行几次(例如main在32位代码的开头),则此复杂的序言就可以了,但是它看起来越多,就越有必要对其进行优化。GCC有时仍会在函数中对堆栈进行过度对齐,在这些函数中,所有> 16字节对齐的对象都已优化到寄存器中,这虽然已经错过了优化,但是当堆栈对齐更便宜时,它的影响就较小。


即使在启用优化的情况下,在对齐函数中的堆栈时,gcc也会生成一些笨拙的代码。我有一个可能的理论(见下文),关于为什么gcc可能会将返回地址复制到保存ebp堆栈堆栈的上方(是的,我同意gcc在做什么)。在此函数中看起来没有必要,并且clang不会执行任何此类操作。

除此之外,胡说八道ecx可能只是gcc没有优化掉其对齐堆栈样板中不需要的部分。(需要使用pre-alignment值esp来引用堆栈上的args,因此将第一个可能为arg的地址放入寄存器是有意义的。)


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

gcc的执行方式不需要ABI合规性,因为clang的功能要简单得多。

我添加了一个对齐的变量(以volatile一种简单的方式强制编译器在堆栈上为其实际保留对齐的空间,而不是对其进行优化)。我将您的代码放在Godbolt编译器资源管理器上,以使用来查看asm -O3。我在gcc 4.9、5.3和6.1中看到了相同的行为,但是在clang中却看到了不同的行为。

int main(){
    __attribute__((aligned(32))) volatile int v = 1;
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

似乎gcc想要对齐堆栈使其堆栈框架(带有push ebp)。我想这很有意义,因此它可以引用相对于的本地语言。否则,如果要对齐的局部变量,则必须使用-relative寻址。ebpesp

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

对齐后但在压入之前,返回地址的额外副本ebp意味着将返回地址复制到相对于保存ebp(以及ebp调用子函数时将位于的值)的预期位置。因此,通过遵循堆栈框架的链接列表并查看返回地址以找出所涉及的功能,这确实可以帮助希望放松堆栈的代码。

我不确定这与现代堆栈展开信息是否相关,该信息允许使用进行堆栈展开(回溯/异常处理)-fomit-frame-pointer。(这是本.eh_frame节中的元数据。这是针对.cfi_*每个修改的指令esp。)我应该看看clang在非叶函数中必须对齐堆栈时的作用。


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

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