正在尝试理解指令缓存(用于在重复的JIT编译中重复利用内存页)
我在树莓派5(aarch64架构,使用Linux)上进行JIT编译。
我的目标是生成本地代码并执行它,然后继续生成更多本地代码并执行它们(这些后续代码可能与之前生成的代码相同,也可能不同)。
我的第一种做法是把新代码追加到同一个内存页上:
- 用
mmap分配一个内存页 - 把代码拷贝到该页,使用
memcpy - 使其可执行,使用
mprotect - 执行它
- 使其可写,使用
mprotect - 把新代码追加到该页,使用
memcpy - 再次使其可执行,使用
mprotect - 执行新拷贝的指令 <-- 这里进程崩溃
我的有根据的[1] 猜测是,CPU的指令缓存似乎没有被更新,CPU试图执行该缓存区域内旧的全零内容。
据 这个回答 所述,在aarch64 flush_cache_range(显然是由mprotect调用)并不起作用。
于是我想在追加第二组指令之前,先加一个足够大的偏移量,这样CPU还没有缓存该地址。
我的第一直觉是缓存行的宽度应该足够。
我的缓存行长度是 64
$ getconf -a | grep -i cache_linesize
LEVEL1_ICACHE_LINESIZE 64
LEVEL1_DCACHE_LINESIZE 64
但在下面的最小示例中,当偏移量超过64字节时,应用仍然会崩溃:
- 使用
OFFSET==64时总是崩溃。 - 使用128和 256时也经常崩溃。
- 使用512时从不崩溃。
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef __linux__
#error Unsupported OS
#endif
#include <sys/mman.h>
#include <unistd.h>
#define ASSERT(X) if(!(X)) { perror("ASSERT failed: " #X); abort(); }
int main(int argc, char** argv)
{
(void)argc, (void)argv;
#ifdef __aarch64__
const uint32_t instr_ret42[2] = {
0x52800540, // mov w0, 42
0xd65f03c0 // ret
};
const uint32_t instr_ret37[2] = {
0x528004a0, // mov w0, 37
0xd65f03c0 // ret
};
#else
#error Unsupported ISA
#endif
const size_t mem_page_size = getpagesize();
const size_t num_bytes = mem_page_size * 2;
#if 1
const size_t OFFSET1 = 0;
const size_t OFFSET2 = 512; // >>>> CRASH if <= 256 <<<<
#else
// >>>> NEVER CRASHES <<<<
const size_t OFFSET1 = mem_page_size-sizeof(instr_ret42);
const size_t OFFSET2 = mem_page_size;
#endif
ASSERT(OFFSET1 + sizeof(instr_ret42) <= num_bytes);
ASSERT(OFFSET2 + sizeof(instr_ret37) <= num_bytes);
// ==== Write and exec instr_ret42 ===========================================
void* const addr = mmap(NULL, num_bytes, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
ASSERT(addr != (void*)-1);
// Copy intruction bytes from instr_ret42 to the page, ...
memcpy(addr+OFFSET1, instr_ret42, sizeof(instr_ret42));
// ... make it executable ...
ASSERT(mprotect(addr, num_bytes, PROT_READ | PROT_EXEC) == 0);
// ... and verify that it works.
unsigned int (*test_fn_42)() = (unsigned int (*)())(addr+OFFSET1);
ASSERT(test_fn_42() == 42); // So far so good.
// ==== Append and exec instr_ret37 ==========================================
// Make page writable, again ...
ASSERT(mprotect(addr, num_bytes, PROT_READ | PROT_WRITE) == 0);
// ... copy intruction bytes from instr_ret42 to the page (with OFFSET), ...
memcpy(addr + OFFSET2, instr_ret37, sizeof(instr_ret37));
// memcpy(addr, instr_ret37, sizeof(instr_ret37));
// ... make it executable, again ...
ASSERT(mprotect(addr, num_bytes, PROT_READ | PROT_EXEC) == 0);
// ... and verify that it works.
unsigned int (*test_fn_37)() = (unsigned int (*)())(addr + OFFSET2);
ASSERT(test_fn_37() == 37);
ASSERT(test_fn_42() == 42);
// ==== cleanup ==============================================================
ASSERT(munmap(addr, num_bytes) == 0);
return 0;
}
我的猜测是,CPU以一个近似随机的启发式规则来预取下一个缓存行,这可以解释这种不可预测的可观测行为。
[1]: 重新改写指令并再次调用会得到我预期的未修改行为——如果CPU仍在从缓存执行旧指令而忽略任何新的修改。
TLDR
我的问题是:
- 在aarch64的 Linux上真的没有刷新指令缓存的方法吗?
- 是否有可靠的方法来确定我可以安全地写入指令的地址偏移量,而不陷入缓存问题?
- CPU预取到指令缓存中的缓存行数量是否存在上限?
- 预取器是否会在页边界处停止?我不太清楚它为何会停,但如果把
#if 1改成#if 0,尽管它们处于相邻的缓存行中,仍然不会崩溃。
解决方案
答案很简单,我可以直接使用 __builtin___clear_cache 内在函数(在GCC和 Clang都可用)。
对于没有这个内在函数的C 编译器,我可以使用 __clear_cache libgcc函数(在树莓派5 的tcc上测试过)。
根据 gcc文档 这正是我需要的:
内建函数:
void __builtin___clear_cache (void *begin, void *end)这个函数用于刷新处理器在begin(含)到end(不含)之间的内存区域的指令缓存。 [...]
如果目标不需要指令缓存刷新,
__builtin___clear_cache没有作用。否则要么将指令内联以清空指令缓存,要么调用libgcc中的__clear_cache函数。
有了它,我可以把 OFFSET2 设置得低至 sizeof(instr_ret42),它就能正常工作。