eBPF concepts and tools
Programs, maps, the verifier, BTF and CO-RE, program types and attach points — the mental model you need before writing the first Aya program, and how bpftool and bpftrace fit in.
You have a lab and a toolchain. Before writing the first program, this
chapter builds the mental model: what an eBPF program actually is,
how it gets from your .rs file into a running kernel, what the
verifier will and won’t let you do, and how Aya’s pieces map onto the
kernel’s. It’s deliberately concept-only — no deploys — so that when
Chapter 6’s code appears, every line has a place to land.
What an eBPF program is
eBPF lets you load a small program into the running kernel and attach it to an event — a function entry, a syscall, a packet arriving on a NIC, a tracepoint firing. When the event happens, your program runs, in kernel context, and then returns. It cannot loop forever, cannot call arbitrary kernel functions, and cannot touch arbitrary memory. Those restrictions are the price of safety, and they’re enforced before your program ever runs.
Think of it as the kernel’s plugin system: you don’t recompile or reboot the kernel, you hand it a verified program and it runs that program at the events you chose.
The load-and-attach lifecycle
Every Aya program follows the same arc, and it’s worth memorizing because the API mirrors it exactly. Figure 5.1 showed the build side — source to bytecode to a verified, JIT-compiled program attached to a hook. Figure 5.2 shows what happens once it’s running: the hook fires the program, the program writes a map, and your loader reads that map and reports it.
In Aya, “load” is Ebpf::load(...), “attach” is program.load()
followed by program.attach(...), and “read the map” is opening a
typed map handle and iterating it. Chapter 6 shows each of these.
Maps: how the two worlds share data
An eBPF program has no return channel to user space except maps. A map is a kernel-resident data structure both sides can access by a file descriptor. The kinds you’ll use most early:
PerCpuArray/Array— fixed-size; great for counters. Per-CPU variants avoid cross-CPU contention; user space sums them.HashMap— keyed lookups, e.g. “latency histogram bucket per PID” or “first-seen timestamp per socket”.PerfEventArray/RingBuf— streaming events from kernel to user space.RingBuf(the modern choice) is a single shared ring; perfect for “emit one record perexecve”.
The shared *-common crate (Chapter 4) holds the #[repr(C)] structs
that go into maps, so the kernel writer and the user-space reader
agree on the byte layout. Get that layout wrong and you’ll read
garbage — it’s the most common early bug.
The verifier: your strict but fair reviewer
Before your program runs, the kernel verifier walks every possible execution path and proves the program is safe: it terminates (bounded loops only), never dereferences an unchecked pointer, never reads uninitialized stack, and stays within its instruction budget. If it can’t prove safety, it rejects the load with an error.
Two things to know now:
- Verifier errors are normal, especially early. They read like a disassembly dump with a complaint at the end. The usual causes are an unchecked bounds access (you indexed a packet without first checking the packet is long enough) or an unbounded loop.
- Rust + Aya prevents whole classes of these at compile time. Bounds-checked slice access, no uninitialized memory, no wild pointers — the same guarantees that make Rust Rust also keep the verifier happy. That’s a large part of why write eBPF in Rust.
Anatomy: how bytecode becomes a running probe
It’s worth seeing the whole subsystem at once, because every later chapter is a
variation on this one picture. Your Rust compiles (via bpf-linker, Chapter 4)
to BPF bytecode; the loader hands that bytecode to the kernel with the
bpf() syscall; the verifier proves it safe; the kernel JIT-compiles it
to native instructions and attaches it to an event source — a kprobe, a
uprobe, a tracepoint, a perf event. From then on the program runs on every event
and shares data with user space through exactly two channels: maps (lookup
tables both sides hold by file descriptor) and perf/ring-buffer output
(streamed records). There is no other way in or out — which is precisely what
makes eBPF safe to run in the kernel.
The dashed line down the middle of the figure is the boundary that matters: user space on the left, the kernel on the right. Crossing it safely is the entire point of eBPF. Read the pipeline as seven steps — the first three are the ①②③ marked on the diagram:
- Compile (①).
cargo buildruns your eBPF crate throughrustcandbpf-linker(Chapter 4) into BPF bytecode, embedded into the loader binary withinclude_bytes_aligned!. - Load (②). The loader calls
Ebpf::load(...), handing that bytecode to the kernel through thebpf()syscall — the first crossing into kernel space. - Verify. The kernel verifier walks every path and proves the program safe; if it can’t, the load returns an error and nothing runs.
- JIT-compile. Accepted bytecode is translated to native machine code, so it runs at full speed rather than being interpreted.
- Attach.
program.attach(...)wires the program to an event source — a kprobe, uprobe, tracepoint, or perf event (and later XDP, tc, LSM). - Run. On every event the program executes in kernel context and writes results into a map, or streams a record out a ring/perf buffer.
- Consume (③). Back in user space, your loader reads the map (an async read) or drains the ring buffer (perf_output) and does something with the data — in this book, exports it over OTLP to Grafana.
That final step is where “user space” earns its name, and it needn’t be Rust:
the Part 10 field-guide tools (bpftrace, bcc) follow the exact same kernel
path — compile, load, verify, attach, run — but are driven and read from
Python instead of an Aya loader. Same pipeline, different front end.
This is the same arc as Figures 5.1 and 5.2, drawn as a system rather than a
timeline. Aya gives each kernel-side piece a typed Rust name — Ebpf::load for
step 2, program.attach for step 5, a typed map handle for step 7 — so the code
you write maps one-to-one onto this diagram.
BTF and CO-RE: compile once, run everywhere
Kernel data structures (struct task_struct, struct file, …) change
layout between kernel versions. A probe that reads a field at a
hardcoded offset breaks the moment the kernel changes. BTF (BPF
Type Format) is the kernel describing its own types to you at runtime —
that vmlinux blob you confirmed at /sys/kernel/btf/vmlinux in
Chapter 2. CO-RE (Compile Once – Run Everywhere) uses BTF to
relocate field accesses at load time, so one compiled program runs
across kernel versions without recompiling.
Aya supports CO-RE transparently when the target kernel has BTF — which Fedora 44’s stock kernel does. This is why you can build on your laptop and run on the guest even if their kernels differ slightly, and why we confirmed BTF presence during lab setup.
Program types and attach points
The type of an eBPF program determines what it can do and where it attaches. The ones this tutorial works through, grouped by part:
| Family | Attaches to | Tutorial chapters |
|---|---|---|
| kprobe / kretprobe | any kernel function entry/return | kprobe+unlink, opensnoop |
| fentry / fexit | function entry/exit (BTF-based, lower overhead than kprobes) | fentry+unlink |
| tracepoint / raw tracepoint | stable kernel trace events | execsnoop, exitsnoop, sigsnoop |
| uprobe / USDT | user-space function entry, in any process | bashreadline, uprobe rust, sslsniff |
| perf / profiling | sampled on a timer or PMU counter | profile, runqlat, hardirqs |
| XDP | earliest point a packet hits the NIC driver | xdp, load balancer, xdp tcpdump |
| TC / tcx | traffic control ingress/egress | tc, tcx |
| socket / sockops | socket lifecycle and data | L7 tracing, sockops |
| LSM | security hooks; can deny operations | lsm connect, hardening |
| struct_ops / sched_ext | implement a kernel interface in BPF | scx_simple, scx_nest |
You don’t need to memorize this. The point is that “write an eBPF
program” always means “pick a program type, write the handler, attach
it to the right event” — and Aya gives you a typed Rust macro for each
type (#[kprobe], #[xdp], #[tracepoint], …).
The wider event landscape
Those families fit into a larger map of everything eBPF can hook, and it’s
worth seeing once so the scope is clear. eBPF attaches across the entire stack:
dynamic tracing of any function (kprobes in the kernel, uprobes in user
space), static tracepoints the kernel exposes at stable points
(syscalls:, sched:, block:, net:, …), hardware PMCs (cycles,
instructions, cache misses) and software events (page faults, context
switches) sampled through the perf subsystem. Each capability arrived in a
specific kernel (kprobes in 4.1, uprobes in 4.3, the syscall tracepoints in 4.7,
PMC/software-event access in 4.9) — all comfortably present in Fedora 44’s modern 6.x kernel — the one this
workshop runs on — which is why the tutorial can reach any of them.
The practical takeaway is reach: if the kernel or a user program does something, there is almost certainly an eBPF attach point for it — and the rest of the book is a guided tour of the most useful ones.
Where bpftool and bpftrace fit
Two Fedora-packaged tools are your ground truth when an Aya program misbehaves. You run these inside the target VM:
| Tool | What it is | What it’s for | Try |
|---|---|---|---|
bpftool |
inspects the live BPF subsystem | what’s loaded, what maps hold, and what’s attached where — when user space reads zeros from a map, it tells you whether the kernel side is writing anything at all, isolating the bug to one half | bpftool prog list · bpftool map dump |
bpftrace |
a high-level tracing language (not Aya) | the fastest way to confirm an event even fires before you invest in a full program | bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm]=count() }' |
Treat them as the multimeter you check against, not as competitors to Aya. The tutorial uses them throughout to verify that what your Rust program reports matches what the kernel actually did — and Part 10 returns to both, plus the BCC tools, to show how to drive them from Python.
The shape of every chapter from here
With this model, each program chapter follows the same shape:
- Concept — which event, which program type, and what we’ll measure, with a diagram of the flow.
- How the code works — the maps (and why each type), the kernel handler in full, and the user-space side (load, attach, drain the map, export via OTLP); plus the shared record type, when the program has one.
- Build, deploy, observe —
demo.shships the binary to the target, drives load (often from a Python client), and shows the result in Grafana. - Cross-check — confirm against
bpftool,bpftrace, or the native tool the program imitates. - What you learned — a short recap.
- Verification status — each chapter records that it was run end-to-end on the real lab (Fedora 44, kernel 7.1.3); your own hardware is the final check.
Next: Chapter 6 — Hello, eBPF →
This chapter is conceptual, but its claims about Aya’s API surface and kernel behavior are now verified transitively: Chapter 6’s program — and the rest of the corpus — is built, deployed, and run on the lab (Fedora 44, kernel 7.1.3), which exercises these concepts end to end. The concepts themselves draw on Learning eBPF (Liz Rice), BPF Performance Tools (Brendan Gregg), the Aya Book, and ebpf.io.