Code: ../24-pythonbpf/hello_pythonbpf.py
Run: sudo python3 24-pythonbpf/hello_pythonbpf.py
Status: deref() note at the bottom — an earlier version of this example had a silent counter bug). It has not been run on the host (macOS) or on a native x86 kernel.
Every previous chapter wrote the kernel-side program in C — either as a string
BCC compiles at runtime (ch1–6) or as a .c file libbpf compiles ahead of time
(ch7+). Python-BPF removes the C entirely: you write the eBPF program as
decorated Python functions, and the toolchain lowers their AST to LLVM IR (via
llvmlite), compiles that to a BPF object, and loads it with pylibbpf.
This chapter reuses a familiar shape — count execve in a hash map, the same
idea as ch02 — so the authoring model is the thing to compare.
It is the "kernel side, three ways" capstone. (Two small differences from ch02:
we key the map on PID rather than UID, and we hook the sys_enter_execve
tracepoint from ch04 instead of ch02's kprobe — the
section string names the tracepoint directly.)
| Way | Kernel code is… | Compiled… | Target needs |
|---|---|---|---|
| ch02 BCC | a C string | at runtime, by clang | LLVM/clang installed |
| ch07 libbpf/CO-RE | a real .c file |
ahead of time → portable .o |
nothing (CO-RE) |
| ch24 Python-BPF | Python | Python AST → LLVM IR → .o |
clang + bpftool, pre-1.0 libs |
@bpf— marks a function as kernel-side eBPF. It never executes in CPython; its AST is the input to the compiler. This is the key mental shift: the Python here is source for a compiler, not a script.@bpf+@map(stacked, over a function returningHashMap) — declares a BPF map. Referenced inside a@sectionfunction by the bare name (counts.lookup(...)/counts.update(...), notcounts()), it is the in-kernel map, much like theBPF_HASHof ch02.@bpf+@bpfglobalLICENSEreturning"GPL"— mandatory. The compiler emits a reference to this symbol unconditionally; omit it andllcfails with "use of undefined value '@LICENSE'". (GPL-only helpers also refuse to load without a license, exactly as in C.)@section("tracepoint/syscalls/sys_enter_execve")— Python-BPF's equivalent of libbpf'sSEC(): it sets the program type and attach point. The string is the same tracepoint path used since ch04.- Helpers as Python calls —
pid()compiles tobpf_get_current_pid_tgid() >> 32; map methods compile tobpf_map_*helpers. The Python names a helper; it doesn't call it in user space. b = BPF()thenb.load()thenb.attach_all()—BPF()reads this module's own source (viainspect), compiles every@bpfobject to a.o(Python AST → LLVM IR viallvmlite→llc -march=bpf), and returns a loadable object;.load()loads it into the kernel,.attach_all()wires each section. Because it inspects the caller's source frame,BPF()only works from a real.pyfile — notpython3 -cor stdin.b["counts"]— read a map back by indexing the loaded object with the map's name (a string). The handle's.items()returns a plaindict {key: value}snapshot;.keys()/.values()return lists;mp[k]works too.
The BCC and libbpf chapters lean on tooling that has shipped for years. Python-BPF
(and its pylibbpf loader) is young — pre-1.0 and explicitly not for production
per upstream. Two practical consequences for this repo:
- Pin the dependencies (
requirements.txt) — the decorator/import API still moves, so an unpinned upgrade can break the example. - The API in this chapter was pinned by running it, not by reading docs. The
upstream README and the installed package disagreed on several points; the
forms used here are the ones that actually compiled, loaded, and ran on the
tested version. In particular: imports are
from pythonbpf.helper import pid(singularhelper); the loader isb.load()+b.attach_all()(there is noload_and_attach/attachon the installedBpfObject); maps are read withb["counts"](by name), whose.items()is adict; theLICENSE@bpfglobalis required orllcerrors. Re-verify on the version you install, since both packages are pre-1.0.
Conceptually this is a fourth way to get bytecode into the kernel, alongside
the trace-buffer/map/perf/ring-buffer bridges of ch1–6 and the compiled-C model
of ch7+. It changes who writes the kernel program and in what language —
the hook (sys_enter_execve tracepoint, as in ch04+) and the bridge (a hash map
read from user space, as in ch02) are both things the repo has already done in C.
What's new is authoring the kernel side in Python. If the
project matures, "write eBPF in Python, compile to a portable object" could
collapse the BCC-vs-libbpf trade-off (runtime clang vs. ahead-of-time C) into a
single Pythonic workflow. For now: a promising preview, flagged as such.
This repo's host is Apple Silicon macOS, which has no Linux kernel to load eBPF into. The other chapters assume a Linux box; for this one a privileged container on Docker Desktop's LinuxKit VM works, and is how the example was verified. The VM kernel (6.12) ships BTF; you only need to mount tracefs so libbpf can resolve the tracepoint id. Sketch:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-dev clang llvm \
libbpf-dev libelf-dev zlib1g-dev libcap-dev libssl-dev \
git make gcc g++ cmake ninja-build pybind11-dev pkg-config ca-certificates
# bpftool isn't installable standalone on Ubuntu — build it from source:
RUN git clone --depth 1 --recurse-submodules https://github.com/libbpf/bpftool.git /tmp/bpftool \
&& make -C /tmp/bpftool/src -j"$(nproc)" && make -C /tmp/bpftool/src install
RUN pip3 install --break-system-packages pythonbpf pylibbpf llvmlite# then, inside a --privileged run with the program mounted at /work:
mount -t tracefs nodev /sys/kernel/tracing
python3 /work/hello_pythonbpf.py # Ctrl-C to stopNotes from the actual run: pip install pythonbpf pulls in pylibbpf, which
builds from source (cmake/ninja/pybind11 + python3-dev — hence those
packages). The verified run was on aarch64 (the Docker Desktop VM's native
arch). tracefs must be mounted at /sys/kernel/tracing specifically — libbpf
looks there first, and a mount only at /sys/kernel/debug/tracing gave
-ENOENT on the tracepoint.
counts.lookup(key) returns a pointer into the map (NULL if absent), not the
stored value — exactly like bpf_map_lookup_elem in C. To read the count you
must dereference it: deref(prev). The obvious-looking (prev or 0) + 1 is a
trap: Python-BPF compiles prev or 0 as a truthiness test on the pointer
(see the emitted IR: icmp ne ptr, null → phi i1 → sext i1 to i64), so the
"previous value" is only ever 0 or 1 and the counter never accumulates. That is
why this chapter uses the explicit form:
prev = counts.lookup(process_id)
if prev:
counts.update(process_id, deref(prev) + 1)
else:
counts.update(process_id, 1)A fixed-key test makes the bug obvious: with (prev or 0) + 1, hammering one key
20× leaves it at 0; with deref(prev), it reads 20. Verified on aarch64.
The x86 "miscompile" was a misdiagnosis. An earlier run under
docker --platform linux/amd64 showed wrong counts and I suspected an
llc/qemu codegen difference. It wasn't: the per-PID counter only looked
correct on arm64 because fresh execs get fresh PIDs, so every hit took the
first-insert path (store 1) and the broken accumulation never showed. The
(prev or 0) bug is architecture-independent — the generated IR is identical
on both arches, and the fixed-key test fails on arm64 too. The real fix was
deref(), not anything arch-specific. (Reported upstream:
pythonbpf/Python-BPF#89.)
Other run notes: pip install pythonbpf pulls in pylibbpf, which builds from
source (cmake/ninja/pybind11 + python3-dev). tracefs must be mounted at
/sys/kernel/tracing specifically — libbpf looks there first, and a mount only
at /sys/kernel/debug/tracing gave -ENOENT on the tracepoint.