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

Assembly 无法理解cdecl调用约定的示例,其中调用方不需要清理堆栈,assembly,x86,calling-convention,cdecl,Assembly,X86,Calling Convention,Cdecl,我正在看报纸。在第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 stac

我正在看报纸。在第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的参数预先分配了存储空间

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

他后来说,当对demo_cdecl的调用完成时,调用方不需要调整堆栈。但是可以肯定的是,在调用之后必须有一个
addesp,0x10

我到底错过了什么

我将假设有一个子esp,0x10位于 代码片段。否则,您将破坏堆栈

参数存储在距堆栈指针正偏移量的地址处。请记住,堆栈向下生长。这意味着保存这些参数所需的空间已经分配(可能是由调用方的序言代码分配的)。这就是为什么每个调用序列都不需要
子esp,N

他后来说,调用方在调用时不需要调整堆栈 调用demo_cdecl完成。但肯定有一个附加的esp, 呼叫后返回0x10

在cdecl调用约定中,调用方始终必须以某种方式清理堆栈。如果分配是由调用方的序言完成的,那么它将由尾声(连同调用方的局部变量)解除分配。否则,如果被调用方的参数被分配到调用方的代码中间,那么最简单的清理方法是在调用指令之后使用Add <代码> ESP、N< /代码>。
cdecl调用约定的这两种不同实现之间存在权衡。在序言中分配参数意味着必须分配任何被调用方所需的最大空间。它将对每个被调用方重复使用。然后在调用者结束时,它将被清理一次。因此,这可能会不必要地浪费堆栈空间,但可能会提高性能。在另一种技术中,调用方仅在实际到达关联的调用站点时为参数分配空间。然后在被调用方返回后立即执行清理。因此不会浪费堆栈空间。但是,必须在调用者中的每个调用站点上执行分配和清理。您还可以想象一个介于这两个极端之间的实现。

编译器通常选择
mov
来存储参数,而不是
push
,前提是已经分配了足够的空间(例如,在前面的函数中使用
子esp,0x10

下面是一个例子:

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

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

    return f1(a);
}
编者

如果使用
子esp,8
/
推送2
,clang的代码生成器会更好,但其余功能不变。i、 e.让
push
增加堆栈,因为它的代码大小更小,而
mov
,尤其是
mov
-立即,而且性能不会更差(因为我们即将
调用也使用堆栈引擎的
)。有关更多详细信息,请参阅

我还包括在带/不带的Godbolt link GCC输出中

默认情况下(不累积传出参数),gcc允许ESP跳转,甚至使用2x
pop
从堆栈中清除2个参数。(避免堆栈同步uop,代价是在L1d缓存中命中2个无用的加载)。如果要清除3个或更多参数,gcc将使用
添加esp,4*N
。我怀疑在
mov
stores中重用arg传递空间而不是add esp/push有时对整体性能来说是一个胜利,尤其是在寄存器而不是即时存储的情况下。(
push imm8
mov imm32
紧凑得多)

使用
-maccumulate outgoing args
,输出基本上类似于叮当声,但gcc仍然保存/恢复
ebx
,并在执行tailcall之前将
a
保留在其中


请注意,让ESP跳转需要
.eh_frame
中的额外元数据来展开堆栈:

精氨酸积累仍有利弊。我做得相当广泛 在AMD芯片上测试,发现其性能中性。在32位代码上保存 大约4%的代码,但在禁用帧指针的情况下,它将展开信息扩展了相当长的一段时间 很多,所以得到的二进制文件大约大8%。(这也是
-Os
的当前默认设置)

因此,使用push for args可以节省4%的代码大小(以字节为单位;与L1i缓存占用空间有关),并且至少通常在每次
调用后将它们从堆栈中清除。我认为这里有一个很好的媒介,gcc可以使用更多的
push
,而不只是使用
push
/
pop



在调用
之前保持16字节堆栈对齐会产生混淆效应,这是当前版本的i386 System V ABI所要求的。在32位模式下,它过去只是一个gcc默认值,用于维护
-mprefered stack boundary=4
。(即,1如果调用以
ret 0x10
完成,则
ret
指令将调整
esp
寄存器。因此,请检查子例程机器代码,它使用的是哪种
ret
。编辑:或者如果这是一本没有子例程代码的书,则请注意调用约定定义,它可以定义为使用来调整子例程中的堆栈。我记不起到底是哪个平台使用了这个(某些windows?),但我真的不喜欢它,幸运的是在linux上调用约定不同,所以我不在乎。调用方仍然需要清除堆栈,这是正确的
    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
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