4

我认为热补丁假设用 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 实现为第 3 种方法(第 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.

MSVC 生成的二进制文件特意与此技术兼容。编译器/hotpatch选项可确保函数中的第一条指令至少为 2 个字节,/functionpadmin链接器选项可确保函数之间的间隙足以适应间接跳转。在 x86-64 上,这些选项无法识别,因为它们始终是隐含的。请参阅DebugBreak() 中的 break 指令 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;
}

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

4

1 回答 1

3

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

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

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

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

要编写自修改代码并确保它与 IA-32 架构的当前和未来版本兼容,请使用以下编码选项之一:

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

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

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

请注意,最后一条语句是不正确的。作者可能打算改为:“在奔腾或更高版本处理器上运行的程序不需要使用这些选项之一,但建议使用这些选项以确保与 Intel486 处理器兼容。” 这在第 11.6 节中进行了解释,我想从中引用一个重要的声明:

对当前缓存在处理器中的代码段中的内存位置的写入会导致相关的缓存行(或多个行)无效。此检查基于指令的物理地址。此外,P6 系列和 Pentium 处理器检查对代码段的写入是否会修改已预取执行的指令。如果写入影响预取指令,则预取队列无效。后一种检查基于指令的线性地址

简而言之,预取缓冲器用于维护指令取指请求及其结果。从 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+ 处理器上需要完全序列化指令,除非可以保证写入的位置从未从在完成所有需要的修改之前。

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

第 8.1.3 节对 XMC 做了以下说明:

要编写交叉修改代码并确保它与 IA-32 架构的当前和未来版本兼容,必须实现以下处理器同步算法:

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

(* 执行处理器的动作 *)
WHILE (Memory_Flag ≠ 1)
等待代码更新;
艾丽华;
执行序列化指令;(* 例如,CPUID 指令 *)
开始执行修改后的代码;

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

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

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

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

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

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

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

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

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

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

函数的默认 16 字节对齐确保两个字节在同一个 16 字节获取单元中。在 486 及更高版本(第 8.1.1 节)上,保证对 16 字节对齐地址的单个 2 字节存储指令是原子的。但是,存储*(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.

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

于 2021-12-01T07:37:44.850 回答