24

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

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

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

4

5 回答 5

26

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

11.6 自修改代码

对当前缓存在处理器中的代码段中的内存位置的写入会导致相关的缓存行(或多个行)无效。

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

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

例如,许多其他架构(如 PowerPC)总是要求序列化操作,必须明确地完成(E500 核心手册):

3.3.1.2.1 自修改代码

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

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

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

希望这有帮助。

于 2012-06-12T10:25:01.057 回答
6

这很简单;写入指令高速缓存中的一个高速缓存行中的地址会使指令高速缓存中的地址无效。不涉及“同步”。

于 2012-06-12T01:15:53.723 回答
4

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

于 2012-06-12T01:15:57.433 回答
4

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

于 2013-08-22T18:49:56.680 回答
3

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

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

这只是为了解释的目的。假设您的系统没有做太多事情,并且在“a[0]=0b01000000;”之间没有进程切换 并开始 "printf("\n"):"... 另外,假设您的处理器中有 1K 的 L1 iCache、1K dCache 和核心中有一些 L2 缓存,. (现在这些都是几 MB 的顺序)

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