【问题标题】:Can compilers generate self modifying code?编译器可以生成自修改代码吗?
【发布时间】:2020-12-13 13:31:07
【问题描述】:

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

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

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

【问题讨论】:

  • 由于代码和数据可能存在于不同的地址空间中,因此无法保证 C++ 解决方案。被允许?当然,UB。
  • 即使编译器被允许这样做,从实际实现来看,您认为他们将如何做到这一点? static 的初始化必须是线程安全的,这意味着生成的代码必须以某种方式确保在修改初始化时另一个线程不会尝试访问 static
  • 如果代码很热,或者至少在分支预测器中,那么在几次调用之后它就会知道跳过初始化检查,因为条件在初始化后永远不会改变。
  • @RemyLebeau:这不是一个很难解决的问题。使用jmp rel32 或其他任何“冷”代码部分启动函数,该代码执行互斥以在一个线程中运行非常量静态初始化程序。构建完成后,使用 8 字节原子 CAS 或存储将 5 字节指令替换为不同的指令字节。可能只是一个 NOP,或者可能是在“冷”代码的顶部完成的一些有用的事情。或者在非 x86 上,只需一个字存储即可替换一条跳转指令。当然,最大的问题是在具有 W^X 内存保护的系统上执行此操作。
  • @RemyLebeau:没什么。这就是为什么你在跳转到的代码中放入与正常相同的互斥代码,就像我说的那样。它的大小对长期执行速度几乎没有影响,因为它只在启动期间运行,如果页面中没有其他东西是热的,它可以从 RAM 中被逐出(这就是为什么你将“冷” init 函数组合在一个部分中。)跨度>

标签: c++ assembly compiler-optimization self-modifying


【解决方案1】:

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

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

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

正如 Remy Lebeau 和 Nathan Oliver 在 cmets 中提到的那样,还需要考虑线程安全问题,但它们可能可以得到解决,因为有各种热补丁可执行文件的解决方案。

【讨论】:

    【解决方案2】:

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

    热补丁(通常通过外部工具)是一回事,如果编译器生成代码来简化热补丁,它是非常可行的,即函数以可以原子替换的足够长的指令开始。

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

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

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

    仅适用于 非常量 初始化器,当然也仅适用于静态 locals。像 static int foo = 1; 这样的本地代码将与在全局范围内一样编译为带有标签的 .long 1(GCC for x86,GAS 语法)。

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

    例如

    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 微秒:一个零扩展字节负载,一个未采用的宏融合测试和分支 (test + je)。但是,是的,它对于 L1i 缓存和解码的 uop 缓存都具有非零代码大小,并且通过前端发出非零成本。还有一个额外的静态数据字节,必须在缓存中保持热才能获得良好的性能。

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

    但在没有廉价获取负载的 ISA 上,情况就不那么好了。(例如 ARMv8 之前的 ARM)。不是在初始化静态变量后以某种方式将所有线程安排到屏障()一次,而是每次检查保护变量都是一个获取负载。但在 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 或其他任何“冷”代码部分开始该函数,该代码执行互斥以在一个线程中运行非常量静态初始化程序。 (因此,如果您确实有多个线程在一个完成并修改代码之前开始运行它,那么一切都会按照现在的方式运行。)

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

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

    【讨论】:

    • 嗯...我猜在 OpenBSD 上您需要将要修改的页面复制到新页面,修改新页面,然后将新页面映射到旧页面。
    • @RossRidge:有趣的想法,是的,可能比在您修改页面时暂停所有其他线程更好。实际上,提高这样做的成本意味着首先不进行自我修改更具吸引力。
    【解决方案3】:

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

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

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

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

    【讨论】:

    • IIRC,另一个例子是geninterrupt(n),它应该生成软件中断向量n。由于 8086 上的 INT 指令仅将向量作为立即数,因此这是通过自修改代码实现的。
    • @NateEldredge -- 是的。 geninterrupt(n) 创建了一个 0xCC 的操作码,然后是 n。那必须是可执行代码。这就是我了解PrestoChangoSelector的地方。
    • @NateEldredge -- 但是,经过反思,geninterrupt(n) 不是自修改代码,只是动态生成代码。它在数据段中构建 INT 指令,然后使该段可执行。
    • 我回想 8086 本身(或实模式 386),没有内存保护,所以一切都是可执行的。我似乎记得它是通过修改代码来完成的;如果 INT 指令是在其他地方构建的,则会出现不必要的跳转。
    • @NateEldredge -- 可能是我记错了。
    猜你喜欢
    • 1970-01-01
    • 2023-03-25
    • 2014-01-16
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-04-28
    • 1970-01-01
    • 2013-05-22
    相关资源
    最近更新 更多