BPF Bytecode and Disassembly
A compiled BPF program is just an array of 8-byte (occasionally 16-byte) instructions for the eBPF virtual machine, and learning to read it — in source, after the verifier rewrites it, and after the JIT lowers it to native code — is the single most useful debugging skill in eBPF work. The bytecode exists in three distinct forms that do not match each other: the ELF object that
clang -target bpfemits (the program as compiled, before the kernel sees it), the xlated image (bpftool prog dump xlated— the eBPF as the verifier rewrote it, with helper inlining and pointer fix-ups), and the jited image (bpftool prog dump jited— the native x86-64/arm64 machine code the BPF JIT Compiler produced) (bpftool-prog.rst, v6.12). All three render in a compact textual assembly —r1 = r2,*(u64 *)(r10 - 0x8) = r1,if r0 > 0x10 goto +0x3,call bpf_map_lookup_elem#NN— produced by the kernel’sprint_bpf_insn()and by libbpf’s disassembler (kernel/bpf/disasm.c, v6.12).
This note teaches how to compile, dump, and read BPF bytecode. For the instruction encoding bit-by-bit see BPF Instruction Set; for the eleven registers and stack the disassembly references see BPF Virtual Machine and Registers; for the bpftool/skeleton tooling around this see BPF Skeletons and bpftool.
Mental Model: Three Views of One Program
The most common confusion in reading BPF is treating “the bytecode” as one thing. It is three snapshots of a program at three stages of its life, and they legitimately differ:
flowchart LR SRC["BPF C source<br/>(restricted C)"] ELF["ELF object (.o)<br/>bytecode + .BTF + .BTF.ext<br/>llvm-objdump -d"] XLAT["xlated image<br/>(post-verifier eBPF)<br/>bpftool prog dump xlated"] JIT["jited image<br/>(native machine code)<br/>bpftool prog dump jited"] SRC -->|"clang -target bpf -g"| ELF ELF -->|"bpf() BPF_PROG_LOAD<br/>+ verifier rewrites"| XLAT XLAT -->|"JIT compiler"| JIT
The three forms of a BPF program and the tool that shows each. What it shows: llvm-objdump -d reads the ELF on disk — the eBPF exactly as the compiler emitted it, before the kernel exists in the picture; bpftool prog dump xlated reads the program out of the kernel after the verifier has rewritten it; bpftool prog dump jited reads the native code the JIT produced. The insight to take: these are not three encodings of the same instructions — the verifier changes the instructions (inlining some helper calls, adjusting offsets, inserting Spectre nospec barriers), so xlated can have more, fewer, or different instructions than the ELF, and jited is a different ISA entirely. When debugging, always ask “which view am I looking at?”
What clang -target bpf Emits
A BPF program is compiled with Clang/LLVM’s BPF backend (“The extended Berkeley Packet Filter (eBPF) backend” in the LLVM code generator, emitting file format elf64-bpf) by passing -target bpf (LLVM CodeGenerator). The output is an ordinary ELF relocatable object, but with BPF-specific sections. Compiling a minimal kprobe program with clang -O2 -g -target bpf -c prog.c -o prog.o and inspecting it with llvm-readelf -S prog.o yields sections like these (real output, LLVM 21):
[Nr] Name Type Flg
[ 3] kprobe/sys_execve PROGBITS AX <- the program's bytecode (named by SEC())
[ 4] .relkprobe/... REL <- relocations for that program
[ 5] .maps PROGBITS WA <- map definitions (BTF-typed)
[ 6] license PROGBITS WA <- the GPL license string
[16] .BTF PROGBITS <- type + string info (vmlinux-style)
[17] .rel.BTF REL
[18] .BTF.ext PROGBITS <- func_info + line_info + CO-RE relos
[19] .rel.BTF.ext REL
[22] .debug_line PROGBITS <- DWARF line table (from -g)
[26] .symtab SYMTAB
Several things are load-bearing here:
- The program lives in a section named by its
SEC()attribute, not in.text. A program declaredSEC("kprobe/sys_execve")ends up in a PROGBITS section literally namedkprobe/sys_execve. libbpf reads the section name to decide the program type and attach point. Multiple programs mean multiple such sections. .mapsholds the map definitions, encoded as BTF-typed zero-size structs whose member types describe the map (key type, value type,max_entries, etc.). The bytecode does not embed map file descriptors — those don’t exist until load time — so map references in the code are relocations..BTFis the BPF Type Format type/string blob (the same format as/sys/kernel/btf/vmlinux), and.BTF.extcarriesfunc_info,line_info, and CO-RE relocation records (btf.html)..BTF.ext’sline_infois what makes source-annotated disassembly possible (covered below). These are produced from the-gdebug flag; without-gyou still get bytecode but no source interleaving.- Relocations (
.relkprobe/sys_execve) patch map references and (with CO-RE) field offsets. Anllvm-objdump -drshows e.g.R_BPF_64_64 countsagainst the.mapssection where the program loads the address of mapcounts.
Reading the ELF bytecode (llvm-objdump -d)
llvm-objdump -d prog.o disassembles the eBPF as compiled, needing no privilege and no running kernel — the ground truth for the compiler’s output. Real output (LLVM 21) for a program that looks up a per-key counter and increments it up to a cap of 0x10:
0000000000000000 <count_execve>:
0: b4 01 00 00 00 00 00 00 w1 = 0x0
1: 63 1a fc ff 00 00 00 00 *(u32 *)(r10 - 0x4) = w1
2: bf a2 00 00 00 00 00 00 r2 = r10
3: 07 02 00 00 fc ff ff ff r2 += -0x4
4: 18 01 00 00 00 00 00 00
00 00 00 00 00 00 00 00 r1 = 0x0 ll
6: 85 00 00 00 01 00 00 00 call 0x1
7: b4 01 00 00 01 00 00 00 w1 = 0x1
8: 15 00 06 00 00 00 00 00 if r0 == 0x0 goto +0x6 <count_execve+0x78>
9: 79 02 00 00 00 00 00 00 r2 = *(u64 *)(r0 + 0x0)
10: b4 01 00 00 00 00 00 00 w1 = 0x0
11: 25 02 03 00 10 00 00 00 if r2 > 0x10 goto +0x3 <count_execve+0x78>
12: 07 02 00 00 01 00 00 00 r2 += 0x1
13: 7b 20 00 00 00 00 00 00 *(u64 *)(r0 + 0x0) = r2
14: b4 01 00 00 01 00 00 00 w1 = 0x1
15: bc 10 00 00 00 00 00 00 w0 = w1
16: 95 00 00 00 00 00 00 00 exit
Reading it line by line teaches the whole syntax (see Textual syntax below). The left column is the instruction index, then the raw 8 (or 16) bytes, then the textual disassembly. Note instruction 4 is sixteen bytes — the wide load-immediate (lddw, opcode 0x18), here loading a map address (it disassembles as r1 = 0x0 ll, the ll suffix marking the 64-bit immediate; the 0x0 is a placeholder the relocation fills in with the real map fd at load). Note also w1/w0 (the 32-bit sub-register view of r1/r0) versus full r2, and the signed displacement in r2 += -0x4.
xlated vs jited — Dumping a Loaded Program
Once the program is loaded into the kernel (via the bpf() syscall, typically by libbpf), bpftool dumps the two in-kernel forms. The synopses, verbatim from the v6.12 man page (bpftool-prog.rst):
bpftool prog dump xlated PROG [{ file FILE | [opcodes] [linum] [visual] }]
bpftool prog dump jited PROG [{ file FILE | [opcodes] [linum] }]
where PROG selects the program by id ID, tag TAG, name NAME, or pinned FILE.
xlated (“translated”) dumps “eBPF instructions of the programs from the kernel” — the program after the verifier has rewritten it. This is the most important and most surprising view, because it is not the same instructions the ELF held:
- Helper-call inlining. The verifier inlines some common helpers. The classic example is
bpf_map_lookup_elemon array maps: in the xlated dump thecallis gone, replaced by a few inline instructions that compute the element address directly — a major performance optimisation that only shows up in xlated, never in the ELF. - Pointer and offset rewrites. Context-field accesses (e.g. reading a field of
struct __sk_buff) are rewritten by the program type’sconvert_ctx_accessto the real offset in the real kernel struct; ldimm64 map-fd placeholders are filled with the actual map address; subprogram call targets are resolved. - Spectre hardening. The verifier may insert
nospec(speculation-barrier) pseudo-instructions and bounds-clamping it deemed necessary. See BPF and Spectre Hardening. - Former classic programs. A converted classic BPF program shows up in xlated with the translator’s prologue (
w0 = 0; w7 = 0; r6 = r1; ...) — the xlated view is where you see the cBPF→eBPF conversion result.
jited dumps the “jited image (host machine code) of the program” — native instructions. On x86-64 this is push %rbp / mov %rsp,%rbp / sub $0x...,%rsp and so on; it is no longer eBPF at all but the actual code the CPU runs. You read jited to confirm the JIT exists/works, to inspect hardening (constant blinding, retpolines), or to count real instructions for a hot path. Because it is per-architecture, jited output on arm64 looks entirely different.
The defining mental rule: ELF = what the compiler produced; xlated = what the verifier produced; jited = what the CPU runs. Disagreements between them are expected and informative, not bugs.
Textual Instruction Syntax
The eBPF assembly syntax is small and regular. It is produced by the kernel’s print_bpf_insn() in kernel/bpf/disasm.c (used by the verifier log) and, near-identically, by libbpf’s disassembler behind bpftool. The exact format strings from disasm.c (v6.12) define the grammar precisely:
- Register move / ALU:
r<dst> = r<src>,r<dst> = <imm>,r<dst> += r<src>,r<dst> &= 0xf, etc. Awprefix (w1 = 0x0) denotes the 32-bit sub-register operation (BPF_ALUclass); a plainrdenotes the 64-bit operation (BPF_ALU64). The kernel renders this with(%02x) %c%d %s %s%c%dwhere%cis'r'or'w'. - Load:
r<dst> = *(<size> *)(r<src> <off>)— e.g.r2 = *(u64 *)(r0 + 0x0).<size>isu8/u16/u32/u64. The format string is(%02x) r%d = *(%s *)(r%d %+d). - Store from register:
*(<size> *)(r<dst> <off>) = r<src>— e.g.*(u32 *)(r10 - 0x4) = w1. Format:(%02x) *(%s *)(r%d %+d) = r%d. - Store immediate:
*(<size> *)(r<dst> <off>) = <imm>. - Wide load-immediate (ldimm64):
r<dst> = <imm> ll(llvm-objdump) /r<dst> = <value>with a symbolic name in bpftool when it is a map fd — the only 16-byte instruction. - Conditional jump (reg):
if r<dst> <op> r<src> goto pc<+off>— e.g.if r0 == 0x0 goto +0x6. Format:(%02x) if %c%d %s %c%d goto pc%+d.<op>is==,!=,>,>=,<,<=,s>,s>=(signed), etc. With theJMP32class the registers print asw<n>. - Conditional jump (imm):
if r<dst> <op> 0x<imm> goto pc<+off>— e.g.if r2 > 0x10 goto +0x3. Format:(%02x) if %c%d %s 0x%x goto pc%+d. - Unconditional jump:
goto pc<+off>(BPF_JA);gotol pc<+off>is the 32-bit-offset long jump;may_goto pc<+off>is the newer bounded-loop primitive. - Call:
call <name>#<imm>for a helper ((%02x) call %s#%d) — e.g.call bpf_map_lookup_elem#1, where the#Nis the helper id;call pc<+off>(call pc%s) for a BPF-to-BPF call to a subprogram; kfunc calls render with aBPF_PSEUDO_KFUNC_CALLsource. - Exit:
exit(BPF_EXIT) — return to caller with the value inr0.
The parenthesised (%02x) you see in verifier log output (e.g. (bf) r6 = r1) is the raw 1-byte opcode; it is part of the kernel’s verbose() format. In bpftool output the opcode prefix behaviour is governed by the opcodes flag (below). The signed offsets render with an explicit sign (%+d) so backward jumps show as goto -0x3.
Options: opcodes, linum, visual
The dump flags control how much detail and which annotations appear (bpftool-prog.rst, v6.12):
opcodes— “controls if raw opcodes should be printed as well” (xlated) / “controls if raw opcodes will be printed” (jited). For jited the man page’s own example shows it printing the full raw instruction bytes beneath each disassembled line, e.g.mov %rsp,%rbpfollowed by48 89 e5— i.e. it adds the complete machine-code bytes, not a 1-byte tag.linum— “If linum is specified, the filename, line number and line column will also be displayed.” This consumes the.BTF.extline_inforecords (see below) to annotate each instruction with where in the source it came from.visual(xlated only) — builds a control-flow graph of the eBPF instructions and emits it in DOT format on stdout, suitable for piping into Graphviz (dot -Tpng). This is the modern echo of the 1993 paper’s CFG model: the program’s basic blocks and branch edges, visualised.file FILE— write the binary image to a file instead of disassembling to stdout.-p/--pretty(JSON) —bpftoolis JSON-capable; with-j/-pthe dump becomes structured JSON instead of text, for tooling.
Uncertain
Verify: the exact default-vs-
opcodestextual format ofbpftool prog dump xlated(does the default already show the(bf)-style 1-byte opcode prefix, or doesopcodesadd the full 8-byte encoding as it does for jited?). Reason: the v6.12bpftool-prog.rstEXAMPLES section shows only a jited dump withopcodes; it contains no xlated example, andbpftoolwas not available in this environment to run (clang/llvm-objdumpwere, and supplied the ELF-view ground truth above, but those do not exercise the kernel-side xlated dump). To resolve: runbpftool prog dump xlated id <N>and... opcodeson a kernel with a loaded program, or read libbpf’sdump_xlated_plain()intools/bpf/bpftool/xlated_dumper.c. uncertain
Source-Annotated Disassembly via BTF line_info
The reason a BPF dump can interleave source code with instructions is the line_info records in .BTF.ext. Each bpf_line_info record (btf.html) ties an instruction back to a source location:
struct bpf_line_info {
__u32 insn_off; /* instruction this line maps to */
__u32 file_name_off; /* offset into BTF string table -> filename */
__u32 line_off; /* offset into BTF string table -> the source line text */
__u32 line_col; /* packed: line = line_col >> 10, column = line_col & 0x3ff */
};
In the ELF, insn_off is a byte offset from the program section start; the kernel API form uses instruction units (struct bpf_insn counts). Crucially, .BTF.ext stores not just the number but the actual source line text (line_off points at the string), so tools can print the C line without access to the original .c file. When you pass linum (or simply load a -g program and let libbpf attach the line info), the verifier log and bpftool dumps show, above an instruction, the file/line/column and the source statement — making “the verifier rejected instruction 42” actionable by pointing at the exact C line. This is also why func_info (the sibling record mapping insn_off → BTF_KIND_FUNC type) lets dumps label subprograms by name rather than pc+N.
Failure Modes and Gotchas
- “My xlated dump doesn’t match my C / my objdump.” Expected — the verifier inlines helpers and rewrites context/map accesses. Compare source intent against xlated, not instruction-for-instruction. Use
llvm-objdump -dfor the un-rewritten view. - No source lines in the dump. You compiled without
-g, so there is no.BTF.extline_info; or the loader stripped BTF; or yourbpftool/kernel lacks BTF support. Recompile with-g. call -1/ unresolved helper name. The disassembler could not resolve the helper id to a name (missing BTF/kernel symbol info); the#Nid is still correct. kfunc calls in particular need kernel BTF to name.- jited dump empty or “not jited”. The program is running on the interpreter, not JITed — the architecture lacks a JIT, or
net.core.bpf_jit_enableis0. See BPF Interpreter vs JIT. - Wide-immediate confusion. The 16-byte ldimm64 (
r1 = ... ll) occupies two instruction slots; indexes after it jump by two. Map fds appear as this instruction with a placeholder until relocation. Miscounting it is a classic off-by-one when hand-reading offsets. opcodesfloods output. On a large program the raw-byte column dwarfs the disassembly; use it surgically when you suspect an encoding issue, not by default.
Alternatives and When to Choose Them
llvm-objdump -d prog.o— no kernel, no privilege, shows the compiler’s output. Best for: “what did clang emit?”, verifying CO-RE relocations (-dr), inspecting before loading. Cannot show verifier rewrites or the JIT.bpftool prog dump xlated— needs the program loaded (andCAP_BPF/root). Best for: “what is actually running after the verifier?”, confirming helper inlining, seeing a converted classic program. The canonical debugging view.bpftool prog dump jited— loaded program, root. Best for: native-code inspection, JIT/hardening confirmation, micro-optimisation.- The verifier log (returned by
BPF_PROG_LOAD, surfaced by libbpf on failure) — the same textual syntax, but annotated with per-instruction register state and the rejection reason. The primary tool for debugging verifier errors. readelf -S/llvm-readelf -S— for the ELF structure (sections, BTF presence) rather than the instructions.
Production Notes
In practice, engineers reach for bpftool prog dump xlated id <N> linum as the first move when a loaded program “isn’t doing what the source says”: the xlated view plus source lines immediately shows whether the verifier inlined or rewrote the path in question. The visual CFG dump (DOT → Graphviz) is invaluable for understanding a large program’s branch structure — for example confirming that an early-exit fast path really is reached before the expensive logic. Tools like Cilium and bpftrace ship deep BTF and rely on exactly this line_info machinery so that their generated programs remain debuggable; the move to BTF-everywhere is partly what made BPF programs introspectable in production rather than opaque blobs. When filing a kernel-BPF bug, maintainers routinely ask for the xlated dump (and the verifier log), because the ELF tells them what you meant and the xlated tells them what the kernel did.
See Also
- BPF Instruction Set — the bit-level encoding behind every disassembled line (opcode classes, fields)
- BPF Virtual Machine and Registers — R0–R10, the 512-byte stack, and the
w/rsub-register view the syntax uses - Classic BPF vs Extended BPF — what a converted classic program looks like in the xlated dump
- BPF Skeletons and bpftool — the wider
bpftooland skeleton-generation tooling - BTF (BPF Type Format) — the
.BTF/.BTF.exttype and line-info data that powers source-annotated dumps - BTF and vmlinux.h — the kernel-side BTF that names helpers/kfuncs in dumps
- BPF JIT Compiler — what produces the jited image
- BPF Interpreter vs JIT — why a jited dump may be empty
- Reading and Debugging Verifier Errors — the verifier log, same syntax plus register state
- Linux eBPF MOC — the parent map of the eBPF subsystem