正在尝试理解指令缓存(用于在重复的JIT编译中重复利用内存页)

编程语言 2026-07-10

我在树莓派5(aarch64架构,使用Linux)上进行JIT编译。

我的目标是生成本地代码并执行它,然后继续生成更多本地代码并执行它们(这些后续代码可能与之前生成的代码相同,也可能不同)。

我的第一种做法是把新代码追加到同一个内存页上:

  1. mmap 分配一个内存页
  2. 把代码拷贝到该页,使用 memcpy
  3. 使其可执行,使用 mprotect
  4. 执行它
  5. 使其可写,使用 mprotect
  6. 把新代码追加到该页,使用 memcpy
  7. 再次使其可执行,使用 mprotect
  8. 执行新拷贝的指令 <-- 这里进程崩溃

我的有根据的[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

我的问题是:

  1. 在aarch64的 Linux上真的没有刷新指令缓存的方法吗?
  2. 是否有可靠的方法来确定我可以安全地写入指令的地址偏移量,而不陷入缓存问题?
  3. CPU预取到指令缓存中的缓存行数量是否存在上限?
  4. 预取器是否会在页边界处停止?我不太清楚它为何会停,但如果把 #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),它就能正常工作。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章