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

Is it guaranteed that x86 instruction fetch is atomic, so that rewriting an instruction with a short jump is safe for concurrent thread execution?

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

因此假定取指令是原子的。

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


上下文:LLVM在https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/interception/interception_win.cpp. This is used at least for Address Sanitizer, maybe for something else too. It implements HotPatch as 3rd method (line 61中拦截了API个函数:

// 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.

MSVC 生成的二进制文件特意与此技术兼容/hotpatch 编译器选项确保函数中的第一条指令至少为 2 个字节,/functionpadmin 链接器选项使函数之间的间隙足以适应间接跳转。在 x86-64 上,这些选项不被识别,因为它们总是隐含的。参见

我的印象是HotPatch在执行被拦截的函数时也意味着安全。然而,我正在查看的 API 拦截甚至没有尝试以原子方式编写跳转 (line 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;
}

所以我想知道使热补丁对并发执行安全是否是一个目标,甚至是否可能。

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

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

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

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

To write self-modifying code and ensure that it is compliant with current and future versions of the IA-32 architectures, use one of the following coding options:

(* OPTION 1 *)
Store modified code (as data) into code segment;
Jump to new code or an intermediate location;
Execute new code;

(* OPTION 2 *)
Store modified code (as data) into code segment;
Execute a serializing instruction; (* For example, CPUID instruction *)
Execute new code;

The use of one of these options is not required for programs intended to run on the Pentium or Intel486 processors, but are recommended to ensure compatibility with the P6 and more recent processor families.

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

A write to a memory location in a code segment that is currently cached in the processor causes the associated cache line (or lines) to be invalidated. This check is based on the physical address of the instruction. In addition, the P6 family and Pentium processors check whether a write to a code segment may modify an instruction that has been prefetched for execution. If the write affects a prefetched instruction, the prefetch queue is invalidated. This latter check is based on the linear address of the instruction

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

Store modified code (as data) into code segment;  
Execute new code;

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

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

Store modified code (as data) into code segment;
Execute a serializing instruction; (\* For example, CPUID instruction \*)
Execute new code;

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

Store modified code (as data) into code segment;
If (486 or AMD before K5) Jump to new code;
ElseIf (Intel P5 or later) Execute a serializing instruction; (\* For example, CPUID instruction \*)
Else; (\* Do nothing on AMD K5 and later \*)
Execute new code;

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

Store modified code (as data) into code segment;
Execute new code;

如前所述,在实践中,这在任何情况下(是否存在别名)都可以正常工作。

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

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

第 8.1.3 节说明了有关 XMC 的以下内容:

To write cross-modifying code and ensure that it is compliant with current and future versions of the IA-32 architecture, the following processor synchronization algorithm must be implemented:

(* Action of Modifying Processor *)
Memory_Flag := 0; (* Set Memory_Flag to value other than 1 *)
Store modified code (as data) into code segment;
Memory_Flag := 1;

(* Action of Executing Processor *)
WHILE (Memory_Flag ≠ 1)
Wait for code to update;
ELIHW;
Execute serializing instruction; (* For example, CPUID instruction *)
Begin executing modified code;

(The use of this option is not required for programs intended to run on the Intel486 processor, but is recommended to ensure compatibility with the Pentium 4, Intel Xeon, P6 family, and Pentium processors.)

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

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

更方便

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

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

(\* Action of Modifying Processor \*)    
Store 0xEB;     
Store offset;   

(\* Action of Executing Processor \*)      
Execute the first instruction of the function, which is at least two bytes in size;

如果两个字节越过指令缓存行边界,可能会出现以下情况:

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

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

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

如果这两个字节完全包含在同一个 16 字节的提取单元中,并且如果编译器发出的代码使得这两个字节可能不会作为一个单元自动写入,则有可能一个字节到达 GO 并被提取并在另一个字节到达 GO 之前由执行处理器执行。因此,同样在这种情况下,执行处理器可能会尝试执行以新字节和旧字节开头的指令。

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

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

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

如果满足某些要求,AMD 正式支持非同步 XMC。英特尔在体系结构上根本不支持非同步 XMC,但如果已经讨论过某些要求得到满足,它在实践中确实有效。

关于以下评论:

// 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.

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