202510
systems-programming

JIT 编译中的可执行内存分配:性能与安全的权衡

深入探讨 JIT 编译器在分配可执行内存时面临的 W^X 安全策略挑战。本文分析了从简单的 RWX 映射到 mprotect 权限切换,再到双重映射(Dual-Mapping)和独立缓冲区等高级策略的性能与安全权衡,并讨论了内存碎片化问题。

即时编译(Just-In-Time, JIT)技术是现代高性能语言运行时(如 V8、JVM)和系统(如 BPF)的核心引擎,它通过在运行时将字节码或中间表示动态编译为本地机器码,极大地提升了执行效率。然而,这份“免费的午餐”背后,隐藏着与操作系统底层内存管理和安全策略的深刻博弈。其中,如何为动态生成的代码分配可执行内存,尤其是在面对“写异或执行”(W^X)这一基本安全原则时,成为 JIT 编译器设计者必须解决的关键问题。

核心困境:性能需求与 W^X 安全红线

JIT 的基本流程是“生成代码 -> 写入内存 -> 执行代码”。最直观的实现方式,莫过于向操作系统申请一块同时具备“可读、可写、可执行”权限的内存区域(即 RWX 内存)。通过 mmap 等系统调用获得这样的内存后,JIT 编译器可以直接将生成的机器码写入,然后像调用普通函数一样跳转到该地址执行。

这种方式简单直接,对编译器本身而言效率极高。然而,它却打开了一个巨大的安全缺口。现代操作系统普遍遵循**数据执行保护(Data Execution Prevention, DEP)**策略,其核心思想就是 W^X 原则:一块内存要么是可写的,要么是可执行的,但绝不能同时是两者。这一设计的目的是为了封堵一类经典而危险的攻击——代码注入。攻击者若能向一个可写的内存区域(如堆栈上的缓冲区)植入恶意代码(Shellcode),并欺骗程序跳转到该地址,就能获得系统的控制权。RWX 内存区域完美地满足了攻击者的所有需求。

因此,在 OpenBSD、SELinux 加强的 Linux、iOS 以及华为鸿蒙等注重安全的操作系统上,直接申请 RWX 匿名内存的行为会受到严格限制甚至被直接禁止。这迫使 JIT 引擎必须采用更精巧的策略来绕过这道安全红线。

策略一:权限动态切换(mprotect Toggling)

最先被想到的合规方法是“分时复用”内存权限。该策略严格遵守 W^X,确保内存在任何时刻都只具备单一的核心权限。

  1. 申请内存:首先,通过 mmap 申请一块可读写PROT_READ | PROT_WRITE)的内存。
  2. 写入代码:JIT 编译器将生成的机器码写入这块内存区域。
  3. 切换权限:写入完成后,调用 mprotect 系统调用,将该内存区域的权限修改为可读可执行PROT_READ | PROT_EXEC)。
  4. 执行代码:此时,代码可以被安全地执行。

如果代码需要被再次修改(例如,基于新的运行时信息进行再优化),则需要再次调用 mprotect 将权限切换回可写,修改完毕后再切换为可执行。

这种方法的优点是逻辑清晰,且完全符合 W^X 安全模型。但其致命缺点在于性能开销mprotect 是一个系统调用,会引发用户态到内核态的切换,并可能导致 TLB (Translation Lookaside Buffer) 的刷新,开销相对昂贵。如果 JIT 编译发生得非常频繁,且针对的都是小段代码,那么反复调用 mprotect 带来的性能损耗可能会抵消掉 JIT 本身带来的部分收益。

策略二:双重映射(Dual-Mapping)

为了兼顾安全与性能,一种更为优雅的方案应运而生:双重映射。这种技术利用了虚拟内存系统的灵活性,巧妙地将“写入”和“执行”两种操作在空间上分离开来。

其核心思想是:将同一块物理内存,通过页表映射到两个不同的虚拟地址上。

  1. 虚拟地址 A:此映射被设置为可读写PROT_READ | PROT_WRITE)。
  2. 虚拟地址 B:此映射被设置为可读可执行PROT_READ | PROT_EXEC)。

当 JIT 编译器需要生成代码时,它通过虚拟地址 A 将机器码写入内存。而当程序需要执行这段代码时,则通过虚拟地址 B 进行调用。从始至终,没有任何一个虚拟内存页同时拥有写和执行权限,完美地遵守了 W^X 约定。同时,由于避免了在运行时频繁修改页面权限,它也消除了 mprotect 带来的性能瓶颈。

这种方案是高性能 JIT 实现(如 AsmJit 库)和一些对性能敏感的平台(如 iOS)所采用的主流方法。它以略微增加的实现复杂度,换取了安全与性能的最佳平衡。

策略三:独立缓冲区与代码拷贝

另一种常见于系统级 JIT(例如 Linux 内核中的 BPF JIT)的策略是物理分离。

  1. 分配临时缓冲区:在普通的、仅可写的内存区域(例如,通过 malloc 或内核中的 kvmalloc)分配一个临时缓冲区。
  2. 分配最终执行区:同时,另行分配一块目标内存区域,并将其权限直接设置为只读可执行PROT_READ | PROT_EXEC)。
  3. 编译与拷贝:JIT 编译器在临时的可写缓冲区中完成所有代码生成和优化工作。
  4. 最终迁移:当代码生成完毕,将其从临时缓冲区拷贝到最终的只读可执行内存区域中。
  5. 释放临时区:销毁临时缓冲区。

此方法逻辑非常简单,同样严格遵循 W^X。其主要的性能开销在于最后的 memcpy 操作。对于体积较小的 JIT 函数,这次拷贝的成本几乎可以忽略不计。但如果需要 JIT 大量的代码,内存拷贝的开销就需要被纳入考量。这种方法因其简单和鲁棒,在不方便进行复杂虚拟内存操作的环境(如内核)中备受欢迎。

不可忽视的角落:内存碎片与分配效率

解决了 W^X 问题后,JIT 设计者还面临另一个挑战:内存碎片化。操作系统内存分配的基本单位是页(通常是 4KB)。如果 JIT 编译器为每一个小函数(可能只有几十或几百字节)都向操作系统申请一个完整的内存页,将导致巨大的内部碎片,造成严重的内存浪费。

为了解决这个问题,成熟的 JIT 实现通常会采用**代码堆(Code Heap)竞技场(Arena)**式的内存管理策略。

  • 预分配大块内存:启动时,JIT 运行环境会一次性向操作系统申请一大块可执行(或可通过双重映射实现 W^X 的)内存区域。
  • 内部管理:JIT 引擎内部实现一个专门的内存分配器,来管理这个大的内存竞技场。当需要为新函数分配空间时,它就从这个竞技场中切出一小块返回。
  • 碎片整理:这个内部分配器可以实现紧凑的“打包”算法,将多个小函数紧密地排列在一起,最大限度地减少空间浪费。当代码被废弃时,它还能进行垃圾回收和碎片整理。

这种方式将多次、零散的系统调用摊销为单次、批量的操作,并用更高效的内部算法取代了通用的页分配,显著提升了内存利用率和分配性能。

结论

JIT 编译器的可执行内存分配,是性能优化与现代安全体系之间一场精彩的“舞蹈”。简单的 RWX 方案因其固有的安全风险已然过时。在严格的 W^X 策略下,开发者必须在不同方案中做出权衡:

  • mprotect 切换:合规但可能慢,适用于编译不频繁或性能要求不极致的场景。
  • 双重映射:性能与安全的最佳结合点,是当前高性能 JIT 的首选,但实现相对复杂。
  • 独立缓冲区拷贝:逻辑简单可靠,适用于内核或对内存拷贝开销不敏感的场景。

同时,结合一个高效的竞技场式内存管理器来对抗内存碎片,是任何生产级 JIT 编译器不可或缺的组成部分。理解这些底层策略,不仅有助于我们编写更安全、更高效的 JIT 引擎,也让我们对现代操作系统的内存安全机制有了更深刻的认识。