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 type | Shape | Kernel access | Userspace access | Best for |
|---|---|---|---|---|
HASH | key -> value, open addressing | bpf_map_lookup_elem | get/insert/delete by key | per-flow state: backends, sessions |
ARRAY | index -> value, preallocated | bpf_map_lookup_elem (no delete) | get/update by index | fixed tables: config, counters |
PERCPU_HASH | key -> per-CPU value | lookup returns this CPU’s slot | sum across CPUs on read | hot per-flow counters |
PERCPU_ARRAY | index -> per-CPU value | lookup (no delete) | sum across CPUs on read | hot counters - the default choice |
LRU_HASH | hash with eviction | lookup + update, evicts | get/insert/delete | bounded connection tables |
RINGBUF | lock-free ring | bpf_ringbuf_output | poll + read events | event streaming - the default choice |
STACK / QUEUE | LIFO / FIFO | push/pop/peek | push/pop/peek | work distribution |
SOCKMAP / SOCKHASH | socket -> socket | redirect helpers | update with socket fds | in-kernel socket redirect (Ch. 8) |
ARRAY_OF_MAPS | index -> map fd | map-in-map lookup | update with map fds | namespaced 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.