为什么编译器在子程序之间插入 INT3 指令?
Why Do Compilers Insert INT3 Instructions Between Subroutines?
在调试某些软件时,我注意到在许多情况下,INT3 指令被插入到子程序之间。
我假设这些不是技术上插入的 'between' 函数,而是在它们之后,以便在子例程由于任何原因未在末尾执行 retn
时暂停执行。
我的假设是否正确?如果不是,这些说明的目的是什么?
不正确的假设。
它们在函数之间填充,而不是之后。随机决定跳过指令的 CPU 坏了,应该扔掉。
INT 3
的原因是双重的。它是单字节指令,这意味着即使只有 space 的单个字节也可以使用它。绝大多数指令是不合适的,因为它们太长了。此外,它是 "debug break" 指令。这意味着调试器可以捕获在函数之间执行代码的尝试。这不是由忽略 retn
引起的,而是出于更简单的原因,例如使用未初始化的函数指针。
在 Linux 上,gcc 和 clang 填充 0x90 (NOP) 以对齐函数。 (即使链接器也会这样做,当链接 .o
与大小不均匀的部分时)。
通常没有任何特别的优势,除非 CPU 在函数末尾没有 RET 指令的分支预测。在这种情况下,NOP 不会让 CPU 启动任何需要时间才能从发现正确的分支目标时恢复的内容。
函数的最后一条指令可能不是RET;它可能是间接 JMP(例如,通过函数指针进行尾调用)。在那种情况下,分支预测更有可能失败。 (CALL/RET 对由 return 堆栈专门预测。请注意,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)之后的字节中解码缓慢的长度更改前缀很有用,因为这会延迟分支的解码。
LCP 停顿只影响 Intel CPUs:AMD 在其 L1 缓存中标记指令边界,并在更大的组中解码。 (英特尔使用解码 uop 缓存来获得高吞吐量,而无需在循环中每次实际解码的功率成本。)
请注意,在 Intel CPUs 中,指令长度查找发生在比实际解码更早的阶段。例如,Sandybridge 前端看起来像这样:
(从 David Kanter 的 Haswell 文章中复制的图表。不过,我链接到他的 Sandybridge 文章。它们都很棒。)
另请参阅 Agner Fog's microarch pdf, and more links in the x86 标签 wiki,了解我在此答案中描述的内容的详细信息(以及更多)。
在调试某些软件时,我注意到在许多情况下,INT3 指令被插入到子程序之间。
我假设这些不是技术上插入的 'between' 函数,而是在它们之后,以便在子例程由于任何原因未在末尾执行 retn
时暂停执行。
我的假设是否正确?如果不是,这些说明的目的是什么?
不正确的假设。
它们在函数之间填充,而不是之后。随机决定跳过指令的 CPU 坏了,应该扔掉。
INT 3
的原因是双重的。它是单字节指令,这意味着即使只有 space 的单个字节也可以使用它。绝大多数指令是不合适的,因为它们太长了。此外,它是 "debug break" 指令。这意味着调试器可以捕获在函数之间执行代码的尝试。这不是由忽略 retn
引起的,而是出于更简单的原因,例如使用未初始化的函数指针。
在 Linux 上,gcc 和 clang 填充 0x90 (NOP) 以对齐函数。 (即使链接器也会这样做,当链接 .o
与大小不均匀的部分时)。
通常没有任何特别的优势,除非 CPU 在函数末尾没有 RET 指令的分支预测。在这种情况下,NOP 不会让 CPU 启动任何需要时间才能从发现正确的分支目标时恢复的内容。
函数的最后一条指令可能不是RET;它可能是间接 JMP(例如,通过函数指针进行尾调用)。在那种情况下,分支预测更有可能失败。 (CALL/RET 对由 return 堆栈专门预测。请注意,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)之后的字节中解码缓慢的长度更改前缀很有用,因为这会延迟分支的解码。
LCP 停顿只影响 Intel CPUs:AMD 在其 L1 缓存中标记指令边界,并在更大的组中解码。 (英特尔使用解码 uop 缓存来获得高吞吐量,而无需在循环中每次实际解码的功率成本。)
请注意,在 Intel CPUs 中,指令长度查找发生在比实际解码更早的阶段。例如,Sandybridge 前端看起来像这样:
(从 David Kanter 的 Haswell 文章中复制的图表。不过,我链接到他的 Sandybridge 文章。它们都很棒。)
另请参阅 Agner Fog's microarch pdf, and more links in the x86 标签 wiki,了解我在此答案中描述的内容的详细信息(以及更多)。