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

问题描述

通常将static变量初始化包装在if中,以防止其被多次初始化。

为此,以及其他一次性条件,让代码在第一次通过自我修改删除条件将更为有效。

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

解决方法

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

要实现自修改代码,对于在Windows或Linux上运行的典型C ++实现,编译器必须插入将更改代码页权限的代码,修改代码,然后还原代码。权限。与隐含的“如果”操作将占用程序的整个生命周期相比,这些操作可能容易花费更多的周期。

这也将导致阻止修改的代码页在进程之间共享。这看似无关紧要,但是编译器经常对代码进行悲观(对于i386而言,这是非常糟糕的),以便实现与位置无关的代码,这些代码可以在运行时加载不同的地址,而无需修改代码并防止代码页共享。

正如雷米·勒博(Remy Lebeau)和内森·奥利弗(Nathan Oliver)在评论中提到的那样,还需要考虑线程安全性问题,但是由于可以通过多种解决方案来修补此类可执行文件,因此可以解决这些问题。

,

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

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

正如罗斯指出的那样,大多数C ++实现的自我修改的主要障碍是它们使通常用于映射可执行页面的OS程序成为只读程序。 W ^ X是避免代码注入的重要安全功能。仅对于运行时间很长且代码路径非常热的程序,进行必要的系统调用以使页面变为临时读取+写入+ exec,原子地修改一条指令,然后将其回退才是总值得的。

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

通常说,静态变量初始化包含在if中,以防止多次初始化。

仅适用于非恒定初始化程序,当然也仅适用于静态 locals 。像static int foo = 1;这样的本地语言将与全局范围内的内容相同,编译为带有标签的.long 1(GCC for x86,GAS语法)。

但是,是的,使用非常量初始化程序,编译器将发明一个可以测试的保护变量。它们安排了东西,因此guard变量是只读的,不像读取器/写入器锁那样,但这在快速路径上仍然会花费一些额外的指令。

例如

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

通常情况下,内联使其可以忽略不计。如果您实际上在一开始就call经常使用此功能就足够了,那么其余的调用/ ret开销将是一个更大的问题。

但是在没有廉价获取负载的ISA上情况并非如此。(例如,ARMv8之前的ARM)。初始化静态变量后,不必以某种方式将所有线程安排为barrier()一次,而是对保护变量的每次检查都是获取负载。但是在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,pc}      @ return by popping saved LR into PC

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

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

使用jmp rel32或其他代码的“冷”部分启动函数,该函数相互排斥以在一个线程中运行非恒定静态初始化程序。 (因此,如果您确实有多个线程在一个线程完成并修改代码之前就开始运行它,那么它们都将以现在的方式运行。)

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

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

,

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

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

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

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