【问题标题】:Why use push/pop instead of sub and mov?为什么使用 push/pop 而不是 sub 和 mov?
【发布时间】:2020-07-07 10:07:43
【问题描述】:

当我在https://godbolt.org 上使用不同的编译器时,我注意到编译器生成这样的代码是很常见的:

push    rax
push    rbx
push    rcx
call    rdx
pop     rcx
pop     rbx
pop     rax

我知道每个pushpop 做两件事:

  1. 将操作数移入/移出堆栈空间
  2. 递增/递减堆栈指针 (rsp)

所以在我们上面的例子中,我假设 CPU 实际上正在执行 12 次操作(6 次移动,6 次加/减),不包括 call。组合添加/订阅不是更有效吗?例如:

sub rsp, 24
mov [rsp-24], rax
mov [rsp-16], rbx
mov [rsp-8], rcx
call    rdx
mov rcx, [rsp-8]
mov rbx, [rsp-16]
mov rax, [rsp-24]
add rsp, 24

现在只有 8 个操作(6 个移动,2 个加/减),不包括 call。为什么编译器不使用这种方法?

【问题讨论】:

  • 每条指令的大小是多少?推送/弹出多少字节,寄存器偏移寻址多少字节?以 xor rax,rax 与 mov rax,0 为例。

标签: assembly x86 x86-64 cpu-architecture micro-optimization


【解决方案1】:

如果您使用-mtune=pentium3 或早于-mtune=pentium-m 的东西进行编译,GCC 像您想象的那样进行代码生成,因为在那些旧 CPU 上,push/pop 确实会解码为单独的 ALU堆栈指针上的操作以及加载/存储。 (您必须使用 -m32-march=nocona(64 位 P4 Prescott),因为这些旧 CPU 也不支持 x86-64)。 Why does gcc use movl instead of push to pass function args?

但是 Pentium-M 在前端引入了一个“堆栈引擎”,它消除了诸如 push/call/ret/pop 等堆栈操作的堆栈调整部分。它有效地以零延迟重命名堆栈指针。见Agner Fog's microarch guideWhat is the stack engine in the Sandybridge microarchitecture?

一般趋势是,现有二进制文件中广泛使用的任何指令都会促使 CPU 设计人员加快速度。例如,Pentium 4 试图让所有人停止使用 INC/DEC;那没有用; current CPUs do partial-flag renaming better than ever。现代 x86 晶体管和功率预算可以支持这种复杂性,至少对于大核 CPU(不是 Atom / Silvermont)。不幸的是,我认为对于 sqrtsscvtsi2ss 等指令的错误依赖关系(在目的地上)没有任何希望。


add rsp, 8 等指令中显式使用堆栈指针需要 Intel CPU 中的堆栈引擎插入一个同步 uop 以更新无序后端寄存器的值。如果内部偏移量太大,则相同。

事实上,pop dummy_register 在现代 CPU 上比add rsp, 8add esp,4 效率更多,因此编译器通常会使用默认调整或@987654337 来弹出一个堆栈槽@ 例如。 Why does this function push RAX to the stack as the first operation?

另见What C/C++ compiler can use push pop instructions for creating local variables, instead of just increasing esp once? re: 使用push 来初始化堆栈上的局部变量,而不是sub rsp, n / mov。在某些情况下,这可能是一种胜利,尤其是对于具有小值的代码大小,但编译器不会这样做。


另外,不,GCC / clang 不会生成与您显示的代码完全的代码。

如果他们需要在函数调用周围保存寄存器,他们通常会在内存中使用mov。或者 mov 到他们保存在函数顶部的调用保留寄存器,并将在最后恢复。

除了传递堆栈参数之外,我从未见过 GCC 或 clang 在函数调用之前推送多个调用破坏寄存器。之后绝对不会多次弹出以恢复到相同(或不同)的寄存器中。函数内部的溢出/重新加载通常使用 mov。这避免了循环内推/弹出的可能性(将堆栈参数传递给call 除外),并允许编译器进行分支,而不必担心将推与弹出匹配。它还降低了堆栈展开元数据的复杂性,该元数据必须为每条移动 RSP 的指令都有一个条目。 (使用 RBP 作为传统帧指针时,指令计数与元数据和代码大小之间的有趣权衡。)

类似的东西你的代码生成可以通过调用保留寄存器 + 一些 reg-reg 在一个小函数中移动,该函数只是调用另一个函数,然后返回一个 __int128 这是一个函数arg 在寄存器中。因此需要保存传入的 RSI:RDI,以便在 RDX:RAX 中返回。

或者,如果您在非内联函数调用之后存储到全局或通过指针,编译器还需要将函数 args 保存到调用之后。

【讨论】:

  • 感谢详细的解释和参考链接!
  • “我从未见过 GCC 或 clang 在一个函数调用周围推送寄存器,然后将它们弹回不同的寄存器。” - 我相信你误读了这个问题。虽然该示例确实推送和弹出寄存器,但它弹出到被推送的同一寄存器中,每个。
猜你喜欢
  • 2019-10-30
  • 2016-12-27
  • 2011-08-23
  • 2014-04-11
  • 2010-09-30
  • 2012-10-18
  • 2020-10-08
  • 1970-01-01
相关资源
最近更新 更多