x86指令缓存如何同步?

26

我喜欢示例,所以我在C语言中编写了一些自修改代码...

#include <stdio.h>
#include <sys/mman.h> // linux

int main(void) {
    unsigned char *c = mmap(NULL, 7, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|
                            MAP_ANONYMOUS, -1, 0); // get executable memory
    c[0] = 0b11000111; // mov (x86_64), immediate mode, full-sized (32 bits)
    c[1] = 0b11000000; // to register rax (000) which holds the return value
                       // according to linux x86_64 calling convention 
    c[6] = 0b11000011; // return
    for (c[2] = 0; c[2] < 30; c[2]++) { // incr immediate data after every run
        // rest of immediate data (c[3:6]) are already set to 0 by MAP_ANONYMOUS
        printf("%d ", ((int (*)(void)) c)()); // cast c to func ptr, call ptr
    }
    putchar('\n');
    return 0;
}

看起来这个方法有效:

>>> gcc -Wall -Wextra -std=c11 -D_GNU_SOURCE -o test test.c; ./test
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29

但是,说实话,我并没有预料到它会工作。我预计包含c[2]=0的指令将在第一次调用c时被缓存,之后所有连续调用c将忽略对c所做的重复更改(除非我明确地使缓存无效)。幸运的是,我的 CPU 似乎比那聪明多了。

我猜测当指令指针进行较大跳跃(例如调用上面的 mmapped 存储器),CPU 会将 RAM(假设c甚至驻留在 RAM 中)与指令缓存进行比较,并在不匹配(全部?)时使缓存失效,但我希望能获得更精确的信息。特别地,我想知道这种行为是否可以被认为是可预测的(除了硬件和操作系统的差异),并且是否可靠?

(我可能应该查阅英特尔手册,但那本书有成千上万页,我往往会迷失其中...)


你使用的是什么环境/编译器,可以让mmap和奇怪的0b...二进制语法(不是有效的C语法)正常工作? - R.. GitHub STOP HELPING ICE
1
mmap 是纯 POSIX,但 0b... 这个东西看起来像是一些遗留的 DOS 编译器的东西... 我不知道 GCC 居然也有它。 - R.. GitHub STOP HELPING ICE
2
@mmpeng: mmap在c11和GNU标准中都完全缺失——它是POSIX的一部分,而POSIX是一个完全独立的标准。如果你的系统支持POSIX,那么无论你使用什么编译器标志,它都将支持mmap。如果它不支持POSIX,那么无论你使用什么-std标志,mmap(可能)都无法工作。 - Chris Dodd
1
严格来说,需要一个特性测试宏(通常以-D_POSIX_C_SOURCE=200809L-D_XOPEN_SOURCE=700的形式指定)才能获得POSIX接口。 - R.. GitHub STOP HELPING ICE
你应该使用纯汇编而不是C语言来更好地理解x86部分。类似于 https://dev59.com/13I-5IYBdhLWcg3woJ5I - Ciro Santilli OurBigBook.com
5个回答

27
你所做的通常被称为自修改代码。正如手册(手册3A,系统编程)中指出,英特尔平台(可能也包括AMD)会为您执行维护

11.6 自修改代码

对当前在处理器中缓存的代码段内存位置进行写操作会使相关联的高速缓存行(或行)无效。

但是,只要用于修改和获取的线性地址相同,此断言就是有效的,而这不适用于调试器和二进制加载程序,因为它们不在同一地址空间运行:

包含自修改代码的应用程序使用相同的线性地址来修改和获取指令。系统软件(例如调试器)可能会使用与获取指令时不同的线性地址来修改指令,则会执行序列化操作(例如CPUID指令),在修改后的指令执行之前,这将自动重新同步指令缓存和预取队列。

例如,许多其他体系结构(例如PowerPC)始终要求进行序列化操作,其中必须显式完成(E500 Core Manual):

3.3.1.2.1 自修改代码

当处理器修改任何可能包含指令的内存位置时,软件必须确保指令缓存与数据内存一致,并且将修改内容对指令获取机制进行可见。即使高速缓存已禁用或页面标记为禁止缓存,也必须执行此操作。

有趣的是,PowerPC要求在禁用缓存时发出上下文同步指令;我怀疑它强制刷新更深层次的数据处理单元,例如加载/存储缓冲区。

你提出的代码在没有缓存嗅探或先进的高速缓存一致性设施的架构上是不可靠的,因此很可能会失败。

希望这可以帮到你。


1
相关:在x86上观察使用自修改代码的陈旧指令获取 - 当前的x86 CPU实际上比手动保证更强。即使从同一物理页的不同虚拟映射中,您也无法让它们在存储后执行陈旧指令。是的,x86很少具有一致的I-cache(和流水线);这一切都是为了保持与现有代码的向后兼容性而发展的,该代码适用于早期非流水线的x86 CPU,例如8086和386。与从一开始就进行流水线处理的其他ISA不同。 - Peter Cordes

6
很简单,向指令高速缓存中一个缓存行的地址写入数据会使该缓存行从指令高速缓存中失效。这个过程不需要进行“同步”。

仅仅从icache中使其失效通常是不够的,因为它可能已经在管道的某个地方了。如果您的系统保留相对严格的内存排序,您还需要进行深度刷新以清除该代码行的任何旧副本和任何年轻的依赖计算(基本上是所有内容)。 - Leeor
1
@Leeor:由于这个问题特别涉及到x86,我想补充一下,据我所知,英特尔处理器上的自动缓存失效伴随着深度刷新,因此SMC可以正常工作(尽管对性能的代价很高)。 - Nathan Fellman
1
更准确地说,“它会触发自修改代码机器核弹(也称为流水线刷新)”。英特尔CPU有一个性能计数器事件可以实现这一点(类似于machine_nuke.smc,如果我没记错的话)。此外,我认为我曾经读过像OP代码中包含的calljmp指令对于保证检测SMC是必不可少的。修改其后面的下一条指令的存储可能对某些CPU没有立即效果。 - Peter Cordes

5
顺便说一下,许多x86处理器(我曾经使用过的)不仅会监视指令缓存,还会监视管道、指令窗口——当前正在执行的指令。因此,自修改代码将在下一条指令中生效。但是,建议您使用类似CPUID的序列化指令来确保您新写入的代码将被执行。

4

我刚刚在搜索中找到了这个页面,想要分享一下我对Linux内核领域的知识。

你的代码按预期执行,对我来说没有什么意外。mmap()系统调用和处理器缓存一致性协议为你完成了这个技巧。标志“PROT_READ|PROT_WRITE|PROT_EXEC”请求mmamp()正确设置物理页面的L1 Cache的iTLB、dTLB和L2 Cache的TLB。这个低级别的架构特定内核代码会根据处理器架构(x86、AMD、ARM、SPARC等)以不同的方式进行操作。任何内核错误都会使你的程序出错!

这只是为了解释目的。

假设你的系统没有多少活动,而且在“a[0]=0b01000000;”和“printf("\n"):”开始之间没有进程切换……还假设你的处理器有1K的L1 iCache、1K dCache和一些L2 Cache。(现在这些都是几MB的数量级)

  1. mmap()设置了你的虚拟地址空间和iTLB1、dTLB1和TLB2s。
  2. “a[0]=0b01000000;”实际上将陷入(H/W魔法)内核代码,并设置你的物理地址,内核将加载所有处理器TLB。然后,你将回到用户模式,你的处理器实际上会将16个字节(H/W魔法a[0]到a[3])加载到L1 dCache和L2 Cache中。只有当你引用a[4]等时(现在忽略预测加载!),处理器才会再次进入内存。当你完成“a[7]=0b11000011;”时,你的处理器已经在外部总线上进行了2次16字节的读取。仍然没有实际写入物理内存。所有写入都是在L1 dCache(H/W魔法,处理器知道)和L2 Cache中发生的,并设置了缓存行的DIRTY位。
  3. “a[3]++;”将在汇编代码中产生STORE指令,但处理器只会将其存储在L1 dCache&L2中,不会进入物理内存。
  4. 让我们来看一下函数调用“a()”。处理器再次从L2 Cache中进行指令提取,然后加载到L1 iCache等。
  5. 由于低级别的mmap()系统调用和缓存一致性协议的正确实现,这个用户模式程序在任何Linux下、任何处理器下的结果都是相同的!
  6. 如果你在没有操作系统mmap()系统调用的嵌入式处理器环境下编写此代码,你会发现你所期望的问题。这是因为你没有使用硬件机制(TLB)或软件机制(内存屏障指令)。

什么是TLB2?通常,代码/数据没有单独的TLB条目;但我知道x86有点奇怪。TLB是一个单独的MMU缓存,与dcacheicache无关。 - artless noise
TLB2 => 我指的是L2缓存的TLB。在处理器核心内部和/或外部,MMU和所有级别的高速缓存都存在TLB。内核应该适当地管理所有这些TLB,以有效地利用处理器硬件。高速缓存TLB由处理器硬件用于处理高速缓存一致性协议。当处理器在所有级别的高速缓存(通常为L1、L2,有些情况甚至为L3)中发生缓存未命中时,在总线上放置虚拟地址时,MMU单元使用MMU TLB进行虚拟到物理转换。 - sukumarst
2
“TLB存在于MMU和处理器核心内部和/或外部的所有缓存级别中。” - 实际上,大多数CPU并非如此。对于物理索引和物理标记的缓存不存在TLB。一些x86 CPU可能有L2 TLB,但这不一定与L2缓存有任何关系。据我所知,没有x86有L3 TLB。然而,我最喜欢的实现将L2 TLB和L2统一I/D缓存放在同一个物理数组中-这样你就有了一个单一的结构。//您可能正在考虑GPU,它们通常具有虚拟缓存,并且每个缓存都有TLB。 - Krazy Glew

4

CPU会自动处理缓存失效,您不需要手动操作。软件无法合理地预测任何时刻CPU缓存中会或不会有什么内容,因此这是硬件的责任。当CPU检测到您修改了数据时,它会相应地更新其各种缓存。


1
它并非必然是完全自动化的。对于其他处理器,例如ARM,您可能需要插入一条特殊指令来使管道/缓存无效。 - starblue
这对于英特尔处理器中的指令缓存来说并不是真实的。写入代码段并不总是会使L1代码缓存和iTLB失效。在编写自修改代码时需要特别小心。 - ugoren
在这种情况下,代码不应该在i-cache中,因为它是新创建的(由于MAP_PRIVATE是写时复制),并且从未尝试执行过。如果这是修改现有代码而不是创建新代码的尝试,则可能需要额外的预防措施。虽然出于程序员的理智和可移植性考虑,我希望'mmap'和编译器会尽可能地为您处理这个问题。 - bta

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接