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

jan*_*b04 9 c++ assembly self-modifying compiler-optimization

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

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

C++ 编译器是否允许生成这样的代码,如果不允许,为什么?我听说它可能会对缓存产生负面影响,但我不知道详细信息。

Ros*_*dge 8

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

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

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

正如 Remy Lebeau 和 Nathan Oliver 在评论中提到的,还有线程安全问题需要考虑,但它们可能可以解决,因为有各种解决方案可以对此类可执行文件进行热修补。


Pet*_*des 7

是的,那将是合法的。ISO C++ 对能够通过强制转换为unsigned char*. 在大多数实际实现中,它是明确定义的,除了在代码和数据具有单独地址空间的纯哈佛机器上。

热修补(通常通过外部工具)是一件事,如果编译器生成代码以使其变得容易,即该函数以可以原子替换的足够长的指令开始,则它是非常可行的。

正如罗斯指出的那样,大多数 C++ 实现的自我修改的一个主要障碍是它们为通常将可执行页面映射为只读的操作系统编写程序。W^X 是避免代码注入的重要安全功能。只有对于具有非常热的代码路径的非常长时间运行的程序,进行必要的系统调用以使页面 read+write+exec 临时,原子地修改指令,然后将其翻转回来,总体上是值得的。

在像 OpenBSD 这样真正强制 W^X 的系统上是不可能的,不让mprotect一个页面同时具有 PROT_WRITE 和 PROT_EXEC。如果其他线程可以随时调用该函数,则使页面暂时不可执行是行不通的。

通常说一个静态变量初始化被包装在一个 if 中,以防止它被多次初始化。

仅适用于非常量初始值设定项,当然也仅适用于静态locals。本地 likestatic int foo = 1;将与全局范围内的相同,编译为.long 1(x86 的 GCC,GAS 语法),上面带有标签。

但是是的,使用非常量初始值设定项,编译器会发明一个他们可以测试的保护变量。他们安排了一些东西,使守卫变量是只读的,不像读者/作者锁,但这在快速路径上仍然需要一些额外的指令。

例如

int init();

int foo() {
    static int counter = init();
    return ++counter;
}
Run Code Online (Sandbox Code Playgroud)

使用GCC10.2 -O3 为 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
Run Code Online (Sandbox Code Playgroud)

因此,快速路径检查在主流 CPU 上花费 2 uop:一个零扩展字节负载,一个未采用的宏融合测试和分支 ( test + je)。但是,是的,它对于 L1i 缓存和解码 uop 缓存都具有非零代码大小,并且通过前端发出非零成本。还有一个额外字节的静态数据,必须在缓存中保持热状态才能获得良好的性能。

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

但是在没有廉价获取负载的情况下,ISA 上的情况就不那么好了。 (例如 ARMv8 之前的 ARM)。不是在初始化静态变量后以某种方式对所有线程进行 barrier() 一次,而是每次检查保护变量都是获取负载。但在 ARMv7 及更早版本上,这是通过完整内存屏障dmb ish(数据内存屏障:内部可共享)完成的,包括排空存储缓冲区,与atomic_thread_fence(mo_seq_cst). (ARMv8 有ldar(word) / ldab(byte) 来获取负载,使它们既美观又便宜。)

Godbolt 与 ARMv7 叮当声

# 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
Run Code Online (Sandbox Code Playgroud)

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

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

jmp rel32使用“冷”代码段或其他代码启动函数,该代码段执行互斥以在一个线程中运行非常量静态初始值设定项。(因此,如果您确实有多个线程在一个线程完成并修改代码之前开始运行它,那么它会以现在的方式工作。)

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

或者在具有相同宽度的固定宽度指令的非 x86 上,它可以原子地存储,只需一个字存储就可以替换一个跳转指令。


Pet*_*ker 5

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

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

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

早期版本的 Windows 有一个系统调用,用于在数据和代码之间重新映射内存段选择器。如果你PrestoChangoSelector用数据段选择器调用(是的,这就是它的名字),它会给你一个指向相同物理内存的代码段选择器,反之亦然。