C语言 如何同步 x86 指令缓存



我喜欢例子,所以我用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似乎比这更聪明。

我猜 cpu 会在指令指针进行大幅跳跃时将 RAM(假设c甚至驻留在 RAM 中)与指令缓存进行比较(就像上面对 mmap 内存的调用一样),并在缓存不匹配时使缓存失效(全部?),但我希望获得更准确的信息。特别是,我想知道这种行为是否可以被认为是可预测的(除非硬件和操作系统有任何差异),并被依赖?

(我可能应该参考英特尔手册,但那东西有数千页长,我往往会迷失在其中......

您所做的通常称为自修改代码。英特尔的平台(可能还有AMD的平台)可以为您维护I/D缓存一致性,正如手册所指出的那样(手册3A,系统编程)

11.6 自修改代码

写入当前缓存在处理器导致关联的高速缓存行(或行)失效。

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

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

例如,序列化操作总是由许多其他体系结构(如 PowerPC)请求,其中必须明确完成(E500 核心手册):

3.3.1.2.1 自修改代码

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

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

您提出的代码在没有窥探或高级缓存一致性功能的体系结构上是不可靠的,因此可能会失败。

希望这有帮助。

这很简单;写入指令缓存中某个缓存行中的地址会使它从指令缓存中失效。不涉及"同步"。

顺便说一下,许多x86处理器(我工作过)不仅窥探指令缓存,还窥探管道,指令窗口 - 当前正在运行的指令。 因此,自修改代码将在下一条指令中生效。 但是,建议您使用像 CPUID 这样的序列化指令来确保执行新编写的代码。

CPU 会自动处理缓存失效,您无需手动执行任何操作。 软件无法合理地预测在任何时间点 CPU 缓存中的内容,因此由硬件来解决这个问题。 当 CPU 看到您修改了数据时,它会相应地更新其各种缓存。

我刚刚在我的一个搜索中到达了这个页面,想分享我在 Linux 内核领域的知识!

您的代码按预期执行,这里对我来说没有什么惊喜。mmap() 系统调用和处理器缓存一致性协议为您完成了这个技巧。旗帜"PROT_READ|PROT_WRITE|PROT_EXEC"要求 mmamp() 正确设置此物理页面的 iTLB、一级缓存的 dTLB 和二级缓存的 TLB。这种低级架构特定的内核代码根据处理器架构(x86,AMD,ARM,SPARC等)而有所不同。这里的任何内核错误都会搞砸你的程序!

这只是为了解释目的。假设您的系统没有执行太多操作,并且在"a[0]=0b01000000;"和"printf(""):"的开头之间没有进程切换...此外,假设您有 1K 的 L1 iCache,处理器中有 1K dCache,内核中有一些 L2 缓存,.(现在这些是几MB的数量级)

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

最新更新