常言道,为了避免静态变量被多次初始化,其初始化通常会被包装在一个if语句中。
对于这种和其他一次性条件,代码通过自我修改在首次执行后删除条件将更加高效。
C++编译器是否允许生成这样的代码?如果不允许,为什么?我听说这可能对缓存产生负面影响,但我不知道具体细节。
常言道,为了避免静态变量被多次初始化,其初始化通常会被包装在一个if语句中。
对于这种和其他一次性条件,代码通过自我修改在首次执行后删除条件将更加高效。
C++编译器是否允许生成这样的代码?如果不允许,为什么?我听说这可能对缓存产生负面影响,但我不知道具体细节。
编译器并没有阻止你实现你提出的想法,但这是一个非常重量级的解决方案,用于解决一个非常小的性能问题。
为了实现自修改代码,对于运行在Windows或Linux上的典型C ++实现,编译器必须插入代码来更改代码页的权限,修改代码,然后恢复权限。这些操作的成本很容易比隐含的“if”操作在程序生命周期内花费的周期要多得多。
这也会导致修改后的代码页无法在进程之间共享。这可能看起来微不足道,但编译器通常会压缩他们的代码(i386情况下非常严重),以实现可以在运行时加载到不同地址而无需修改代码并防止代码页共享的位置独立代码。
正如Remy Lebeau和Nathan Oliver在评论中提到的那样,还需要考虑线程安全问题,但是可以像这样热修补可执行文件有各种解决方案。
是的,这是合法的。ISO C++不保证通过将函数指针强制转换为unsigned char*
能够访问数据(机器代码)。在大多数真实的实现中,它是良好定义的,除了纯哈佛架构的机器,在这种机器上,代码和数据具有单独的地址空间。
热补丁(通常由外部工具完成)是一件事情,并且如果编译器生成使其易于进行的代码,则非常可行,即函数以足够长的指令开始,可以原子地替换。
正如Ross所指出的那样,大多数C++实现中自我修改的主要障碍是它们为通常将可执行页面映射为只读的操作系统生成程序。 W^X是一个重要的安全功能,用于避免代码注入。只有在非常长时间运行的程序中,才值得进行必要的系统调用,使页面临时变为读+写+执行,原子地修改指令,然后将其翻转回来。
在像OpenBSD这样真正强制实施W^X的系统上不可能,因为它不允许进程mprotect
同时具有PROT_WRITE和PROT_EXEC的页面。如果其他线程随时可以调用该函数,则将页面暂时设置为不可执行无效。
int init();
int foo() {
static int counter = init();
return ++counter;
}
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
test + je
)。但是,它对于L1i缓存和解码uop缓存的代码大小都不为零,并且通过前端发出的成本也不为零。还有一个额外的静态数据字节,必须保持在高速缓存中以获得良好的性能。
通常,内联可以使这个成本变得微不足道。如果您实际上经常使用call
函数来启动此功能,则调用/返回开销的剩余部分将成为更大的问题。
# 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
jmp rel32
或其他内容开始函数,跳转到执行互斥操作以在一个线程中运行非常量静态初始化程序的“冷”代码部分。 (因此,如果您有多个线程在一个完成并修改代码之前开始运行它,则它们将像现在一样全部工作。)早期的8086处理器不支持浮点数运算。可以添加数学协处理器8087,并编写使用它的代码。这样的代码包含“trap”指令,将控制权转移给8087来执行浮点数操作。
Borland的编译器可以设置为生成浮点代码,该代码在运行时检测协处理器是否已安装。每次执行每个浮点指令时,如果存在协处理器,则跳转到一个内部例程,该例程将通过8087陷阱指令(后跟几个NOPs)回溯指令。如果不存在协处理器,则调用适当的库程序。然后,内部例程将跳回已修补的指令。
所以,是的,这是可行的。有些评论指出,现代体系结构使这种事情变得难以或不可能。
早期版本的Windows有一个系统调用,可以重新映射数据和代码之间的内存段选择器。如果您使用数据段选择器调用PrestoChangoSelector(是的,这是它的名称),它会返回一个指向相同物理内存的代码段选择器,反之亦然。
geninterrupt(n)
,它应该生成软件中断向量 n
。由于 8086 上的 INT
指令只接受向量作为立即数,因此这是通过自修改代码来实现的。 - Nate Eldredgegeninterrupt(n)
会创建一个opcode为0xCC,后面跟着n
的可执行代码。而我就是从这里学到了PrestoChangoSelector
。 - Pete Becker
static
的初始化必须是线程安全的,这意味着生成的代码必须以某种方式确保另一个线程在修改过程中不会尝试访问static
。 - Remy Lebeaujmp rel32
或其他方式,跳转到执行互斥操作的“冷”代码段,以便在一个线程中运行非常量静态初始化器。一旦构造完全完成,使用8字节原子CAS或存储来替换那个5字节指令为不同的指令字节。可能只是一个NOP,或者可能是在“冷”代码顶部完成的某些有用的操作。或者在非x86上,只需一个单词存储即可替换一个跳转指令。当然,大问题是在具有W^X内存保护的系统上执行此操作。 - Peter Cordesjmp
指令吗? - Remy Lebeau