编译器可以生成自修改代码吗?

Can compilers generate self modifying code?

通常说一个static变量初始化包裹在一个if中以防止它被多次初始化。

对于这个和其他一次性条件,让代码在第一次通过后通过自我修改删除条件会更有效。

是否允许 C++ 编译器生成此类代码?如果不允许,为什么?听说可能对缓存有负面影响,不知道具体情况

没有什么可以阻止编译器实现您的建议,但它是解决非常小的性能问题的相当重量级的解决方案。

为了在 Windows 或 Linux 上实现 self-modifying 代码,对于典型的 C++ 实现 运行,编译器必须插入会更改权限的代码在代码页上,修改代码,然后恢复权限。与隐含的“if”操作相比,这些操作很容易花费更多的周期来接管程序的生命周期。

这也会导致修改后的代码页无法在进程之间共享。这可能看起来无关紧要,但编译器经常悲观他们的代码(在 i386 的情况下非常糟糕)以实现位置无关的代码,这些代码可以在运行时加载不同的地址而无需修改代码并防止共享代码页。

正如雷米·勒博 (Remy Lebeau) 和内森·奥利弗 (Nathan Oliver) 在评论中提到的那样,还需要考虑线程安全问题,但这些问题可能可以得到解决,因为有多种解决方案可以像这样热修补可执行文件。

在过去,8086 处理器对 floating-point 数学一无所知。您可以添加一个数学协处理器 8087,并编写使用它的代码。 Fo-code 由“陷阱”指令组成,这些指令将控制转移到 8087 以执行 floating-point 操作。

Borland 的编译器可以设置为生成 floating-point 代码,在运行时检测是否安装了协处理器。第一次执行每个 fp 指令时,它会跳转到一个内部例程,该例程将回溯该指令,如果有协处理器,则使用 8087 陷阱指令(后跟几个 NOP),如果有,则调用适当的库例程没有。然后内部例程将跳回到修补指令。

所以,是的,我可以完成。有点。正如各种评论所指出的那样,现代架构使这种事情变得困难或不可能。

早期版本的Windows有一个系统调用re-mapped数据和代码之间的内存段选择器。如果您使用数据段选择器调用 PrestoChangoSelector(是的,这就是它的名字),它将返回一个指向同一物理内存的代码段选择器,反之亦然。

是的,那是合法的。 ISO C++ 对能够通过强制转换为 unsigned char* 的函数指针访问数据(机器代码)做出零保证。在大多数实际实现中,它都得到了很好的定义,除了 pure-Harvard 代码和数据具有独立地址空间的机器。

Hot-patching(通常通过外部工具)是一回事,如果编译器生成代码使其变得简单,即函数以可以原子替换的 long-enough 指令开头,那么它是非常可行的.

正如 Ross 指出的那样,在大多数 C++ 实现中 self-modification 的一个主要障碍是它们为通常映射可执行页面 read-only 的操作系统制作程序。 W^X 是要避免 code-injection 的重要安全功能。仅对于非常 long-running 具有非常热代码路径的程序来说,进行必要的系统调用以使页面 read+write+exec 临时,原子地修改指令,然后将其翻转回来是总体上值得的。

并且在像 OpenBSD 这样真正强制执行 W^X 的系统上是不可能的,不允许进程 mprotect 页面同时包含 PROT_WRITE 和 PROT_EXEC。如果其他线程可以随时调用该函数,则临时创建页面non-executable 不起作用。

It is commonly said that a static variable initialization is wrapped in an if to prevent it from being initialized multiple times.

仅适用于non-constant初始化器,当然也仅适用于静态locals。像 static int foo = 1; 这样的局部变量将编译为与全局范围相同的 .long 1 (GCC for x86,GAS 语法),上面有一个标签。

但是,是的,使用 non-constant 初始值设定项,编译器将发明一个他们可以测试的保护变量。他们安排的东西是 read-only,不像 readers/writers 锁,但在快速路径上仍然需要一些额外的指令。

例如

int init();

int foo() {
    static int counter = init();
    return ++counter;
}

GCC10.2 -O3 for x86-64

编译
foo():             # with demangled symbol names
        movzx   eax, BYTE PTR guard variable for foo()::counter[rip]
        test    al, al
        je      .L16
        mov     eax, DWORD PTR foo()::counter[rip]
        add     eax, 1
        mov     DWORD PTR foo()::counter[rip], eax
        ret

.L16:  # slow path
   acquire lock, one thread does the init while the others wait

所以快速路径检查在主流 CPU 上花费 2 微指令:一个 zero-extending 字节加载,一个 macro-fused test-and-branch (test + je) 即 not-taken .但是,是的,它对 L1i 缓存和 decoded-uop 缓存都有 non-zero code-size,并且通过 front-end 发出 non-zero 成本。还有一个额外字节的静态数据必须在高速缓存中保持热才能获得良好的性能。

通常内联可以忽略不计。如果您实际上 call 在一开始经常用这个来调用一个函数,那么其余的 call/ret 开销就是一个更大的问题。

但是如果没有廉价的获取负载,ISA 上的情况就不太好了。(例如 ARMv8 之前的 ARM)。不是在初始化静态变量后以某种方式将所有线程安排到 barrier() 一次,而是每次检查 guard 变量都是获取负载。但是在 ARMv7 和更早版本上,这是通过 full 内存屏障 dmb ish(数据内存屏障:内部可共享)完成的,其中包括耗尽存储缓冲区,与 atomic_thread_fence(mo_seq_cst)。 (ARMv8 有 ldar(字)/ldab(字节)来获取负载,使它们既好又便宜。)

Godbolt with ARMv7 clang

# ARM 32-bit clang 10.0 -O3 -mcpu=cortex-a15
# GCC output is even more verbose because of Cortex-A15 tuning choices.
foo():
        push    {r4, r5, r11, lr}
        add     r11, sp, #8
        ldr     r5, .LCPI0_0           @ load a PC-relative offset to the guard var
.LPC0_0:
        add     r5, pc, r5
        ldrb    r0, [r5, #4]           @ load the guard var
        dmb     ish                    @ full barrier, making it an acquire load
        tst     r0, #1
        beq     .LBB0_2                @ go to slow path if low bit of guard var == 0
.LBB0_1:
        ldr     r0, .LCPI0_1           @ PC-relative load of a PC-relative offset
.LPC0_1:
        ldr     r0, [pc, r0]           @ load counter
        add     r0, r0, #1             @ ++counter leaving value in return value reg
        str     r0, [r5]               @ store back to memory, IDK why a different addressing mode than the load.  Probably a missed optimization.
        pop     {r4, r5, r11, pc}      @ return by popping saved LR into PC

但是为了好玩,让我们看看您的想法是如何实现的。

假设您可以 PROT_WRITE|PROT_EXEC(使用 POSIX 术语)包含代码的页面,对于大多数 ISA(例如 x86)来说,这不是一个很难解决的问题。

使用 jmp rel32 或任何代码的“冷”部分启动函数,该代码与一个线程中的 运行 和 non-constant 静态初始值设定项互斥。 (因此,如果您确实有多个线程在一个完成并修改代码之前开始 运行 它,那么它的工作方式与现在一样。)

一旦构造完全完成,使用 8 字节原子 CAS 或存储用不同的指令字节替换该 5 字节指令。可能只是一个 NOP,或者可能是在“冷”代码顶部完成的一些有用的东西。

或者在非 x86 上,具有相同宽度的 fixed-width 指令,它可以自动存储,只需一个单词存储就可以替换一个跳转指令。