libbpf and the BPF Loader
The [[The bpf() Syscall|
bpf()syscall]] is a low, fiddly door; libbpf is the staircase everyone actually walks up. libbpf is the canonical C library that turns a Clang-compiled BPF ELF object into running, attached kernel programs: it parses the ELF, discovers the maps and programs and global variables inside it, creates the maps in the kernel, relocates the bytecode so that map references and field accesses point at the right things on this kernel, loads each program throughbpf(), and attaches it to its hook. It also generates skeletons — a per-object header that gives userspace typed, string-free access to those maps and programs. Crucially, libbpf is not a third-party project bolted onto the kernel: its authoritative source lives inside the Linux tree attools/lib/bpfand is periodically mirrored to GitHub (libbpf README), which is exactly why it tracks new kernel features first and is the de-facto standard loader that BCC, bpftool, Cilium-adjacent tooling, and the kernel’s own selftests build on.
As of mid-2026, the latest tagged libbpf release is v1.7.0 (2025-03-16), following v1.6.x; the library has had a stable 1.0 ABI since 2022 (libbpf releases). Because libbpf is userspace, its version is independent of the kernel’s — but new APIs land in tools/lib/bpf in the kernel tree first and flow out to the GitHub mirror via scripts/sync-kernel.sh.
Re-checked 2026-09-12 against github.com/libbpf/libbpf/tags.atom (the Atom feed rather than the REST API, which rate-limits under fleet load): the newest tag is still v1.7.0, preceded by v1.6.3, v1.6.2, v1.6.1, v1.6.0, v1.5.1, v1.5.0. This note’s source readings are all taken at the v1.7.0 tag of the GitHub mirror (libbpf/libbpf), not from tools/lib/bpf in the kernel tree — the two are kept in sync by scripts/sync-kernel.sh, but only the mirror carries release tags, so it is the citable one.
What this note owns
The loader: the
bpf_objectstate machine, the ELF-to-kernel pipeline and its exact ordering, thesection_defs[]table that turns aSEC()string into a program type, theenum reloc_typefamilies libbpf patches into bytecode, the placeholder-fd trick, and the skeleton API surface. It deliberately does not own: the raw ABI it wraps (The bpf() Syscall); the CO-RE algorithm — candidate search, type matching, bitfield handling, the flavour convention — which belongs to CO-RE (Compile Once Run Everywhere) and CO-RE Relocations and Field Access; the type format underneath it (BTF (BPF Type Format), BTF and vmlinux.h); the generator (BPF Skeletons and bpftool); or the verifier that judges the result (eBPF Verifier, Reading and Debugging Verifier Errors). What this note shows of CO-RE is strictly the loader’s role in it: where in the pipeline relocation runs, what a relocation record physically contains, and what libbpf writes into the instruction.
Mental Model: A Compiler Output Needs a Linker-at-Load-Time
A normal C program is compiled to an object file, then a linker resolves symbols and a loader maps it into a process. A BPF program goes through the same shape, but the “linking and loading” must happen against the running kernel, at load time, because the kernel’s struct layouts and the set of available helpers differ between machines. libbpf is that load-time linker-loader. The compiler (clang -target bpf) emits an ELF with: BPF bytecode in code sections named by SEC(), map definitions in a .maps section, global variables in .rodata/.data/.bss, BTF type info, and relocation records marking the spots that can’t be resolved until load. libbpf reads all of that and finishes the job the compiler started.
flowchart TB C["prog.bpf.c"] -->|"clang -target bpf -g -O2"| OBJ["prog.bpf.o (ELF)<br/>SEC() code · .maps · .rodata/.data/.bss · BTF · relocations"] OBJ -->|"bpftool gen skeleton"| SKEL["prog.skel.h<br/>(embeds the .o bytes + typed structs)"] subgraph LIB["libbpf at runtime"] OPEN["__open: parse ELF, build bpf_object<br/>discover maps/progs/globals, read BTF"] PREP["__prepare (v1.6.0+): read kernel BTF,<br/>apply CO-RE + map-fd relocations,<br/>BTF_LOAD, then MAP_CREATE"] LOAD["__load: verify+load progs (PROG_LOAD)"] ATTACH["__attach: SEC()-driven auto-attach<br/>(kprobe, fentry, xdp, ...) -> links"] DESTROY["__destroy: detach, close fds, free"] OPEN --> PREP --> LOAD --> ATTACH --> DESTROY end SKEL --> OPEN LOAD -->|"bpf() syscall"| K["kernel: verifier -> JIT -> maps/progs"]
The full pipeline from BPF C source to attached, running programs. What it shows: Clang produces an ELF that is incomplete on purpose — it carries relocation records instead of resolved map addresses and field offsets — and libbpf’s runtime phases finish the work: open builds an in-memory bpf_object model from the ELF without touching the kernel at all, prepare rewrites the bytecode to match this kernel’s struct layouts and creates the maps, load submits each program to the verifier, and attach wires programs to hooks. The insight to take: the heavy lifting is relocation, in the prepare phase — this is what makes one compiled .o portable across kernels, and it is the step a raw bpf() caller would have to reimplement by hand. Note that prepare became a separately callable phase only in libbpf v1.6.0; before that it was the first half of load, and the skeleton’s prog__load() still runs both.
The bpf_object Lifecycle, Phase by Phase
libbpf models a loaded BPF application as a struct bpf_object and moves it through four phases (kernel.org libbpf overview). The phase split is deliberate: it gives userspace a window between open and load to reconfigure the object (resize maps, set global variables, disable programs) before anything is committed to the kernel.
Open (bpf_object__open / bpf_object__open_file / bpf_object__open_mem). libbpf parses the ELF and “discovers BPF maps, BPF programs, and global variables” (overview). It reads the section headers, identifies each SEC() code section as a candidate program, reads the .maps section’s BTF-defined map declarations into bpf_map structs, treats .rodata/.data/.bss as backing storage for global variables, loads the embedded BTF, and records every relocation. No syscalls happen yet — the entire object is an in-memory description. This is when you can call bpf_map__set_max_entries(), set a const volatile global the program reads as a configuration knob, or bpf_program__set_autoload(prog, false) to skip a program you don’t want on this kernel.
Load (bpf_object__load). This is the phase that does real kernel work and the heart of libbpf. It “creates BPF maps, resolves various relocations, and verifies and loads BPF programs into the kernel” (overview). The documentation’s word order is not the execution order, though — the actual v1.7.0 sequence is: load the running kernel’s BTF, apply CO-RE relocations, apply subprogram-call and map-fd/data relocations, upload the object’s own BTF with BPF_BTF_LOAD, issue BPF_MAP_CREATE for each map, and only then issue BPF_PROG_LOAD per program, which runs the verifier and the JIT. Relocation precedes map creation; the next subsection traces why that is possible and what it costs. If the verifier rejects a program, libbpf surfaces its log. After this phase the maps and programs exist in the kernel and are referenced by fds libbpf holds — but nothing is attached yet, so nothing runs.
Attach (bpf_object__attach_skeleton, or per-program bpf_program__attach*). libbpf “attaches BPF programs to various BPF hook points (e.g., tracepoints, kprobes, cgroup hooks)” (overview). For program types whose SEC() carries enough information to auto-attach, a single __attach() call wires them all up, returning a BPF link per program. Programs whose target can’t be inferred from the section name (e.g. a bare xdp that needs an interface index, or a perf_event program) are attached manually with the specific bpf_program__attach_xdp(prog, ifindex)-style call.
Tear down (bpf_object__destroy_skeleton / bpf_object__close). libbpf “detaches BPF programs and unloads them from the kernel. BPF maps are destroyed, and all resources used by the BPF app are freed” (overview) — concretely, it closes the link fds (detaching), then the program and map fds, dropping the kernel refcounts so the objects are reaped (unless pinned).
There is a fifth phase, and the load order is not what the docs imply
The four-phase story above is the one docs.kernel.org tells, and it is how the skeleton API is shaped — but reading the v1.7.0 source shows two corrections that matter in practice.
First, bpf_object__prepare() exists and splits the load phase in two. Its own doc comment in libbpf.h L269–L280 says it “prepares BPF object for loading: performs ELF processing, relocations, prepares final state of BPF program instructions (accessible with bpf_program__insns()), creates and (potentially) pins maps. Leaves BPF object in the state ready for program loading.” It was added in v1.6.0 — existence-checked 2026-09-12 by fetching src/libbpf.h at four tags and grepping: absent at v1.4.7, v1.5.0 and v1.5.1, present at v1.6.0. The point of the split is that everything except BPF_PROG_LOAD becomes inspectable: you can call bpf_object__prepare(), read back the fully relocated instruction stream with bpf_program__insns(), and only then commit. The object now carries an explicit obj->state that runs OBJ_OPEN → OBJ_PREPARED → OBJ_LOADED, and both bpf_object_prepare() and bpf_object_load() refuse to run twice.
Second — and this is the correction that surprises people — relocation happens before map creation, not after. The whole prepare phase is one err = err ? : … chain, and it reads in this order:
static int bpf_object_prepare(struct bpf_object *obj, const char *target_btf_path)
{
...
err = bpf_object_prepare_token(obj);
err = err ? : bpf_object__probe_loading(obj);
err = err ? : bpf_object__load_vmlinux_btf(obj, false);
err = err ? : bpf_object__resolve_externs(obj, obj->kconfig);
err = err ? : bpf_object__sanitize_maps(obj);
err = err ? : bpf_object__init_kern_struct_ops_maps(obj);
err = err ? : bpf_object_adjust_struct_ops_autoload(obj);
err = err ? : bpf_object__relocate(obj, obj->btf_custom_path ? : target_btf_path);
err = err ? : bpf_object__sanitize_and_load_btf(obj);
err = err ? : bpf_object__create_maps(obj);
err = err ? : bpf_object_prepare_progs(obj);
...
obj->state = OBJ_PREPARED;
return 0;
}bpf_object__relocate sits two steps above bpf_object__create_maps. That ought to be impossible: the map-fd relocation described below stamps map->fd into an instruction, and at relocation time no map has been created, so there is no fd. libbpf resolves this with a trick worth knowing, because it explains an otherwise baffling memfd in every BPF process’s fd table. At open time, bpf_object__add_map() reserves a placeholder file descriptor for every discovered map:
/* Preallocate map FD without actually creating BPF map just yet.
* These map FD "placeholders" will be reused later without changing
* FD value when map is actually created in the kernel.
* ...
*/
map->fd = create_placeholder_fd();create_placeholder_fd() is memfd_create("libbpf-placeholder-fd", MFD_CLOEXEC | MFD_NOEXEC_SEAL), with a retry without MFD_NOEXEC_SEAL for kernels that predate that flag. It is an anonymous memory file used purely to reserve an integer. Relocation then stamps that integer into the bytecode. Later, bpf_object__create_maps issues the real BPF_MAP_CREATE, gets back a different fd number, and swaps the real map in behind the reserved number:
/* Keep placeholder FD value but now point it to the BPF map object.
* This way everything that relied on this map's FD (e.g., relocated
* ldimm64 instructions) will stay valid and won't need adjustments.
*/
return reuse_fd(map->fd, map_fd);and reuse_fd in libbpf_internal.h L699–L707 is exactly two calls:
static inline int reuse_fd(int fixed_fd, int tmp_fd)
{
int err;
err = sys_dup3(tmp_fd, fixed_fd, O_CLOEXEC);
err = err < 0 ? -errno : 0;
close(tmp_fd); /* clean up temporary FD */
return err;
}dup3(tmp_fd, fixed_fd, O_CLOEXEC) atomically replaces whatever fixed_fd referred to — the placeholder memfd — with the new map, closing the old one as a side effect. The bytecode’s baked-in fd number is now a real map fd, and nothing had to be re-patched. The insight to take: an fd number is a stable name a process can mint before it knows what it will name, and libbpf exploits that to reverse the apparently mandatory order of “create, then relocate.”
The complete ordered pipeline, with the kernel round-trips marked:
| # | Step (v1.7.0 function) | Phase | bpf() commands issued |
|---|---|---|---|
| 1 | bpf_object__open_file / __open_mem — ELF parse, section walk, .maps / .rodata / .data / .bss discovery, BTF load, relocation-record collection | open | none |
| 2 | bpf_object__add_map (per map) — reserve a placeholder fd | open | none (memfd_create) |
| 3 | userspace window: bpf_map__set_max_entries, bpf_map__set_autocreate, bpf_program__set_autoload, writes to skel->rodata->* | open | none |
| 4 | bpf_object_prepare_token — open a BPF token if token_path was set | prepare | BPF_TOKEN_CREATE |
| 5 | bpf_object__probe_loading — load a two-instruction trivial program to prove BPF works at all | prepare | BPF_PROG_LOAD (probe) |
| 6 | bpf_object__load_vmlinux_btf — read /sys/kernel/btf/vmlinux (or a custom BTF path) | prepare | none (file read) |
| 7 | bpf_object__resolve_externs — fill extern kconfig values and ksyms | prepare | feature probes |
| 8 | bpf_object__sanitize_maps, __init_kern_struct_ops_maps, _adjust_struct_ops_autoload | prepare | none |
| 9 | bpf_object__relocate — CO-RE relocations first, then subprogram-call relocation, then per-program bpf_object__relocate_data (map-fd, data, extern, kfunc) | prepare | none |
| 10 | bpf_object__sanitize_and_load_btf — upload the object’s own BTF | prepare | BPF_BTF_LOAD |
| 11 | bpf_object__create_maps — create each map, then reuse_fd() it onto its placeholder number | prepare | BPF_MAP_CREATE, BPF_MAP_UPDATE_ELEM, BPF_MAP_FREEZE, optionally BPF_OBJ_PIN |
| 12 | bpf_object_prepare_progs | prepare | none |
| 13 | bpf_object__load_progs — the verifier runs here, per program | load | BPF_PROG_LOAD |
| 14 | bpf_object_init_prog_arrays, bpf_object_prepare_struct_ops | load | BPF_MAP_UPDATE_ELEM |
| 15 | bpf_object__attach_skeleton / bpf_program__attach* | attach | BPF_LINK_CREATE, BPF_RAW_TRACEPOINT_OPEN, BPF_PROG_ATTACH |
| 16 | bpf_object__detach_skeleton then bpf_object__close | destroy | none — close() on link, prog, map and BTF fds |
Read the right-hand column as the syscall trace you would see under strace -e trace=bpf. The insight to take: the verifier is not reached until step 13, which is two-thirds of the way down the list — so most libbpf failures a developer meets (missing BTF, an unresolvable SEC(), a map that cannot be created, a kconfig extern with no value) happen before any program is verified, and produce a libbpf error message rather than a verifier log.
sequenceDiagram autonumber participant App as your loader participant Skel as prog.skel.h participant Lib as libbpf participant K as kernel App->>Skel: prog__open() Skel->>Lib: bpf_object__open_skeleton(s, NULL) Note over Lib: parse embedded ELF, build bpf_object,<br/>discover maps/progs/globals, read object BTF Lib->>Lib: memfd_create("libbpf-placeholder-fd") per map Lib-->>App: obj->state = OBJ_OPEN App->>App: skel->rodata->cfg = 42<br/>bpf_map__set_max_entries(...) App->>Skel: prog__load() Skel->>Lib: bpf_object__load_skeleton(s) Lib->>K: BPF_PROG_LOAD (trivial 2-insn probe) K-->>Lib: fd or errno — "does BPF work here" Lib->>K: read /sys/kernel/btf/vmlinux K-->>Lib: kernel BTF blob Note over Lib: CO-RE relocations against kernel BTF,<br/>then subprog calls, then map-fd and data relos<br/>— all against PLACEHOLDER fds Lib->>K: BPF_BTF_LOAD (object's own BTF) K-->>Lib: btf fd loop each map Lib->>K: BPF_MAP_CREATE K-->>Lib: real map fd Lib->>Lib: dup3(real_fd, placeholder_fd, O_CLOEXEC) end Lib->>K: BPF_MAP_UPDATE_ELEM + BPF_MAP_FREEZE (.rodata) loop each autoload program Lib->>K: BPF_PROG_LOAD (insns, license, log_buf, fd_array) Note over K: verifier walks every path,<br/>then the JIT emits native code K-->>Lib: prog fd, or EACCES + verifier log end Lib-->>App: obj->state = OBJ_LOADED — nothing is running yet App->>Skel: prog__attach() Skel->>Lib: bpf_object__attach_skeleton(s) loop each attachable program Lib->>K: BPF_LINK_CREATE (or RAW_TRACEPOINT_OPEN / PROG_ATTACH) K-->>Lib: link fd — the program is now live end Lib-->>App: skel->links.* populated App->>Skel: prog__destroy() Skel->>Lib: detach then close every fd Lib->>K: close(link fd) — detaches Lib->>K: close(prog fd), close(map fd), close(btf fd) Note over K: refcounts hit zero, objects are reaped<br/>unless pinned to bpffs
One full open → load → attach → destroy cycle with every kernel round-trip marked, derived from bpf_object_prepare, bpf_object_load and bpf_object__create_map in libbpf v1.7.0 src/libbpf.c. What it shows: the open phase touches the kernel zero times — it is pure userspace ELF work — which is exactly why the window between open and load is the only place configuration can be changed. The insight to take: count the arrows into kernel and you have the cost model: one probe, one BTF upload, one MAP_CREATE per map, one PROG_LOAD per program, one LINK_CREATE per attachment. A skeleton that loads twenty programs makes twenty verifier runs, which is why large BPF applications spend most of their startup inside bpf_object__load_progs.
SEC() — From an ELF Section Name to a Program Type
A BPF program’s type is not written as an annotation in C; it is encoded in the name of the ELF section the program lands in, set by the SEC("...") macro. libbpf maps section-name prefixes to a (program_type, expected_attach_type, flags, attach_fn) tuple via a static table, section_defs[], in tools/lib/bpf/libbpf.c. Reading the actual v1.7.0 table clears up confusion that secondary summaries routinely get wrong. The ten rows below were checked verbatim against libbpf.c L9828–L9887 on 2026-09-12 (only whitespace is reflowed):
SEC_DEF("kprobe+", KPROBE, 0, SEC_NONE, attach_kprobe),
SEC_DEF("uprobe.s+", KPROBE, 0, SEC_SLEEPABLE, attach_uprobe),
SEC_DEF("tp+", TRACEPOINT, 0, SEC_NONE, attach_tp),
SEC_DEF("fentry+", TRACING, BPF_TRACE_FENTRY, SEC_ATTACH_BTF, attach_trace),
SEC_DEF("fexit.s+", TRACING, BPF_TRACE_FEXIT, SEC_ATTACH_BTF | SEC_SLEEPABLE, attach_trace),
SEC_DEF("lsm+", LSM, BPF_LSM_MAC, SEC_ATTACH_BTF, attach_lsm),
SEC_DEF("xdp", XDP, BPF_XDP, SEC_ATTACHABLE_OPT),
SEC_DEF("tcx/ingress", SCHED_CLS, BPF_TCX_INGRESS, SEC_NONE),
SEC_DEF("perf_event", PERF_EVENT, 0, SEC_NONE),
SEC_DEF("socket", SOCKET_FILTER, 0, SEC_NONE),How to read a row: the first string is the section-name pattern; a trailing + means “this is a prefix and extra text follows” (so SEC("kprobe/do_unlinkat") matches the kprobe+ row and do_unlinkat is the function to probe). The .s infix designates a sleepable program — that’s why fexit.s+ carries the SEC_SLEEPABLE flag while plain fexit+ does not; sleepability is a property of the section spelling, not the base program type. The second/third fields are the resulting bpf_prog_type and expected_attach_type that go into the BPF_PROG_LOAD arm. The flag field controls behavior: SEC_NONE means “no auto-attach” (you must attach manually), SEC_ATTACHABLE/SEC_ATTACHABLE_OPT mark sections the skeleton’s __attach() can wire up automatically, and SEC_ATTACH_BTF means the program targets an in-kernel function identified by BTF (fentry/fexit/LSM), so libbpf must resolve the target’s attach_btf_id before loading. The last field, when present, is the C function libbpf calls to perform the attach (attach_kprobe, attach_trace, …).
This table is also why two summaries can disagree: a casual reader sees a “struct_ops” or “xdp” entry and reports “auto-attach: yes,” but whether __attach() actually fires depends on the precise flag (SEC_ATTACHABLE vs SEC_ATTACHABLE_OPT vs SEC_NONE) and on whether the program has the data it needs (an xdp program still needs an ifindex). The source table is the ground truth; the prose tables in tutorials are lossy.
The Load-Time Relocation Mechanism (the part raw bpf() can’t do for you)
The reason a BPF ELF cannot be loaded as-is — and the reason libbpf is more than a syscall wrapper — is that the compiled bytecode contains references it could not resolve at compile time. libbpf records each unresolved spot as a struct reloc_desc during open and patches it during prepare. There are eight kinds in v1.7.0, and knowing the whole set is what makes a relocation error message legible; the enum is at libbpf.c L366–L375:
enum reloc_type | What the compiler left unresolved | What libbpf writes into the instruction |
|---|---|---|
RELO_LD64 | &my_map in bpf_map_lookup_elem(&my_map, …) — the compiler cannot know the map’s fd | src_reg = BPF_PSEUDO_MAP_FD, imm = map->fd (the placeholder number) |
RELO_DATA | A reference to a global in .rodata / .data / .bss | src_reg = BPF_PSEUDO_MAP_VALUE, imm = map->fd, insn[1].imm = base + sym_off |
RELO_CALL | A call to another function in the same object (a BPF-to-BPF call) | The relative jump offset, after subprogram code has been appended to the main program |
RELO_SUBPROG_ADDR | Taking the address of a subprogram (a callback passed to bpf_loop, bpf_for_each_map_elem, …) | src_reg = BPF_PSEUDO_FUNC, imm = the subprogram’s instruction index |
RELO_EXTERN_LD64 | An extern variable: a kconfig value, or a typed/typeless ksym | The kconfig map’s fd and offset, or the resolved kernel symbol address |
RELO_EXTERN_CALL | A call to a kfunc (see BPF Kernel Functions (kfuncs)) — an extern function | src_reg = BPF_PSEUDO_KFUNC_CALL, imm = the kfunc’s BTF id, off = the module’s BTF fd index |
RELO_CORE | A CO-RE field/type/enum query — the case the rest of this section is about | Nothing directly: it is handed to bpf_core_apply_relo_insn, which patches the instruction’s off or imm |
RELO_INSN_ARRAY | An entry in an indirect jump table (the BPF_MAP_TYPE_INSN_ARRAY support added for newer BPF ISA) | The instruction-array map’s fd and index |
The eight families share one shape — rewrite an instruction so the verifier can finish resolving it — but split cleanly by what the answer depends on: RELO_LD64/RELO_DATA/RELO_INSN_ARRAY depend on objects libbpf itself creates, RELO_CALL/RELO_SUBPROG_ADDR depend only on the object’s own layout, and RELO_EXTERN_*/RELO_CORE depend on the running kernel. Only that last group is what “portability” means: the first two groups would need patching even on the exact kernel the object was compiled against.
Reading the v1.7.0 source, the two simplest cases are worth walking instruction by instruction:
Map-FD relocations (RELO_LD64). When BPF C does bpf_map_lookup_elem(&my_map, &key), the compiler emits a 64-bit load-immediate (ldimm64) instruction whose immediate is a placeholder — the compiler has no idea what file descriptor my_map will get, because the map doesn’t exist until load time. After libbpf creates the map and has its fd, it patches the instruction:
/* libbpf v1.7.0, src/libbpf.c ~L6367, inside bpf_object__relocate_data() */
case RELO_LD64:
map = &obj->maps[relo->map_idx];
if (obj->gen_loader) {
insn[0].src_reg = BPF_PSEUDO_MAP_IDX;
insn[0].imm = relo->map_idx;
} else if (map->autocreate) {
insn[0].src_reg = BPF_PSEUDO_MAP_FD;
insn[0].imm = map->fd;
} else {
poison_map_ldimm64(prog, i, relo->insn_idx, insn,
relo->map_idx, map);
}
break;Three branches, and each teaches something. The middle branch is the common path: write the map’s fd into the instruction’s imm field and tag the source register as BPF_PSEUDO_MAP_FD. The verifier recognizes that pseudo-register and, during BPF_PROG_LOAD, converts the fd-bearing instruction into a real in-kernel map pointer. So the chain is: compiler emits a placeholder → libbpf stamps a reserved fd number into the bytecode → the map is created behind that number → the verifier turns the fd into a pointer.
The first branch is the light skeleton path (described below): when obj->gen_loader is set libbpf is not loading at all, it is generating a loader program, so there are no fds yet and the instruction gets a map index (BPF_PSEUDO_MAP_IDX) to be resolved later from inside the kernel.
The third branch handles bpf_map__set_autocreate(map, false) — a map the caller has told libbpf not to create on this kernel, typically because the map type does not exist there. Rather than failing the whole load, poison_map_ldimm64 deliberately corrupts the instruction:
/* we turn single ldimm64 into two identical invalid calls */
for (i = 0; i < 2; i++) {
insn->code = BPF_JMP | BPF_CALL;
insn->dst_reg = 0;
insn->src_reg = 0;
insn->off = 0;
/* if this instruction is reachable (not a dead code),
* verifier will complain with something like:
* invalid func unknown#2001000123
* where lower 123 is map index into obj->maps[] array
*/
insn->imm = POISON_LDIMM64_MAP_BASE + map_idx;
insn++;
}The two-slot ldimm64 becomes two calls to a function number that cannot exist. If the verifier’s dead-code analysis proves the site unreachable — because a const volatile feature flag was set to 0 and constant-folded — the program loads and runs normally. If it is reachable, the verifier rejects the program with invalid func unknown#2001000123, whose low decimal digits are the index into obj->maps[], so the message names the offending map. This is the mechanism behind “this tool works on kernels that lack the newer map type, as long as you do not enable the feature that uses it,” and it is why that particular verifier error should send you looking at set_autocreate, not at your C.
Global-variable relocations (RELO_DATA). Global variables don’t live in registers — libbpf backs each data section with an internal BPF map. .rodata becomes a single-entry array map that is populated with the initial values and then frozen (BPF_MAP_FREEZE) so it is read-only to userspace and the verifier can treat its contents as constants — which is how const volatile config knobs get constant-folded by the verifier. .data/.bss become writable array maps. A reference to a global is compiled as a load from a fixed offset into the section; libbpf relocates it to a BPF_PSEUDO_MAP_VALUE load against the backing map’s fd at the right offset:
/* libbpf v1.7.0, src/libbpf.c ~L6380 */
case RELO_DATA:
map = &obj->maps[relo->map_idx];
insn[1].imm = insn[0].imm + relo->sym_off;
if (relo->map_idx == obj->arena_map_idx)
insn[1].imm += obj->arena_data_off;
if (obj->gen_loader) {
insn[0].src_reg = BPF_PSEUDO_MAP_IDX_VALUE;
insn[0].imm = relo->map_idx;
} else if (map->autocreate) {
insn[0].src_reg = BPF_PSEUDO_MAP_VALUE;
insn[0].imm = map->fd;
}
...The detail that repays attention is insn[1].imm: a ldimm64 is a two-slot instruction, and the second slot carries the offset within the map’s single value. So BPF_PSEUDO_MAP_VALUE does not mean “a pointer to the map” — it means “a pointer to byte insn[1].imm of this map’s one element,” which is how a whole .data section collapses into one array map with one big value and every global becomes a fixed offset into it. The arena_map_idx branch is the same idea for the BPF_MAP_TYPE_ARENA sparse-memory map added in 6.9, where globals are placed at the end of the arena.
CO-RE at the Loader: What the Record Contains and What Gets Patched
RELO_CORE is the family that earns libbpf its reputation, and it is where the loader’s job stops being bookkeeping and starts being genuinely load-bearing. The algorithm — how candidate types are found, how flavours (struct task_struct___v58) are matched, how bitfields are handled — belongs to CO-RE (Compile Once Run Everywhere) and CO-RE Relocations and Field Access. What belongs here is the loader’s half: the physical shape of a relocation record, where it lives in the ELF, and what libbpf writes into the instruction.
The relocation record is sixteen bytes
A CO-RE relocation is not a symbol-table entry. It is a fixed 16-byte struct defined in the kernel’s own UAPI header, because the kernel can apply these relocations too (that is how the light skeleton works), so the format is ABI (include/uapi/linux/bpf.h L7491–L7496 at v6.12):
struct bpf_core_relo {
__u32 insn_off; /* byte offset of the instruction to patch */
__u32 type_id; /* BTF type id, in the OBJECT's BTF, of the root type */
__u32 access_str_off; /* offset into the object's BTF string table */
enum bpf_core_relo_kind kind;
};Walk it field by field. insn_off is a byte offset into the program’s code section, so the instruction index is insn_off / 8 — BPF instructions are 8 bytes. type_id names the root type as the program’s own BTF describes it, not as the kernel describes it; this is the “local” side of the match. access_str_off points at a colon-separated string such as "0:1:3", which is a path of member indices, not names — "0:1" means “array element 0, then member #1 of that struct.” The header’s own example makes it concrete:
struct sample { int a; struct { int b[10]; }; };
struct sample *s = ...;
int *x = &s->a; // encoded as "0:0" (a is field #0)
int *y = &s->b[5]; // encoded as "0:1:0:5" (anon struct is field #1,
// b is field #0 inside anon struct, accessing elem #5)
int *z = &s[10]->b; // encoded as "10:1" (ptr is used as an array)
kind selects what question is being asked, and there are thirteen of them. Only the first six are field queries; the rest let a program interrogate the kernel’s type system at load time and constant-fold the answer, which is how portable code branches on “does this kernel have that field at all.” The kind names below are the strings core_relo_kind_str() prints in libbpf’s debug output (relo_core.c L85–L102 at v1.7.0):
| Value | enum bpf_core_relo_kind | libbpf’s short name | The question, and what is patched |
|---|---|---|---|
| 0 | BPF_CORE_FIELD_BYTE_OFFSET | byte_off | “Where is this field on the target kernel?” → the instruction’s off (for LDX/ST/STX) or imm (for ALU) |
| 1 | BPF_CORE_FIELD_BYTE_SIZE | byte_sz | “How wide is this field there?” → the instruction’s memory-size bits |
| 2 | BPF_CORE_FIELD_EXISTS | field_exists | “Does the field exist at all?” → imm becomes 0 or 1, then the verifier dead-codes the branch |
| 3 | BPF_CORE_FIELD_SIGNED | signed | “Is it signed?” → imm 0 or 1 |
| 4 | BPF_CORE_FIELD_LSHIFT_U64 | lshift_u64 | Bitfield extraction: the left shift needed → imm |
| 5 | BPF_CORE_FIELD_RSHIFT_U64 | rshift_u64 | Bitfield extraction: the right shift needed → imm |
| 6 | BPF_CORE_TYPE_ID_LOCAL | local_type_id | “What is this type’s id in my BTF?” → imm (used by bpf_core_type_id_local()) |
| 7 | BPF_CORE_TYPE_ID_TARGET | target_type_id | “What is its id in the kernel’s BTF?” → imm |
| 8 | BPF_CORE_TYPE_EXISTS | type_exists | “Does this type exist on the target?” → imm 0 or 1 |
| 9 | BPF_CORE_TYPE_SIZE | type_size | “How big is it there?” → imm |
| 10 | BPF_CORE_ENUMVAL_EXISTS | enumval_exists | “Does this enumerator exist?” → imm 0 or 1 |
| 11 | BPF_CORE_ENUMVAL_VALUE | enumval_value | “What integer does it have there?” → imm |
| 12 | BPF_CORE_TYPE_MATCHES | type_matches | “Does the target type structurally match mine?” → imm 0 or 1 |
Nine of the thirteen kinds resolve to a constant written into imm, not to an offset. That is the design insight: CO-RE is not only “fix up offsets,” it is a small query language answered at load time whose answers the verifier then constant-folds, deleting the branches that do not apply to this kernel. A single .o can therefore contain code for three kernel generations and load only the applicable third.
A real relocation record, end to end
The following was produced on the measurement host described in The bpf() Syscall — Linux 7.1.13-200.fc44.x86_64, uid 1000, no bpftool and no perf installed — using clang version 22.1.8 (Fedora 22.1.8-4.fc44), whose -print-targets lists bpf, bpfeb and bpfel. No vmlinux.h and no libbpf headers were used: the program declares a deliberately wrong, four-field local view of struct task_struct and marks it preserve_access_index, which is the attribute form of __builtin_preserve_access_index applied to every access of that type.
struct task_struct {
int prio;
int pid;
int tgid;
struct task_struct *real_parent;
} __attribute__((preserve_access_index));
__attribute__((section("kprobe/do_unlinkat"), used))
int probe(void *ctx)
{
struct task_struct *t = (struct task_struct *)ctx;
int pid = t->pid; /* one field access */
int ppid = t->real_parent->tgid; /* two chained ones */
return pid + ppid;
}Compiled with clang -target bpf -g -O2 -c core.bpf.c -o core.bpf.o, llvm-readelf -S shows the sections that matter — kprobe/do_unlinkat (40 bytes of code), .BTF (669 bytes) and .BTF.ext (204 bytes). Disassembling the code section shows the offsets Clang actually emitted:
$ llvm-objdump -d --section="kprobe/do_unlinkat" core.bpf.o
0000000000000000 <probe>:
0: 79 12 10 00 00 00 00 00 r2 = *(u64 *)(r1 + 0x10)
1: 61 11 04 00 00 00 00 00 w1 = *(u32 *)(r1 + 0x4)
2: 61 20 08 00 00 00 00 00 w0 = *(u32 *)(r2 + 0x8)
3: 0c 10 00 00 00 00 00 00 w0 += w1
4: 95 00 00 00 00 00 00 00 exit
+0x10, +0x4 and +0x8 are the offsets of real_parent, pid and tgid in the four-field struct written above — placeholders, and wrong for any real kernel. The compiler knows they are placeholders: it emitted a relocation for each. Parsing .BTF.ext directly (a ~60-line Python reader of the btf_ext_header, its core_relo section, and the .BTF string table — no bpftool btf dump, which is unavailable here) yields the records verbatim:
.BTF magic=0xeb9f ver=1 hdr_len=24 type_len=228 str_len=417
.BTF.ext magic=0xeb9f ver=1 hdr_len=32 core_relo_off=112 core_relo_len=60
CO-RE relocation records (record_size=16 bytes = sizeof(struct bpf_core_relo)):
section 'kprobe/do_unlinkat': 3 records
# insn_off insn_idx type_id access_str kind
0 0 0 5 0:3 byte_off
1 8 1 5 0:1 byte_off
2 16 2 5 0:2 byte_off
Every field of struct bpf_core_relo is visible. record_size is 16, exactly sizeof(struct bpf_core_relo) — the wire format and the C struct are the same thing. insn_off counts bytes (0, 8, 16) and divides by 8 to give instruction indices 0, 1, 2, which line up one-for-one with the three memory loads in the disassembly. type_id is 5 for all three: the local BTF id of struct task_struct as this object defines it. access_str is 0:3, 0:1, 0:2 — member indices 3, 1 and 2 of that four-member struct, which are real_parent, pid and tgid. And kind is byte_off for all three, because all three are plain field reads.
Now the other side of the match. /sys/kernel/btf/vmlinux is world-readable (-r--r--r-- root root 6.7M) on this host because it is built with CONFIG_DEBUG_INFO_BTF=y, so the kernel’s own answer can be read directly. Parsing it with a second short Python BTF reader:
/sys/kernel/btf/vmlinux : BTF v1, 159359 types, string table 2914882 bytes
BTF type id [145856] struct task_struct (size = 10176 bytes, 272 members)
member byte off bit off type id type
prio 108 864 115271 int int
pid 2864 22912 131231 typedef pid_t
tgid 2868 22944 131231 typedef pid_t
real_parent 2880 23040 40 ptr
comm 3400 27200 192 array
Putting the two halves side by side is the whole of CO-RE in one table:
| Insn | Disassembly (as compiled) | Relocation record | Field | Local offset | Target offset on Linux 7.1.13 |
|---|---|---|---|---|---|
| #0 | r2 = *(u64 *)(r1 + 0x10) | type_id=5 access="0:3" kind=byte_off | task_struct::real_parent | 16 (0x10) | 2880 (0xB40) |
| #1 | w1 = *(u32 *)(r1 + 0x4) | type_id=5 access="0:1" kind=byte_off | task_struct::pid | 4 (0x4) | 2864 (0xB30) |
| #2 | w0 = *(u32 *)(r2 + 0x8) | type_id=5 access="0:2" kind=byte_off | task_struct::tgid | 8 (0x8) | 2868 (0xB34) |
Three instructions, three offsets, every one of them wrong by roughly 2.8 KB before relocation. The real struct task_struct on this kernel is 10,176 bytes with 272 members; the object was compiled against a four-member, 24-byte fiction. The insight to take: this is not a marginal correction. Without CO-RE, a hard-coded offset is not merely fragile across kernel versions — it is wrong even against a struct you declared yourself, because you cannot declare 272 members and every field ahead of yours shifts the ones behind it. The compiler’s offsets exist only to be overwritten, and the relocation record’s type_id + access_str pair is what survives the trip.
When libbpf performs the rewrite it says so, in one line per instruction, at LIBBPF_DEBUG level (relo_core.c L1103):
pr_debug("prog '%s': relo #%d: patched insn #%d (LDX/ST/STX) off %llu -> %llu\n",
prog_name, relo_idx, insn_idx, orig_val, new_val);For the object above, that would read prog 'probe': relo #1: patched insn #1 (LDX/ST/STX) off 4 -> 2864. Turning that on — libbpf_set_print() with a callback that accepts LIBBPF_DEBUG, or simply LIBBPF_LOG_LEVEL=debug for tools that honour it — is the single most useful thing to do when a CO-RE program misbehaves, because it prints the decision rather than the symptom. The sibling failure messages in the same file are the ones to recognize: relo #%d: no matching targets found means the field or type is absent from the kernel’s BTF, and relo #%d: unrecognized CO-RE relocation %s (%d) means the object was built by a Clang newer than the libbpf loading it.
sequenceDiagram autonumber participant CC as clang -target bpf -g participant ELF as prog.bpf.o participant Lib as libbpf (prepare phase) participant BTF as /sys/kernel/btf/vmlinux participant K as kernel verifier CC->>ELF: emit insn #1 as "w1 = *(u32 *)(r1 + 0x4)"<br/>offset 4 taken from the LOCAL struct CC->>ELF: emit .BTF type [5] struct task_struct<br/>(the local, possibly wrong, definition) CC->>ELF: emit .BTF.ext record { insn_off=8, type_id=5,<br/>access_str="0:1", kind=byte_off } Lib->>ELF: open — collect the record as RELO_CORE Lib->>BTF: read the running kernel's BTF once BTF-->>Lib: 159359 types, incl. [145856] struct task_struct Note over Lib: match local [5] "task_struct" against<br/>candidate target types by name and shape Lib->>Lib: resolve access path "0:1" -> member #1 -> "pid" Lib->>Lib: read target member byte offset = 2864 Lib->>ELF: patch insn #1 field off: 4 -> 2864 Note over Lib: pr_debug "relo #1: patched insn #1<br/>(LDX/ST/STX) off 4 -> 2864" Lib->>K: BPF_PROG_LOAD with the patched insns Note over K: the verifier sees an ordinary load at +2864<br/>and bounds-checks it against task_struct's size.<br/>It never learns a relocation happened. K-->>Lib: prog fd
One field access, from compilation to verification. What it shows: the relocation record is the only thing that crosses from compile time to load time — the compiler’s +0x4 is discarded, and the name-and-path description in .BTF.ext is what gets re-answered against whichever kernel is running. The insight to take: the kernel verifier is not a participant in CO-RE. By the time BPF_PROG_LOAD is issued the instruction stream contains only concrete offsets, indistinguishable from hand-written ones, which is why CO-RE needs no kernel support beyond exposing BTF — and why a CO-RE bug shows up as a verifier complaint about an out-of-bounds access rather than as a relocation error.
The remaining families — RELO_EXTERN_LD64 for kconfig externs and typed/typeless ksyms, RELO_EXTERN_CALL for kfunc calls — are patched with the same discipline: consult the running kernel, rewrite the instruction so the verifier can finish resolving it. See BTF and vmlinux.h for where vmlinux.h comes from and BPF Kernel Functions (kfuncs) for what RELO_EXTERN_CALL resolves against.
Uncertain
Verify: that the local-to-target offsets above (
4 → 2864,8 → 2868,16 → 2880) are what libbpf would actually write, rather than what a correct implementation should write. Reason: the relocation records and the kernel BTF offsets were both read directly, but the patch itself was not observed — libbpf could not be run end to end here, becauseunprivileged_bpf_disabled = 2makesBPF_PROG_LOADreturnEPERMfor uid 1000 (measured), so nopr_debug“patched insn” line could be captured from a real load. The arithmetic is therefore reconstructed fromrelo_core.c, not witnessed. To resolve: run the same object throughbpf_object__open_mem()+bpf_object__prepare()as root withlibbpf_set_print()atLIBBPF_DEBUG, and compare the printedoff X -> Ylines against the table.#uncertain
Skeletons — Typed, String-Free Access
Even with libbpf doing the loading, talking to a map by string name (bpf_object__find_map_by_name(obj, "events")) is error-prone and verbose. The skeleton removes that. Running bpftool gen skeleton prog.bpf.o > prog.skel.h produces a header that (overview) gives “direct access to all the BPF maps and BPF programs as struct fields,” eliminates string-based lookups, and embeds the BPF object bytecode directly in the header so the resulting userspace binary is self-contained — no separate .o to ship. The “four entry points” you see in tutorials is an undercount. Reading the generator itself — do_skeleton() in tools/bpf/bpftool/gen.c at v6.12, which emits the header as printf templates — the surface for an object named prog is:
| Generated symbol | Body (what it actually calls) | When to use it |
|---|---|---|
struct prog | { struct bpf_object_skeleton *skeleton; struct bpf_object *obj; struct {…} maps; struct {…} progs; struct {…} links; … } | The handle. Every map, program and link is a named struct field, which is the whole point — no bpf_object__find_map_by_name() string lookups. |
prog__open_opts(const struct bpf_object_open_opts *opts) | prog__create_skeleton(obj) then bpf_object__open_skeleton(obj->skeleton, opts) | The real open. Use it when you need opts — a custom BTF path, a token_path, a pinning root, or kernel_log_level. |
prog__open(void) | return prog__open_opts(NULL) | The common case. |
prog__load(struct prog *obj) | bpf_object__load_skeleton(obj->skeleton) | Runs prepare + load. |
prog__open_and_load(void) | prog__open() then prog__load(), destroying on failure | One-liner when you have nothing to configure between the phases. Using this is how you lose the ability to set rodata. |
prog__attach(struct prog *obj) | bpf_object__attach_skeleton(obj->skeleton) | Attaches every program whose SEC() row is auto-attachable, filling obj->links.*. |
prog__<progname>__attach(struct prog *skel) | per-program bpf_program__attach() into skel->links.<progname> | Emitted once per program by codegen_attach_detach(). Attach one program without attaching the rest. |
prog__detach(struct prog *obj) | bpf_object__detach_skeleton(obj->skeleton) | Detach but keep everything loaded — useful for pause/resume. |
prog__destroy(struct prog *obj) | bpf_object__destroy_skeleton() then free(obj) | Detach, close all fds, free. |
prog__create_skeleton(struct prog *obj) | Fills in bpf_object_skeleton — map_cnt, map_skel_sz, prog_cnt, prog_skel_sz, and the per-map/per-prog descriptor arrays | Internal. Its sz fields are how a skeleton generated by one bpftool stays loadable by a different libbpf. |
prog__elf_bytes(size_t *sz) | return "\x7f\x45\x4c\x46…" — the entire .o, hex-escaped into a string literal | Internal. This is the embedding: the compiled object becomes C source, so the final binary has no external .o. |
Ten generated functions, not four. The insight to take: the two that tutorials omit are the ones that solve real problems — prog__open_opts is the only way to pass a BPF token or a custom BTF path, and the per-program prog__<progname>__attach is how you attach selectively instead of all-or-nothing. The sz fields threaded through bpf_object_skeleton, bpf_map_skeleton and bpf_prog_skeleton are the same forward/backward-compatibility idea as the size argument to bpf() itself, applied one layer up: the generator and the library version independently.
A typical loader is then a dozen lines: skel = prog__open(); /* set skel->rodata->config = ...; */ prog__load(skel); prog__attach(skel); then read events off skel->maps.events. The skeleton fields are typed — skel->bss->counter is a real __u64, mmap-backed and shared with the kernel-side program, so userspace and kernel see the same memory without any syscall per access (because .data/.bss/.rodata maps are created BPF_F_MMAPABLE and mmap’d into the process).
A second, more specialized variant is the light skeleton (bpftool gen skeleton -L). Instead of embedding the full libbpf-dependent loader, it embeds a tiny self-contained loader and a loader BPF program that performs map creation and program loading from inside the kernel via the in-kernel bpf_sys_bpf/kern_sys_bpf path (the whitelisted re-entry into __sys_bpf noted in The bpf() Syscall). Because it doesn’t link against full libbpf and can run very early, the light skeleton is what lets BPF be loaded during kernel boot and from kernel modules (syscall.c comment at v6.12). For ordinary userspace tools, the regular skeleton is the right choice; the light skeleton is a niche for very-early or self-contained loading. See BPF Skeletons and bpftool for the generator and the field layout in depth.
Failure Modes and Gotchas
- Verifier rejection at load. A
BPF_PROG_LOADfailure surfaces through libbpf as a load error with the verifier log attached; libbpf automatically retries with a larger log buffer if the first was truncated. The fix lives in the program, not the loader — see Reading and Debugging Verifier Errors. - Missing kernel BTF for CO-RE. CO-RE relocations need
/sys/kernel/btf/vmlinux, which requires the kernel to be built withCONFIG_DEBUG_INFO_BTF=y. On a kernel without it, CO-RE field relocations cannot be resolved and load fails; the workaround is shipping an external BTF blob for the target kernel. SEC()typos and stale names. A misspelled or renamed section (e.g. legacySEC("classifier")vs modernSEC("tcx/ingress")) either fails to match anysection_defs[]row (load error) or matches a deprecated row. libbpf 1.0 made section-name matching strict — the days of fuzzy prefix matching are over (Nakryiko, libbpf 1.0).- Objects vanish when the loader exits. This is a property of the fd object model, not a libbpf bug: when your process ends, libbpf’s fds close and unpinned objects are reaped. Long-lived programs must be pinned or attached via a link that holds the reference.
- Global init only takes before load. Setting
skel->rodata->cfgmust happen between__openand__load—.rodatais frozen at load, so writes after load are rejected.
The first wall on a hardened host is libbpf’s own probe, and its message is misleading
Step 5 of the pipeline is bpf_object__probe_loading(), and it is worth reading in full because it is where most “libbpf does not work here” reports actually originate:
static int bpf_object__probe_loading(struct bpf_object *obj)
{
struct bpf_insn insns[] = { BPF_MOV64_IMM(BPF_REG_0, 0), BPF_EXIT_INSN() };
...
ret = bump_rlimit_memlock();
if (ret)
pr_warn("Failed to bump RLIMIT_MEMLOCK (err = %s), you might need to do it explicitly!\n", errstr(ret));
/* make sure basic loading works */
ret = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, NULL, "GPL", insns, insn_cnt, &opts);
if (ret < 0)
ret = bpf_prog_load(BPF_PROG_TYPE_TRACEPOINT, NULL, "GPL", insns, insn_cnt, &opts);
if (ret < 0) {
ret = errno;
pr_warn("Error in %s(): %s. Couldn't load trivial BPF program. Make sure your kernel supports BPF (CONFIG_BPF_SYSCALL=y) and/or that RLIMIT_MEMLOCK is set to big enough value.\n",
__func__, errstr(ret));
return -ret;
}
close(ret);
return 0;
}libbpf loads a two-instruction program (r0 = 0; exit) as SOCKET_FILTER, falling back to TRACEPOINT, purely to prove BPF works at all before doing anything expensive. On the measurement host both attempts fail, and the diagnosis the message offers is wrong on both counts. Reproducing the probe’s exact first call by hand, with a 64 KiB verifier log buffer attached:
SOCKET_FILTER, 2 valid insns, GPL, log_buf set -> ret=-1 EPERM
verifier log bytes written: 0
KPROBE (CAP_PERFMON class), 2 valid insns -> ret=-1 EPERM
CONFIG_BPF_SYSCALL=y is set on this kernel (checked in /boot/config-7.1.13-200.fc44.x86_64), and RLIMIT_MEMLOCK is irrelevant — BPF memory accounting moved to memcg years before this kernel. The actual cause is kernel.unprivileged_bpf_disabled = 2, the default that CONFIG_BPF_UNPRIV_DEFAULT_OFF=y installs, which makes BPF_PROG_LOAD return EPERM to a process without CAP_BPF. The empty verifier log is the tell: the kernel rejected the call before bpf_check() ran, so there is nothing to print. See The bpf() Syscall for the measured errno-precedence ladder and Unprivileged BPF and Its Restrictions for the policy.
The practical consequence for this note is a disclosure, not a caveat: the end-to-end open → prepare → load → attach cycle could not be executed here. Everything above about the ELF side — the relocation records, the BTF offsets, the disassembly — is directly measured, because that half runs entirely in userspace. Everything about what libbpf writes is read from the v1.7.0 source, not witnessed, and is flagged as such in the callout above. bpftool and perf are not installed on this host, so neither bpftool gen skeleton nor bpftool prog list could be used to corroborate; the skeleton surface was instead enumerated from bpftool’s generator source in the kernel tree.
Alternatives and When to Choose Them
libbpf is the default, but it is not the only loader, and the alternatives illuminate what it does:
- BCC (BPF Compiler Collection). BCC’s model is runtime compilation: it embeds BPF C as a string and compiles it on the target machine using an embedded Clang/LLVM, which means every host running a BCC tool needs a full LLVM toolchain and matching kernel headers present (BCC INSTALL.md). That is heavy (hundreds of megabytes of LLVM) and fragile (compilation can fail on a host you didn’t test). libbpf + CO-RE replaced this for most tools: compile once on the developer’s machine, ship a small static binary, relocate at load. Modern BCC even offers a libbpf-based path for exactly this reason. Reach for BCC’s runtime-compile model only when you genuinely need to generate program source dynamically at runtime.
cilium/ebpf(Go). A pure-Go reimplementation of the ELF loader and CO-RE relocation that is “compatible with the upstream libbpf” but does not wrap it via cgo (ebpf-go loader docs). It parses the ELF into GoCollectionSpec/ProgramSpec/MapSpecvalues, lets you modify them in Go, then loads to the kernel; itsbpf2gotool plays the role of skeleton generation. Choose it when your control plane is Go and you want to avoid cgo (a major reason Cilium’s userspace is Go).- Raw
bpf()/ aya (Rust) / other bindings. Hand-rolledbpf()is for selftests and the truly minimal.ayais a Rust ecosystem that, like cilium/ebpf, reimplements loading without libC dependency. The common thread: any serious loader must reimplement ELF parsing, map creation, relocation, and CO-RE — which is precisely the body of work libbpf standardized first.
Production Notes
libbpf’s status as the de-facto standard is structural, not just popular: because its source of truth is tools/lib/bpf in the kernel tree and it is mirrored out, new kernel features (a new map type, a new attach mode, a new SEC() spelling) appear in libbpf essentially as they merge, and the kernel’s own selftests exercise it continuously. This is why distros ship libbpf as a system library and why bpftool (also in tools/bpf) and most observability agents build on it. The library’s 1.0 release (2022) froze a clean, stable ABI and removed a decade of deprecated APIs; subsequent 1.x releases (through v1.7.0, 2025-03-16) add features — exclusive map creation, kprobe-session programs, module-qualified fentry targets, indirect-jump-table support for newer BPF ISA — without ABI breaks (releases, v1.5.0 notes). For a team building eBPF tooling in C, the practical recipe is: write *.bpf.c, compile with clang -target bpf -g -O2, generate vmlinux.h from the kernel’s BTF, bpftool gen skeleton, link against libbpf, ship one static binary — the workflow that made eBPF deployable at industry scale.
See Also
- The bpf() Syscall — the raw ABI libbpf wraps; the fd object model and the
bpf_sys_bpfre-entry the light skeleton uses - BPF Skeletons and bpftool —
bpftool gen skeleton, the generated header layout, and the light skeleton in depth - CO-RE (Compile Once Run Everywhere) · BTF and vmlinux.h — the relocation mechanism libbpf applies at load to make one
.oportable - BPF Maps — the objects libbpf creates in the load phase;
.rodata/.data/.bssbecome internal maps - BPF Links and Attachment Lifecycle — what
__attach()returns; the refcounted attachment fd - BPF Object Pinning and Lifetime — how to make libbpf-loaded objects outlive the loader process
- eBPF Verifier — what
bpf_object__loadtriggers per program; where load failures originate - BPF Program Types — the types that
SEC()section names map to - CO-RE Relocations and Field Access — the matching algorithm this note only shows the loader’s half of
- BPF Kernel Functions (kfuncs) — what
RELO_EXTERN_CALLresolves against - BCC BPF Compiler Collection — the runtime-compilation loader libbpf + CO-RE displaced
- Unprivileged BPF and Its Restrictions · CAP_BPF and BPF Privilege Model — why
bpf_object__probe_loading()fails on a default-hardened host - BPF Instruction Set — the
ldimm64two-slot encoding thatRELO_LD64andRELO_DATApatch - Linux eBPF MOC — parent map of content