是的,这是合法的。 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 上具有相同宽度的固定宽度指令,它可以原子存储,只需一个字存储即可替换一条跳转指令。