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 through bpf(), 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 at tools/lib/bpf and 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_object state machine, the ELF-to-kernel pipeline and its exact ordering, the section_defs[] table that turns a SEC() string into a program type, the enum reloc_type families 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)Phasebpf() commands issued
1bpf_object__open_file / __open_mem — ELF parse, section walk, .maps / .rodata / .data / .bss discovery, BTF load, relocation-record collectionopennone
2bpf_object__add_map (per map) — reserve a placeholder fdopennone (memfd_create)
3userspace window: bpf_map__set_max_entries, bpf_map__set_autocreate, bpf_program__set_autoload, writes to skel->rodata->*opennone
4bpf_object_prepare_token — open a BPF token if token_path was setprepareBPF_TOKEN_CREATE
5bpf_object__probe_loading — load a two-instruction trivial program to prove BPF works at allprepareBPF_PROG_LOAD (probe)
6bpf_object__load_vmlinux_btf — read /sys/kernel/btf/vmlinux (or a custom BTF path)preparenone (file read)
7bpf_object__resolve_externs — fill extern kconfig values and ksymspreparefeature probes
8bpf_object__sanitize_maps, __init_kern_struct_ops_maps, _adjust_struct_ops_autoloadpreparenone
9bpf_object__relocate — CO-RE relocations first, then subprogram-call relocation, then per-program bpf_object__relocate_data (map-fd, data, extern, kfunc)preparenone
10bpf_object__sanitize_and_load_btf — upload the object’s own BTFprepareBPF_BTF_LOAD
11bpf_object__create_maps — create each map, then reuse_fd() it onto its placeholder numberprepareBPF_MAP_CREATE, BPF_MAP_UPDATE_ELEM, BPF_MAP_FREEZE, optionally BPF_OBJ_PIN
12bpf_object_prepare_progspreparenone
13bpf_object__load_progs — the verifier runs here, per programloadBPF_PROG_LOAD
14bpf_object_init_prog_arrays, bpf_object_prepare_struct_opsloadBPF_MAP_UPDATE_ELEM
15bpf_object__attach_skeleton / bpf_program__attach*attachBPF_LINK_CREATE, BPF_RAW_TRACEPOINT_OPEN, BPF_PROG_ATTACH
16bpf_object__detach_skeleton then bpf_object__closedestroynone — 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_typeWhat the compiler left unresolvedWhat libbpf writes into the instruction
RELO_LD64&my_map in bpf_map_lookup_elem(&my_map, …) — the compiler cannot know the map’s fdsrc_reg = BPF_PSEUDO_MAP_FD, imm = map->fd (the placeholder number)
RELO_DATAA reference to a global in .rodata / .data / .bsssrc_reg = BPF_PSEUDO_MAP_VALUE, imm = map->fd, insn[1].imm = base + sym_off
RELO_CALLA 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_ADDRTaking 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_LD64An extern variable: a kconfig value, or a typed/typeless ksymThe kconfig map’s fd and offset, or the resolved kernel symbol address
RELO_EXTERN_CALLA call to a kfunc (see BPF Kernel Functions (kfuncs)) — an extern functionsrc_reg = BPF_PSEUDO_KFUNC_CALL, imm = the kfunc’s BTF id, off = the module’s BTF fd index
RELO_COREA CO-RE field/type/enum query — the case the rest of this section is aboutNothing directly: it is handed to bpf_core_apply_relo_insn, which patches the instruction’s off or imm
RELO_INSN_ARRAYAn 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):

Valueenum bpf_core_relo_kindlibbpf’s short nameThe question, and what is patched
0BPF_CORE_FIELD_BYTE_OFFSETbyte_off“Where is this field on the target kernel?” → the instruction’s off (for LDX/ST/STX) or imm (for ALU)
1BPF_CORE_FIELD_BYTE_SIZEbyte_sz“How wide is this field there?” → the instruction’s memory-size bits
2BPF_CORE_FIELD_EXISTSfield_exists“Does the field exist at all?” → imm becomes 0 or 1, then the verifier dead-codes the branch
3BPF_CORE_FIELD_SIGNEDsigned“Is it signed?” → imm 0 or 1
4BPF_CORE_FIELD_LSHIFT_U64lshift_u64Bitfield extraction: the left shift needed → imm
5BPF_CORE_FIELD_RSHIFT_U64rshift_u64Bitfield extraction: the right shift needed → imm
6BPF_CORE_TYPE_ID_LOCALlocal_type_id“What is this type’s id in my BTF?” → imm (used by bpf_core_type_id_local())
7BPF_CORE_TYPE_ID_TARGETtarget_type_id“What is its id in the kernel’s BTF?” → imm
8BPF_CORE_TYPE_EXISTStype_exists“Does this type exist on the target?” → imm 0 or 1
9BPF_CORE_TYPE_SIZEtype_size“How big is it there?” → imm
10BPF_CORE_ENUMVAL_EXISTSenumval_exists“Does this enumerator exist?” → imm 0 or 1
11BPF_CORE_ENUMVAL_VALUEenumval_value“What integer does it have there?” → imm
12BPF_CORE_TYPE_MATCHEStype_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:

InsnDisassembly (as compiled)Relocation recordFieldLocal offsetTarget offset on Linux 7.1.13
#0r2 = *(u64 *)(r1 + 0x10)type_id=5 access="0:3" kind=byte_offtask_struct::real_parent16 (0x10)2880 (0xB40)
#1w1 = *(u32 *)(r1 + 0x4)type_id=5 access="0:1" kind=byte_offtask_struct::pid4 (0x4)2864 (0xB30)
#2w0 = *(u32 *)(r2 + 0x8)type_id=5 access="0:2" kind=byte_offtask_struct::tgid8 (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, because unprivileged_bpf_disabled = 2 makes BPF_PROG_LOAD return EPERM for uid 1000 (measured), so no pr_debug “patched insn” line could be captured from a real load. The arithmetic is therefore reconstructed from relo_core.c, not witnessed. To resolve: run the same object through bpf_object__open_mem() + bpf_object__prepare() as root with libbpf_set_print() at LIBBPF_DEBUG, and compare the printed off X -> Y lines 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 symbolBody (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 failureOne-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 arraysInternal. 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 literalInternal. 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_LOAD failure 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 with CONFIG_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. legacy SEC("classifier") vs modern SEC("tcx/ingress")) either fails to match any section_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->cfg must happen between __open and __load — .rodata is 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 Go CollectionSpec/ProgramSpec/MapSpec values, lets you modify them in Go, then loads to the kernel; its bpf2go tool 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-rolled bpf() is for selftests and the truly minimal. aya is 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