是否保证 x86 取指令是原子的,以便用短跳转重写指令对于并发线程执行是安全的?

Ale*_*iev 5 x86 atomic self-modifying hotpatching

我认为热补丁假设用 2 字节跳转覆盖任何 2 或更多字节长的指令对于并发执行相同的代码是安全的。

因此取指令被假定为原子的。

考虑到使用前缀可以有超过 8 字节的指令,并且它可以跨越任何对齐的边界,它确实是原子的吗?(或者热补丁是否依赖于函数开始的 16 字节对齐?如果是这样,那么大小超过 8 字节又是什么?)


上下文:LLVM 在https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/interception/interception_win.cpp中拦截了 API 函数。这至少用于 Address Sanitizer,也许也用于其他用途。它将 HotPatch 实现为第三种方法(第 61 行):

// 3) HotPatch
//
//    The HotPatch hooking is assuming the presence of an header with padding
//    and a first instruction with at least 2-bytes.
//
//    The reason to enforce the 2-bytes limitation is to provide the minimal
//    space to encode a short jump. HotPatch technique is only rewriting one
//    instruction to avoid breaking a sequence of instructions containing a
//    branching target.
Run Code Online (Sandbox Code Playgroud)

MSVC 生成的二进制文件是特意与该技术兼容的。编译器选项/hotpatch可确保函数中的第一条指令至少为 2 个字节,/functionpadmin链接器选项可确保函数之间的间隙足以适应间接跳转。在 x86-64 上,这些选项无法识别,因为它们始终是隐含的。请参阅DebugBreak() 中的中断指令 int 3 之前 xchg ax,ax 的用途是什么?

我的印象是,HotPatch 还意味着被拦截的函数执行时的安全性。然而,我正在查看的 API 拦截甚至没有尝试以原子方式编写跳转(第 259 行):

static void WriteShortJumpInstruction(uptr from, uptr target) {
  sptr offset = target - from - kShortJumpInstructionLength;
  if (offset < -128 || offset > 127)
    InterceptionFailed();
  *(u8*)from = 0xEB;
  *(u8*)(from + 1) = (u8)offset;
}
Run Code Online (Sandbox Code Playgroud)

所以我想知道这是否是使热补丁安全以防止并发执行的目标,以及是否有可能。

Had*_*ais 4

指令获取在架构上不保证是原子的。尽管实际上,指令高速缓存填充事务根据定义是原子的,这意味着在事务完成之前,高速缓存中填充的行不能更改(当整行存储在 IFU 中,但不一定存储在指令中时,会发生这种情况)缓存本身)。指令字节还以某种原子粒度传送到指令预解码单元的输入缓冲器。在现代 Intel 处理器上,指令缓存行大小为 64 字节,预编码单元的输入宽度为 16 字节,地址在 16 字节边界上对齐。(请注意,在获取包含这 16 字节的缓存行的整个事务完成之前,可以将 16 字节输入传送到预解码单元。)因此,保证以原子方式获取在 16 字节边界上对齐的指令,以及后续连续指令的至少一个字节,具体取决于指令的大小。但这是微架构的保证,而不是架构的。

\n

在我看来,通过指令获取原子性,您指的是单个指令粒度的原子性,而不是某个固定数量的字节。无论哪种方式,热补丁正常工作都不需要指令获取原子性。这实际上是不切实际的,因为在取指时指令边界是未知的。

\n

如果指令获取是原子的,则仍然可以在仅写入两个字节之一(或不写入任何字节或全部字节)的情况下获取、执行和退出正在修改的指令。写入到达 GO 的允许顺序取决于目标内存位置的有效内存类型。所以热补丁仍然不安全。

\n

Intel 在 SDM V3 的第 8.1.3 节中指定了自修改代码 (SMC) 和交叉修改代码 (XMC) 应如何工作以保证所有 Intel 处理器上的正确性。关于SMC,它说如下:

\n
\n

要编写自修改代码并确保其符合\n当前和未来版本的 IA-32 架构,请使用\n以下编码选项之一:

\n

(* OPTION 1 *)
\n将修改后的代码(作为数据)存储到代码段中;
\n跳转到新代码或中间位置;
\n执行新代码;

\n

(* OPTION 2 *)
\n将修改后的代码(作为数据)存储到代码段中;
\n执行序列化指令;(* 例如CPUID指令*)
\n执行新代码;

\n

对于打算在 Pentium 或 Intel486 处理器上运行的程序来说,不需要使用这些选项之一,但建议\n以确保与 P6 和更新的处理器系列的兼容性。

\n
\n

请注意,最后的陈述是不正确的。作者可能想说的是:“对于打算在 Pentium 或更高版本处理器上运行的程序来说,不需要使用这些选项之一,但建议使用这些选项之一以确保与 Intel486 处理器的兼容性。” 这在 11.6 节中有解释,我想引用其中的一个重要声明:

\n
\n

对当前缓存在处理器中的代码段中的内存位置进行写入会导致关联的缓存行无效。该检查基于指令的物理地址。此外,P6 系列和奔腾处理器会检查对代码段的写入是否可能修改已预取执行的指令。如果写入影响预取\指令,则预取队列无效。后一个检查基于指令的线性地址\n

\n
\n

简而言之,预取缓冲区用于维护指令获取请求及其结果。从 P6 开始,它们被流缓冲区取代,流缓冲区具有不同的设计。该手册仍然对所有处理器使用术语“预取缓冲区”。这里重要的一点是,就架构上的保证而言,预取缓冲区中的检查是使用线性地址而不是物理地址完成的。也就是说,可能所有英特尔处理器都使用物理地址进行这些检查,这可以通过实验证明。否则,这可能会破坏基本的顺序程序顺序保证。考虑在同一处理器上执行以下操作序列:

\n
Store modified code (as data) into code segment;  \nExecute new code;\n
Run Code Online (Sandbox Code Playgroud)\n

假设写入的线性地址的页偏移量与取出的线性地址的页偏移量相同,但线性页号不同。但是,两个页面都映射到同一物理页面。如果我们遵循架构上的保证,旧代码中的指令可能会退出,即使它们相对于修改代码的写入按程序顺序放置在后面。这是因为仅基于比较线性地址无法检测到 SMC 条件,并且允许存储退出,并且稍后的指令可以在提交写入之前退出。实际上,这不会发生,但在架构上是可能的。在 AMD 处理器上,AMD APM V2 第 7.6.1 节指出这些检查基于物理地址。英特尔也应该这样做并使其正式化。

\n

因此,要完全遵守英特尔手册,应该有一个完全序列化指令,如下所示:

\n
Store modified code (as data) into code segment;\nExecute a serializing instruction; (\\* For example, CPUID instruction \\*)\nExecute new code;\n
Run Code Online (Sandbox Code Playgroud)\n

这与手册中的选项 2 相同。但为了与486兼容,部分486处理器不支持CPUID指令。以下代码适用于所有处理器:

\n
Store modified code (as data) into code segment;\nIf (486 or AMD before K5) Jump to new code;\nElseIf (Intel P5 or later) Execute a serializing instruction; (\\* For example, CPUID instruction \\*)\nElse; (\\* Do nothing on AMD K5 and later \\*)\nExecute new code;\n
Run Code Online (Sandbox Code Playgroud)\n

否则,如果保证不存在别名,则以下代码可以在现代处理器上正常工作:

\n
Store modified code (as data) into code segment;\nExecute new code;\n
Run Code Online (Sandbox Code Playgroud)\n

正如已经提到的,实际上,这在任何情况下(无论是否存在别名)都可以正常工作。

\n

如果正在修改的指令存储在不可缓存的内存位置(UC 或 WC)中,则部分或全部 Intel P5+ 和 AMD K5+ 处理器上需要完全序列化指令,除非可以保证写入的位置永远不会从在完成所有需要的修改之前。

\n

在热修补的上下文中,修改字节的线程和执行代码的线程可能碰巧在同一逻辑处理器上运行。如果线程位于不同的进程中,则在它们之间切换需要更改当前进程上下文,这涉及执行至少一条完全序列化指令来更改线性地址空间。无论如何,SMC 的架构要求最终都会得到满足。代码修改不必以原子方式发生,即使它们跨越多个指令。

\n

第 8.1.3 节规定了有关 XMC 的以下内容:

\n
\n

要编写交叉修改代码并确保其符合 IA-32 体系结构的当前和未来版本,必须实现以下处理器同步算法:

\n

(* 修改处理器的动作 *)
\nMemory_Flag := 0; (* 将 Memory_Flag 设置为 1 以外的值 *)
\n将修改后的代码(作为数据)存储到代码段中;
\nMemory_Flag := 1;

\n

(* 执行处理器的动作 *)
\nWHILE (Memory_Flag \xe2\x89\xa0 1)
\n等待代码更新;
\nELIHW;
\n执行序列化指令;(* 例如CPUID指令*)
\n开始执行修改后的代码;

\n

(对于要在 Intel486 处理器上运行的程序,不需要使用此选项,但建议使用此选项以确保与 Pentium 4、Intel Xeon、P6 系列和 Pentium 处理器的兼容性。)

\n
\n

由于某些英特尔处理器勘误表中提到的不同原因,此处需要完全序列化指令:跨处理器监听可能仅监听指令缓存,而不监听预取缓冲区或内部管道缓冲区。处理器可能会在观察到所有修改之前推测性地获取指令,并且在没有完全串行化的情况下,它可能会执行新旧指令字节的混合。完全序列化指令可防止推测性读取。没有序列化的代码称为非同步XMC。正如手册所述,486 上不需要序列化。

\n

AMD 处理器还需要在修改指令之前在执行处理器上执行完全串行化的指令。在 AMD 上,MFENCE是完全序列化的并且比CPUID.

\n

Intel 的算法假设执行处理器一直处于等待状态,直到Memory_Flag 更改为 1。假设 的初始状态Memory_Flag不为 1。如果两个处理器并行执行,则修改处理器应确保执行处理器在 1 之外。修改任何指令之前的执行区域。通常,这可以使用 reader\xe2\x80\x93writer 互斥锁来实现。

\n

现在让我们回到您提供的热修补示例,并检查它是否仅在英特尔处理器上的架构保证方面正常工作。它可以建模如下:

\n
(\\* Action of Modifying Processor \\*)    \nStore 0xEB;     \nStore offset;   \n\n(\\* Action of Executing Processor \\*)      \nExecute the first instruction of the function, which is at least two bytes in size;\n
Run Code Online (Sandbox Code Playgroud)\n

如果两个字节跨越指令高速缓存行边界,则可能会发生以下情况:

\n
    \n
  1. 执行处理器可以将包含第一个字节的行提取到预编码单元的输入缓冲区中,但还不能提取另一行。
  2. \n
  3. 修改处理器(原子地或非原子地)写入两个字节。
  4. \n
  5. 在字节到达 GO 之前,将监听正在执行的处理器的指令高速缓存中的两个高速缓存行,如果找到,则使其无效。
  6. \n
  7. 此时,第一个字节已经被传送到管道中,并且没有被 RFO 监听刷新(尽管它应该在 Pentium P5 及更高版本上)。现在提取第二行,其中包含修改后的字节。处理器继续解码并执行以旧字节和新字节开始的指令。
  8. \n
\n

顺便说一句,指令粒度上的取指令原子性可以防止这种情况发生。

\n

我认为,如果两个字节跨越预解码块边界(16 字节)并且由于前面提到的勘误表而位于同一行,则这种情况也是可能的。尽管这种情况不太可能发生,因为高速缓存行必须在两次连续的 16 字节块提取到预解码单元之间恰好无效。

\n

如果这两个字节完全包含在同一个 16 字节获取单元中,并且如果编译器发出的代码使得这两个字节不能作为单个单元原子写入,则一个字节可能到达 GO 并由执行程序获取并执行在其他字节到达 GO 之前处理器。因此,在这种情况下,执行处理器也可能尝试执行以新字节和旧字节开始的指令。

\n

最后,如果这两个字节完全包含在同一个 16 字节获取单元中,并且如果编译器发出代码使得两个写入的字节原子地到达 GO,则执行处理器将执行旧字节或新字节,而不会执行混合字节。reader\xe2\x80\x93writer 互斥语义是自然提供的。

\n

函数的默认 16 字节对齐可确保两个字节位于同一 16 字节读取单元中。到 16 字节对齐地址的单个 2 字节存储指令在 486 及更高版本上保证是原子的(第 8.1.1 节)。然而,不保证存储*(u8*)from = 0xEB;*(u8*)(from + 1) = (u8)offset;存储指令被编译成单个存储指令。对于多个存储指令,在所有指令到达 GO 之前,修改处理器上可能会发生中断,从而大大增加了执行处理器执行混合字节的机会。这是一个错误。依赖 16 字节对齐在实践中是有效的,但它违反了第 8.1.3 节。

\n

在 AMD 处理器上,前两个字节也必须以原子方式修改,但根据 APM V2 第 7.6.1 节中的体系结构要求,16 字节对齐是不够的。正在修改的指令必须完全包含在自然对齐的四字内。如果编译器在函数开头发出一条虚拟的 2 字节指令,那么它将满足此要求。

\n

如果满足某些要求,AMD 正式支持非同步 XMC。英特尔在架构上根本不支持非同步 XMC,尽管如前所述,如果满足某些要求,它在实践中确实可以工作。

\n

关于以下评论:

\n
// 3) HotPatch\n//\n//    The HotPatch hooking is assuming the presence of an header with padding\n//    and a first instruction with at least 2-bytes.\n//\n//    The reason to enforce the 2-bytes limitation is to provide the minimal\n//    space to encode a short jump. HotPatch technique is only rewriting one\n//    instruction to avoid breaking a sequence of instructions containing a\n//    branching target.\n
Run Code Online (Sandbox Code Playgroud)\n

那么,如果第一条指令的大小只有一个字节,则无论对齐和原子性如何,在退出第一条指令之后但在退出第二条指令之前,执行处理器上都会立即发生中断。如果修改处理器在执行处理器从处理中断返回之前修改了字节,那么当它返回时,行为是不可预测的。因此,即使函数内部没有分支目标,第一条指令的大小仍然必须至少为 2 个字节。

\n

  • 回复:“实验证明”Intel CPU 基于物理地址检测 SMC:[Observing stale instructions fetching on x86 with self-modifying code](/sf/ask/1217689021/) 就是那个实验,是的人们测试过的 CPU,即使使用同一页面的两个共享映射,也总会看到新指令的执行。 (2认同)