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

10

常言道,为了避免静态变量被多次初始化,其初始化通常会被包装在一个if语句中。

对于这种和其他一次性条件,代码通过自我修改在首次执行后删除条件将更加高效。

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


1
即使编译器被允许这样做,从实际实现的角度来看,你认为它们如何能够做到呢?static的初始化必须是线程安全的,这意味着生成的代码必须以某种方式确保另一个线程在修改过程中不会尝试访问static - Remy Lebeau
1
如果代码是热的,或者至少在分支预测器中,那么经过几次调用后,它将知道跳过初始化检查,因为条件在初始化后永远不会改变。 - NathanOliver
1
@RemyLebeau:这不是一个难以解决的问题。在函数中使用jmp rel32或其他方式,跳转到执行互斥操作的“冷”代码段,以便在一个线程中运行非常量静态初始化器。一旦构造完全完成,使用8字节原子CAS或存储来替换那个5字节指令为不同的指令字节。可能只是一个NOP,或者可能是在“冷”代码顶部完成的某些有用的操作。或者在非x86上,只需一个单词存储即可替换一个跳转指令。当然,大问题是在具有W^X内存保护的系统上执行此操作。 - Peter Cordes
@PeterCordes 那么,有什么方法可以防止另一个线程在它被覆盖之前执行相同的jmp指令吗? - Remy Lebeau
1
@RemyLebeau:没有什么。这就是为什么你要像我说的那样,在你跳转到的代码中放置相同的互斥代码。它的大小在长期执行速度方面几乎没有影响,因为它只在启动期间运行,并且如果页面中没有其他热点内容,它可以从RAM中驱逐出去(这就是为什么你将“冷”初始化函数组合到一个部分中的原因)。 - Peter Cordes
显示剩余3条评论
3个回答

9

编译器并没有阻止你实现你提出的想法,但这是一个非常重量级的解决方案,用于解决一个非常小的性能问题。

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

这也会导致修改后的代码页无法在进程之间共享。这可能看起来微不足道,但编译器通常会压缩他们的代码(i386情况下非常严重),以实现可以在运行时加载到不同地址而无需修改代码并防止代码页共享的位置独立代码。

正如Remy Lebeau和Nathan Oliver在评论中提到的那样,还需要考虑线程安全问题,但是可以像这样热修补可执行文件有各种解决方案。


7

是的,这是合法的。ISO C++不保证通过将函数指针强制转换为unsigned char*能够访问数据(机器代码)。在大多数真实的实现中,它是良好定义的,除了纯哈佛架构的机器,在这种机器上,代码和数据具有单独的地址空间。

热补丁(通常由外部工具完成)是一件事情,并且如果编译器生成使其易于进行的代码,则非常可行,即函数以足够长的指令开始,可以原子地替换。

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

在像OpenBSD这样真正强制实施W^X的系统上不可能,因为它不允许进程mprotect同时具有PROT_WRITE和PROT_EXEC的页面。如果其他线程随时可以调用该函数,则将页面暂时设置为不可执行无效。

常言道,静态变量的初始化使用if语句包裹起来是为了防止其被多次初始化。只适用于非常量初始值,当然也仅适用于静态局部变量。像static int foo = 1;这样的局部变量将与全局范围中编译相同,编译器会生成一个.long 1 (x86 GAS语法下的GCC),并在其上加上标签。但是,对于非常量初始值,编译器将发明一个守卫变量进行测试。他们会安排好守卫变量是只读的,而不是像读者/写者锁那样,但这仍会在快速路径上增加几个额外的指令。例如:
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个uops:一个零扩展字节加载,一个未执行的测试和分支(test + je)。但是,它对于L1i缓存和解码uop缓存的代码大小都不为零,并且通过前端发出的成本也不为零。还有一个额外的静态数据字节,必须保持在高速缓存中以获得良好的性能。

通常,内联可以使这个成本变得微不足道。如果您实际上经常使用call函数来启动此功能,则调用/返回开销的剩余部分将成为更大的问题。

但是没有廉价获取负载的ISA上的情况并不那么美好。(例如,在ARMv8之前的ARM)。与在初始化静态变量后以某种方式安排barrier()所有线程不同,对保护变量的每次检查都是获取加载。但是在ARMv7及更早版本中,这是通过一个完整的内存屏障dmb ish (数据内存屏障:内部共享)来完成的,该内存屏障包括清空存储缓冲区,与atomic_thread_fence(mo_seq_cst)完全相同。( ARMv8具有ldar(字)/ldab(字节)来执行获取加载,使它们变得便宜和好用。) 使用ARMv7 clang的Godbolt
# 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上,它可以原子地存储,只需一个字存储即可替换一个跳转指令。

1
嗯...我猜在OpenBSD上,你需要将要修改的页面复制到一个新页面,修改新页面,然后将新页面映射到旧页面上。 - Ross Ridge
@RossRidge:有趣的想法,可能比在修改页面时挂起所有其他线程更好。实际上,提高此操作的成本意味着在第一次不进行自我修改更具吸引力。 - Peter Cordes

6

早期的8086处理器不支持浮点数运算。可以添加数学协处理器8087,并编写使用它的代码。这样的代码包含“trap”指令,将控制权转移给8087来执行浮点数操作。

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

所以,是的,这是可行的。有些评论指出,现代体系结构使这种事情变得难以或不可能。

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


1
如果我没记错的话,另一个例子是 geninterrupt(n),它应该生成软件中断向量 n。由于 8086 上的 INT 指令只接受向量作为立即数,因此这是通过自修改代码来实现的。 - Nate Eldredge
@NateEldredge -- 是的。geninterrupt(n)会创建一个opcode为0xCC,后面跟着n的可执行代码。而我就是从这里学到了PrestoChangoSelector - Pete Becker
@NateEldredge -- 但是,经过反思,“geninterrupt(n)”并不是自修改代码,而只是动态生成的代码。它在数据段中构建了INT指令,然后使该段可执行。 - Pete Becker
我在回想8086本身(或实模式386),那里没有内存保护,所以一切都是可执行的。我似乎记得它是通过就地修改代码来完成的;如果INT指令在其他地方构建,就会有一个不必要的跳转。 - Nate Eldredge
@NateEldredge - 可能是我记错了。 - Pete Becker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接