3

我正在阅读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,通话后必须有一个。

我到底错过了什么?

4

2 回答 2

1

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

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

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

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

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

于 2018-03-27T16:22:10.320 回答
1

如果已经分配了足够的空间(例如,像您建议的那样在函数中使用较早的空间),编译器通常会选择mov存储 args 而不是。pushsub 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=haswellon 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使用/时clang 的 code-gen 会更好push 2,但函数的其余部分保持不变。即让push堆栈增长,因为它具有更小的代码大小mov,尤其是mov-immediate,并且性能并不差(因为我们即将call使用堆栈引擎)。请参阅哪些 C/C++ 编译器可以使用 push pop 指令来创建局部变量,而不仅仅是增加 esp 一次?更多细节。

我还在 Godbolt 链接 GCC 输出中包含/不-maccumulate-outgoing-args包含延迟清除堆栈直到函数结束。.

默认情况下(不累积传出参数)gcc 确实让 ESP 反弹,甚至使用 2xpop从堆栈中清除 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 在 2014 年写道

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

因此,使用 push for args 并至少通常在每次call. 我认为这里有一个快乐的媒介,gcc 可以在push使用/的情况下使用更多。 pushpop


之前维护 16 字节堆栈对齐会产生混淆效果call,这是当前版本的 i386 System V ABI 所要求的。在 32 位模式下,它曾经只是 gcc 默认维护的-mpreferred-stack-boundary=4. (即 1<<4)。我认为您仍然可以使用 -mpreferred-stack-boundary=2违反 ABI 并制作只关心 ESP 的 4B 对齐的代码。

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

于 2018-03-27T17:07:22.317 回答