BPF JIT Compiler

Once the verifier has proven a program safe, the JIT (Just-In-Time) compiler is what turns its architecture-neutral bytecode into native machine instructions so it runs at the speed of compiled kernel C rather than crawling through a software interpreter. Each CPU architecture ships its own backend — arch/x86/net/bpf_jit_comp.c for x86-64, arch/arm64/net/bpf_jit_comp.c for arm64, and so on — but all are driven through the same entry point, bpf_int_jit_compile(), called from bpf_prog_select_runtime() right after verification (per kernel/bpf/core.c, v6.12 LTS). The translation is largely mechanical because the BPF instruction set and calling convention were deliberately designed to mirror native 64-bit ABIs: BPF’s argument registers map one-to-one onto the host’s argument registers, so most BPF instructions become a short, fixed x86-64 or arm64 instruction sequence with no register shuffling. The payoff is enormous — a JIT-compiled XDP program processes on the order of tens of millions of packets per second on a single core (Høiland-Jørgensen et al., CoNEXT ‘18). On most modern, security-hardened kernels the interpreter is compiled out entirely (CONFIG_BPF_JIT_ALWAYS_ON), making the JIT not an optimization but the execution engine.

This note covers the JIT as a translator and as kernel text: the per-architecture backend, the iterative image-sizing-then-emission loop, the BPF→native register mapping, the prologue/epilogue and tail-call handling, why translation is mechanical, the bpf_prog_pack huge-page packing of JITed images, and how BPF programs become first-class kernel symbols visible to perf and bpftool. The defensive side of the JIT — constant blinding, the W^X memory protections, the bpf_jit_limit DoS cap — lives in its sibling JIT Hardening and Constant Blinding. The choice between JIT and the software interpreter is covered in BPF Interpreter vs JIT.

Kernel version, and why this pin

All code paths and line numbers in this note are pinned to Linux v6.12, read at that tag from raw.githubusercontent.com/torvalds/linux/v6.12/.... v6.12 is this vault’s pin, not the only longterm branch: as of 2026-09-11 kernel.org/releases.json lists mainline 7.3-rc2, latest stable 7.2.5, and longterm branches 6.18.51, 6.12.109, 6.6.156, 6.1.187, 5.15.220, 5.10.269. Where a mechanism changed near this pin the note says when and shows the existence check that dates it (see the execmem transition below). Live measurements at the end of this note were taken on a Fedora 44 / Linux 7.1.13-200.fc44.x86_64 box, so where the running kernel and the pinned source could differ the note says so explicitly.


Mental Model

Think of the JIT as a transliterator, not a translator. A general-purpose compiler (say, the one that built the kernel from C) does heavy optimization: register allocation, instruction scheduling, inlining, loop transformations. The BPF JIT does almost none of that. It walks the verified bytecode instruction by instruction and emits a short, fixed native sequence for each one — the BPF program already is a register machine with a small, RISC-like instruction set, and the verifier already did the hard analysis. The JIT’s only real cleverness is in sizing the output image (native instructions are variable-length on x86-64, and the size of a jump depends on how far it jumps, which depends on the sizes of the instructions in between — a chicken-and-egg problem solved by iterating to a fixpoint) and in emitting a correct prologue/epilogue that bridges BPF’s calling convention to the host ABI.

flowchart TB
  V["Verifier approves<br/>(safe bytecode + insn rewrites)"]
  SR["bpf_prog_select_runtime()<br/>(core.c)"]
  BLIND["bpf_jit_blind_constants()<br/>(optional: constant blinding,<br/>bytecode-level rewrite)"]
  subgraph JIT["bpf_int_jit_compile()  (per-arch, e.g. arch/x86/net)"]
    SIZE["Pass 0: estimate<br/>64 bytes / BPF insn"]
    EMIT["Passes 1..N: emit native code,<br/>image SHRINKS each pass"]
    CONV{"image stopped<br/>shrinking?"}
    SIZE --> EMIT --> CONV
    CONV -->|no, &lt; 20 passes| EMIT
    CONV -->|yes| DONE["final image"]
  end
  PACK["bpf_jit_binary_pack_alloc()<br/>(prog_pack: 2 MB huge page,<br/>RO+X executable copy)"]
  KSYM["bpf_prog_ksym_add()<br/>(register in kallsyms:<br/>bpf_prog_&lt;tag&gt;_&lt;name&gt;)"]
  RUN["prog->bpf_func points at<br/>native code; runs at near-native speed"]
  V --> SR --> BLIND --> SIZE
  DONE --> PACK --> KSYM --> RUN

The path from verified bytecode to running native code. What it shows: after the verifier approves a program, bpf_prog_select_runtime() optionally applies constant blinding (a bytecode-level rewrite, covered in the sibling note) and then calls the architecture’s bpf_int_jit_compile(), which sizes the image with a rough estimate and then re-emits it repeatedly until the image stops shrinking; the final native code is copied into a read-only-executable huge page and registered as a kernel symbol. The insight to take: there is no single “compile” step — the JIT iterates to a fixpoint because native instruction sizes depend on jump distances which depend on instruction sizes. And the output is not scratch memory: it is first-class kernel text, named in kallsyms and protected W^X like the rest of .text.


Where the JIT Sits in the Load Pipeline

A program reaches the JIT through bpf_prog_select_runtime() in kernel/bpf/core.c. After the verifier has run and (for programs with subprograms) done its preparation, this function decides whether the program runs as native code or interpreted bytecode. The relevant logic (v6.12, core.c):

struct bpf_prog *bpf_prog_select_runtime(struct bpf_prog *fp, int *err)
{
	bool jit_needed = false;
	...
	if (IS_ENABLED(CONFIG_BPF_JIT_ALWAYS_ON) ||
	    bpf_prog_has_kfunc_call(fp))
		jit_needed = true;
 
	bpf_prog_select_func(fp);          /* set interpreter entry as fallback */
	...
	fp = bpf_int_jit_compile(fp);      /* <-- the per-arch JIT */
	bpf_prog_jit_attempt_done(fp);
	if (!fp->jited && jit_needed) {
		*err = -ENOTSUPP;              /* JIT mandatory but failed -> refuse */
		...
	}
}
flowchart TD
  A["bpf_prog_select_runtime(fp)"] --> B{"fp->bpf_func<br/>already set?"}
  B -->|yes| Z["finalize"]
  B -->|no| C{"CONFIG_BPF_JIT_ALWAYS_ON<br/>or program calls a kfunc?"}
  C -->|yes| D["jit_needed = true"]
  C -->|no| E["jit_needed = false"]
  D --> F["bpf_prog_select_func(fp)"]
  E --> F
  F --> G{"CONFIG_BPF_JIT_ALWAYS_ON?"}
  G -->|"yes — interpreter compiled out"| H["fp->bpf_func = __bpf_prog_ret0_warn<br/>(a poison value, not a fallback)"]
  G -->|no| I["fp->bpf_func = interpreters[stack_depth/32]"]
  H --> J["bpf_int_jit_compile(fp)  — per-arch"]
  I --> J
  J --> K{"fp->jited?"}
  K -->|yes| Z
  K -->|"no, and jit_needed"| L["*err = -ENOTSUPP<br/>program REFUSED"]
  K -->|"no, and !jit_needed"| M["run interpreted"]
  M --> Z

The JIT/interpreter decision in bpf_prog_select_runtime() (core.c:2396) and bpf_prog_select_func() (core.c:2374), v6.12. What it shows: the “fallback” entry point is chosen before the JIT is attempted, and on a CONFIG_BPF_JIT_ALWAYS_ON kernel that fallback is __bpf_prog_ret0_warn — a function that returns 0 and warns, not an interpreter. The insight to take: on a hardened kernel there is no graceful degradation path. Either the JIT succeeds, or the load is rejected with -ENOTSUPP; the poison entry point exists only so that a bug which somehow runs a non-JITed program fails loudly instead of executing bytecode that was never compiled.

Two facts are load-bearing here. First, the JIT is best-effort by default: bpf_int_jit_compile() is documented to “always return a valid program” — if anything goes wrong (out of memory, an unsupported instruction, a blinding failure), it returns the unmodified, non-JITed program and the kernel falls back to the interpreter. Second, two situations make the JIT mandatory: when the kernel is built with CONFIG_BPF_JIT_ALWAYS_ON (standard on hardened distributions, and the configuration that lets the interpreter be compiled out entirely as a security measure), and when the program calls a kfunc (which the interpreter cannot dispatch). In those cases a JIT failure is fatal: the program is refused with -ENOTSUPP.

The bpf_int_jit_compile() function is __weak in the generic code and overridden by each architecture that has a JIT backend. The sysctl documentation lists the eBPF-JIT architectures as x86_64, x86_32, arm64, arm32, ppc64, ppc32, sparc64, mips64, s390x, riscv64, riscv32, loongarch64, and arc (per net.rst, v6.12). The rest of this note uses the x86-64 backend as the worked example, with arm64 as a contrast.

Which architectures actually have a backend

The prose list above is easy to misread, because “has a JIT” is not one property but three: does the architecture have an eBPF JIT (can it compile programs loaded through bpf(2)), does it have only the older cBPF JIT (socket filters and seccomp only, with eBPF migrated down to cBPF first), or does it have neither and fall back to the interpreter? A third distinction matters for memory behaviour: whether the backend has been converted to the shared bpf_prog_pack huge-page allocator described later in this note. Each row below was existence-checked by fetching the named file at tag v6.12 and recording the HTTP status (all 200), and the pack column by grepping that file for bpf_jit_binary_pack_alloc:

ArchitectureeBPF JIT backend file (v6.12)FlavourUses bpf_prog_pack?
x86-64arch/x86/net/bpf_jit_comp.ceBPFyes (the original)
x86-32arch/x86/net/bpf_jit_comp32.ceBPFno
arm64arch/arm64/net/bpf_jit_comp.ceBPFyes
arm32arch/arm/net/bpf_jit_32.ceBPFno
ppc64 / ppc32arch/powerpc/net/bpf_jit_comp{64,32}.ceBPFyes (in the shared bpf_jit_comp.c)
riscv64 / riscv32arch/riscv/net/bpf_jit_comp{64,32}.ceBPFyes (in bpf_jit_core.c)
s390xarch/s390/net/bpf_jit_comp.ceBPFno
loongarch64arch/loongarch/net/bpf_jit.ceBPFno
arcarch/arc/net/bpf_jit_core.ceBPFno
sparc64arch/sparc/net/bpf_jit_comp_64.ceBPFno
mips64arch/mips/net/bpf_jit_comp64.ceBPFno
mips (32-bit), sparc (32-bit)—cBPF onlyn/a
everything else—interpreter onlyn/a

Existence-checked at v6.12 on 2026-09-11. What it shows: the eBPF JIT is broad but not universal, and the bpf_prog_pack conversion is narrower still — four architecture families out of eleven. The insight to take: “Linux JITs BPF” is an x86-64/arm64 statement generalised; on a riscv32 or arc box you get native code but not huge-page packing, and on anything not listed you get the interpreter — which, on a kernel built CONFIG_BPF_JIT_ALWAYS_ON, means programs that need a kfunc simply will not load.


The Iterative Sizing Loop — Not Really “Two Passes”

It is common shorthand to say the JIT does “two passes: one to size the image, one to emit it.” That is a useful mental model but it is not what the code does. The x86-64 backend (bpf_int_jit_compile() in arch/x86/net/bpf_jit_comp.c, v6.12) runs an iterative convergence loop of up to MAX_PASSES (defined as 20) iterations.

The problem it is solving: on x86-64, instruction lengths are variable, and the encoding of a jump depends on its displacement. A jump to a target 100 bytes away uses a short 2-byte form; a jump 5,000 bytes away needs a 6-byte form. But the displacement itself depends on the total size of all the instructions between the jump and its target — which the JIT does not know until it has emitted them. This is a classic branch displacement fixpoint problem.

The kernel resolves it by over-estimating, then shrinking:

/* Before first pass, make a rough estimation of addrs[]:
 * each BPF instruction is translated to less than 64 bytes
 */
for (proglen = 0, i = 0; i <= prog->len; i++) {
	proglen += 64;
	addrs[i] = proglen;
}
...
/* JITed image shrinks with every pass and the loop iterates
 * until the image stops shrinking. Very large BPF programs
 * may converge on the last pass. In such case do one more pass.
 */
for (pass = 0; pass < MAX_PASSES || image; pass++) {
	...
	proglen = do_jit(prog, addrs, image, rw_image, oldproglen, &ctx, padding);
	...
	if (proglen == oldproglen) {
		/* converged: image stopped changing -> done */
	}
	oldproglen = proglen;
}

Pass 0 assumes every BPF instruction needs at most 64 bytes of native code and fills the addrs[] table (the byte offset of each instruction in the output) with that pessimistic estimate. With this conservative addrs[], every jump is assumed to need its longest encoding, so the first emitted image is larger than necessary. Each subsequent pass uses the actual offsets measured in the previous pass, so jumps shrink to their true encoding, the whole image shrinks, and addrs[] is refined. The loop terminates when proglen == oldproglen — the image has reached a fixpoint and re-emitting it produces no change. The padding flag (enabled after PADDING_PASSES) inserts a few bytes of NOP padding to prevent a pathological case where an image could oscillate in size between passes and never converge.

The reason a fixpoint exists at all — and why it is approached from above — is that the estimate only ever shrinks. Each pass’s addrs[] is a set of upper bounds; emitting with tighter bounds can only produce equal-or-shorter jump encodings, which only tighten the bounds further. Monotone decreasing and bounded below, so it terminates. The kernel’s own worked counter-example, quoted at length in the source comment above is_imm8_jmp_offset() (bpf_jit_comp.c:~478), shows how the monotonicity can be broken in practice: a conditional jump that shrinks 6→2 bytes can push a nearby unconditional jump’s displacement from −125 to −129, forcing it to grow 2→5 bytes, which re-grows the conditional on the next pass, and the image oscillates forever between proglen = 4391 and 4392. The fix is not a smarter algorithm but a deliberately conservative threshold:

static bool is_imm8_jmp_offset(int value)
{
	return value <= 123 && value >= -128;   /* not <= 127 */
}

Refusing the short encoding for positive displacements in 124..127 breaks the je ↔ jmp cycle. This is a real fix for a real non-convergence, not a theoretical guard — and it is the reason the loop’s bound is MAX_PASSES = 20 rather than 2:

stateDiagram-v2
    [*] --> Estimate
    Estimate: Pass 0 — addrs[i] = 64*i<br/>(pessimistic upper bound,<br/>every jump assumed long)
    Estimate --> Emit
    Emit: Pass n — do_jit() into rw_image<br/>using previous pass's addrs[]<br/>proglen measured
    Emit --> Check
    Check: proglen == oldproglen ?
    Check --> Padding: still shrinking,<br/>and pass >= PADDING_PASSES (15)
    Padding: padding = true —<br/>emit NOP padding so<br/>instruction sizes freeze
    Padding --> Emit
    Check --> Emit: still shrinking,<br/>refine addrs and go again
    Check --> Allocate: proglen == oldproglen
    Allocate: bpf_jit_binary_pack_alloc()<br/>RO+X image plus extable<br/>image is now non-NULL
    Allocate --> Final
    Final: one more do_jit() —<br/>this time into the real image
    Final --> Verify
    Verify: proglen == oldproglen ?
    Verify --> Done: yes — break out of the loop
    Verify --> Bail: no — pr_err, goto out_image
    Done: bpf_jit_binary_pack_finalize()<br/>text_poke the bytes into RO+X
    Done --> [*]
    Emit --> Bail: pass reached MAX_PASSES (20)<br/>and image is still NULL
    Bail: fall back to the original prog —<br/>the jited flag stays 0
    Bail --> [*]

The branch-displacement fixpoint loop, bpf_int_jit_compile() (bpf_jit_comp.c:3318), with MAX_PASSES = 20 and PADDING_PASSES = MAX_PASSES - 5 = 15 (lines 3315–3316). Note the loop condition is pass < MAX_PASSES || image, so once an image has been allocated the loop is guaranteed one further iteration regardless of the pass count — that is the “very large BPF programs may converge on the last pass” case the source comment calls out. What it shows: the loop starts from a deliberate over-estimate and only ever shrinks; convergence does not end it but triggers allocation, and a final pass emits into the real RO+X-backed image. The insight to take: “the JIT does two passes” is wrong in a way that matters. The loop is a convergence algorithm whose termination had to be engineered (the <= 123 threshold, the padding passes), and if the final pass disagrees with the converged length by even one byte the kernel logs bpf_jit: proglen=%d != oldproglen=%d and abandons the JIT rather than shipping a mis-sized image.

Crucially, the JIT emits into a real buffer on every pass, not just the last. The early passes emit into a temporary read-write buffer (rw_image); only once the size has converged is the final image committed to its executable home. The extra_pass path supports re-JITing for BPF-to-BPF calls, where the addresses of callee subprograms are only known after they too have been JITed.

arm64 uses the same idea but a fixed two-build structure: build_body() is called once with a NULL image purely to compute the offset of each instruction, then the executable buffer is allocated and build_prologue()/build_body()/build_epilogue() are called to emit. The principle — measure, then emit at known offsets — is identical; only the loop shape differs.


The Instruction Set the JIT Consumes

Before looking at how BPF registers land on hardware registers, it is worth being precise about what the JIT is reading, because the shape of the input is most of the explanation for why do_jit() is a single large switch rather than a compiler.

Every eBPF instruction is exactly eight bytes, laid out as struct bpf_insn (include/uapi/linux/bpf.h, v6.12):

packet-beta
0-7: "code (8b): class | op | flags"
8-11: "dst_reg (4b)"
12-15: "src_reg (4b)"
16-31: "off (s16) — jump/memory displacement"
32-63: "imm (s32) — immediate constant"

The 8-byte eBPF instruction word. What it shows: a fixed-width encoding with one opcode byte, two 4-bit register fields, a signed 16-bit offset, and a signed 32-bit immediate — on a little-endian host the bytes appear in memory in exactly that order. The insight to take: there is no variable-length decoding, no prefix soup, no addressing-mode matrix. Decoding an instruction is switch (insn->code) plus four field reads, which is precisely why an architecture backend is one large switch statement and not a compiler back end.

The code byte is itself a packed field. Its low three bits are the instruction class (BPF_CLASS(code) = code & 0x07), and the remaining five bits mean different things depending on the class. The class values come from two headers, and the split between them is a genuine historical artefact worth knowing: the six classes eBPF inherited from classic BPF live in include/uapi/linux/bpf_common.h, while the two classes eBPF added live in include/uapi/linux/bpf.h (lines 17–18), reusing the two opcode values cBPF had spent on BPF_RET and BPF_MISC:

ClassValueHeaderMeaningRemaining code bitsTypical x86-64 expansion
BPF_LD0x00bpf_common.hNon-standard loads: BPF_IMM 64-bit constant, legacy BPF_ABS/BPF_IND packet accessBPF_SIZE | BPF_MODEmovabs (16 bytes) for LD_IMM64, or a helper call
BPF_LDX0x01bpf_common.hLoad from memory into a registerBPF_SIZE | BPF_MODEone mov/movzx/movsx with a displacement
BPF_ST0x02bpf_common.hStore an immediate to memoryBPF_SIZE | BPF_MODEone mov [reg+off], imm
BPF_STX0x03bpf_common.hStore a register to memory; also atomics (BPF_ATOMIC mode)BPF_SIZE | BPF_MODEone mov, or lock xadd / cmpxchg for atomics
BPF_ALU0x04bpf_common.h32-bit arithmetic/logicBPF_OP | BPF_SRCone 32-bit ALU instruction (implicitly zero-extends)
BPF_JMP0x05bpf_common.h64-bit-compare branches, call, exit, tail_callBPF_OP | BPF_SRCcmp + jcc, or call, or the epilogue
BPF_JMP320x06bpf.h (eBPF-only)32-bit-compare branchesBPF_OP | BPF_SRCcmp (no REX.W) + jcc
BPF_ALU640x07bpf.h (eBPF-only)64-bit arithmetic/logicBPF_OP | BPF_SRCone 64-bit ALU instruction (REX.W prefix)

The eight eBPF instruction classes. What it shows: six classes are literally the classic-BPF classes with their original numeric values, and the two eBPF additions (BPF_JMP32, BPF_ALU64) took over the slots cBPF used for BPF_RET (0x06) and BPF_MISC (0x07). The insight to take: the 32-bit/64-bit distinction is carried in the class, not in an operand-size field — BPF_ALU vs BPF_ALU64 and BPF_JMP32 vs BPF_JMP are separate classes. On x86-64 that distinction collapses to a single bit: whether the JIT emits a REX.W prefix (0x48) before the opcode. maybe_emit_mod() (bpf_jit_comp.c:~1090) is exactly this one-line decision.

Within the ALU and JMP classes the operation is the top four bits (BPF_OP(code) = code & 0xf0) and bit 0x08 (BPF_SRC) selects register-source (BPF_X) versus immediate-source (BPF_K). The x86-64 backend’s simple_alu_opcodes[] table (bpf_jit_comp.c:~283) is literally a lookup from BPF_OP to a single x86 opcode byte:

static u8 simple_alu_opcodes[] = {
	[BPF_ADD]  = 0x01,   /* add  */
	[BPF_SUB]  = 0x29,   /* sub  */
	[BPF_AND]  = 0x21,   /* and  */
	[BPF_OR]   = 0x09,   /* or   */
	[BPF_XOR]  = 0x31,   /* xor  */
	[BPF_LSH]  = 0xE0,   /* shl (ModRM /4) */
	[BPF_RSH]  = 0xE8,   /* shr (ModRM /5) */
	[BPF_ARSH] = 0xF8,   /* sar (ModRM /7) */
};

That array is the entire “instruction selection” phase of this compiler. Read it and the claim “translation is mechanical” stops being a slogan.


BPF Registers → Native Registers: Why It’s Mechanical

The single most important reason the JIT is simple is that the BPF calling convention was designed in 2014 to match the native 64-bit ABIs. BPF has eleven registers: R0 (return value), R1–R5 (arguments), R6–R9 (callee-saved), and R10 (the read-only frame pointer). The x86-64 backend’s mapping (reg2hex[], v6.12) is:

static const int reg2hex[] = {
	[BPF_REG_0] = 0,  /* RAX */
	[BPF_REG_1] = 7,  /* RDI */
	[BPF_REG_2] = 6,  /* RSI */
	[BPF_REG_3] = 2,  /* RDX */
	[BPF_REG_4] = 1,  /* RCX */
	[BPF_REG_5] = 0,  /* R8  */
	[BPF_REG_6] = 3,  /* RBX  callee saved */
	[BPF_REG_7] = 5,  /* R13  callee saved */
	[BPF_REG_8] = 6,  /* R14  callee saved */
	[BPF_REG_9] = 7,  /* R15  callee saved */
	[BPF_REG_FP] = 5, /* RBP  readonly */
	[BPF_REG_AX] = 2, /* R10  temp register */
	...
};

Read literally, that table is a list of ModRM register numbers, not register names: reg2hex[BPF_REG_1] = 7 means “when encoding an instruction whose operand is BPF R1, put 7 in the ModRM reg/rm field”, and 7 is x86-64’s encoding for rdi. Registers whose encoding needs the fourth bit — r8–r15 — carry it in the REX prefix instead, which is what is_ereg() (bpf_jit_comp.c:214) tests for and what add_2mod()/maybe_emit_mod() fold into the 0x48/0x40 prefix byte. Laid out against both ABIs:

BPF regRole in BPFx86-64reg2hexis_ereg?SysV AMD64 roleCallee-saved (SysV)?arm64 (bpf2a64[])Callee-saved (AAPCS64)?
BPF_REG_0return value / exit valuerax0noreturn valuenox7no
BPF_REG_11st argument, also program contextrdi7no1st integer argnox0no
BPF_REG_22nd argumentrsi6no2nd integer argnox1no
BPF_REG_33rd argumentrdx2no3rd integer argnox2no
BPF_REG_44th argumentrcx1no4th integer argnox3no
BPF_REG_55th argumentr80yes5th integer argnox4no
BPF_REG_6callee-savedrbx3no—yesx19yes
BPF_REG_7callee-savedr135yes—yesx20yes
BPF_REG_8callee-savedr146yes—yesx21yes
BPF_REG_9callee-savedr157yes—yesx22yes
BPF_REG_FPread-only frame pointer (R10)rbp5noframe pointeryesx25yes
BPF_REG_AXhidden scratch (blinding, rewrites)r102yes(unused by SysV args)nox9no
AUX_REGJIT-internal temporaryr113yesscratchnox10–x12 (TMP_REG_*)no
X86_REG_R96th SysV arg, used by helper thunksr91yes6th integer argno——
X86_REG_R12arena base pointerr124yes—yesx28 (ARENA_VM_START)yes

BPF register file mapped onto two native ABIs. Sources: reg2hex[] at bpf_jit_comp.c:178–194 and bpf2a64[] in the arm64 backend, both v6.12. What it shows: the argument registers line up in order on both architectures, the return register lines up, and BPF’s four callee-saved registers land on native callee-saved registers on both. The insight to take: the alignment in the “argument” rows is why a helper call costs one call instruction and zero movs; the alignment in the “callee-saved” rows is why a helper call cannot clobber R6–R9, which is exactly the guarantee the BPF ABI promises the program. The table also shows the cost: four of BPF’s eleven registers sit in r13–r15/r8, so many instructions need a REX prefix byte that the equivalent hand-written kernel C would not — a small, permanent code-size tax that the mapping’s simplicity buys.

Now compare against the System V AMD64 ABI — the calling convention Linux uses for ordinary kernel C functions. SysV passes the first six integer arguments in rdi, rsi, rdx, rcx, r8, r9, returns values in rax, and treats rbx, r12–r15 as callee-saved. Line up BPF’s mapping against it:

  • R1 → rdi, R2 → rsi, R3 → rdx, R4 → rcx, R5 → r8 — these are exactly the first five SysV argument registers, in order.
  • R0 → rax — exactly the SysV return register.
  • R6–R9 → rbx, r13, r14, r15 — all SysV callee-saved registers.

This alignment is not a coincidence; it is the whole point. Because BPF’s argument registers land on the host’s argument registers, a call from a BPF program to a kernel function (a helper or kfunc) needs no argument shuffling at all — the values are already in the right hardware registers, and the JIT emits a plain call instruction (emit_call() → opcode 0xE8 with a 32-bit relative displacement). A BPF_ALU64 | BPF_ADD | BPF_X instruction (R1 += R2) becomes a single x86-64 add rdi, rsi. A BPF_MOV64 becomes a mov. This is what “the translation is largely mechanical” means concretely: most BPF opcodes have a one-to-few native instruction expansion, looked up through small tables like simple_alu_opcodes[].

The arm64 backend (bpf2a64[], v6.12) makes the same design choice against the AArch64 procedure call standard: R0 → x7 (return), R1–R5 → x0–x4 (the first five argument registers), R6–R9 → x19–x22 (callee-saved), R10/FP → x25. Same principle, different ABI: arguments map to argument registers, callee-saved map to callee-saved, so translation stays mechanical.

There are a handful of registers the BPF architecture does not expose but the JIT needs internally: BPF_REG_AX (mapped to x86 r10 / arm64 x9) is a hidden scratch register used for constant blinding and a few rewrites, and AUX_REG (x86 r11) is a temporary for multi-step instruction expansions. R10, BPF’s frame pointer, maps to the host rbp/x25 and is read-only to the program — it points at the program’s 512-byte BPF stack, which the prologue carves out of the native stack.


Prologue, Epilogue, and Tail-Call Handling

Every JITed program begins with a prologue that establishes the native stack frame and bridges into the BPF execution model. The x86-64 emit_prologue() (v6.12) emits, in order:

emit_cfi(&prog, ...);                 /* CFI/kCFI type hash for indirect-call protection */
emit_nops(&prog, X86_PATCH_SIZE);     /* 5-byte NOP: patch site for fentry/trampolines */
...
EMIT1(0x55);                          /* push rbp                                  */
EMIT3(0x48, 0x89, 0xE5);              /* mov rbp, rsp        (set up frame pointer) */
/* X86_TAIL_CALL_OFFSET is here */
EMIT_ENDBR();                         /* endbr64: CET indirect-branch landing pad   */
if (stack_depth)
	EMIT3_off32(0x48, 0x81, 0xEC, round_up(stack_depth, 8)); /* sub rsp, N */

That listing is the shape; the bytes are worth pinning down exactly, because every offset in the tail-call machinery is derived from them. Expanding each EMIT for a kernel built with CONFIG_X86_KERNEL_IBT=y and no Clang CFI (so cfi_mode falls to the default: arm of emit_cfi(), bpf_jit_comp.c:435, which emits a bare ENDBR):

OffsetBytesx86-64Emitted byWhy
0x00f3 0f 1e faendbr64emit_cfi() → EMIT_ENDBR()CET landing pad: JITed programs are entered by indirect call
0x040f 1f 44 00 00nopl 0x0(%rax,%rax,1)emit_nops(&prog, X86_PATCH_SIZE)5-byte text_poke patch site for fentry/trampolines (X86_PATCH_SIZE = 5)
0x090f 1f 00nopl (%rax)emit_nops(&prog, 3)placeholder keeping the layout identical to the xor rax,rax a tail-call-reachable program would emit here
0x0c55push %rbpEMIT1(0x55)establish the native frame
0x0d48 89 e5mov %rsp,%rbpEMIT3(0x48,0x89,0xE5)frame pointer = BPF R10
0x10f3 0f 1e faendbr64EMIT_ENDBR()the tail-call landing pad — X86_TAIL_CALL_OFFSET points here
0x1448 81 ec <u32>sub $N,%rspEMIT3_off32(...)carve out round_up(stack_depth, 8) bytes of BPF stack; omitted entirely when stack_depth == 0

The nop encodings are the kernel’s canonical ones from arch/x86/include/asm/nops.h — BYTES_NOP5 = 0x0f,0x1f,0x44,0x00,0x00 and BYTES_NOP3 = 0x0f,0x1f,0x00 in the 64-bit block (lines 56–66) — reached through the x86_nops[] table in alternative.c.

Count the bytes up to the tail-call landing pad: 4 + 5 + 3 + 1 + 3 = 16. And indeed the constant is defined (bpf_jit_comp.c:326) as

#define X86_TAIL_CALL_OFFSET	(12 + ENDBR_INSN_SIZE)

with ENDBR_INSN_SIZE defined as (4 * HAS_KERNEL_IBT) in arch/x86/include/asm/ibt.h (line 114). So on an IBT kernel the offset is 16 and on a non-IBT kernel it is 12 — and in both cases it is exactly “everything before the second ENDBR”. The 12 is the sum of the first endbr being absent (0) plus nop5 (5) plus nop3 (3) plus push rbp (1) plus mov rbp,rsp (3); on an IBT build the leading endbr adds its 4 and ENDBR_INSN_SIZE adds the same 4 to the constant. This is the kind of invariant that would silently break attach/tail-call if the prologue gained an instruction, which is why the nop3 in row 0x09 exists at all: a program that is tail-call-reachable emits xor rax,rax (48 31 c0, also 3 bytes) in that slot, and the nop keeps both layouts byte-identical.

Three things in this prologue are worth understanding because they connect to other kernel features:

  1. The 5-byte NOP patch site. Right at the entry the JIT emits a 5-byte run of NOPs (X86_PATCH_SIZE). This is a patch target: when an fexit program or a BPF trampoline wants to hook this program, it atomically overwrites those NOPs with a call to the trampoline using text_poke. Pre-reserving the exact bytes is what makes attach/detach cheap and atomic — no recompilation.

  2. endbr64 and CFI. EMIT_ENDBR() emits the Intel CET (Control-flow Enforcement Technology) “end branch” landing pad, and emit_cfi() emits a kernel Control Flow Integrity type hash. Because JITed programs are entered through indirect calls, they need the same control-flow-integrity markings as ordinary kernel functions or CET/kCFI would trap the call. This is concrete evidence that JITed BPF is treated as genuine kernel text, not a sandboxed blob.

  3. The BPF stack. sub rsp, stack_depth reserves the program’s stack below rbp. The verifier computed stack_depth (≤ 512 bytes) by tracking the highest stack slot the program touches, so the JIT reserves exactly that much.

For programs that use tail calls, the prologue additionally manages a tail-call counter. A tail call jumps from one program into another without returning, and to prevent unbounded chains the kernel enforces MAX_TAIL_CALL_CNT (33). emit_prologue_tail_call() initializes a counter (zeroing rax at the entry of a tail-call chain) and the tail-call emission code increments and bounds-checks it:

EMIT4(0x48, 0x83, 0x38, MAX_TAIL_CALL_CNT); /* cmp qword ptr [rax], MAX_TAIL_CALL_CNT */
/* if (*tcc_ptr)++ >= MAX_TAIL_CALL_CNT -> fall through, do NOT tail-call */

The tail call itself does not use a call instruction — it patches the program-array map lookup, skips the callee’s prologue (jumping past X86_TAIL_CALL_OFFSET so the stack frame is reused rather than re-established), and jmps into the callee’s body. That offset is precisely why the prologue records “X86_TAIL_CALL_OFFSET is here” after the frame setup: tail-callees must enter after the push rbp; mov rbp,rsp to share the caller’s frame.

The full emitted sequence for an indirect tail call (emit_bpf_tail_call_indirect(), bpf_jit_comp.c:689) is short enough to read end to end, and reading it makes the “skip the prologue” trick concrete:

sequenceDiagram
    autonumber
    participant P as Caller program<br/>(JITed body)
    participant A as bpf_array map<br/>(in rsi)
    participant S as Native stack<br/>(tail_call_cnt)
    participant C as Callee program
    P->>P: mov edx,edx  (truncate index to 32 bits)
    P->>A: cmp [rsi+16], edx   (index vs map.max_entries)
    A-->>P: index out of range → jbe out (fall through, no call)
    P->>S: mov rax,[rbp - tcc_ptr_off]  (load tail_call_cnt pointer)
    P->>S: cmp qword [rax], 33  (MAX_TAIL_CALL_CNT)
    S-->>P: depth reached → jae out (fall through)
    P->>A: mov rcx,[rsi + rdx*8 + ptrs]  (fetch prog)
    A-->>P: NULL slot → je out (fall through)
    P->>S: add qword [rax], 1  (charge one tail call)
    P->>P: pop callee-saved regs, pop rax twice, add rsp,stack_depth
    P->>C: mov rcx,[rcx+32] (prog->bpf_func)<br/>add rcx,X86_TAIL_CALL_OFFSET<br/>jmp *rcx
    Note over P,C: no `call`, no new frame —<br/>the callee reuses the caller's rbp

The x86-64 indirect tail-call sequence, transcribed from emit_bpf_tail_call_indirect() (v6.12). What it shows: three bounds checks (index within max_entries, depth below MAX_TAIL_CALL_CNT = 33, slot non-NULL), each of which falls through rather than faulting, then a stack teardown and an indirect jmp into the callee’s body past its prologue. The insight to take: a failed tail call is not an error — every check branches to out:, meaning the calling program simply continues after the bpf_tail_call() instruction with R0 unchanged. That is why bpf_tail_call() “returns” in BPF C when the map slot is empty and why a tail-call chain silently stops at depth 33 instead of reporting anything.

Three details in that sequence repay attention. First, the counter is a pointer, not a value: since the v6.12-era rework the prologue pushes a tail_call_cnt and then a pointer to it (emit_prologue_tail_call(), bpf_jit_comp.c:456), so a chain of tail calls all charge against one shared counter slot living in the entry frame, found at BPF_TAIL_CALL_CNT_PTR_STACK_OFF(stack) = -16 - round_up(stack, 8) from rbp. Second, the counter is incremented only after the slot is found populated — an out-of-range index or a NULL slot costs nothing, so probing an empty prog-array cannot exhaust the budget. Third, the final jmp *rcx goes through emit_indirect_jump() (bpf_jit_comp.c:~641), which on a retpoline kernel becomes a jump to __x86_indirect_thunk_rcx rather than a bare indirect branch — the BPF tail call is subject to the same Spectre-v2 mitigation as every other indirect branch in the kernel, and pays the same cost. See BPF and Spectre Hardening.

The epilogue restores callee-saved registers, tears down the frame (leave, opcode 0xC9), and returns with the program’s return value already in rax (BPF R0). “Returns” is doing some work there: emit_return() (bpf_jit_comp.c:658) emits a plain ret (0xC3) only when the CPU does not have X86_FEATURE_RETHUNK; on a kernel using return thunks it emits a 5-byte jump to x86_return_thunk instead, and when CONFIG_MITIGATION_SLS (Straight-Line Speculation) is set it appends an int3 (0xCC) after the ret so the CPU cannot speculate past the return into whatever bytes follow. So “the epilogue is leave; ret” is true only on an unmitigated machine; on a hardened one the last instruction may not be a ret at all.


JITed Programs as First-Class Kernel Text

A JITed program is not anonymous executable memory — it is registered with the kernel’s symbol table so that profilers, tracers, and bpftool can attribute samples and stack frames to it. After compilation, bpf_prog_ksym_set_addr() and bpf_prog_ksym_set_name() (v6.12, core.c) populate a bpf_ksym with the program’s start address, length, and a synthesized name of the form bpf_prog_<tag>_<name> where the tag is the program’s 8-byte (16-hex-digit) content hash (e.g. bpf_prog_6deef7357e7b4530_xdp_drop), and bpf_prog_ksym_add() inserts it into the kallsyms index.

The visibility of these symbols is gated by the bpf_jit_kallsyms sysctl (per net.rst, v6.12):

bpf_jit_kallsyms
  0 - disable JIT kallsyms export (default value)
  1 - enable JIT kallsyms export for privileged users only

Resolved: the documented default for bpf_jit_kallsyms is wrong for most shipping kernels

An earlier revision of this note flagged the default as uncertain. It is now settled from source. net.rst labels 0 as “(default value)”, but the variable is initialized in kernel/bpf/core.c:568 as

int bpf_jit_enable   __read_mostly = IS_BUILTIN(CONFIG_BPF_JIT_DEFAULT_ON);
int bpf_jit_kallsyms __read_mostly = IS_BUILTIN(CONFIG_BPF_JIT_DEFAULT_ON);

— so on any kernel built with CONFIG_BPF_JIT_DEFAULT_ON=y, which is the near-universal distribution setting (verified on this note’s test box: CONFIG_BPF_JIT_DEFAULT_ON=y in /boot/config-7.1.13-200.fc44.x86_64), both bpf_jit_enable and bpf_jit_kallsyms default to 1, not 0. The documentation’s “(default value)” annotations describe the CONFIG_BPF_JIT_DEFAULT_ON=n case only. Note also that bpf_jit_kallsyms is mode 0600 (sysctl_net_core.c:453), so an unprivileged shell cannot even read it back to check — cat returns Permission denied, which is what happened on the test box.

When enabled, the practical consequence is that perf top shows bpf_prog_<tag>_<name> frames instead of opaque 0xffffffffc0xxxxxx addresses, and bpftool prog can correlate a loaded program with its in-kernel symbol. This is observability; the security tradeoff of exposing JIT addresses — and why turning on bpf_jit_harden force-disables bpf_jit_kallsyms — is covered in JIT Hardening and Constant Blinding.


The JIT’s Control Surface — Four Sysctls

Everything above is compiled-in behaviour. At runtime the JIT exposes exactly four knobs, all under /proc/sys/net/core/, all declared in one table in net/core/sysctl_net_core.c (v6.12, lines 429–472). Their security rationale lives in JIT Hardening and Constant Blinding; what belongs here is the mechanics — who may write them, what range the kernel enforces, and what the build configuration pins:

SysctlTypeFile modeEnforced rangeWrite requiresInitial value (v6.12)Effect
bpf_jit_enableint06440–2, or 1–1 if CONFIG_BPF_JIT_ALWAYS_ONCAP_SYS_ADMIN; writing 2 additionally needs bpf_dump_raw_ok()IS_BUILTIN(CONFIG_BPF_JIT_DEFAULT_ON)0 = interpret, 1 = JIT, 2 = JIT and dump the generated image to the kernel log
bpf_jit_hardenint06000–2CAP_SYS_ADMIN (proc_dointvec_minmax_bpf_restricted)00 = off, 1 = blind constants for unprivileged loaders only, 2 = blind for everyone
bpf_jit_kallsymsint06000–1CAP_SYS_ADMINIS_BUILTIN(CONFIG_BPF_JIT_DEFAULT_ON)1 = export JITed images to /proc/kallsyms as bpf_prog_<tag>_<name>; force-disabled whenever bpf_jit_harden is non-zero
bpf_jit_limitlong06001 – bpf_jit_limit_maxCAP_SYS_ADMINround_up(bpf_jit_limit_max >> 1, PAGE_SIZE)global byte budget for JIT images; exceeding it makes unprivileged loads fail -EPERM

The four JIT sysctls, read from the v6.12 sysctl table and the variable definitions at core.c:567–571. What it shows: three of the four are mode 0600 — not merely root-writable but root-readable, because the values themselves are attacker-useful. And bpf_jit_enable’s range is set by #ifdef: with CONFIG_BPF_JIT_ALWAYS_ON both extra1 and extra2 become SYSCTL_ONE, so the only legal value is 1. The insight to take: on a hardened distribution kernel the “disable the JIT” knob does not exist. Writing 0 fails validation and the kernel prints CONFIG_BPF_JIT_ALWAYS_ON is enabled, bpf_jit_enable is permanently set to 1. (sysctl_net_core.c:356) — the interpreter is not merely off, it is not in the binary.

bpf_jit_limit_max is derived at boot by bpf_jit_charge_init() (core.c:1038) from the generic bpf_jit_alloc_exec_limit() (core.c:1029), which on any architecture defining MODULES_VADDR returns MODULES_END - MODULES_VADDR — the size of the module address window — and elsewhere the whole vmalloc range. The default budget is then half of that, rounded up to a page. The charge itself is a single atomic in bpf_jit_charge_modmem() (core.c:1048):

if (atomic_long_add_return(size, &bpf_jit_current) > READ_ONCE(bpf_jit_limit)) {
	if (!bpf_capable()) {
		atomic_long_sub(size, &bpf_jit_current);
		return -EPERM;
	}
}

Read carefully, that is not a hard cap: a bpf_capable() (i.e. CAP_BPF-holding) caller sails straight past it, having already added its size to the running total. Only unprivileged callers are refused, and they are refused with -EPERM, which in a libbpf error message is indistinguishable from a permission problem. This is the single most confusing JIT failure in practice and the reason it appears in the failure-modes list below.

Uncertain

The concrete default value of bpf_jit_limit on x86-64. The formula is verified from source (half the module window, page-rounded), but turning it into a number requires MODULES_END - MODULES_VADDR for the running kernel’s memory layout, which varies with CONFIG_RANDOMIZE_BASE and the architecture’s pgtable_64_types.h. Reason: bpf_jit_limit is mode 0600 and could not be read on the unprivileged test box (cat → Permission denied). To resolve: sudo cat /proc/sys/net/core/bpf_jit_limit on the target kernel, or compute from that kernel’s MODULES_VADDR/MODULES_END. #uncertain


bpf_prog_pack — Packing JITed Code into Huge Pages

Naively, each JITed program would get its own page (or more) of executable memory via vmalloc. That wastes memory (most BPF programs are tiny, far smaller than a 4 KB page) and, more importantly, fragments the instruction TLB: scattering many small programs across many 4 KB pages means each lives in its own TLB entry, and on a busy system running hundreds of BPF programs the iTLB thrashes. The bpf_prog_pack allocator (introduced in 5.18; the design comment lives in v6.12 core.c) fixes both:

/*
 * Most BPF programs are pretty small. Allocating a whole page for each
 * program is sometimes a waste. Many small bpf programs also add pressure
 * to instruction TLB. To solve this issue, we introduce a BPF program pack
 * allocator. The prog_pack allocator uses HPAGE_PMD_SIZE page (2MB on x86)
 * to host BPF programs.
 */
#define BPF_PROG_CHUNK_SHIFT	6
#define BPF_PROG_CHUNK_SIZE	(1 << BPF_PROG_CHUNK_SHIFT)   /* 64 bytes */
#define BPF_PROG_PACK_SIZE	(SZ_2M * num_possible_nodes()) /* 2MB per NUMA node */

The mechanism: the allocator reserves a 2 MB region (one PMD-sized huge page on x86-64), maps it as a single huge-page mapping so all the programs inside it share one iTLB entry, and sub-allocates 64-byte chunks from it using a bitmap (bpf_prog_pack_alloc()). Many small programs share one huge page; the iTLB pressure collapses from N entries to roughly one. The reserved space is pre-filled with illegal/trap instructions (bpf_fill_ill_insns → 0xcc INT3 on x86) so that any control flow straying into an unused gap traps immediately rather than executing garbage.

Dated by existence check

bpf_prog_pack_alloc() and bpf_jit_binary_pack_alloc() are absent from kernel/bpf/core.c at tag v5.17 (0 occurrences) and present at v5.18 (2 each) — fetched at both tags on 2026-09-11. So “introduced in 5.18” is not folklore here; it is a measured 0→2 transition. Song Liu’s series and the design argument are written up in LWN’s “A memory allocator for BPF code”; the version that first landed had to be reverted and reworked, which LWN covered in “The BPF allocator runs into trouble”.

flowchart LR
  subgraph PACK["struct bpf_prog_pack — one 2 MB PMD-sized huge page"]
    direction LR
    C0["chunk 0<br/>64 B"]
    C1["chunk 1"]
    C2["chunk 2"]
    C3["… 32,768 chunks …"]
    CN["chunk N"]
  end
  BM["bitmap[]<br/>one bit per 64 B chunk<br/>first-fit via<br/>bitmap_find_next_zero_area()"]
  FILL["pre-filled with 0xCC (int3)<br/>by bpf_fill_ill_insns()"]
  ROX["set_memory_rox()<br/>→ page is RO + X, never W + X"]
  RW["rw_header = kvmalloc(size)<br/>plain writable kernel memory"]
  JITW["JIT emits bytes here"]
  COPY["bpf_jit_binary_pack_finalize()<br/>→ bpf_arch_text_copy() (text_poke)"]
  BM --> PACK
  FILL --> PACK
  ROX --> PACK
  RW --> JITW --> COPY --> C2
  C2 -.->|"prog->bpf_func points here"| RUN2["execution"]

How one JITed program gets into a shared huge page. What it shows: the 2 MB pack is allocated once, immediately filled with int3 trap bytes and flipped to read-only-executable; thereafter each program takes a run of 64-byte chunks marked in a bitmap, and the JIT never writes to the pack — it emits into a separate kvmalloc’d read-write buffer that is copied in via text_poke at finalize time. The insight to take: the RW/RO split is not an extra safety layer bolted onto packing — it is forced by packing. Once many programs share one page, that page cannot be made writable to install a new program without making every neighbouring program writable too, so the only way to keep W^X while sharing is to emit elsewhere and text_poke the result in.

Because the executable copy lives in a shared, read-only huge page, the JIT cannot simply emit into it. The bpf_jit_binary_pack_alloc() path therefore allocates a pair: a read-write scratch buffer the JIT emits into, and the read-only-executable home in the prog_pack. After emission the finished bytes are copied into the executable region with bpf_arch_text_copy() (which uses text_poke under the hood to write to RO+X memory), and the RW scratch is freed. The pack page itself is set to read-only-and-executable at allocation time via set_memory_rox() — this is the W^X (write XOR execute) guarantee that the page is never simultaneously writable and executable. The defensive significance of W^X belongs to JIT Hardening and Constant Blinding; here the point is that huge-page packing and W^X are inseparable — the same set_memory_rox() call that hardens the page is what lets many programs share it.

Two details in the v6.12 allocator are easy to get wrong. First, BPF_PROG_PACK_SIZE is SZ_2M * num_possible_nodes(), not a flat 2 MB — on a two-socket machine each pack reservation is 4 MB, and the comment in core.c says why: PMD_SIZE is enormous on some architectures, so the kernel deliberately hardcodes 2 MB per node rather than following PMD_SIZE. Second, a program larger than one pack bypasses packing entirely: bpf_prog_pack_alloc() (core.c:937) begins with if (size > BPF_PROG_PACK_SIZE), rounds up to a page, allocates standalone executable memory, fills it with traps, and set_memory_rox()es it — so a very large BPF program gets its own RO+X allocation and none of the iTLB benefit. Chunking is 64 bytes (BPF_PROG_CHUNK_SHIFT = 6), which is also the granularity of the 16-byte random gap that bpf_jit_binary_pack_alloc() (core.c:1131) leaves in front of each image: it adds 16 bytes to the request and then picks a random start offset inside the slack with get_random_u32_below(hole), so an attacker cannot predict an image’s exact address within its chunk.

The allocator underneath changed in v6.10 — module_alloc → execmem

Below bpf_prog_pack sits bpf_jit_alloc_exec(), the __weak function that actually obtains executable kernel memory. This is the layer the brief-level summary “BPF uses module_alloc()” refers to, and it stopped being true recently enough that it is worth dating precisely rather than asserting. Fetching kernel/bpf/core.c at successive tags:

Taginclude/linux/execmem.hbpf_jit_alloc_exec() body
v6.8404 (does not exist)return module_alloc(size); (core.c:1043)
v6.9404return module_alloc(size); (core.c:1053)
v6.10200return execmem_alloc(EXECMEM_BPF, size); (core.c:1067)
v6.12200return execmem_alloc(EXECMEM_BPF, size); (core.c:1065)
v6.18200(execmem retained)

Existence-checked on 2026-09-11 by fetching each path at each tag and recording HTTP status plus the matching line. What it shows: the switch from the module loader’s allocator to the general execmem API happened in v6.10, not gradually. The insight to take: any write-up that says “the BPF JIT allocates from the module space via module_alloc()” is describing v6.9 and earlier. From v6.10 the BPF JIT is one named client (EXECMEM_BPF) of a general executable-memory allocator that modules, kprobes, and ftrace also use — which is how per-client policy (address range, ROX caching, alignment) became expressible at all.

The motivation, and the several-year path from Mike Rapoport’s jit_text_alloc() proposal to the merged execmem_alloc(), is covered in LWN’s “Yet another memory allocator for executable code”. The short version: module_alloc() was a module-loader implementation detail that five subsystems had come to depend on, each with slightly different needs, and each architecture reimplemented it; execmem gives the allocator an explicit per-client type so BPF’s requirements stop being “whatever the module loader happens to do.”


Near-Native Performance

The reason all of this exists is speed. The original cBPF JIT (Eric Dumazet, 2011, LWN 437981) demonstrated the core idea — “every BPF instruction maps to a straightforward x86 instruction sequence … the accumulator and index registers are stored directly in processor registers” — and measured roughly a 50 ns saving per filter invocation versus the interpreter. For eBPF the stakes are far higher because the programs sit on hot paths. The XDP data path runs a BPF program on every received packet, in the NIC driver, before the kernel allocates an sk_buff. The XDP paper (Høiland-Jørgensen et al., CoNEXT ‘18) reports XDP sustaining 24 million packets per second on a single core for a drop workload — about a fivefold improvement over the fastest in-stack processing mode. That number is only achievable because each packet is processed by native code, not interpreted bytecode: at 24 Mpps a single core has roughly 40 ns per packet, a budget the interpreter’s per-instruction dispatch overhead would blow instantly.

This is also why hardened kernels set CONFIG_BPF_JIT_ALWAYS_ON and compile the interpreter out: on those systems there is no interpreter to fall back to, so the JIT is mandatory, and the absence of an interpreter removes a class of speculation attack surface as a bonus. The performance story and the security story converge on the same answer — always JIT.


Reading the JIT’s Output on a Real Machine

Everything above is derived from source. This section closes the loop by producing the bytes on a live box and disassembling them, so the claims about register mapping, prologue layout, and X86_TAIL_CALL_OFFSET are checkable rather than asserted.

Environment. Fedora 44, Linux 7.1.13-200.fc44.x86_64, AMD Ryzen AI Max+ 395 (Zen 5), 32 logical CPUs. GNU objdump 2.46.1, gcc 16.2.1, unprivileged user (uid 1000, no sudo without a password prompt). Relevant lines from /boot/config-7.1.13-200.fc44.x86_64:

CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y        # interpreter compiled out; bpf_jit_enable pinned to 1
CONFIG_BPF_JIT_DEFAULT_ON=y       # bpf_jit_enable and bpf_jit_kallsyms start at 1
CONFIG_BPF_UNPRIV_DEFAULT_OFF=y   # unprivileged_bpf_disabled starts at 2
CONFIG_X86_KERNEL_IBT=y           # ENDBR_INSN_SIZE = 4 -> X86_TAIL_CALL_OFFSET = 16
CONFIG_MITIGATION_RETPOLINE=y
CONFIG_MITIGATION_RETHUNK=y
CONFIG_MITIGATION_SLS=y           # epilogue gets an int3 after the ret
CONFIG_EXECMEM=y                  # v6.10+ allocator, see above

The sysctl state, and what an unprivileged user can and cannot see. Only one of the four JIT sysctls is world-readable:

$ cat /proc/sys/net/core/bpf_jit_enable
1
$ cat /proc/sys/net/core/bpf_jit_harden /proc/sys/net/core/bpf_jit_kallsyms /proc/sys/net/core/bpf_jit_limit
cat: /proc/sys/net/core/bpf_jit_harden: Permission denied
cat: /proc/sys/net/core/bpf_jit_kallsyms: Permission denied
cat: /proc/sys/net/core/bpf_jit_limit: Permission denied
$ echo 0 > /proc/sys/net/core/bpf_jit_enable
zsh: permission denied: /proc/sys/net/core/bpf_jit_enable

That is the 0644 vs 0600 split from the sysctl table above, visible in practice. bpf_jit_enable = 1 is exactly what IS_BUILTIN(CONFIG_BPF_JIT_DEFAULT_ON) predicts, and even root could not write 0 here because CONFIG_BPF_JIT_ALWAYS_ON pins the range to 1–1.

Loading a program unprivileged fails, and the errno says why. A minimal four-instruction socket-filter program submitted through bpf(BPF_PROG_LOAD, ...):

struct bpf_insn insns[] = {
  { .code = 0xb7, .dst_reg = 1, .imm = 42 },      /* r1 = 42  */
  { .code = 0xb7, .dst_reg = 0, .imm = 1  },      /* r0 = 1   */
  { .code = 0x0f, .dst_reg = 0, .src_reg = 1 },   /* r0 += r1 */
  { .code = 0x95 },                               /* exit     */
};
$ ./tryload
bpf(BPF_PROG_LOAD, SOCKET_FILTER, 4 insns) = -1 (Operation not permitted)
$ cat /proc/sys/kernel/unprivileged_bpf_disabled
2

unprivileged_bpf_disabled = 2 is the CONFIG_BPF_UNPRIV_DEFAULT_OFF=y default: the bpf() syscall requires CAP_BPF (or CAP_SYS_ADMIN) for everything, including a program that touches nothing. Combined with bpftool not being installed and no passwordless root on this box, bpftool prog dump jited could not be run here; see the uncertainty callout at the end of this section. What can be done without privilege is to expand the JIT’s EMIT macros by hand from the pinned v6.12 source and check the result against a real disassembler — which tests exactly the same claims.

Step 1 — the BPF side. Encoding those four struct bpf_insn values little-endian and disassembling with binutils’ BPF target:

$ xxd prog.bpf
00000000: b701 0000 2a00 0000 b700 0000 0100 0000  ....*...........
00000010: 0f10 0000 0000 0000 9500 0000 0000 0000  ................

$ objdump -D -b binary -m bpf -EL prog.bpf
   0:	b7 01 00 00 2a 00 00 00 	mov %r1,42
   8:	b7 00 00 00 01 00 00 00 	mov %r0,1
  10:	0f 10 00 00 00 00 00 00 	add %r0,%r1
  18:	95 00 00 00 00 00 00 00 	exit

Two things fall out of the raw bytes. 0xb7 decomposes as BPF_ALU64 (0x07) | BPF_MOV (0xb0) | BPF_K (0x00), and 0x0f as BPF_ALU64 (0x07) | BPF_ADD (0x00) | BPF_X (0x08) — the class table above, read off an actual instruction. And the 10 in the second byte of the add is the packed src_reg:4 | dst_reg:4 field: source r1, destination r0. (One trap worth recording: objdump -m bpf defaults to big-endian decoding on this binutils, turning imm = 42 into 704643072. Passing -EL is mandatory.)

Step 2 — expanding the JIT by hand. For stack_depth = 0, no callee-saved registers used, not tail-call reachable, IBT on, CFI off:

BPFJIT path (v6.12 bpf_jit_comp.c)Emitted bytes
(prologue)emit_cfi → EMIT_ENDBR; emit_nops(5); emit_nops(3); EMIT1(0x55); EMIT3(0x48,0x89,0xE5); EMIT_ENDBRf3 0f 1e fa 0f 1f 44 00 00 0f 1f 00 55 48 89 e5 f3 0f 1e fa
r1 = 42emit_mov_imm32(sign_propagate=true, dst=R1, 42); 42 ≥ 0 and ≠ 0, is_ereg(R1) false → EMIT1_off32(0xB8 + reg2hex[R1], 42), and reg2hex[R1] = 7bf 2a 00 00 00
r0 = 1same path, reg2hex[R0] = 0b8 01 00 00 00
r0 += r1maybe_emit_mod(is64=true) → 0x48; simple_alu_opcodes[BPF_ADD] = 0x01; add_2reg(0xC0, R0, R1) = 0xC0 + 0 + (7<<3) = 0xF848 01 f8
exitEMIT1(0xC9) (leave), then emit_return() → ret plus SLS int3c9 c3 cc

Step 3 — check it against a disassembler. Writing those 36 bytes to a file and disassembling as raw x86-64:

$ objdump -D -b binary -m i386:x86-64 jited.bin
   0:	f3 0f 1e fa          	endbr64
   4:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)
   9:	0f 1f 00             	nopl   (%rax)
   c:	55                   	push   %rbp
   d:	48 89 e5             	mov    %rsp,%rbp
  10:	f3 0f 1e fa          	endbr64
  14:	bf 2a 00 00 00       	mov    $0x2a,%edi
  19:	b8 01 00 00 00       	mov    $0x1,%eax
  1e:	48 01 f8             	add    %rdi,%rax
  21:	c9                   	leave
  22:	c3                   	ret
  23:	cc                   	int3

Four claims from earlier in this note are now visible as bytes rather than prose:

  1. The register map is real. r1 = 42 became mov $0x2a,%edi and r0 = 1 became mov $0x1,%eax. BPF R1 is rdi; BPF R0 is rax. r0 += r1 is one instruction, add %rdi,%rax — no shuffling.
  2. X86_TAIL_CALL_OFFSET lands exactly where the source says. The second endbr64 sits at offset 0x10 = 16 = 12 + ENDBR_INSN_SIZE with ENDBR_INSN_SIZE = 4. A tail call into this program would jmp to bpf_func + 16, skipping push rbp; mov rbp,rsp and inheriting the caller’s frame.
  3. The 32-bit mov is deliberate, not a bug. BPF’s mov here is BPF_ALU64 — a 64-bit move — yet the JIT emitted a 32-bit mov $0x2a,%edi with no REX.W. That is the emit_mov_imm32() optimisation: on x86-64 a 32-bit write to a register zero-extends into the full 64 bits, so for a non-negative immediate the 5-byte form is semantically identical to the 7-byte REX.W-prefixed form and saves two bytes. For a negative immediate the same function takes the other branch and emits 48 c7 c7 <imm32> (mov $imm,%rdi), which sign-extends. This is the entirety of the JIT’s “optimisation” budget: two special cases in one function (zero → xor, non-negative → 32-bit mov).
  4. The epilogue is not simply ret. CONFIG_MITIGATION_SLS=y on this kernel appends int3 after the ret, and on a CPU where X86_FEATURE_RETHUNK is active emit_return() would replace the c3 entirely with a 5-byte jmp x86_return_thunk. Reading a real bpftool prog dump jited on a mitigated machine and expecting c3 at the end is a way to conclude the dump is corrupt when it is not.

What bpf_jit_harden=2 would change. Constant blinding is a bytecode rewrite that happens before the JIT runs, in bpf_jit_blind_constants() (core.c:1466), so its effect is visible in this same derivation. r1 = 42 (BPF_ALU64 | BPF_MOV | BPF_K) matches the BPF_ALU64 | BPF_MOV | BPF_K case of bpf_jit_blind_insn() (core.c:1288) and expands to three instructions:

r1 = 42                 ->   rAX = 42 ^ imm_rnd      (BPF_ALU64_IMM(BPF_MOV, BPF_REG_AX, imm_rnd ^ 42))
                             rAX ^= imm_rnd          (BPF_ALU64_IMM(BPF_XOR, BPF_REG_AX, imm_rnd))
                             r1  = rAX               (BPF_ALU64_REG_OFF(code, R1, BPF_REG_AX, off))

with a fresh get_random_u32() per instruction. BPF_REG_AX is r10 on x86-64, so the emitted native code would contain r10 traffic and two extra instructions per blinded constant, and the literal 0x2a would never appear in the image — which is the point, since JIT-sprayed shellcode is smuggled in as immediates. The mechanism, the attack it defeats, and its measured cost belong to JIT Hardening and Constant Blinding; what this section adds is that you can see where in the pipeline it happens — before bpf_int_jit_compile(), on a bpf_prog_clone_create()d copy, with clone->blinded = 1 set at the end so it is never applied twice.

Uncertain

This section derives the JIT output rather than dumping it. Verify: that a real bpftool prog dump jited on a v6.12 kernel with this configuration produces byte-identical output to jited.bin above. Reason: bpftool is not installed on the test box, perf is not installed, unprivileged_bpf_disabled = 2 blocks bpf(BPF_PROG_LOAD) for uid 1000, and no passwordless root was available — so the derivation was checked against objdump (which validates the encodings) but not against the kernel (which would validate the emission). Two byte sequences in particular are configuration-dependent and were reasoned about rather than observed: whether emit_return() emits c3 or a jump to x86_return_thunk depends on the runtime X86_FEATURE_RETHUNK bit, not the CONFIG_MITIGATION_RETHUNK=y build flag; and the running kernel is 7.1.13, not the pinned v6.12, so a prologue change between those releases would not be caught here. To resolve: on a root shell, bpftool prog load ./prog.o /sys/fs/bpf/t && bpftool prog dump jited name t and diff against the table above. #uncertain


Failure Modes and Common Misunderstandings

  • “The JIT optimizes my program.” It does not, beyond branch-displacement minimization. There is no instruction scheduling, no inlining of helpers (helpers are real calls), no constant folding. All optimization happens in the LLVM/Clang frontend that produced the bytecode, and in the verifier’s instruction rewrites. If your hot loop is slow, the JIT will not save you — restructure the BPF code.
  • “bpf_int_jit_compile failing means my program is rejected.” Only if the JIT is mandatory (CONFIG_BPF_JIT_ALWAYS_ON or a kfunc call). Otherwise a JIT failure silently falls back to the interpreter, and prog->jited is left 0. Check it: bpftool prog show reports whether a program is JITed.
  • Confusing “two passes” with the real loop. As shown above, the x86-64 JIT iterates up to 20 times to a fixpoint. A program that fails to converge (extremely rare, and guarded against by the padding mechanism) would be rejected — but in practice convergence happens within a handful of passes.
  • Assuming the JIT image is writable. It is W^X. You cannot patch a JITed program by writing to prog->bpf_func; all modifications (fentry attach, tail-call patching) go through text_poke/bpf_arch_text_poke against RO+X memory.
  • “The JIT emitted a 32-bit mov for my 64-bit BPF mov — that’s a truncation bug.” It is not. emit_mov_imm32() uses the 5-byte mov $imm,%e<reg> form for non-negative immediates precisely because x86-64 zero-extends 32-bit register writes to 64 bits, making it equivalent to the 7-byte REX.W form. Negative immediates take the other branch and get 48 c7 c7 <imm32>, which sign-extends. Same rule, opposite encoding.
  • Expecting ret at the end of every JITed image. On a kernel with return thunks active the epilogue’s final instruction is a jmp into x86_return_thunk, and with CONFIG_MITIGATION_SLS there is an int3 after the ret. Both are correct output, not a truncated dump.
  • Disassembling raw BPF bytes with the wrong endianness. objdump -D -b binary -m bpf on binutils 2.46 defaults to big-endian and silently byte-swaps every immediate — 42 reads back as 704643072. Always pass -EL on a little-endian host.
  • -EPERM at load that looks like a verifier error but is bpf_jit_limit. If the global JIT memory budget is exhausted, an unprivileged load fails with -EPERM even though the program is perfectly valid. This is the DoS cap, covered in JIT Hardening and Constant Blinding.

Alternatives and When to Choose Them

The only real alternative execution engine is the software interpreter (___bpf_prog_run in core.c), a giant computed-goto dispatch loop over the bytecode. It is portable (works on architectures with no JIT backend), needs no executable-memory allocation, and is the fallback when the JIT is disabled or fails. Its cost is roughly an order of magnitude in per-instruction overhead and a larger speculative-execution attack surface — which is why hardened kernels remove it. The full comparison is in BPF Interpreter vs JIT.

It is worth contrasting the BPF JIT against a language-runtime JIT such as CPython’s tier-2 copy-and-patch JIT. The contrast is instructive precisely because they share a name and almost nothing else. CPython’s JIT is adaptive and speculative: it traces hot bytecode at runtime, specializes on observed types, emits guards, and can deoptimize back to the interpreter when a guard fails — because Python is dynamically typed and the runtime cannot know in advance what types flow through a loop. The BPF JIT is the opposite: it is static, eager, and total. It compiles the entire program once, before first execution, with no speculation, no specialization, no deoptimization, and no guards — because the verifier has already proven every type and bound statically, so there is nothing to discover at runtime. CPython JITs to recover type information dynamic typing threw away; BPF JITs because the type information is already proven, and the only job left is transliteration. They sit at opposite ends of the JIT design space.


Production Notes

In production, three JIT-adjacent observations recur. First, always confirm programs are actually JITed — bpftool prog show lists jited/not jited; an accidentally interpreted XDP program on a hardened kernel will instead fail to load (mandatory JIT) or, on a non-hardened kernel, silently run an order of magnitude slower. Second, perf attribution depends on bpf_jit_kallsyms — without it, BPF-heavy workloads show large unattributed [unknown] time in flame graphs; Cilium and Katran deployments routinely enable kallsyms export on trusted hosts for exactly this reason. Third, iTLB pressure was a real scaling problem before bpf_prog_pack — environments running hundreds of small BPF programs (large Cilium clusters, dense tracing deployments) saw measurable iTLB-miss overhead that the huge-page packing allocator (5.18+) largely eliminated; on a 6.12/6.18 kernel this is handled transparently, but it explains why the allocator exists.


See Also