How eBPF Works: Verifier, Maps, JIT, and Why the Kernel Runs Your Code
A kernel module can do almost anything. That is why loading one is a privileged act and why a bug in one can take the machine with it. eBPF is the kernel's answer to a narrower request: run a small program at a documented hook, prove it cannot loop forever or walk off a buffer, then let it observe or steer events at near-native speed.
This article is about that contract. It covers the virtual machine, the verifier's path-sensitive proof, maps as the only durable shared state, helpers and kfuncs as the only kernel calls a program may make, JIT compilation, and the program types that decide what a hook is allowed to see. It is not a second tour of system calls, namespaces and cgroups, or io_uring. Those are other ways user code talks to the kernel. eBPF is code the kernel agrees to run inside itself.
The contract
Classic Berkeley Packet Filter (cBPF) was a tiny two-register machine for socket filters. Extended BPF keeps the idea and changes the machine:
- Eleven 64-bit registers:
r0throughr9plus a read-only frame pointerr10. - Fixed 64-bit instruction encoding.
- Maps for shared state between programs and userspace.
- Dozens of program types, each bound to a hook and a context struct.
- A verifier that must accept the program before it is attached.
- A JIT on every major architecture Linux ships.
Everything userspace does with this subsystem goes through the bpf() syscall: create a map, load a program, attach it, update an element, pin an object in bpffs. A successful BPF_PROG_LOAD returns a file descriptor. That fd is a capability. Close it (and drop other references) and the program is gone.
The kernel path on load is roughly: copy bytecode from userspace, select the ops for the declared program type, run bpf_check(), then choose a runtime (JIT or interpreter) and hand back an anon-inode fd.
Why not a kernel module
A module is compiled against kernel headers, linked into kernel address space, and trusted. It can call arbitrary internals. It can sleep. It can deadlock. It can scribble on a list head. Reloading it after a bug often means a reboot.
An eBPF program cannot do those things by construction. It does not get a general C ABI into the kernel. It gets a typed context pointer in r1, a bounded stack, helper calls the verifier has approved for that program type, and map lookups whose return values are tracked as maybe-null. If the verifier cannot prove every path is safe, the load fails and nothing is attached.
That is the product: programmable hooks without the blast radius of out-of-tree C.
The virtual machine
eBPF is not a general-purpose userspace language. Compilers such as Clang emit BPF bytecode from restricted C (or from Rust via a BPF target). The instruction set is ALU ops, jumps, loads and stores, calls to helpers or BPF-to-BPF functions, and exits.
r0 is the return register. Helper calls use the same calling convention the JIT maps onto the host ABI, which is why a helper is usually a single call instruction after JIT. r1–r5 carry arguments. r6–r9 are callee-saved. The stack is small and the verifier tracks every slot.
There is no unrestricted pointer arithmetic into kernel memory. Packet pointers, map-value pointers, and socket pointers are different types in the verifier's state. Adding an offset to a packet pointer is legal only if a later bounds check against data_end dominates that use.
The verifier is a proof, not a linter
The verifier is an abstract interpreter. It walks every reachable path, not just the path a particular packet would take today. At each instruction it tracks register types, signed and unsigned value ranges, stack slot types, and whether a pointer has been null-checked.
What it must establish:
- The program terminates. Unbounded loops are rejected. Bounded loops are accepted only when the verifier can see the bound.
- Every memory access is in a region the program is allowed to touch: stack, a map value whose size is known, packet data between
dataanddata_end, or memory copied through an approved helper such asbpf_probe_read_kernel. - Map lookups are checked for NULL before dereference. A lookup that is checked on one branch and used raw on another is a reject.
- Helper arguments match the helper's declared prototype. A tracing helper is not callable from an XDP program unless that type allows it.
- BTF, when present, lets the verifier insist that a map value contains a spinlock or a timer field before the corresponding helper is used.
Path explosion is the practical limit. A program with many poorly nested branches can exceed the verifier's complexity budget even if each path is locally fine. That is why production BPF is written to look boring: early bounds checks, few overlapping conditions, explicit NULL tests immediately after lookups.
A rejected program never runs. That sentence is the whole security story. JIT bugs can still exist, but they compile bytecode the verifier already accepted; they do not get to invent new memory accesses the abstract machine forbade.
Maps: the only shared memory you are supposed to use
BPF programs are short-lived. An XDP program returns per packet. A kprobe returns per function entry. Durable state lives in maps.
A map is a kernel object with a type, a key size, a value size, and a max-entries cap. Userspace creates it with bpf() and holds an fd. Programs reference it by fd at load time; after load the kernel patches those references into map pointers.
Common types and the jobs they actually do:
- Hash and LRU hash. Keyed records. LRU variants evict under pressure so a tracing program cannot pin unbounded kernel memory.
- Array and per-CPU array. Index is the key. Per-CPU maps avoid cache-line ping-pong when many CPUs update the same logical counter.
- Ring buffer and perf event array. Programs reserve a slot, write an event, submit. Userspace reads a stream. This is how most tracers export data without a syscall per event on the kernel side.
- LPM trie. Longest-prefix match for addresses and CIDRs.
- Sockmap / sockhash. Hold sockets so a program can redirect a message without climbing back through userspace.
- Devmap / cpumap. XDP redirect targets: another interface, or another CPU's backlog.
Userspace reads and writes maps through the same syscall. There is no mmap of arbitrary kernel memory. Some map types offer mmapable regions for fast userspace access; that is still a typed window, not a general kernel dump.
Pinning a map or program under /sys/fs/bpf keeps the object alive after the loader exits. That is how a daemon can crash and restart without dropping counters, and how one process can load a program another process inspects with bpftool.
Helpers, kfuncs, and what a program may call
An eBPF program cannot issue open, take a lock the kernel did not hand it, or call an arbitrary exported symbol. It calls helpers: functions the kernel exposes in UAPI (include/uapi/linux/bpf.h) with stability rules close to the rest of the userspace API.
Helpers are typed. bpf_map_lookup_elem returns a pointer the verifier treats as nullable. bpf_ktime_get_ns returns a scalar. bpf_redirect is meaningful only for program types that own a packet. The verifier ops for each program type publish which helper ids are legal.
kfuncs are the newer, finer-grained kernel API for BPF. They are not the same stability promise as helpers. Tooling and CO-RE (compile once, run everywhere) via BTF exist so a program compiled on one kernel can relocate field offsets on another, but a kfunc that disappeared is still a load failure. If you need a hook to survive kernel upgrades, prefer helpers and stable tracepoints over private kfuncs.
Program types decide the world the program sees
The same VM runs at many hooks. The program type selects the context in r1, the allowed helpers, and whether the program may change the event or only watch it.
Three types make the distinction concrete.
XDP runs in the driver receive path, before sk_buff allocation on many NICs. Context is struct xdp_md: data, data_end, and a few metadata fields. The program returns XDP_PASS, XDP_DROP, XDP_TX, or XDP_REDIRECT. Every packet-data load must be dominated by a comparison against data_end. That is why XDP C looks like a sequence of header-length checks. Dropping a packet here is cheap because the stack never built a socket buffer.
TC / sched_cls sees an sk_buff after GRO on ingress, or after the stack on egress. It can classify, redirect, or (with care) mutate headers. Cilium and similar datapaths live here because they need sockets, conntrack-shaped state, and both directions, not only the earliest RX hook.
kprobe / tracepoint / fentry / fexit see kernel functions and static events. A kprobe receives pt_regs. A tracepoint receives a layout published under /sys/kernel/tracing/events/.... fentry and fexit, when BTF is available, take typed arguments instead of register scraping. These programs generally observe. They do not drop packets. They write maps and ring buffers so a userspace agent can draw a latency histogram or a syscall timeline.
Other types exist for cgroup socket hooks, LSM, struct_ops (replace a kernel subsystem ops table with BPF), iterators, and syscalls implemented in BPF. The pattern is the same: declare a type, accept a context, return a code the hook understands.
JIT: why this is fast enough for the packet path
After verification the kernel usually JITs the bytecode to native instructions. On x86-64 an ALU add becomes an add; a helper call becomes a call with registers already in the System V places the JIT assigned. The interpreter remains as a fallback and for architectures or configs without JIT.
Speed comes from three places. First, no syscall on the hot path: the hook calls the program like a function. Second, native code, not a walk of bytecode for every packet. Third, maps that are already in kernel memory, so a counter increment does not bounce through userspace.
That is also why verifier friction matters. Extra NULL checks and bounds checks the verifier demanded stay in the JIT output unless the JIT can prove they are redundant. Writing BPF that the verifier understands often produces faster machine code than writing clever BPF the verifier only barely accepts.
A small XDP example, read as the verifier would
SEC("xdp")
int xdp_drop_udp_on_port(struct xdp_md *ctx)
{
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != bpf_htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
if (ip->protocol != IPPROTO_UDP)
return XDP_PASS;
struct udphdr *udp = (void *)ip + (ip->ihl * 4);
if ((void *)(udp + 1) > data_end)
return XDP_PASS;
if (udp->dest == bpf_htons(53))
return XDP_DROP;
return XDP_PASS;
}
Each data_end comparison is not style. Without it the verifier refuses the following header field load, because it cannot prove the packet is long enough. The IHL-scaled UDP header pointer is accepted only if the program also proves ihl is in range; many real programs read ihl into a register, bound it, then add. A production drop rule would also live behind a map of ports rather than a constant, so operators can change policy without reloading bytecode.
Loading and attaching, without the mythology
A typical loader (libbpf, or a higher wrapper such as Cilium's agent) does this:
- Open an ELF object whose sections are named after attach points (
xdp,tracepoint/syscalls/sys_enter_execve,fentry/tcp_sendmsg). - Create maps declared in that object.
- Relocate CO-RE field accesses against the running kernel's BTF.
- Load each program. The kernel verifies and JITs.
- Attach: netlink or
bpf_linkfor XDP and tc, perf or bpf_link for probes, cgroup fd for cgroup programs. - Optionally pin programs, maps, and links.
A bpf_link is the modern attachment object. It owns the relationship between program and hook. Destroying the link detaches without requiring the original process to still be alive if the link was pinned.
Capabilities still matter. Unprivileged BPF exists in a reduced form and is often disabled. Production loaders usually need CAP_BPF and, for many attach points, CAP_PERFMON or CAP_NET_ADMIN. eBPF is not a way to escape the admin boundary. It is a way for an admin program to run constrained code in the kernel without shipping a module.
Misconceptions
"eBPF is a sandbox that makes any C safe." The verifier proves a restricted machine. It does not prove your policy is correct. An XDP program that drops the wrong port is perfectly verified and perfectly wrong. A tracing program that copies sensitive buffers into a ring buffer is safe for the kernel and not safe for the data.
"If it compiled, it will load." Clang can emit bytecode the verifier rejects: unbounded loops, missing packet bounds, map value used before a NULL check, helper not allowed for that type, stack too deep, too many branches. Load-time is the real compile.
"Maps are just hash tables." Some are. Ring buffers, devmaps, and sockmaps are not interchangeable with a hash. Picking the wrong type shows up as contention, dropped events, or an attach that cannot redirect.
"This replaces syscalls." Hooks fire because the kernel already took an interrupt, a syscall, or a packet. eBPF piggybacks. It does not remove the privilege crossing that the syscall article describes. It avoids extra crossings when you would otherwise copy every event to userspace to make a decision the kernel could have made at the hook.
When to use it
Use eBPF when the decision or the measurement must happen at a kernel hook and the alternative is a module, a high-rate userspace poll, or iptables rules you cannot express. Packet drop and redirect at XDP, per-cgroup socket policy, syscall latency histograms, and custom congestion or scheduling via struct_ops are the shapes that fit.
Do not use it as a general application runtime. There is no libc, no allocator in the usual sense, no blocking, and no "just parse this protobuf" without first proving every byte you touch. Put policy and aggregation in maps and ring buffers. Put presentation in userspace.
Takeaways
eBPF is a verified, JIT-compiled program that the kernel runs at a typed hook. The verifier is a path-sensitive proof that the program terminates and only touches memory the hook allows. Maps are the shared state. Helpers and kfuncs are the only kernel functions the program may call. The program type is not a label; it is the world the program is allowed to see.
Related reading on this site: How System Calls Work, How Linux Containers Work, and How io_uring Works.