为什么使用 push/pop 而不是 sub 和 mov?
Why use push/pop instead of sub and mov?
当我在 https://godbolt.org 上使用不同的编译器时,我注意到编译器生成这样的代码是很常见的:
push rax
push rbx
push rcx
call rdx
pop rcx
pop rbx
pop rax
我知道每个 push
或 pop
做两件事:
- 将操作数to/from移入堆栈space
- increment/decrement 堆栈指针 (rsp)
所以在我们上面的例子中,我假设 CPU 实际上进行了 12 次操作(6 步,6 adds/subs),不包括 call
。结合 adds/subs 不是更有效吗?例如:
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个adds/subs),不包括call
。为什么编译器不使用这种方法?
如果你用 -mtune=pentium3
或早于 -mtune=pentium-m
的东西编译,GCC 将 像你想象的那样进行代码生成,因为在那些旧的 CPUs push/pop 确实解码为堆栈指针上的单独 ALU 操作以及 load/store。 (您必须使用 -m32
或 -march=nocona
(64 位 P4 Prescott),因为那些旧的 CPU 也不支持 x86-64)。 Why does gcc use movl instead of push to pass function args?
但是 Pentium-M 在前端引入了 "stack engine",消除了堆栈操作的堆栈调整部分,如 push/call/ret/pop。它以零延迟有效地重命名堆栈指针。参见 Agner Fog's microarch guide and
作为一般趋势,任何在现有二进制文件中广泛使用的指令都会激励 CPU 设计人员使其更快。比如Pentium 4试图让大家停止使用INC/DEC;那没有用; 。现代 x86 晶体管和功率预算可以支持这种复杂性,至少对于大核 CPUs(不是 Atom / Silvermont)。不幸的是,我认为对于像 sqrtss
或 cvtsi2ss
这样的指令的错误依赖(在目的地)没有任何希望。
在像add rsp, 8
这样的指令中显式使用堆栈指针需要英特尔CPU中的堆栈引擎插入一个同步微指令来更新乱序后端的值登记。如果内部偏移量太大,则相同。
事实上 pop dummy_register
比现代 CPU 上的 add rsp, 8
或 add esp,4
更高效 ,因此编译器通常会使用它来弹出一个带有默认调整的堆栈槽,或者例如 -march=sandybridge
。
另见 回复:使用 push
而不是 sub rsp, n
/ mov
来初始化堆栈上的局部变量。在某些情况下,这可能是一个胜利,尤其是对于具有较小值的代码大小,但编译器不会这样做。
此外,不,GCC / clang 不会生成与您显示的完全相同的代码。
如果他们需要在函数调用周围保存寄存器,他们通常会使用 mov
来存储。或者 mov
到他们保存在函数顶部的调用保留寄存器,并将在最后恢复。
除了传递堆栈参数外,我从未见过 GCC 或 clang 在函数调用之前压入多个被调用破坏的寄存器。之后绝对不会有多个 pops 恢复到相同(或不同)的寄存器中。 Spill/reload 函数内部通常使用 mov。这避免了循环内 push/pop 的可能性(除了将堆栈参数传递给 call
),并允许编译器进行分支而不必担心匹配推送与弹出。它还降低了堆栈展开元数据的复杂性,该元数据必须为移动 RSP 的每条指令都有一个条目。 (将 RBP 用作传统帧指针的指令计数与元数据和代码大小之间的有趣权衡。)
有些东西像你的代码生成可以通过调用保留寄存器看到+一些reg-reg在一个小函数中移动,这个函数只是调用另一个函数然后returned 一个 __int128
,它是寄存器中的函数 arg。所以传入的RSI:RDI需要保存,到return in RDX:RAX.
或者,如果您在非内联函数调用之后存储到全局变量或通过指针,编译器还需要在调用之后保存函数参数。
当我在 https://godbolt.org 上使用不同的编译器时,我注意到编译器生成这样的代码是很常见的:
push rax
push rbx
push rcx
call rdx
pop rcx
pop rbx
pop rax
我知道每个 push
或 pop
做两件事:
- 将操作数to/from移入堆栈space
- increment/decrement 堆栈指针 (rsp)
所以在我们上面的例子中,我假设 CPU 实际上进行了 12 次操作(6 步,6 adds/subs),不包括 call
。结合 adds/subs 不是更有效吗?例如:
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个adds/subs),不包括call
。为什么编译器不使用这种方法?
如果你用 -mtune=pentium3
或早于 -mtune=pentium-m
的东西编译,GCC 将 像你想象的那样进行代码生成,因为在那些旧的 CPUs push/pop 确实解码为堆栈指针上的单独 ALU 操作以及 load/store。 (您必须使用 -m32
或 -march=nocona
(64 位 P4 Prescott),因为那些旧的 CPU 也不支持 x86-64)。 Why does gcc use movl instead of push to pass function args?
但是 Pentium-M 在前端引入了 "stack engine",消除了堆栈操作的堆栈调整部分,如 push/call/ret/pop。它以零延迟有效地重命名堆栈指针。参见 Agner Fog's microarch guide and
作为一般趋势,任何在现有二进制文件中广泛使用的指令都会激励 CPU 设计人员使其更快。比如Pentium 4试图让大家停止使用INC/DEC;那没有用; sqrtss
或 cvtsi2ss
这样的指令的错误依赖(在目的地)没有任何希望。
在像add rsp, 8
这样的指令中显式使用堆栈指针需要英特尔CPU中的堆栈引擎插入一个同步微指令来更新乱序后端的值登记。如果内部偏移量太大,则相同。
事实上 pop dummy_register
比现代 CPU 上的 add rsp, 8
或 add esp,4
更高效 ,因此编译器通常会使用它来弹出一个带有默认调整的堆栈槽,或者例如 -march=sandybridge
。
另见 push
而不是 sub rsp, n
/ mov
来初始化堆栈上的局部变量。在某些情况下,这可能是一个胜利,尤其是对于具有较小值的代码大小,但编译器不会这样做。
此外,不,GCC / clang 不会生成与您显示的完全相同的代码。
如果他们需要在函数调用周围保存寄存器,他们通常会使用 mov
来存储。或者 mov
到他们保存在函数顶部的调用保留寄存器,并将在最后恢复。
除了传递堆栈参数外,我从未见过 GCC 或 clang 在函数调用之前压入多个被调用破坏的寄存器。之后绝对不会有多个 pops 恢复到相同(或不同)的寄存器中。 Spill/reload 函数内部通常使用 mov。这避免了循环内 push/pop 的可能性(除了将堆栈参数传递给 call
),并允许编译器进行分支而不必担心匹配推送与弹出。它还降低了堆栈展开元数据的复杂性,该元数据必须为移动 RSP 的每条指令都有一个条目。 (将 RBP 用作传统帧指针的指令计数与元数据和代码大小之间的有趣权衡。)
有些东西像你的代码生成可以通过调用保留寄存器看到+一些reg-reg在一个小函数中移动,这个函数只是调用另一个函数然后returned 一个 __int128
,它是寄存器中的函数 arg。所以传入的RSI:RDI需要保存,到return in RDX:RAX.
或者,如果您在非内联函数调用之后存储到全局变量或通过指针,编译器还需要在调用之后保存函数参数。