无法理解cdecl调用约定的示例,其中调用者不需要清理堆栈

时间:2018-03-27 13:06:53

标签: assembly x86 calling-convention cdecl

我正在阅读IDA Pro Book。在讨论调用约定时,在第86页上,作者展示了一个cdecl调用约定的示例,它消除了调用者从堆栈中清除参数的需要。我正在复制下面的代码片段:

; demo_cdecl(1, 2, 3, 4); //programmer calls demo_cdecl
mov [esp+12], 4 ; move parameter z to fourth position on stack
mov [esp+8], 3 ; move parameter y to third position on stack
mov [esp+4], 2 ; move parameter x to second position on stack
mov [esp], 1 ; move parameter w to top of stack
call demo_cdecl ; call the function

作者继续说

  

在上面的例子中,编译器在函数序言期间为堆栈顶部的demo_cdecl的参数预先分配了存储空间。

我将假设代码段顶部有一个sub esp, 0x10。否则,你只会破坏堆栈。

他后来说,当调用demo_cdecl完成时,调用者不需要调整堆栈。但肯定的是,通话结束后必须有add esp, 0x10

我到底错过了什么?

2 个答案:

答案 0 :(得分:1)

  

我将假设在顶部有一个子esp,0x10   代码段。否则,你只会破坏堆栈。

参数存储在堆栈指针正偏移的地址中。请记住,堆栈向下增长。这意味着已经分配了保存这些参数所需的空间(可能是通过调用者的序言代码)。这就是为什么每个呼叫序列都不需要sub esp, N

  

他后来说调用者不需要调整堆栈   调用demo_cdecl完成。但当然,必须有一个额外的esp,   通话后0x10。

在cdecl调用约定中,调用者总是必须以这种或那种方式清理堆栈。如果分配是由调用者的序言完成的,那么它将被结尾释放(与调用者的局部变量一起)。否则,如果被调用者的参数被分配在调用者代码中间的某个位置,那么最简单的清理方法是在调用指令之后使用add esp, N

在cdecl调用约定的这两个不同实现之间存在权衡。在序言中分配参数意味着必须分配任何被调用者所需的最大空间。它将被重用于每个被叫方。然后在调用者的末尾,它将被清理一次。因此,这可能会不必要地浪费堆栈空间,但它可能会提高性能。在另一种技术中,当实际将要到达相关联的呼叫站点时,呼叫者仅为参数分配空间。然后在被调用者返回后立即执行清理。因此没有浪费堆栈空间。但是必须在呼叫者的每个呼叫站点执行分配和清理。您还可以想象处于这两个极端之间的实现。

答案 1 :(得分:1)

编译器通常选择mov来存储args而不是push,如果已经分配了足够的空间(例如,如您所建议的那样在函数中使用sub esp, 0x10)。

以下是一个例子:

int f1(int);
int f2(int,int);

int foo(int a) {
    f1(2);
    f2(3,4);

    return f1(a);
}

clang6.0 -O3 -march=haswell on Godbolt编译

    sub     esp, 12                # reserve space to realign stack by 16
    mov     dword ptr [esp], 2     # store arg
    call    f1(int)
                    # reuse the same arg-passing space for the next function
    mov     dword ptr [esp + 4], 4  
    mov     dword ptr [esp], 3
    call    f2(int, int)
    add     esp, 12
                    # now ESP is pointing to our own arg
    jmp     f1(int)                  # TAILCALL
使用sub esp,8 / push 2,clang的代码可能会更好,但是函数的其余部分保持不变。即让push增加堆栈,因为它的代码大小更小mov,特别是mov - 即时,性能不会更差(因为我们即将{{1}它也使用堆栈引擎)。有关详细信息,请参阅What C/C++ compiler can use push pop instructions for creating local variables, instead of just increasing esp once?

我还在Godbolt链接GCC输出中加入/不加-maccumulate-outgoing-args that defers clearing the stack until the end of the function.

默认情况下(不累积传出args)gcc会让ESP反弹,甚至使用2x call来清除堆栈中的2个args。 (避免堆栈同步uop,代价是在L1d缓存中遇到2个无用的负载)。有3个或更多args要清除,gcc使用pop。我怀疑重复使用带有add esp, 4*N存储的arg传递空间而不是添加esp / push对于整体性能来说是有利的,特别是对于寄存器而不是immediates。 (movpush imm8更紧凑。)

mov imm32

使用foo(int): # gcc7.3 -O3 -m32 output push ebx sub esp, 20 mov ebx, DWORD PTR [esp+28] # load the arg even though we never need it in a register push 2 # first function arg call f1(int) pop eax pop edx # clear the stack push 4 push 3 # and write the next two args call f2(int, int) mov DWORD PTR [esp+32], ebx # store `a` back where we it already was add esp, 24 pop ebx jmp f1(int) # and tailcall ,输出基本上就像clang一样,但gcc仍会保存/恢复-maccumulate-outgoing-args并保留ebx,然后再进行尾调用。

请注意,ESP反弹需要a中的额外元数据才能进行堆栈展开。 Jan Hubicka writes in 2014

  

arg积累仍有利弊。我做得很广泛   测试AMD芯片,发现它性能中立。在32位代码上它可以保存   大约4%的代码但禁用了帧指针它会扩展展开信息   很多,所以产生的二进制大约8%。 (这也是.eh_frame

的当前默认值

因此,使用push for args可以节省4%的代码大小(以字节为单位;对于L1i缓存占用而言很重要),并且至少通常在每个-Os之后将它们从堆栈中清除。我认为这里有一个快乐的媒介,即gcc可以使用更多call而不使用 {/ 1}} / push

push之前保持16字节堆栈对齐会产生混淆效果,这是当前版本的i386 System V ABI所要求的。在32位模式下,它曾经只是一个gcc默认值来维护pop。 (即1 <&lt; 4)。我想你仍然可以使用 call违反ABI并制作仅关注ESP对齐的代码。

我没有在Godbolt上试过这个,但你可以。