Chapter 3: Aya - eBPF Development in Rust
“One language, two worlds. The program that runs in the kernel and the program that feeds it maps - both Rust, both in one workspace, both reviewed by the same engineer.”
Chapter 2 described the machine. This chapter describes the workshop. Aya
is the Rust eBPF library: the aya crate for the userspace side (loading
ELF objects, creating maps, attaching programs, reading events) and the
aya-bpf crate for the kernel side (the program context types, the map
views, and the helper wrappers). Together with bpf-linker - a fork of
LLVM’s ld.lld that understands eBPF relocations - they make it possible to
write the entire eBPF stack in Rust. This chapter builds the project
layout that every later chapter uses, and walks the load/attach lifecycle
in real code: the workspace, the build chain, the kernel-side program, the
userspace loader, and the error handling that turns verifier rejections
into readable failures.
3.1 The Two-Crate Problem and the Aya Solution
An eBPF deployment has two halves that cannot be one crate:
- The kernel side compiles to the
bpfel-unknown-nonetarget - no standard library, no OS, no allocation, nostd. It is a freestanding program that must fit the verifier’s rules. It lives in a crate of its own, built withcargo build --target bpfel-unknown-none, and linked withbpf-linkerinto an ELF object. - The userspace side is an ordinary Rust program (with
std, withtokioif you like, withclapfor flags). It embeds the compiled kernel ELF as bytes (viainclude_bytes!), loads it withEbpf::load, attaches the programs, and pumps the maps and ring buffers.
Aya’s answer to the two-world problem is a workspace with two crates
plus a shared crate for the #[repr(C)] types that both sides read - the
map keys, values, and event structs. One file defines the contract; both
sides compile it. This is the same discipline as the shared header of a C
kernel/userspace project, but with the type checker on both sides.
code/ch03_aya_toolchain/
├── Cargo.toml # workspace: members = [kernel, userspace, common]
├── common/ # shared #[repr(C)] types: map keys, values, events
│ └── src/lib.rs
├── kernel/ # eBPF programs, target = bpfel-unknown-none
│ ├── Cargo.toml
│ ├── .cargo/config.toml # sets the linker to bpf-linker
│ └── src/main.rs
└── userspace/ # the loader / control plane
├── Cargo.toml
└── src/main.rs
3.2 The Build Chain: bpf-linker and the Toolchain
Compiling Rust to eBPF needs three pieces that are not default:
- A target:
bpfel-unknown-none(little-endian, freestanding). Install it withrustup target add bpfel-unknown-none. - A linker:
bpf-linker(cargo install bpf-linker), a fork of lld that produces the ELF sections the kernel’s loader expects and that Aya understands. It is selected in the kernel crate’s.cargo/config.toml:[target.bpfel-unknown-none] linker = "bpf-linker" - A panic strategy: the kernel side cannot panic;
panic = "abort"in[profile.release]andno_stdin the crate root.
Source: code/ch03_aya_toolchain/kernel/src/main.rs
#![allow(unused)]
fn main() {
// code/ch03_aya_toolchain/kernel/src/main.rs
// Kernel side: a tracepoint program that records every execve into a
// HASH map, with the event struct shared from the `common` crate.
#![no_std]
#![no_main]
use aya_bpf::{
macros::{map, tracepoint},
maps::HashMap,
programs::TracePointContext,
};
use common::ExecEvent;
// The tracepoint argument block layout for sched/sched_process_exec is
// defined by the kernel (include/trace/events/sched.h): pid at byte 0,
// comm at byte 16.
const PID_OFF: usize = 0;
const COMM_OFF: usize = 16;
#[map]
static EXECS: HashMap<u32, ExecEvent> = HashMap::with_max_entries(1024, 0);
#[tracepoint]
pub fn sched_process_exec(ctx: TracePointContext) -> i32 {
let Ok(ev) = try_sched_process_exec(&ctx) else {
return 0; // truncated tracepoint block: nothing to record
};
let mut ev = ev;
let _ = EXECS.insert(&ev.pid, &mut ev, 0); // flags = 0: plain insert
0
}
fn try_sched_process_exec(ctx: &TracePointContext) -> Result<ExecEvent, i32> {
let pid: u32 = unsafe { ctx.read_at(PID_OFF)? };
let comm: [u8; 16] = unsafe { ctx.read_at(COMM_OFF)? };
Ok(ExecEvent { pid, comm })
}
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
}
Two details deserve comment. The #[map] macro declares the map and its
ELF section; userspace will find it by name. And ctx.read_at is Aya’s
checked read: it compiles to the bounds-checked load the verifier demands
(Chapter 2), so the unsafe block is small and its invariant is exactly
“the tracepoint block is at least this large” - a property of the kernel’s
tracepoint ABI, not of our code.
3.3 The Userspace Loader: Ebpf::load and the Lifecycle
The userspace side is where the lifecycle of Chapter 2 becomes code:
Source: code/ch03_aya_toolchain/userspace/src/main.rs
// code/ch03_aya_toolchain/userspace/src/main.rs
// Userspace side: load the ELF, attach the tracepoint, read the map.
// Every step is a Result - no silent error handling (rule 4).
use aya::{maps::HashMap, programs::TracePoint, Ebpf};
use common::ExecEvent;
use std::error::Error;
const KERNEL_ELF: &[u8] =
include_bytes!("../../kernel/target/bpfel-unknown-none/release/kernel");
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
fn main() -> Result<(), Box<dyn Error>> {
// Load + attach. Ebpf::load runs the verifier (Ch.2) - a rejection
// here is a load-time bug, not a runtime surprise.
let mut ebpf = Ebpf::load(KERNEL_ELF)?;
let program: &mut TracePoint = ebpf.program_mut("sched_process_exec")?.try_into()?;
program.load()?;
program.attach("sched", "sched_process_exec")?;
println!("attached sched/sched_process_exec; Ctrl-C to exit");
let mut execs: HashMap<_, u32, ExecEvent> = ebpf.map_mut("EXECS")?.try_into()?;
loop {
std::thread::sleep(POLL_INTERVAL);
for key in execs.keys().collect::<Result<Vec<_>, _>>()? {
let Some(ev) = execs.get(&key, 0)? else { continue };
let comm = String::from_utf8_lossy(&ev.comm);
println!("pid {} ({}) executed", ev.pid, comm.trim_end_matches('\0'));
}
}
}
Note the shape of the error handling: every fallible step is a Result, and
the two most important failures - load (verifier) and attach (hook) - are
mapped to messages that name the stage. The C++ book’s rule “no silent
error handling” (CODING_STANDARDS rule 4) applies to eBPF doubly: a silent
attach failure leaves you with a program that looks loaded and does
nothing.
3.4 Attaching: Programs, Hooks, and Links
program.attach(...) is where the abstraction matters, because each
program type has its own hook and its own attach signature:
| Program type (Aya) | Hook | Attach argument |
|---|---|---|
Xdp | interface | iface, XdpFlags (Chapter 5) |
SchedClassifier | qdisc (clsact) | iface, ingress/egress (Chapter 6) |
TracePoint | kernel tracepoint | category, name |
KProbe | kernel function | function name, optional offset (Chapter 7) |
SockMap / SockHash | sockmap | map + attach type (Chapter 8) |
CgroupSkb / CgroupSockAddr | cgroup | cgroup fd + attach type (Chapter 9) |
Modern kernels (5.7+) attach through links (BPF_LINK_CREATE): a link
is an object with a lifetime - when the link is dropped (process exit, or
explicit detach), the program is detached automatically. Aya wraps this so
that program.attach(...) keeps the program attached for as long as the
returned link (or the process) lives. For a long-running daemon this is
exactly what you want: no orphaned programs left attached after a crash.
3.5 The Shared Contract: One File, Two Compilers
The common crate deserves its own note, because it is where eBPF projects
in Rust quietly succeed or loudly break. The kernel side and userspace side
agree on map keys, values, and event structs only because both compile
the same #[repr(C)] types. The rules:
#[repr(C)]everywhere. Rust’s default struct layout is unspecified and may be reordered; the kernel and the userspace loader both need the stable C layout. Mark every type that crosses the boundary.- Fixed-size integers.
u32,u64,[u8; N], notusize(which is 64-bit on both sides here but a trap on other targets) and notString(which cannot cross at all - use[u8; N]and convert at the boundary). - One source of truth. The struct lives in
common; both crates depend on it. Copying the struct into both crates is how a kernel-side change silently desynchronises the userspace reader - the kind of bug that produces “map value has wrong size” at load time, or garbage events at runtime.
3.6 Running It: The Loop That Makes It Real
With the workspace above, the workflow is:
# kernel side: compile to eBPF ELF
cd code/ch03_aya_toolchain/kernel
cargo build --release --target bpfel-unknown-none
# userspace side: build and run (requires Linux, BTF enabled, root or
# CAP_BPF/CAP_PERFMON, and the tracepoint to exist on this kernel)
cd ../userspace
cargo run --release
When it works, you have done something that was impossible before Aya: a program running inside the Linux kernel, written entirely in Rust, attached and fed by a Rust control plane, in the same repository. When it fails, the failure will be one of the three this book teaches you to fix: a verifier rejection (Chapter 14), an attach error (hook name, privileges, or kernel config), or a contract mismatch between the two sides (Section 3.5).
Hands-On Lab
# 1. The toolchain (Linux):
rustup target add bpfel-unknown-none
cargo install bpf-linker
# 2. Build the kernel side, then the userspace side.
cd code/ch03_aya_toolchain/kernel
cargo build --release --target bpfel-unknown-none
cd ../userspace && cargo build --release
# 3. Run (root / CAP_BPF + CAP_PERFMON) and watch it load.
sudo ./target/release/userspace &
sudo bpftool prog list | grep sched_process_exec # the tracepoint program
Then run ls a few times in another shell - every execve is recorded in
the EXECS map, and the userspace loop prints the pid. That is the whole
Chapter 3 lifecycle (load, attach, run, read maps) working end to end.
Summary
- eBPF in Rust is a two-crate problem:
bpfel-unknown-nonekernel programs plus an ordinary userspace loader, joined by acommoncrate of#[repr(C)]shared types. - The build chain is
rustup target add bpfel-unknown-none+bpf-linker; the kernel crate isno_std, panics abort, and the linker is set in.cargo/config.toml. - The lifecycle in code is
Ebpf::load(verifier) ->program.attach(hook) -> map/ring reads (data path) - every step aResult, every failure logged with the stage that failed. - Attach signatures differ per program type; modern kernels attach through links, so detach is automatic when the process exits.
- The shared contract is the discipline:
#[repr(C)], fixed-size types, one source of truth incommon.
Next: Part II starts with the thing both sides of every eBPF project agree on - the maps. Chapter 4 is the complete map reference, from HASH to RINGBUF, with the access patterns that decide which one you need.