skip to content

In a libbpf project on Linux, what does `bpftool gen skeleton prog.bpf.o > prog.skel.h` produce, and how does the generated skeleton change the user-space loader code?

level: middleimportance: nice to knowfreq 28%

answer

  1. a generated header, not a library
  2. object bytes travel inside the binary
  3. string lookups become struct members
  4. something useful happens between open and load
  5. const volatile becomes a verifier constant

basics

~20 s

It generates a C header that embeds the compiled BPF object and declares a struct named after it, with open, load, attach and destroy functions plus typed members for every map, program, link and global variable — replacing hand-written libbpf boilerplate and file loading at runtime.

solid answer

~40 s

The skeleton is a generated header holding the BPF object's bytes plus a `struct prog_bpf` with the functions `prog_bpf__open()`, `prog_bpf__load()`, `prog_bpf__open_and_load()`, `prog_bpf__attach()` and `prog_bpf__destroy()`. Instead of calling `bpf_object__open_file()` and then looking every map and program up by string name, you get typed members: `skel->maps.events`, `skel->progs.handle_exec`, `skel->links.handle_exec`. Two things follow. First, the object is embedded in your binary, so there is no `.o` to find at runtime and you can ship a single executable. Second, the split between `open` and `load` becomes useful: global variables declared `const volatile` in the BPF source appear as `skel->rodata->…` and can be set between the two calls, so the verifier sees them as constants and dead-code-eliminates the branches they guard.

code

c · 19 lines
c
#include <unistd.h>
#include "count_exec.skel.h"

int main(void)
{
    struct count_exec_bpf *skel = count_exec_bpf__open();
    if (!skel)
        return 1;

    skel->rodata->target_uid = 1000;

    if (count_exec_bpf__load(skel) || count_exec_bpf__attach(skel))
        goto cleanup;

    pause();
cleanup:
    count_exec_bpf__destroy(skel);
    return 0;
}

go deeper

for a junior

Know that a libbpf tool has two halves — kernel-side BPF C and a user-space loader — and that bpftool can generate a header that wires them together.

for a middle

Describe what the generated struct contains, name the open/load/attach/destroy calls, and explain that the object bytes are embedded so the binary needs no external file.

for a senior

Use the open/load window deliberately: rodata configuration that the verifier folds away, map sizing and pin paths, and selectively disabling programs so one binary handles kernels with different hooks.

for a principal

Treat the skeleton as build-pipeline policy — generated per build, never committed, with the BPF object embedded so the shipped artifact is a single self-contained binary across the fleet.

## What the generator emits `bpftool gen skeleton` reads a compiled BPF object and writes a self-contained C header. It contains the object file's bytes as a static array, and a struct named from the object's basename — `prog.bpf.o` yields `struct prog_bpf` — whose members mirror the object's contents: - `skel->maps.<name>` — a `struct bpf_map *` per map declared in the BPF source - `skel->progs.<name>` — a `struct bpf_program *` per program - `skel->links.<name>` — a `struct bpf_link *` slot, filled in by attach - `skel->bss`, `skel->rodata`, `skel->data` — pointers to memory-mapped views of the program's global variables, typed with real C structs and a small API: `__open()`, `__load()`, `__open_and_load()`, `__attach()`, `__detach()`, `__destroy()`, plus an `__open_opts()` variant taking `struct bpf_object_open_opts`. ## Before and after Without a skeleton the loader is boilerplate: open the object file from a path you have to locate at runtime, load it, then `bpf_object__find_map_by_name(obj, "events")` and `bpf_object__find_program_by_name(obj, "handle_exec")`, checking for NULL each time. Every lookup is a string, so a typo or a rename in the BPF source becomes a runtime NULL rather than a build error. With a skeleton those lookups are struct members. Rename a map in the BPF C, regenerate the header, and the user-space code stops compiling — which is precisely what you want. The header is a build artifact: it is regenerated on every build, and checking it into source control is a smell. ## The open/load split is the interesting part `__open()` parses the object and creates the in-memory representation; `__load()` is what actually calls into the kernel. Between them you can mutate things that must be fixed before the kernel sees the program: - **Set configuration in `rodata`.** A BPF program declares `const volatile pid_t target_pid = 0;`. After `__open()`, user space writes `skel->rodata->target_pid = 1234`. Because the variable lives in a read-only map frozen at load, the verifier treats it as a known constant — so `if (target_pid && pid != target_pid) return 0;` is resolved at verification time and the untaken branch is eliminated. This is the standard way to parameterise a CO-RE tool, and it costs nothing at run time. - **Adjust maps.** `bpf_map__set_max_entries(skel->maps.events, n)` sizes a map from a command-line flag; `bpf_map__set_pin_path()` arranges for it to be pinned or reused. - **Disable programs** you do not want on this kernel with `bpf_program__set_autoload(skel->progs.optional_probe, false)`, which is how one binary copes with a kernel that lacks a hook. After load, `skel->bss->counter` reads counters written by the BPF side directly through mapped memory, without a map lookup syscall. ## A typical main() ```c struct prog_bpf *skel = prog_bpf__open(); skel->rodata->target_pid = pid; prog_bpf__load(skel); prog_bpf__attach(skel); /* poll a ring buffer, print skel->bss->counter, ... */ prog_bpf__destroy(skel); ``` `__attach()` attaches every program whose section name declares its hook and stores the resulting links in `skel->links`; `__destroy()` detaches them and frees everything, which is why a plain skeleton tool leaves nothing behind when it exits. ## Where it fits in the build The usual Makefile sequence is: compile `prog.bpf.c` to `prog.bpf.o` with `clang -g -O2 -target bpf`, run `bpftool gen skeleton` on it to produce `prog.skel.h`, then compile the user-space `prog.c` that includes that header and link against libbpf. The output is one binary with the BPF program inside it — no data files, no compiler on the target, which is the whole point of the libbpf model.

  • Why is setting a variable in skel->rodata before load better than filtering on that value inside the BPF program at run time?
    Because a `const volatile` global lives in a read-only map that libbpf freezes at load, so the verifier knows its value and can eliminate the branches guarding it. You get the same filtering with no comparison executed per event, and the program that reaches the verifier is smaller. Setting it after load is not possible anyway — the map is frozen.
  • Should the generated .skel.h be committed to version control?
    No — it is a build artifact derived from the compiled BPF object, and it will drift the moment the BPF source changes. Generate it in the build, the way you would any generated header. Committing it invites the situation where the checked-in struct no longer matches the maps and programs the object actually contains.
  • What does skel->bss give you that reading a map does not?
    Direct memory access. Global variables in the BPF program are backed by an internal map that libbpf memory-maps into the process, so `skel->bss->counter` is a plain memory read of a live value with no `bpf()` syscall per read. It suits scalars and counters; bulk per-key data still belongs in a real map.

saying these in an interview costs you the question

  • Calling the skeleton a runtime library rather than generated code
  • Committing the generated header to version control
  • Trying to write rodata after the program is loaded
  • Thinking the .o must still be shipped alongside the binary
  • Assuming open and load are just two names for the same step

context