【问题标题】:What's the reason for padding executable sections with "long NOPs"?用“长 NOP”填充可执行部分的原因是什么?
【发布时间】:2017-09-22 06:37:14
【问题描述】:

我发现 x86-64 程序(至少是那些使用 GCC 编译的程序)的函数默认从与 16 字节的倍数对齐的地址开始,并且填充是由带有 前缀的 NOP 指令完成的可以适合以最佳地填充空间。例如,

  (...)
  447454:   c3                              retq   
  447455:   90                              nop
  447456:   66 2e 0f 1f 84 00 00 00 00 00   nopw   %cs:0x0(%rax,%rax,1)

0000000000447460 <__libc_csu_fini>:
  447460:   f3 c3                           repz retq 

用常规的NOPs(如观察到的herehere)填充空间有什么好处?

【问题讨论】:

  • 这些链接问题的答案似乎也适用于这里
  • @HongOoi 他们说为什么对齐很好,但没有说为什么 nopw %cs:0x0(%rax,%rax,1) 比 10× nop 好。实际上,这种可能性只提到过一次;所有的例子都只是重复nops 并且其中一个引号甚至说“对齐子例程条目就像根据需要放置 尽可能多的 NOP 一样简单......”
  • 将分支目标对齐到 16 的倍数是标准优化规则。它帮助指令解码器处理分支错误预测,它不必遍历未使用的指令。 REP 前缀是一个有助于 AMD 处理器的不稳定前缀,它避免了错误预测。顺便说一句,他们现在推荐 RET 0。处理器没有变得更容易编程;)
  • @HansPassant:rep ret 在 AMD Bulldozer 系列和 Ryzen 上比 ret 0 快。在英特尔 CPU 上也是如此。它们在 K8/K10 上的速度相同。所以这是一件好事gcc 使用rep ret 而不是ret 0 在它仍然关心Athlon64 / PhenomII 的“通用”调整中。现在切换没有意义,因为rep ret 存在于无处不在的二进制文件中(来自gcc 的默认输出),因此几十年来没有CPU 供应商会重新利用该字节序列。 stackoverflow.com/questions/20526361/what-does-rep-ret-mean/…ret 0 最初会“更安全”。
  • 处理器没有变得更容易编程这不是事实。

标签: gcc assembly x86 x86-64 micro-optimization


【解决方案1】:

没有缺点,为什么不呢?它使反汇编更易于人类阅读,因为您没有大量的行分隔函数。

GCC(将 C 转换为汇编的实际编译器部分)使用相同的 .p2align 指令来要求汇编器插入填充,无论它是在函数内部以对齐分支目标,还是在函数之间以对齐函数入口点。

GCC 可以发出 .p2align 4,,0x90 来要求汇编程序在 NOP 不会被执行的情况下填充单字节 NOP,但就像我说的那样,没有理由这样做而不是 .p2align 4(pad到下一个2^4 边界,默认选择填充)。


如果函数的结尾是间接分支(带有jmp [rax] 的尾调用或其他东西),推测执行可能会遇到这些 NOP 指令。解码许多短 NOP 可能会溢出英特尔 SnB 系列上的 uop 缓存。 (超过 3 个高速缓存行,每 32 字节块最多 6 个微指令)。 (http://agner.org/optimize/microarch pdf)。长 NOP 可能会更好。

IDK Pentium4 的跟踪缓存构建器的行为方式;也许它也有用吗?同样,更少的较长 NOP 指令不太可能在 CPU 前端发现 NOP 未执行之前触发任何奇怪的事情。

MSVC 在函数 IIRC 之间使用 int3 填充,这将停止推测​​执行。这不是一个坏主意。

这是猜测;它可能不是性能的真正因素;如果它在现代 CPU 上仍然很重要,那么所有编译器都可能会避免函数之间的短 NOP,但正如您的链接之一所示,并非所有编译器都这样做。

一些 CPU,如 AMD K8/K10 和 Bulldozer 系列,在 L1I 缓存中标记指令长度。 Agner Fog 说 K8/K10 从 L2 到 L1I 的带宽很低,并猜测可能是由于添加了额外的预解码信息。 IDK 如果在有很多小指令时这需要更长的时间?它必须知道从哪里开始解码,因为指令的中间可以跨越缓存线边界。 IDK 是如何工作的。


顺便说一句,这些指令可能被解码为包含普通 ret 的组的一部分,但我认为在这种情况下任何一种方式都无需担心。

在某些 CPU 中,解码分两个阶段进行:首先是指令长度解码,它查找包含最多 4 条指令的最多 16 个字节的块(例如,在 Intel P6-family / Sandybridge-family 上)。然后它将这些块提供给解码器。

有了ret 的正确分支预测,即使是像 LCP 这样的讨厌的东西在ret 之后停止似乎也不会受到伤害。

无论如何,我认为这种差异并不显着。在RET 之后解码的NOP 指令应该在它们去任何地方之前被取消,因为RET 是一个无条件分支。指令长度解码器是否找到许多单字节指令与一些前缀但不是在 16 字节窗口结束之前找到指令的结尾,我可能没有区别。

【讨论】:

  • 我注意到,简单和长 nop 都解码为发送到 IDQ 的单个 uop(这里 dispatched 是正确的术语吗?)但是,当然,从未发行(再次,*发行*正确吗?)并且刚刚退休。我没有测量过一个长的nop 是否可以通过简单的解码来处理,但假设它可以。假设在这种情况下,用简单的nops 填充 16 字节块会在到达块末尾之前成为前端(每个周期 4/6 微秒)的瓶颈,而使用一些长 nop 不会t?
  • @MargaretBloom:在英特尔术语中,没有特殊的词可以将微指令添加到 IDQ。 dispatched:调度器 -> 执行单元。 issued:从前端添加到乱序核心。 (一些计算机架构人员使用“已发布”来描述发送到执行单元的微指令/指令;英特尔称之为“调度”。)
  • @MargaretBloom:是的,用短 nops 填充 16B 块会成为前端的瓶颈。因此,您永远不想对将要执行的块执行此操作。如果前端在函数之间解码nops,它没有做任何有用的事情,所以它做什么并不重要,只要它不会驱逐有价值的uop缓存行,比如函数以jmp [rax]。 (除非让它获取下一个函数的开头很有用......但更有可能你不想用下一个函数污染 I-cache。)
  • @MargaretBloom:我认为ret 之后的nop 甚至会在添加到 IDQ 之前被取消,因为此时解码器知道有一个无条件分支(ret 是间接 jmp,但我怀疑默认预测是下一条指令)。如果分支预测运行良好,ret 之后的线性指令根本不会发送到解码器,因为它预测那里有一个分支。发送到解码器的指令字节队列可能在调用者中紧跟ret 之后有下一条指令,在同一个周期中解码。 (IDK)。
  • @MargaretBloom:顺便说一句,long nop 是一个单一的微指令,所以是的,它可以被任何解码器处理。只要您避免使用过多的前缀(AMD 解码器阻塞这些前缀,以及 Atom / Silvermont / 早期 P6),AMD 和 Intel CPU 就可以通过它们而没有瓶颈,除了通常的指令长度 / 16B 块的东西。他们必须发布/退休,但不使用执行单元。与 Sandybridge 家族的异或归零或被淘汰的mov 一样便宜。 (如果 NOP 很常见,CPU 可能会避免在每个上花费一个 ROB 条目,但通常只是不要将 nop 放入热循环中:P)
猜你喜欢
  • 2011-12-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-10-31
  • 1970-01-01
  • 2014-09-25
相关资源
最近更新 更多