Deep dive
How it works
In: machine-code bytes, host page size · Out: a function pointer into a page that is executable and not writable · State: the code region (page-aligned arena, per-thread write state on macOS) · Cost: O(n) copy + one protection change + one I-cache flush; the flip is about 0.5-0.9 µs per mprotect pair on one thread here (measured), more with many threads
- Reserve whole pages with
mmap(anonymous, private). Policy decides the form: Linux and OpenBSD allow RW thenmprotect, macOS on Apple Silicon needsMAP_JIT. - Make the region writable:
mprotect(RW), or on macOSpthread_jit_write_protect_np(0)(affects only the calling thread). - Copy or emit the code; patch relocations and call targets here.
- Make it executable and not writable:
mprotect(RX), orpthread_jit_write_protect_np(1). - On AArch64 flush:
sys_icache_invalidate(start, len)on macOS,__builtin___clear_cache(begin, end)(GCC/Clang) on Linux. x86-64 needs nothing. - Cast the address to a function pointer and call it. Repatching (inline caches, deoptimization) repeats steps 2-5 on the touched pages.
Dual mapping avoids flipping: create a shared memory object (memfd_create on Linux, shm_open elsewhere), map it twice, once RW at address A (writer only, ideally unmapped or private) and once RX at address B (what runs). Writes through A become visible through B; B is never writable. Relative branches inside the code assume B, so emit for B's addresses.
Per-thread toggles make the switch a register write, not a page-table change. Apple's pthread_jit_write_protect_np documents this: with protection enabled the thread cannot write the MAP_JIT region and may execute it; disabled, it can write and not execute. Other threads keep executing meanwhile. Intel PKU (pkey_mprotect, WRPKRU) gives a per-thread write gate on x86, but the kernel documents that protection keys apply to data accesses only, not instruction fetch, so it can deny writes but cannot grant execute; arm64 protection keys (POR_EL0) do cover instruction fetch.
Code
Emits fn(x) = x + 42 for the host and calls it. Compiled and run on Apple M4 Pro (arm64 macOS, unsigned binary). The Linux x86-64 branch is written to the man pages but was not run here.
use std::ffi::c_void;
extern "C" {
fn mmap(addr: *mut c_void, len: usize, prot: i32, flags: i32, fd: i32, off: i64) -> *mut c_void;
fn munmap(addr: *mut c_void, len: usize) -> i32;
}
const PROT_READ: i32 = 1;
const PROT_WRITE: i32 = 2;
const PROT_EXEC: i32 = 4;
const MAP_PRIVATE: i32 = 0x2;
#[cfg(target_os = "macos")]
const MAP_ANON: i32 = 0x1000;
#[cfg(target_os = "linux")]
const MAP_ANON: i32 = 0x20;
#[cfg(target_os = "macos")]
const MAP_JIT: i32 = 0x800;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
extern "C" {
fn pthread_jit_write_protect_np(enabled: i32); // per-thread: 0 = this thread may write
fn sys_icache_invalidate(start: *mut c_void, len: usize);
}
#[cfg(target_os = "linux")]
extern "C" {
fn mprotect(addr: *mut c_void, len: usize, prot: i32) -> i32;
}
// fn(x: u64) -> u64 { x + 42 }
#[cfg(target_arch = "aarch64")]
const CODE: [u8; 8] = [0x00, 0xA8, 0x00, 0x91, 0xC0, 0x03, 0x5F, 0xD6]; // add x0,x0,#42 ; ret
#[cfg(target_arch = "x86_64")]
const CODE: [u8; 5] = [0x48, 0x8D, 0x47, 0x2A, 0xC3]; // lea rax,[rdi+42] ; ret
const PAGE: usize = 16384; // 16 KiB on Apple Silicon; 4 KiB pages on x86-64 Linux, a multiple is fine
/// Returns a page holding CODE, executable and never writable+executable at once
/// (on macOS the W^X switch is per thread, via the MAP_JIT mapping).
unsafe fn emit() -> *mut u8 {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
let p = mmap(std::ptr::null_mut(), PAGE, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANON | MAP_JIT, -1, 0);
assert!(p as isize != -1, "mmap MAP_JIT failed");
pthread_jit_write_protect_np(0); // this thread: RW view
std::ptr::copy_nonoverlapping(CODE.as_ptr(), p as *mut u8, CODE.len());
pthread_jit_write_protect_np(1); // this thread: RX view
sys_icache_invalidate(p, CODE.len()); // I-cache is not coherent with stores
p as *mut u8
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
let p = mmap(std::ptr::null_mut(), PAGE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANON, -1, 0);
assert!(p as isize != -1, "mmap failed");
std::ptr::copy_nonoverlapping(CODE.as_ptr(), p as *mut u8, CODE.len());
// RW -> RX in one call; x86 keeps I-cache coherent with stores, no flush needed
assert_eq!(mprotect(p, PAGE, PROT_READ | PROT_EXEC), 0, "mprotect failed");
p as *mut u8
}
}
fn main() {
unsafe {
let p = emit();
let f: extern "C" fn(u64) -> u64 = std::mem::transmute(p);
assert_eq!(f(0), 42);
assert_eq!(f(100), 142);
println!("ok: f(100) = {}", f(100));
munmap(p as *mut c_void, PAGE);
}
}
Under the hardened runtime a signed macOS binary also needs the com.apple.security.cs.allow-jit entitlement for MAP_JIT.
Complexity and constants
- Emit: O(n) copy, one protection change, one I-cache flush per batch.
- Measured here (Apple M4 Pro, macOS, 16 KiB pages, one thread, 200k iterations):
pthread_jit_write_protect_np(0)+(1)pair 24-40 ns;sys_icache_invalidateon 64 bytes about 100 ns;mprotectRW to RX to RW on one page 0.5-0.9 µs per pair. - Cost drivers: syscalls (one per
mprotect), page-table edits, TLB invalidation on every thread that ran the process, I-cache flush by cache line, and the rounding of the region to whole pages (16 KiB on Apple Silicon, 4 KiB usual on x86-64 Linux).
Efficiency
- Cost: per code buffer, one RW/RX flip plus one flush. Measured on M4 Pro: flip by thread toggle 24-40 ns per pair, by
mprotect0.5-0.9 µs per pair on one thread with one page; single-thread numbers understate multi-thread cost, which was not measured. Bound: syscall and TLB latency, not copying. - Levers:
- Batch: emit a whole function (or arena chunk) between one flip and one flush; gain is the ratio of flips saved, one syscall pair per function instead of per patch (measured pair costs above); when code is compiled in bursts.
- Per-thread toggle instead of
mprotect— about 15-20x cheaper per pair in my measurement (24-40 ns vs 0.5-0.9 µs) and no cross-thread TLB work; when the platform hasMAP_JITor protection keys. - Dual mapping — no flip at all on the hot path, patch through the RW alias; gain is zero page-table edits per patch; cost is double virtual address use and a shared-memory object.
- Pool code in a large arena, flip pages not the whole arena, and flush only the written range (
lenbytes, not the page); when patching many small stubs. - Skip the flush on x86-64 (GCC documents
__builtin___clear_cacheas having no effect where not needed); keep it on AArch64.
- Naive → standard → state of the art:
mmapRWX per function, rejected by hardened systems ·mmapRW, write,mprotectRX per function, one syscall pair and TLB shootdown per function · per-thread write toggle or dual mapping with chunked arenas and one flush per batch, no page-table edits on the patch path.
Implementation notes
- Page-align and size regions to the real page size (
sysconf(_SC_PAGESIZE)); never hard-code 4096 (Apple Silicon is 16 KiB). - Linux
mprotecton part of a mapping splits it into separate VMAs andvm.max_map_countlimits their number (from memory); allocate one arena and sub-allocate. - Flush exactly the bytes written, after the last store and before the first execution, with the region already RX or toggled. On macOS also check
pthread_jit_write_protect_supported_np(). pthread_jit_write_with_callback_nprestricts writes to callbacks listed in a static allow-list, narrowing which code can write the region.memfd_createdual mapping:ftruncate, thenmmaptwice withMAP_SHARED; sealing (F_SEAL_*) is documented on the same man page.- Threads: with a per-thread toggle, every thread that writes code toggles for itself; a thread that forgets leaves its writes faulting. Keep the writable window short and never run JIT-compiled code on a thread in write mode (execution is denied there on macOS).
- Policy differences: macOS needs
MAP_JITand, under hardened runtime, theallow-jitentitlement; OpenBSD refuses W+X mappings unless the filesystem is mountedwxallowedand the binary is taggedwxneeded; SELinux can denyexecmem/execmod, and the safe path is RW thenmprotectRX with no RWX ever (from memory).
Pitfalls
- Missing I-cache flush on AArch64: intermittent illegal-instruction or stale-code crashes that disappear under a debugger.
- Flushing before the code is final, or patching after the flush without flushing again.
- Leaving the thread in write mode (macOS) or the page RW (Linux) while calling into JIT code: it faults.
- Patching code that another thread is executing: even under W^X the patch must be atomic with respect to instruction fetch (single aligned store of a branch, or stop the other threads); consult the architecture manual.
- Leaving the RW alias of a dual mapping mapped everywhere: it defeats the point; map it only in the emitting component.
Variants and alternatives
mprotect flipportable, simplest; costs a syscall and TLB shootdown per flip.MAP_JIT)cheapest on Apple Silicon.In production
Wasmtime and Cranelift JIT, LuaJIT, V8 and JavaScriptCore (code space protected, per-thread toggles on Apple Silicon, from memory), .NET and the JVM code cache (from memory). Patching consumers: inline caches, deoptimization.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| wasmtime | Rust | bytecodealliance/wasmtime | cargo add wasmtime | — | — | — | — | JIT memory handling in Wasmtime / Cranelift |
| cranelift-jit | Rust | bytecodealliance/wasmtime (cranelift/jit) | cargo add cranelift-jit | — | — | — | — | executable memory for Cranelift output |
| dynasm-rs | Rust | CensoredUsername/dynasm-rs | cargo add dynasmrt | — | — | — | — | assembler runtime with executable buffers |
| sljit | C | zherczeg/sljit | — | — | — | — | — | allocator with W^X support (from memory) |
Sources
- Apple, Porting Just-In-Time Compilers to Apple Silicon. doc
- Apple, Allow JIT entitlement (com.apple.security.cs.allow-jit). doc
- macOS man pages
pthread_jit_write_protect_np(3),cache(3)(sys_icache_invalidate),mmap(2)(read locally). - Linux man-pages: mprotect(2), memfd_create(2), pkeys(7); Linux kernel docs, Memory Protection Keys (data-only on x86, covers fetch on arm64).
- OpenBSD, mmap(2) (W+X rules).
- GCC manual, Other Built-in Functions (
__builtin___clear_cache). - Soyeon Park et al., libmpk: Software Abstraction for Intel Memory Protection Keys (Intel MPK), USENIX ATC 2019. paper