我正在阅读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
。
我到底错过了什么?
答案 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。 (mov
比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
,输出基本上就像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上试过这个,但你可以。