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 4: BPF Maps - The Kernel/Userspace Contract

“A map is a promise: the kernel side promises a layout, the userspace side promises a reader, and both promise to agree on the key.”

Chapter 3 built the workspace; this chapter builds its shared vocabulary. BPF maps are the only state an eBPF program can hold across calls, the only channel between the kernel side and the userspace side, and the most common source of both performance wins and silent bugs. This chapter is the complete practical reference: the map types, their access patterns, the kernel-side and userspace-side APIs in Aya, and the decision procedure that picks the right map for a job. The capstone (Chapter 16) will use almost every map described here.

4.1 Why Maps Exist: State Without Trust

An eBPF program is stateless by default: each invocation starts fresh. But a DDoS filter must count packets, a load balancer must know its backends, a tracer must remember which syscalls it has seen. All of that state lives in maps: kernel-allocated tables, created at load time, typed by key and value, and accessible from both the program (through helpers) and userspace (through the bpf() syscall family that Aya wraps).

The security framing from Chapter 2 applies: the kernel owns the map’s memory, so a program can never corrupt adjacent memory through a map - the map lookup returns a pointer the verifier bounds-checks against the value size. What the kernel cannot do is fix your logic: a HASH map with the wrong key layout is perfectly safe and perfectly wrong.

4.2 The Map Family, Ranked by Access Pattern

Map typeShapeKernel accessUserspace accessBest for
HASHkey -> value, open addressingbpf_map_lookup_elemget/insert/delete by keyper-flow state: backends, sessions
ARRAYindex -> value, preallocatedbpf_map_lookup_elem (no delete)get/update by indexfixed tables: config, counters
PERCPU_HASHkey -> per-CPU valuelookup returns this CPU’s slotsum across CPUs on readhot per-flow counters
PERCPU_ARRAYindex -> per-CPU valuelookup (no delete)sum across CPUs on readhot counters - the default choice
LRU_HASHhash with evictionlookup + update, evictsget/insert/deletebounded connection tables
RINGBUFlock-free ringbpf_ringbuf_outputpoll + read eventsevent streaming - the default choice
STACK / QUEUELIFO / FIFOpush/pop/peekpush/pop/peekwork distribution
SOCKMAP / SOCKHASHsocket -> socketredirect helpersupdate with socket fdsin-kernel socket redirect (Ch. 8)
ARRAY_OF_MAPSindex -> map fdmap-in-map lookupupdate with map fdsnamespaced config

The two starred rows are the ones to memorise, because they are the answer to the two questions every eBPF design asks: “where do I keep a hot counter?” (PERCPU_ARRAY) and “how do I stream events to userspace?” (RINGBUF).

4.3 HASH vs ARRAY: Lookup Cost and the Verifier

The choice between HASH and ARRAY is a cost model, not a taste. A hash lookup computes a hash, walks the bucket chain, and compares keys - on the order of tens of nanoseconds, with variance. An array lookup is one indexed load: a few nanoseconds, no variance, and it cannot fail the way a hash lookup can (no hash collision handling). The verifier knows the difference: an array lookup returns a pointer it can treat as always valid, while a hash lookup returns a pointer that may be NULL and must be checked.

In Aya the kernel-side APIs mirror this:

Source: code/ch04_bpf_maps/kernel/src/main.rs

#![allow(unused)]
fn main() {
// code/ch04_bpf_maps/kernel/src/main.rs
// Map choice in practice: ARRAY for fixed config, PERCPU_ARRAY for a hot
// counter, RINGBUF for events - the three default answers (Ch.4.2).
#![no_std]
#![no_main]

use aya_bpf::{
    macros::{map, xdp},
    maps::{Array, PerCpuArray, RingBuf},
    programs::XdpContext,
};

const MAX_IFACES: u32 = 8;
const XDP_DROP: u32 = 1;
const XDP_PASS: u32 = 2;

// Fixed configuration, one slot per interface. Array lookup cannot fail,
// so the verifier lets us use the pointer directly.
#[repr(C)]
pub struct IfaceConfig {
    pub drop: bool,        // drop packets on this interface?
    pub redirect_idx: u32, // XDP_TX via this queue (0 = none)
}
#[map]
static CFG: Array<IfaceConfig> = Array::with_max_entries(MAX_IFACES as u32, 0);

// Hot counter: per-CPU slots mean the kernel side never contends; the
// userspace side sums the CPUs on read.
#[map]
static PACKETS: PerCpuArray<u64> = PerCpuArray::with_max_entries(MAX_IFACES as u32, 0);

// Event stream: lock-free ring, kernel writes, userspace drains.
#[map]
static EVENTS: RingBuf = RingBuf::with_max_entries(4096, 0);

#[repr(C)]
pub struct PacketEvent {
    pub ifindex: u32,
    pub ts: u64, // bpf_ktime_get_ns; filled by the real capstone
}

#[xdp]
pub fn count_packets(ctx: XdpContext) -> u32 {
    let ifindex = ctx.if_index() % MAX_IFACES;

    // PERCPU_ARRAY: bump this CPU's slot - no cache-line contention.
    if let Some(slot) = PACKETS.get_ptr_mut(ifindex) {
        unsafe { *slot += 1 };
    }

    // ARRAY: config read without a possible NULL check.
    let cfg = unsafe { CFG.get_ptr(ifindex) };
    if cfg.is_some_and(|c| unsafe { (*c).drop }) {
        return XDP_DROP;
    }

    // RINGBUF: reserve - write - submit, or drop the event if full.
    let Ok(entry) = EVENTS.reserve::<PacketEvent>(0) else {
        return XDP_PASS; // ring full: the packet still passes
    };
    unsafe { entry.write(PacketEvent { ifindex, ts: 0 }) };
    entry.submit(0);

    XDP_PASS
}

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}
}

Three patterns to internalise. Per-CPU for hot paths: the counter never touches a shared cache line on the kernel side (Chapter 13 measures the difference). Array for config: no NULL-check noise in the program, and the verifier is happy. Ring for events: reserve/write/submit is the whole API, and it is lock-free - the same ring discipline as the NIC of Chapter 1, with the kernel as producer and your userspace process as consumer.

4.4 The Userspace Side: Reads, Writes, and the Poll Loop

The userspace side of the contract is where Aya’s ergonomics show. Map handles come from the loaded Ebpf object by name; reads and writes are typed:

Source: code/ch04_bpf_maps/userspace/src/main.rs

// code/ch04_bpf_maps/userspace/src/main.rs
// Userspace side: configure the table, then read counters and events.
use aya::{
    maps::{Array, PerCpuArray, RingBuf},
    programs::Xdp,
    Ebpf,
};
use std::error::Error;

const KERNEL_ELF: &[u8] =
    include_bytes!("../../kernel/target/bpfel-unknown-none/release/kernel");
const IFACE: &str = "eth0";
const SLOT: u32 = 0;

#[repr(C)]
pub struct IfaceConfig {
    pub drop: bool,
    pub redirect_idx: u32,
}
#[repr(C)]
pub struct PacketEvent {
    pub ifindex: u32,
    pub ts: u64,
}

fn main() -> Result<(), Box<dyn Error>> {
    let mut ebpf = Ebpf::load(KERNEL_ELF)?;
    let program: &mut Xdp = ebpf.program_mut("count_packets")?.try_into()?;
    program.load()?;
    program.attach(IFACE, aya::programs::XdpFlags::default())?;

    // Config: slot 0 = do not drop, no redirect.
    let mut cfg: Array<_, IfaceConfig> = ebpf.map_mut("CFG")?.try_into()?;
    cfg.set(SLOT, IfaceConfig { drop: false, redirect_idx: 0 }, 0)?;

    // Per-CPU counters read per-CPU: sum across CPUs for the true total.
    let mut packets: PerCpuArray<_, u64> = ebpf.map_mut("PACKETS")?.try_into()?;
    let total: u64 = (0..num_cpus::get())
        .map(|cpu| packets.get(&SLOT, 0).ok().flatten().unwrap_or_default())
        .sum();
    println!("packets so far (sum over cpus): {total}");

    // Drain the event ring in batches (Ch.13 rule 4: batch the reads).
    let mut events: RingBuf = ebpf.map_mut("EVENTS")?.try_into()?;
    for item in events.iterator().take(10) {
        let ev: PacketEvent = item.read()?;
        println!("event: ifindex={} ts={}", ev.ifindex, ev.ts);
    }
    Ok(())
}

The detail worth staring at: per-CPU maps read per-CPU. get(&0, 0) returns only the current CPU’s value; summing across CPUs is a userspace responsibility. Aya offers PerCpuValues for reading all slots at once; either way, the moment you forget that a per-CPU map is a vector of values, one per CPU, your counters silently lose events. Chapter 13 returns to this with benchmark numbers.

4.5 Ring Buffers: The Modern Event Channel

BPF_MAP_TYPE_RINGBUF deserves its own section because it replaced the older perf_event_array for good reasons. The ring is lock-free (one producer side: the kernel program; one consumer side: your process), resizable-free (fixed at create), and it handles the memory-ordering concerns of the old perf buffers internally. The kernel side reserves a slot (reserve), fills it, and submits it; userspace polls the read side and advances the consumer index when done. If userspace is slow and the ring fills, the kernel side’s reserve fails - and you decide (drop the event, or drop the oldest) rather than the kernel deciding for you.

The ring is also where the NIC ring of Chapter 1 and the socket queue of Chapter 7 rhyme: the same head/tail producer-consumer shape, the same “keep up or lose data” rule. When the capstone ships events out of the kernel (Chapter 16), it ships them through this ring.

4.6 Map-in-Map and the Namespacing Pattern

ARRAY_OF_MAPS / HASH_OF_MAPS let a program select which map to use per packet - the classic namespacing pattern for multitenant systems: one outer map keyed by tenant id, whose values are map fds for that tenant’s counters. The kernel side does a two-step lookup (bpf_map_lookup_elem into the outer, then into the inner); the verifier treats the inner lookup like any other and the NULL check is mandatory. Cilium uses exactly this shape for per-endpoint and per-policy tables (Chapter 11). Use it when the number of tables is dynamic; prefer a single table with a composite key when the table count is fixed - one lookup beats two.

Hands-On Lab

# 1. Build and attach the XDP program that uses all three maps.
cd code/ch04_bpf_maps/kernel && cargo build --release --target bpfel-unknown-none
cd ../userspace && cargo run --release -- eth0

# 2. Generate traffic, then inspect the maps' live state.
curl -s http://10.0.0.1/ >/dev/null &   # any traffic source
sudo bpftool map dump name PACKETS      # per-CPU slots: sum them yourself
sudo bpftool map show name PACKETS      # lookup/update counters per second
sudo bpftool map dump name EVENTS       # the ring events

The lesson to internalise: PACKETS is a per-CPU map, so bpftool map dump shows one value per CPU and the total is your job to sum - forget that and your counters silently undercount (Chapter 4.4, Chapter 13).

Summary

  • Maps are the only shared state between kernel program and userspace: typed tables the kernel allocates and both sides access through helpers and syscalls.
  • The default choices: PERCPU_ARRAY for hot counters, ARRAY for fixed config, HASH for per-flow state, LRU_HASH for bounded tables, RINGBUF for events.
  • HASH lookups can fail (NULL check required); ARRAY lookups cannot. The verifier knows the difference and so should you.
  • Per-CPU maps read per-CPU: userspace must sum across CPUs to get a true total.
  • Ring buffers are lock-free, producer/consumer, drop-on-overflow-by- choice - the modern event channel, and the capstone’s observability backbone.
  • Map-in-map (array/hash of maps) namespaces tables per tenant or endpoint; one composite key beats two lookups when the table count is fixed.

Next: Chapter 5 attaches the first program that runs per packet - XDP, at the very front of the path, before the skb even exists.