eBPF Maps: The Data Structures That Power Kernel Programs

eBPF programs are stateless. Each invocation runs, returns, and forgets. To remember anything across calls — or to share data with userspace — you need maps.

Maps are the only way an eBPF program holds state. They're also how it talks to the outside world. Get them right and you have aggregation, history, control, and observability. Get them wrong and your program is a black box.

What a Map Actually Is

A kernel-side, typed key/value store registered before the program loads. Both the eBPF program and userspace can read/write it through different APIs:

  • In eBPF: helper functions like bpf_map_lookup_elem, bpf_map_update_elem.
  • In userspace: bpf_map_* syscalls or libbpf wrappers.

Maps live in kernel memory. They survive across program invocations. They survive even if the program is detached, until userspace closes the fd.

The Common Map Types

There are 30+ map types. These five cover most real programs:

Map Type Shape When to use
BPF_MAP_TYPE_HASH Generic key → value Lookup by arbitrary key (PID, IP, syscall).
BPF_MAP_TYPE_ARRAY Index → value Fixed-size counters, stats by CPU/index.
BPF_MAP_TYPE_PERCPU_* Per-CPU shards High-frequency counters without atomics.
BPF_MAP_TYPE_RINGBUF MPSC byte ring Stream events to userspace efficiently.
BPF_MAP_TYPE_LRU_HASH Hash with LRU evict Bounded cache (flow tables, recent PIDs).

Declaring a Map

With libbpf and BTF, modern style is the SEC(".maps") struct:

c
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, u32);          // PID
    __type(value, u64);        // syscall count
    __uint(max_entries, 10240);
} pid_syscalls SEC(".maps");

Use it from a tracepoint:

c
SEC("tracepoint/raw_syscalls/sys_enter")
int count_syscalls(struct trace_event_raw_sys_enter *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *count = bpf_map_lookup_elem(&pid_syscalls, &pid);
    if (count) {
        __sync_fetch_and_add(count, 1);
    } else {
        u64 init = 1;
        bpf_map_update_elem(&pid_syscalls, &pid, &init, BPF_ANY);
    }
    return 0;
}

Userspace then iterates the map to print per-PID syscall rates.

Per-CPU: When You Need Speed

A regular hash map on a hot path is a bottleneck — every CPU contends on the same hash bucket lock. Per-CPU maps sidestep this.

c
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 1);
} packets SEC(".maps");

Each CPU writes to its own private slot. No locks, no atomics. Userspace reads all CPU shards and sums them.

CPU 0: packets[0] = 1,234,567
CPU 1: packets[0] = 1,201,003
CPU 2: packets[0] = 1,189,772
...
user:  sum across CPUs → total

This is the standard pattern for high-rate XDP and tracing.

Ring Buffer: Streaming Events Out

For structured events (a stack trace, an exec'd binary, a TCP retransmit), you don't poll a map — you stream into a ring buffer.

c
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
} events SEC(".maps");

struct event { u32 pid; char comm[16]; };

struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e) return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);

Userspace polls with ring_buffer__poll(). The kernel handles wakeups efficiently — no per-event syscall overhead.

Ringbuf replaced the older BPF_MAP_TYPE_PERF_EVENT_ARRAY for most use cases: better memory efficiency, MPSC, ordered.

LRU Hash: Bounded Caches

Flow tables, recent PIDs, IP reputation — anything where you want "track up to N, evict the oldest."

c
struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 100000);
} recent_ips SEC(".maps");

The kernel tracks access order and evicts cold entries when full. Adding to a regular hash at max capacity fails; adding to LRU silently displaces the oldest.

Userspace Side

c
int fd = bpf_object__find_map_fd_by_name(obj, "pid_syscalls");
u32 key, next_key;
u64 value;
key = -1;
while (bpf_map_get_next_key(fd, &key, &next_key) == 0) {
    bpf_map_lookup_elem(fd, &next_key, &value);
    printf("pid %u: %llu\n", next_key, value);
    key = next_key;
}

For per-CPU maps, the value is an array of nr_cpus entries — sum or aggregate accordingly.

Pitfalls

max_entries is a hard limit. Hash maps return E2BIG when full unless they're LRU. Size for peak.

Pointers in values are not allowed. Maps store flat data. If you need a struct, embed it inline; if you need indirection, use map-in-map.

Userspace iteration is racy. Keys can be added, removed, or have values changed mid-iteration. Use it for sampling, not exact snapshots, unless you stop the program.

Per-CPU maps lie about consistency. Reading CPU 0's value while CPU 0 is updating it gives you a torn read. Use atomic operations on the program side.

Takeaways

  • Maps are the only state in eBPF. Pick the type that matches your access pattern.
  • Per-CPU maps for hot counters; ring buffers for events; LRU for bounded caches.
  • The verifier rejects anything it can't prove safe — pointer checks, bounds checks, return-value checks. This is a feature.
  • Map design choices set the ceiling on what your program can observe.