【问题标题】:Unable to understand example of cdecl calling convention where caller doesnt need to clean the stack无法理解调用者不需要清理堆栈的 cdecl 调用约定示例
【发布时间】:2018-09-05 21:51:54
【问题描述】:

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

; 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 的参数预先分配了存储空间。

我将假设在代码 sn-p 的顶部有一个sub esp, 0x10。否则,您只会破坏堆栈。

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

我到底错过了什么?

【问题讨论】:

  • 如果调用以ret 0x10 完成,ret 指令将调整esp 寄存器。所以检查子程序机器代码,它确实使用了哪种ret。编辑:或者如果这是没有子程序代码的书,那么注意调用约定定义,它可能被定义为使用ret imm16来调整子程序中的堆栈。我不记得到底是哪个平台使用了这个(一些窗口?),但我真的不喜欢它,幸运的是在 linux 上调用约定不同,所以我不在乎。
  • 调用者仍然需要清除堆栈,只是没有立即完成,而是在函数结尾处。这种风格有效地将参数合并到局部变量中。它是cdecl 的事实意味着调用者清理。被调用者清理称为stdcall 或pascal(取决于顺序)。
  • @Ped7g:它是cdecl,被调用者以正常的ret结尾。那是 32 位Linux 调用约定,可能由gcc 用-maccumulate-outgoing-args that defers clearing the stack until the end of the function. 编译,并且避免使用push,即使是在最初的增长。这曾经是一件好事,但现在是now just a waste of code-size and instructions.
  • @PeterCordes 感谢您的纠正,我不记得他们的名字的调用约定。实际上再次阅读我的评论,我确实以有点模棱两可的方式写了它,因为我不确定,所以它并不是完全不正确,但仍然很高兴你确实正确地纠正了。
  • 请注意,在您的示例中,值是 not 推送的,而是在不更改堆栈指针的情况下移动到堆栈中。这样就不需要分任何东西了

标签: assembly x86 calling-convention cdecl


【解决方案1】:

我将假设在顶部有一个 sub esp, 0x10 代码sn-p。否则,您只会破坏堆栈。

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

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

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

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

【讨论】:

  • Re:你的第一段:是的,这就是 OP 所说的“破坏堆栈”的意思。如果您还没有完成sub esp, 0x10,您将在您的返回地址和参数或调用者的堆栈内存上乱涂乱画。我不认为 OP 担心 call 会破坏你刚刚写的 args。
  • @PeterCordes 我不确定我是否理解。我在第一段中犯了错误吗?我说在调用者的prologue中已经分配了栈空间来保存被调用者的参数。这就是为什么被调用者不会破坏堆栈的原因,因为堆栈指针已经指向堆栈的顶部。这就是我在第一段中想说的。
  • OP 说该函数需要已经分配空间来存储这些 args(不与其他任何东西重叠),例如 sub esp, 0x10。你是说sub esp, 0x10 是不必要的,并且它必须已经发生了。 >.sub esp, N,只需要顶部的单个sub esp, max_N。我认为这也是 OP 的含义。我认为你是在假设 OP 有错误的误解。
  • @PeterCordes 是的,我不是那个意思。我的意思是 sub esp, N 在调用者的序言中只需要一次;无需在每个调用站点分配堆栈空间。所以是的,每个调用序列都不需要自己的sub esp, N。我正是这个意思。我会澄清这部分答案。
【解决方案2】:

编译器通常选择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-immediate,并且性能并不差(因为我们即将使用堆栈引擎的call)。详情请见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.。

默认情况下(不累积传出参数)gcc 确实让 ESP 反弹,甚至使用 2x pop 从堆栈中清除 2 个参数。 (避免堆栈同步 uop,代价是在 L1d 缓存中命中 2 个无用负载)。需要清除 3 个或更多参数时,gcc 使用 add esp, 4*N。我怀疑使用 mov 存储重用 arg 传递空间而不是 add esp / push 有时会提高整体性能,尤其是使用寄存器而不是立即数。 (push 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

使用-maccumulate-outgoing-args,输出基本上像clang,但gcc仍然保存/恢复ebx并在其中保留a,然后再进行尾调用。


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

arg 积累还是有利有弊的。我做了相当广泛的 在 AMD 芯片上进行测试,发现它的性能中性。在 32 位代码上它可以保存 大约 4% 的代码,但在禁用帧指针的情况下,它展开了相当多的展开信息 很多,所以生成的二进制文件大约大 8%。 (这也是-Os 的当前默认值)

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


在call 之前保持 16 字节堆栈对齐会产生混淆效果,这是当前版本的 i386 System V ABI 所要求的。在 32 位模式下,它曾经只是一个 gcc 默认维护-mpreferred-stack-boundary=4。 (即 1-mpreferred-stack-boundary=2 违反 ABI 并编写只关心 ESP 的 4B 对齐的代码。

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

【讨论】:

    猜你喜欢
    • 2010-12-05
    • 2012-03-17
    • 2023-03-28
    • 2018-06-30
    • 1970-01-01
    • 2015-04-16
    • 2011-09-19
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多