【问题标题】:Why Do Compilers Insert INT3 Instructions Between Subroutines?为什么编译器在子程序之间插入 INT3 指令?
【发布时间】:2016-10-19 09:40:33
【问题描述】:

在调试某些软件时,我注意到在许多情况下,子程序之间插入了 INT3 指令。

我认为从技术上讲,这些不是插入“介于”函数之间,而是在它们之后插入,以便在子例程由于任何原因最后没有执行 retn 时暂停执行。

我的假设是否正确?如果不是,这些说明的目的是什么?

【问题讨论】:

  • 这肯定是函数之间的空白。一个非常基本的 x86 优化是让函数从 16 的倍数的地址开始。如果你必须想出 some 字节值来填补空白,那么 0xcc 是迄今为止最好的选择.捕捉程序跳入遗忘的极端情况。
  • 有时令我惊讶的是,数十亿个微型晶体管如何能够如此可靠地工作,以至于可以安全地编写循环条件,例如 dec / jnz (do{}while(--i)) 而不是 dec / jg (do{}while(--i > 0))。我想如果在计数器中稍微翻转一下,编写可能仍然有效的代码会“更安全”,但显然没有必要。 (当然,乱序执行 CPU 内的翻转位不太可能在架构状态下简单地翻转位;更有可能你会得到更奇怪的东西。)

标签: assembly compiler-construction x86


【解决方案1】:

在 Linux 上,gcc 和 clang pad 使用 0x90 (NOP) 来对齐功能。 (在将.o 与大小不一的部分链接时,即使链接器也会这样做)。

通常没有任何特别的优势,除非 CPU 对函数末尾的 RET 指令没有分支预测。在这种情况下,当发现正确的分支目标时,NOP 不会让 CPU 启动任何需要时间来恢复的东西。


函数的最后一条指令可能不是 RET;它可能是间接 JMP(例如,通过函数指针进行尾调用)。在这种情况下,分支预测更有可能失败。 (CALL/RET 对由返回堆栈专门预测。注意 RET 是变相的间接 JMP;它基本上是 jmp [rsp]add rsp, 8)。

间接 JMP 或 CALL 的默认预测(当没有可用的分支目标缓冲区预测时)是跳转到下一条指令。 (显然,在知道正确的目标之前不进行预测并停止是不可行的,或者默认预测对于跳转表来说足够可用。)

如果默认预测导致推测性地执行 CPU 无法轻易中止的操作,例如 FP sqrt 或微编码的操作,这会增加分支预测错误的惩罚。如果推测执行的指令导致 TLB 未命中、触发硬件页面遍历或以其他方式污染缓存,则更糟。

INT 3 这样只会产生异常的指令不会有任何这些问题。 CPU 不会在它应该之前尝试执行 INT,所以不会发生任何不好的事情。 IIRC,如果下一条指令默认预测没有用,建议在间接 JMP 之后放置类似的内容。


由于函数之间存在随机垃圾,即使对包含 RET 的 16B 机器代码块进行预解码也可能会减慢速度。现代 CPU 以 4 条指令为一组并行解码,因此在后续指令已经解码之前,它们无法检测到 RET。 (这与推测执行不同)。避免在无条件分支(如 RET)之后的字节中缓慢解码 Length-Changing-Prefixes 很有用,因为这会延迟分支的解码。

LCP 停顿仅影响 Intel CPU:AMD 在其 L1 缓存中标记指令边界,并以更大的组进行解码。 (Intel 使用解码的 uop 缓存来获得高吞吐量,而无需每次循环实际解码的功耗成本。)

请注意,在 Intel CPU 中,指令长度查找发生在实际解码之前的阶段。例如,Sandybridge 前端如下所示:

(从 David Kanter 的 Haswell 文章中复制的图表。不过,我链接到了他的 Sandybridge 文章。它们都很出色。)

另请参阅Agner Fog's microarch pdf,以及 标签 wiki 中的更多链接,了解我在此答案中描述的详细信息(以及更多内容)。

【讨论】:

  • 您希望推测执行在INT 3 处停止,因此它会阻止下一个函数的推测执行。执行下一个函数的序言可能大多是无害的,但很浪费。
  • @Peter Cordes,沿着一个类似的话题,为什么分支预测在遇到无条件分支指令(即 RET、JMP)后继续解码指令?很多时候,您会认为这些字节是垃圾和填充,因此会浪费周期。
  • @byteptr:那不是分支预测,那只是并行解码。指令长度标记发生在 Intel CPU 的 16B 块中,在任何真正的解码器查看该块并检测到无条件分支之前。
  • @MSalters:是的,IIRC INT 是一个序列化指令,或者至少会停止推测执行。
  • UD2 和 INT3 在停止猜测方面可能相似。 Fog 对此没有太多要说的(我可以找到),但来自英特尔优化参考手册:Assembly/Compiler Coding Rule 14。(M 影响,L 通用性)当存在间接分支时,尝试将紧跟在间接分支之后的最可能的间接分支目标。或者,如果间接分支很常见,但分支预测硬件无法预测它们,则使用 UD2 指令跟随间接分支,这将阻止处理器向下解码失败路径。
【解决方案2】:

不正确的假设。

它们是函数之间的填充,而不是之后。一个随机决定跳过指令的 CPU 坏了,应该扔掉。

INT 3 的原因有两个。这是一条单字节指令,这意味着即使只有一个字节的空间,您也可以使用它。绝大多数指令是不合适的,因为它们太长了。此外,它是“调试中断”指令。这意味着调试器可以捕获在函数之间执行代码的尝试。这不是由于忽略 retn 而引起的,而是出于更简单的原因,例如使用未初始化的函数指针。

【讨论】:

  • 因为它不应该被执行,理论上0x00 是可以的。但在实践中,CPU 只是将字节解码为 x86 指令,而不知道函数边界在哪里,因此函数之间的填充是有效指令,在解码(或推测执行)时不会减慢 CPU 的速度也是一个优势。但是,在间接跳转或损坏的返回地址将您带入填充的极少数情况下,INT3 会导致早期/嘈杂的故障。这通常比使用 NOP 填充默默地进入下一个函数要好(就像 Linux 上的典型情况一样)。
  • 次要评论:int3 是一个字节,int 3 是两个字节。两条指令的行为略有不同。
猜你喜欢
  • 2016-06-06
  • 1970-01-01
  • 2014-02-10
  • 2016-01-27
  • 2018-06-09
  • 1970-01-01
  • 2020-03-26
  • 1970-01-01
  • 2013-08-26
相关资源
最近更新 更多