Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-none target - no standard library, no OS, no allocation, no std. It is a freestanding program that must fit the verifier’s rules. It lives in a crate of its own, built with cargo build --target bpfel-unknown-none, and linked with bpf-linker into an ELF object.
  • The userspace side is an ordinary Rust program (with std, with tokio if you like, with clap for flags). It embeds the compiled kernel ELF as bytes (via include_bytes!), loads it with Ebpf::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:

  1. A target: bpfel-unknown-none (little-endian, freestanding). Install it with rustup target add bpfel-unknown-none.
  2. 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"
    
  3. A panic strategy: the kernel side cannot panic; panic = "abort" in [profile.release] and no_std in 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.

program.attach(...) is where the abstraction matters, because each program type has its own hook and its own attach signature:

Program type (Aya)HookAttach argument
Xdpinterfaceiface, XdpFlags (Chapter 5)
SchedClassifierqdisc (clsact)iface, ingress/egress (Chapter 6)
TracePointkernel tracepointcategory, name
KProbekernel functionfunction name, optional offset (Chapter 7)
SockMap / SockHashsockmapmap + attach type (Chapter 8)
CgroupSkb / CgroupSockAddrcgroupcgroup 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], not usize (which is 64-bit on both sides here but a trap on other targets) and not String (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-none kernel programs plus an ordinary userspace loader, joined by a common crate of #[repr(C)] shared types.
  • The build chain is rustup target add bpfel-unknown-none + bpf-linker; the kernel crate is no_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 a Result, 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 in common.

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.