CO-RE (Compile Once Run Everywhere)

CO-RE (Compile Once – Run Everywhere) is the mechanism that lets a single, pre-compiled BPF object file load and run correctly across many different Linux kernel versions, even though those kernels lay out their internal data structures differently. The core problem is brutally simple: a BPF program that reads task->pid must, at the machine-code level, dereference a fixed byte offset into a struct task_struct — but that offset changes when the struct gains, loses, or reorders fields between kernel releases, so a binary compiled against one kernel reads garbage (or crashes the verifier) on another (Nakryiko, BPF Portability and CO-RE). CO-RE solves this by compiling the program once against a description of some kernel’s types, having Clang emit relocation records that say “here I accessed field pid of struct task_struct,” and then having the loader (libbpf) rewrite those accesses at load time to the offsets the target kernel actually uses — using the target kernel’s own self-description, BTF (BPF Type Format), as ground truth. The result is the change that turned eBPF from a kernel-developer toy into an industry technology: modern tools ship as small static binaries instead of dragging a C compiler and kernel headers onto every host. This note is the why and the shape; the deep mechanism — the relocation kinds, the macros, the instruction patching — lives in CO-RE Relocations and Field Access.

Version context and how the dates below were established

Kernel-side facts here are pinned to Linux 6.12 LTS (released 2024-11-17) unless another tag is named. Userspace tooling — libbpf and Clang/LLVM — is versioned separately; libbpf’s latest release is v1.7.0 (read from libbpf/libbpf tags.atom on 2026-09-12) and the bpf_core_read.h macros referenced are read at that tag. Version claims in this note were established by existence-checking: fetching the same file at successive tags and recording where it appears — not from release notes, and not from memory.

Where a figure is described as measured, it was produced on one host: Linux 7.1.13-200.fc44.x86_64, clang version 22.1.8 (Fedora 22.1.8-4.fc44) with a working bpf target, /sys/kernel/btf/vmlinux world-readable at 6.7 MB (CONFIG_DEBUG_INFO_BTF=y), and neither bpftool nor perf installed — so all BTF was parsed by hand. bpf(2) BPF_PROG_LOAD returns EPERM there for uid 1000 (kernel.unprivileged_bpf_disabled = 2), so no program in this note was ever loaded into a kernel; resolving that needs sudo sysctl kernel.unprivileged_bpf_disabled=0, or root.

What this note owns, and what it does not

CO-RE is covered by three notes that divide the subject deliberately. This note owns the concept and the problem: why kernel struct layouts move at all, what BCC did instead and what that cost, the shape of the solution, and — the part that is easiest to get wrong — what classes of kernel change CO-RE does and does not insulate a program from. CO-RE Relocations and Field Access owns the mechanism: the access-string grammar, the thirteen relocation kinds, bitfield handling, and above all the matching algorithm — how libbpf finds a member in a struct whose layout it has never seen. libbpf and the BPF Loader owns the loader’s half: where relocation sits in the bpf_object pipeline and what libbpf physically writes into an instruction. BTF (BPF Type Format) owns BTF itself. Nothing here re-derives those; each is linked at the point it becomes relevant.

The Portability Problem

A BPF program is, at bottom, native-ish bytecode that reads kernel memory directly. When you write task->pid in BPF C, the compiler does not emit “look up the field named pid” — there is no runtime field lookup in compiled C. It emits a load from a constant byte offset: something like “read 4 bytes at task + 0x4d8.” That 0x4d8 is baked in at compile time from the layout of struct task_struct in whatever headers the compiler saw.

The trouble is that kernel data structures are internal implementation detail with no stability guarantee whatsoever. Between any two kernel versions — even minor ones, even between two distro builds of the same version with different CONFIG_* options — fields get added, removed, renamed, reordered, moved into nested sub-structs, or have their types changed (Nakryiko, BPF Portability and CO-RE). In Nakryiko’s words: “Different kernel versions will have struct fields shuffled around inside a struct, or even moved into a new inner struct. Fields can be renamed or removed, their types changed.” Any one of those changes shifts the byte offset of every field after it.

So a BPF program compiled against kernel 5.10’s task_struct that hard-codes offset 0x4d8 for pid will, on a 5.15 kernel where pid actually lives at 0x4e0, silently read four bytes from the wrong place — perhaps the middle of some unrelated field. This is not a theoretical edge case; it is the normal situation. A task_struct on a modern kernel is well over a kilobyte of frequently-rearranged fields, and CONFIG-gated members (debug fields, lockdep state, cgroup pointers) move everything below them. There is no version of “compile against the kernel headers and ship the binary” that survives contact with a second kernel.

flowchart LR
  subgraph A["Compile against kernel A"]
    SA["struct task_struct<br/>pid @ 0x4d8"]
  end
  subgraph B["Run on kernel B"]
    SB["struct task_struct<br/>pid @ 0x4e0"]
  end
  PROG["BPF bytecode:<br/>load 4 bytes @ task+0x4d8"]
  SA --> PROG
  PROG -->|"same binary,<br/>NO relocation"| SB
  SB -->|"0x4d8 now points<br/>at WRONG field"| BAD["reads garbage"]

The portability problem in one picture. What it shows: the offset 0x4d8 is frozen into the bytecode when compiled against kernel A, but on kernel B the field has moved to 0x4e0, so the load reads the wrong memory. The insight to take: the bug is not a crash — it is silent wrong data. The program “works” (the verifier accepts a load of a valid pointer at a valid offset), it just reads the wrong field. This is why you cannot fix portability by “being careful”; you need the offset itself to be rewritten per-kernel.

The Pre-CO-RE Answer: BCC and Runtime Compilation

Before CO-RE, the dominant way to write portable BPF tools was BCC (the BPF Compiler Collection), and its strategy was to dodge the offset problem entirely by never freezing the offsets in the first place. A BCC tool ships its BPF program as C source text embedded in the application binary. At runtime, on the actual target host, BCC invokes an embedded copy of Clang/LLVM to compile that source against the local machine’s kernel headers — so the offsets it bakes in are always correct for the kernel it is about to run on (Nakryiko, BPF Portability and CO-RE).

This works, but the costs are severe, and they are exactly the costs CO-RE was built to eliminate:

  • Enormous binaries. Clang/LLVM is a large library. “Clang/LLVM combo is a big library, resulting in big fat binaries” — a trivial BCC tool that prints one counter can balloon to tens of megabytes because it is statically carrying a compiler.
  • Runtime resource cost. Compilation happens at tool startup, on the production host, every time the tool runs. As Nakryiko notes, this “will use a significant amount of resources, potentially tipping over a carefully balanced production workload” — a monitoring agent that spikes CPU and memory to recompile its BPF program on every restart is a genuine operational hazard.
  • Kernel-header dependency. BCC needs the running kernel’s headers (kernel-devel / linux-headers) installed on every target. These are routinely absent on production and container hosts, which strip them to save space. No headers, no compile, no tool.
  • Errors at runtime, not build time. Because compilation is deferred to the target, you discover “even most trivial compilation errors only in runtime,” on the customer’s host, instead of in CI.

BCC’s model is “ship the compiler, compile on every machine.” It is robust against layout changes precisely because it re-derives the layout each time — but it pays for that robustness with size, runtime cost, a header dependency, and lost compile-time checking. CO-RE keeps the robustness and discards every one of those costs.

The CO-RE Solution, in Shape

CO-RE inverts BCC’s bargain. Instead of shipping a compiler and compiling per-host, it compiles once on the developer’s machine and ships a small native binary, then makes the binary adaptable at load time by carrying enough metadata to fix up its own field offsets. Three ingredients make this possible.

First, BTF — the kernel’s self-description. BTF (BPF Type Format) is a compact encoding of C type information — every struct, every field, every field’s offset and size and signedness — roughly “DWARF debug info, but ~100x smaller” (kernel BTF doc; Nakryiko, BTF dedup). A kernel built with CONFIG_DEBUG_INFO_BTF=y embeds a complete BTF description of itself and exposes it to userspace at /sys/kernel/btf/vmlinux (Nakryiko, BTF dedup). This is the running kernel telling you, authoritatively, where every field of every struct actually lives on this machine right now. The full BTF story is in BTF (BPF Type Format).

Second, vmlinux.h — compiling against types without kernel headers. Rather than #include-ing fragile, version-specific kernel headers, a CO-RE program includes a single generated file, vmlinux.h, produced from BTF by dumping it back into C:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

This one header contains every type the kernel knows about, including internal ones never exported in normal headers, and it removes the kernel-devel dependency at compile time entirely (Nakryiko, BPF Portability and CO-RE). Crucially, which kernel’s vmlinux.h you compile against barely matters — you are compiling against type shapes, and CO-RE will fix up the offsets later. The vmlinux.h workflow has its own note: BTF and vmlinux.h.

Third, relocations — the metadata that makes the binary adaptable. When Clang compiles a CO-RE program (with clang --target=bpf -O2 -g -c, where the -g flag is what makes Clang emit BTF and CO-RE relocation metadata — confirmed in the kernel’s own LLVM reloc doc, which shows exactly this invocation producing .BTF and .BTF.ext ELF sections), it does not simply freeze offsets. For each field access marked for relocation, it records a small note in a special ELF section called .BTF.ext that says, in effect, “the instruction at this offset reads field pid (field index 0:N) of struct task_struct.” The actual byte offset is left as a placeholder. These notes are CO-RE relocations, and the full mechanism — how they are recorded, the thirteen relocation kinds, and how the loader rewrites the bytecode — is the subject of CO-RE Relocations and Field Access.

flowchart TB
  subgraph DEV["Developer machine (once)"]
    SRC["BPF C source<br/>#include vmlinux.h"]
    CLANG["clang -target bpf -g -O2"]
    OBJ[".o ELF object<br/>bytecode + .BTF + .BTF.ext (relocs)"]
    SRC --> CLANG --> OBJ
  end
  subgraph TGT["Every target host (at load)"]
    LIBBPF["libbpf"]
    TBTF["/sys/kernel/btf/vmlinux<br/>(target kernel's BTF)"]
    PATCH["match relocs vs target BTF<br/>rewrite offsets in bytecode"]
    LOAD["BPF_PROG_LOAD<br/>(already-patched bytecode)"]
    LIBBPF --> PATCH
    TBTF --> PATCH
    PATCH --> LOAD
  end
  OBJ -->|"ship one small binary"| LIBBPF

The CO-RE pipeline. What it shows: compilation happens once on the developer’s machine and produces an ELF object whose .BTF.ext section carries relocation records; on each target host, libbpf reads that host’s /sys/kernel/btf/vmlinux, matches each relocation against the target’s real layout, rewrites the offsets in the bytecode, and only then submits the program to the kernel. The insight to take: the heavy work (parsing C, type-checking) happened once at build time; the per-host work is a cheap metadata-driven offset rewrite, not a compile. The target kernel never sees the relocations — it receives ordinary, already-correct bytecode (more in CO-RE Relocations and Field Access).

The Offsets Exist Only To Be Thrown Away

The single hardest idea to internalise about CO-RE is that the byte offsets Clang emits are not a best guess that CO-RE corrects at the margins — they are placeholders that are expected to be wrong, and the compiler emits them only because a BPF instruction must contain some number. The relocation record, not the instruction, carries the real information.

That is a strong claim, so here is the measurement that makes it concrete. Take one source line, t->pid, inside one identical function body, and compile it three times against three deliberately different local declarations of struct task_struct — none of which resembles the real one. On the measurement host this yields:

Local declaration of struct task_structClang’s emitted instructionRelocation access_strPatched to on Linux 7.1.13
{ int pid; }w0 = *(u32 *)(r1 + 0x0)0:0+2864
{ int prio; int pid; int tgid; struct task_struct *real_parent; }w0 = *(u32 *)(r1 + 0x4)0:1+2864
{ …pad…; char comm[16]; struct { int nice; int pid; }; }w0 = *(u32 *)(r1 + 0x2c)0:4:1+2864

One source line, three local layouts, one answer. What it shows: the placeholder offset varies (0, 4, 44) with the local declaration and so does the access string (0:0, 0:1, 0:4:1) — including a three-component path when pid is nested inside an anonymous struct — yet all three resolve to the same target byte, 2864. The insight to take: the only thing that survives from compile time to load time is the symbolic description “member named pid of a type whose essential name is task_struct”. Everything numeric that Clang computed is discarded. This is why the choice of which kernel’s vmlinux.h you compile against genuinely does not matter, and why “compile against the production kernel’s headers” is a misunderstanding rather than a precaution.

The target side, read out of /sys/kernel/btf/vmlinux with a hand-written BTF parser (no bpftool on the host), explains the magnitude of the correction: the kernel’s real struct task_struct is type [145856], 10,176 bytes, 272 members, with pid at byte 2,864, tgid at 2,868 and real_parent at 2,880. Every local declaration above was off by roughly 2.8 kilobytes, and it did not matter.

flowchart LR
  A["source: t-&gt;pid"]
  B["local BTF says<br/>pid is member #1"]
  C["instruction:<br/>load at +0x4<br/>(placeholder, discarded)"]
  D["relocation record:<br/>type 'task_struct', path '0:1'<br/>(the payload)"]
  E["target BTF:<br/>272 members, search by NAME"]
  F["found at member #95,<br/>byte 2864"]
  G["instruction:<br/>load at +2864"]
  A --> B
  B --> C
  B --> D
  D --> E --> F --> G
  C -.->|"overwritten"| G

What actually crosses the compile/load boundary. What it shows: two things come out of the compiler — a number and a description — and only the description is load-bearing. The target search is by member name, so the local member index 1 and the target member index 95 need not agree. The insight to take: CO-RE is not offset arithmetic, it is a name lookup whose byproduct is an offset. The full algorithm — candidate search, recursive descent through anonymous members, what happens when several types match or none do — is in CO-RE Relocations and Field Access.

Why the Loader Does the Work — and Where

The single most important architectural fact about CO-RE is where the relocation happens. In the standard libbpf workflow — the one used by virtually every CO-RE tool — libbpf resolves the relocations in userspace, before the program is ever loaded. Reading the v6.12 libbpf source, the function bpf_object__relocate_core (tools/lib/bpf/libbpf.c, v6.12) parses the target BTF (from /sys/kernel/btf/vmlinux, or an override path), iterates every relocation record in .BTF.ext, computes the correct value against the target layout, and patches the in-memory bytecode — all before the BPF_PROG_LOAD syscall fires. The kernel receives bytecode that already has the right offsets; it never sees the CO-RE relocation records at all in this path.

This matters for the mental model: CO-RE is overwhelmingly a userspace, load-time transformation. The verifier sees normal, fully-resolved bytecode. (There is one exception — the “light skeleton” / gen_loader path, where relocation work is deferred and the kernel does the patching itself, entered through bpf_core_apply() in kernel/bpf/btf.c L8949 at v6.12 — but that is a specialized path, not the default. The two paths cannot diverge within a kernel tree, because kernel/bpf/relo_core.c is literally a two-line file whose body is #include "../../tools/lib/bpf/relo_core.c"; the kernel compiles the very same source as in-tree libbpf. Drift is possible only between a kernel tree and a separately released libbpf — see CO-RE Relocations and Field Access.) The full division of labor is detailed in CO-RE Relocations and Field Access and libbpf and the BPF Loader.

The Moving Parts, Named

CO-RE is a collaboration between a compiler, a metadata format, a loader, and the kernel’s self-description. The pieces, and where each lives:

  • Clang/LLVM (-target bpf -g) — the compiler. The -g flag makes it emit the .BTF section (the program’s own types) and the .BTF.ext section (function info, line info, and CO-RE relocation records). It is also Clang that provides the builtins — __builtin_preserve_access_index, __builtin_preserve_field_info, __builtin_btf_type_id, __builtin_preserve_enum_value — that generate the relocation records. (Confirmed: the v6.12 LLVM reloc doc shows clang --target=bpf -O2 -g -c test.c producing .BTF/.BTF.ext.)
  • .BTF and .BTF.ext — two ELF sections in the compiled object. .BTF holds the program’s type information; .BTF.ext holds per-instruction metadata including the CO-RE relocation records. Both are described in the kernel BTF doc.
  • vmlinux.h — the generated header that lets you compile against kernel types without kernel headers (see BTF and vmlinux.h).
  • libbpf — the userspace loader. It reads the ELF, reads target BTF, resolves relocations, patches bytecode, creates maps, and loads programs. The CO-RE-specific entry point is bpf_object__relocate_core. See libbpf and the BPF Loader.
  • Target BTF — the running kernel’s own BTF at /sys/kernel/btf/vmlinux (requires CONFIG_DEBUG_INFO_BTF=y). This is the ground truth libbpf matches against.
  • The relocation records and builtins — the BPF_CORE_READ family, bpf_core_field_exists(), and friends, plus the underlying relocation kinds. All of this is CO-RE Relocations and Field Access.

What CO-RE Buys, Concretely

The payoff is best seen by contrasting deployments. A BCC tool is a multi-tens-of-megabytes binary that, on startup, needs kernel-devel present and burns CPU recompiling its BPF program against local headers — and fails outright on a header-less container host. A CO-RE tool built with libbpf is a single small static binary (often well under a megabyte) that ships nothing but its own already-compiled bytecode plus a few kilobytes of relocation metadata, needs no compiler and no headers at runtime, starts instantly, and loads correctly on any kernel new enough to expose BTF (Nakryiko, BPF Portability and CO-RE). This is exactly why the modern eBPF ecosystem — bpftool, Cilium’s agent, the libbpf-tools rewrites of the classic BCC tools, observability agents like Parca and Pixie — standardized on CO-RE + libbpf. The portable static binary is the deployment story that made eBPF shippable as a product.

The size of the metadata is worth quantifying, because “carries relocation metadata” sounds expensive and is not. The three-relocation object measured above — 5 instructions, 40 bytes of code — carries a .BTF section of 444 bytes and a .BTF.ext section of 204 bytes, of which the CO-RE records themselves occupy 60 bytes: a 4-byte record size, an 8-byte per-section header, and three 16-byte struct bpf_core_relo records. Relocation metadata is a few bytes per relocated access. The comparison that matters is not against zero, it is against BCC shipping an entire LLVM.

The ground truth on the other side is bigger but is the kernel’s, not the tool’s: /sys/kernel/btf/vmlinux on the measurement host is 6,943,854 bytes describing 159,359 types, split into a 4.0 MB type section and a 2.9 MB string table. That file is generated once at kernel build time by pahole, lives in kernel memory, and is read by every CO-RE loader on the box. A BPF tool ships none of it.

There is one hard prerequisite worth stating plainly: CO-RE needs the target kernel to expose BTF. If /sys/kernel/btf/vmlinux does not exist (an old kernel, or one built without CONFIG_DEBUG_INFO_BTF=y), there is no ground truth to relocate against. For such kernels the ecosystem bridges the gap with BTFHub, a community archive of pre-generated BTF blobs for thousands of distro kernels that never shipped their own, which libbpf can be pointed at as an external BTF source.

What CO-RE Promises, and What It Does Not

“CO-RE makes a BPF program portable” is too coarse to be actionable. What CO-RE actually provides is a small, fixed set of questions the loader can ask the target kernel’s BTF, plus one mechanism (instruction poisoning plus verifier dead-code elimination) for surviving a question that has no answer. Everything a program can adapt to must be expressible in those terms. Enumerating enum bpf_core_relo_kind from include/uapi/linux/bpf.h L7427–L7441 at v6.12, the complete question list is:

FamilyThe question CO-RE can ask at load timeRelocation kinds
FieldWhere is this field? How wide is it? Is it signed? What shifts extract this bitfield? Does it exist at all?FIELD_BYTE_OFFSET, FIELD_BYTE_SIZE, FIELD_SIGNED, FIELD_LSHIFT_U64, FIELD_RSHIFT_U64, FIELD_EXISTS
TypeDoes this type exist? How big is it? What is its id here? Does it structurally match mine?TYPE_EXISTS, TYPE_SIZE, TYPE_ID_LOCAL, TYPE_ID_TARGET, TYPE_MATCHES
Enum valueDoes this enumerator exist? What integer does it have here?ENUMVAL_EXISTS, ENUMVAL_VALUE

The complete CO-RE query surface — thirteen kinds, three families, nothing else. What it shows: every one of these is a question about layout and naming, and none is a question about behaviour. The insight to take: if the difference between two kernels cannot be phrased as one of these thirteen questions, CO-RE cannot help, and the program must handle it some other way. The mechanics of each kind are in CO-RE Relocations and Field Access; what matters here is the shape of the boundary.

Held against the ways a kernel actually changes, that gives a clean taxonomy:

What changed between kernelsHandled howAutomatic?
A field moved (members added/removed/reordered ahead of it, a CONFIG option shifted everything)FIELD_BYTE_OFFSET recomputes the offsetyes, silently
A field’s integer width changed (int → long, 32-bit vs 64-bit pointer)load width adjusted, but only if both sides are pointers or both are unsigned ints — otherwise the instruction is poisonedpartly
A field’s signedness changedFIELD_SIGNED selects arithmetic vs logical shiftyes
A field became (or stopped being) a bitfield, or changed bit width/positionthe four bitfield kinds recompute offset, load size and both shiftsyes
A field moved into an anonymous struct/union, or out of onethe matcher recurses through anonymous members; the access path changes length but the field is still foundyes, silently
A field was removedrelocation fails → instruction poisonedno — needs a bpf_core_field_exists() guard, or the load fails
A field was renamed (state → __state)relocation fails; the fix is a ___flavour struct plus an existence guardno — requires source changes
A type was removed or renamedTYPE_EXISTS / TYPE_MATCHES return 0no — needs a guard
An enumerator was renumberedENUMVAL_VALUE reads the target’s valueyes
An enumerator was removedENUMVAL_EXISTS returns 0no — needs a guard
A field’s meaning changed while keeping its name and type (units changed, flag bit repurposed, a counter became a ratio)nothing at all — CO-RE will relocate it happily and the program will read the right bytes and the wrong answerno, and undetectable
A helper, kfunc, tracepoint or hook was added, removed or changedoutside CO-RE entirely; libbpf handles kfuncs via a separate relocation family, and feature detection is the program’s own problemno

What survives a kernel upgrade and what does not. What it shows: the top block is free, the middle block costs a guard and a ___flavour type, and the last two rows are outside the system. The insight to take: the bottom row is the dangerous one, because it is the only entry with no failure signal. A field whose semantics changed relocates perfectly — CO-RE verifies names and layout, never meaning — so it produces a program that loads cleanly and reports nonsense. That failure mode is not a defect in CO-RE; it is the boundary of what any layout-level mechanism can do, and it is why long-lived BPF tools still need kernel-version awareness at the level of what the numbers mean.

flowchart TB
  Q0["A kernel upgrade changed something.<br/>Will my compiled .o still be correct?"]
  Q1{"Is the difference expressible as<br/>layout or naming?"}
  OUT0["Outside CO-RE.<br/>New helper, new hook, changed semantics.<br/>Handle it yourself."]
  Q2{"Does the field / type / enumerator<br/>still exist under the same name<br/>(modulo ___flavour)?"}
  Q3{"Did only its position,<br/>bit width or signedness move?"}
  FREE["FREE: relocated silently.<br/>Offset, size, signedness and the<br/>two bitfield shifts are recomputed."]
  Q4{"Did its integer width change<br/>in a zero-extension-safe way?<br/>(both pointers, or both unsigned)"}
  ADJ["Load width adjusted automatically."]
  POIS1["Instruction poisoned:<br/>'accesses field incorrectly'"]
  Q5{"Did you guard the access with<br/>bpf_core_field_exists / type_exists /<br/>enum_value_exists?"}
  DEAD["Guard resolves to 0, branch is dead code,<br/>verifier deletes the poisoned insn.<br/>Program loads."]
  FAIL["Poisoned insn is reachable.<br/>Load fails:<br/>invalid func unknown#195896080"]
  SILENT["SILENT WRONG ANSWER.<br/>Relocates perfectly, reads the<br/>right bytes, means something else."]

  Q0 --> Q1
  Q1 -- no --> OUT0 --> SILENT
  Q1 -- yes --> Q2
  Q2 -- yes --> Q3
  Q3 -- yes --> FREE
  Q3 -- no --> Q4
  Q4 -- yes --> ADJ
  Q4 -- no --> POIS1 --> Q5
  Q2 -- "no: removed or renamed" --> Q5
  Q5 -- yes --> DEAD
  Q5 -- no --> FAIL

The same taxonomy as a decision procedure. What it shows: every path through the tree ends in one of four outcomes — free, guarded-and-fine, a hard load failure, or a silent wrong answer — and which one you get is decided almost entirely by whether the entity kept its name. The insight to take: the only branch with no diagnostic is the one that leaves CO-RE’s domain entirely. Within CO-RE’s domain there is always either a correct answer or a loud failure, which is the property that makes shipping one .o across a fleet defensible at all.

When CO-RE Became Usable — Dated by Existence Check

CO-RE is usually described as arriving “around 2019”, which is true but not useful, because it needed three independent pieces to land before a program could be compiled once and loaded anywhere. Each can be pinned exactly by fetching the relevant file at successive kernel tags and recording the first one where it exists. All four rows below were checked this way on 2026-09-12:

PieceFile / symbol checkedAbsent atPresent atShips in
The kernel can build its own BTFCONFIG_DEBUG_INFO_BTF in lib/Kconfig.debugv5.1v5.2Linux 5.2
The kernel exposes that BTF to userspacekernel/bpf/sysfs_btf.c (creates /sys/kernel/btf/vmlinux)v5.3 (HTTP 404)v5.4 (HTTP 200)Linux 5.4
libbpf can match and relocatebpf_core_spec_match in tools/lib/bpf/libbpf.cv5.3v5.4Linux 5.4
Strict type matching (TYPE_MATCHES)BPF_CORE_TYPE_MATCHES in include/uapi/linux/bpf.hv5.19v6.0-rc1Linux 6.0

The CO-RE feature timeline, established by existence check rather than by recollection. What it shows: the ground truth (CONFIG_DEBUG_INFO_BTF) landed one release before the means to read it, and the matching algorithm landed in the same release as the sysfs file — so Linux 5.4 (November 2019) is the first kernel on which the complete CO-RE flow works end to end, not 5.2. The insight to take: “the kernel has BTF” and “the kernel exposes BTF” are different questions, and only the second one matters to a CO-RE tool. A 5.2 or 5.3 kernel can be built with BTF and still be useless as a relocation target.

timeline
  title CO-RE prerequisites landing in the kernel (all dated by existence check)
  Linux 5.2 : CONFIG_DEBUG_INFO_BTF — the kernel can build BTF for itself
  Linux 5.4 : kernel/bpf/sysfs_btf.c — /sys/kernel/btf/vmlinux appears
            : bpf_core_spec_match in libbpf — the matching algorithm
            : first kernel where CO-RE works end to end
  Linux 6.0 : BPF_CORE_TYPE_MATCHES — strict structural type matching
  Linux 6.15 : array-element size fix in the LDX/STX width adjustment

The same table as a timeline. What it shows: two prerequisites landed in different releases, and the second of them (5.4) is the one that gates usability. The insight to take: when someone says “this needs a BTF-enabled kernel”, the number to check against is 5.4, not 5.2 — and for bpf_core_type_matches(), 6.0.

The oldest surviving piece has a name: the Fixes: tag on a 2026 security fix points at ddc7c3042614 ("libbpf: implement BPF CO-RE offset relocation algorithm"), the commit that introduced the matcher, and notes that the flaw it fixes “was in the algorithm from the day it was written”. The algorithm has been stable enough that a 2019 bug was still reachable in 2026.

Failure Modes and Common Misunderstandings

“CO-RE makes BPF programs version-independent automatically.” No. CO-RE relocates field offsets, sizes, signedness, and the existence of types/fields/enum values — the things recorded as relocations. It does not invent a field that does not exist on the target, nor reconcile a genuine semantic change (a field that was renamed and repurposed). If a program reads a field that simply is not present on the target kernel and you did not guard the read, the relocation fails and the instruction is poisoned — turned into a deliberately-invalid instruction so the verifier rejects the load if that path is reachable. Writing programs that degrade gracefully across kernels is a real skill, built on bpf_core_field_exists() and “struct flavors,” both covered in CO-RE Relocations and Field Access.

“CO-RE compiles on the target host.” This is the BCC model, and it is exactly what CO-RE eliminates. CO-RE compiles once, on the developer’s machine. The target-side work is offset patching, not compilation.

“The kernel does the relocation.” In the default libbpf path, no — libbpf does it in userspace and submits already-patched bytecode. The kernel-resident relocation engine exists only for the light-skeleton/gen_loader path.

“CO-RE needs DWARF debug info on the target.” No — it needs BTF, which is ~100x smaller than DWARF and is what kernels actually ship via CONFIG_DEBUG_INFO_BTF=y. DWARF is a build-time input from which BTF is derived; it is never needed at runtime.

“A matching type name is enough to identify the target type.” It usually is, but not always, and the failure is a load error rather than a wrong answer. Because matching is by name (modulo the ___flavour suffix rule), a type name that is duplicated in the kernel’s BTF yields several candidates, and if two of them both contain a member of the right name at different offsets, libbpf refuses the program with field offset ambiguity: X != Y. This is not rare: the measurement kernel’s BTF contains 31 duplicated composite type names, among them three different struct irq_info (136, 32 and 16 bytes) and two different struct console (280 and 40 bytes). A program reading struct irq_info::irq on this kernel would fail to load. The mechanics are in CO-RE Relocations and Field Access; the concept-level point is that CO-RE’s identity notion is a name, and names are not unique in a kernel image.

“The ___ flavour convention is a userspace idiom.” It is a CO-RE rule, and the kernel’s own BTF uses it: scanning the measurement host’s 159,359 types for names whose CO-RE “essential name” is shorter than their full name finds 16 hits, including nf_conn___init, whose essential name is nf_conn. So declaring struct nf_conn in a BPF program already produces two candidates on this kernel, before the program has done anything unusual.

“CO-RE can be disabled by not using the macros.” Partly true and often surprising. vmlinux.h applies __attribute__((preserve_access_index)) to every type via #pragma clang attribute push, so in a program that includes it, an ordinary t->pid in an fentry or tp_btf program emits a relocation with no macro in sight. That is the intended behaviour, but it means “I did not call BPF_CORE_READ” is not evidence that a read is unrelocated — check for .BTF.ext records instead. See BTF and vmlinux.h.

Alternatives and When to Choose Them

CO-RE + libbpfBCC (runtime compile)bpftraceKernel module
When the offsets are decidedload time, from target BTFcompile time, on the target hostcompile time, on the target hostbuild time, per kernel ABI
Needs a compiler on the targetnoyes (embedded Clang/LLVM)yes (under the hood)no, but needs a matching build
Needs kernel headers on the targetnoyes (kernel-devel)usually yesyes, to build
Needs target BTF (CONFIG_DEBUG_INFO_BTF=y)yes (or an external blob)nonono
Shipped artifactone small static binarybinary plus embedded LLVM, tens of MBscript plus the bpftrace runtimeone .ko per kernel
Startup costnegligible — a metadata walka full compile, every runa full compile, every runinsmod
Errors surfaceat build time (C errors) and at load (relocation)on the customer’s host, at runtimeat runtimeat build/insmod
Safetyverifier-checkedverifier-checkedverifier-checkednone — can panic the box
Best atshipping a fleet-wide agent or toolgenerating BPF C dynamically at runtimead-hoc interactive investigationanything eBPF genuinely cannot express

The four ways to read kernel internals, compared on the axes that decide deployments. What it shows: CO-RE trades one dependency for three — it requires target BTF, but drops the compiler, the headers, and the startup cost. The insight to take: the BTF requirement is the only column where CO-RE is worse than BCC, and it is the reason BTFHub exists. Everything else in the table is why the ecosystem migrated.

  • BCC (runtime compilation). Still the right choice when you genuinely need to generate BPF C dynamically at runtime (e.g. an interactive tool that builds a probe from user input), or for quick one-off scripting where binary size and startup cost do not matter. For shippable, deployable tools, CO-RE has superseded it — most classic BCC tools have libbpf-CO-RE rewrites.
  • bpftrace. A higher-level tracing language; it compiles BPF at runtime (BCC-style under the hood historically) but trades portability concerns for expressiveness and interactivity. Right for ad-hoc investigation, not for shipping an agent. Lives in Linux Tracing and Observability MOC.
  • Kernel modules. The pre-eBPF way to read arbitrary kernel state. No verifier safety net, must be recompiled per kernel ABI, and can crash the box — exactly the fragility eBPF + CO-RE exists to avoid.
  • Plain libbpf without CO-RE. You can use libbpf and not touch a single kernel struct (e.g. an XDP program that only parses packet bytes). Then there is nothing to relocate and CO-RE is irrelevant — you get the small-binary benefits for free.

Production Notes

The libbpf-tools subdirectory of the BCC repository is the canonical real-world demonstration: it is a re-implementation of dozens of the classic BCC tracing tools as CO-RE + libbpf programs, each a small static binary with no runtime compiler. Cilium’s datapath and observability stack, Parca’s profiler, and Pixie all build on CO-RE for the same reason — they must ship one artifact that runs across a fleet of heterogeneous kernels. The standard operational gotcha is the missing-BTF kernel: older or minimally-built kernels lack /sys/kernel/btf/vmlinux, and the community answer is BTFHub plus libbpf’s external-BTF support, so the loader can be handed a matching BTF blob even when the kernel did not ship one. The other recurring lesson is that vmlinux.h should be treated as compile-input, not a kernel binding — you can compile against any reasonably recent kernel’s vmlinux.h and still run everywhere, because CO-RE fixes the offsets; pinning vmlinux.h to “the production kernel” is a misunderstanding of what CO-RE does.

See Also

This note is the concept; two siblings carry the machinery, and the split is stated in the ownership box above.

  • CO-RE Relocations and Field Access — the mechanism: the access-string grammar, the thirteen relocation kinds, bitfield handling, and the matching algorithm (candidate search, recursive descent through anonymous members, the ambiguity rule, poisoning). Everything this note points forward to lives there.
  • libbpf and the BPF Loader — the loader’s half: where relocation runs in the bpf_object pipeline, the 16-byte on-disk record, and what libbpf writes into an instruction. That note explicitly defers the matching algorithm to the sibling above.
  • BTF (BPF Type Format) — the type-information format all of this is built on: the kinds, the encoding, the type graph
  • BTF and vmlinux.h — generating the single header that replaces kernel headers, and where preserve_access_index gets applied globally
  • BPF Skeletons and bpftool — bpftool gen skeleton and the light-skeleton/gen_loader path, the one place relocation runs inside the kernel
  • eBPF Verifier — the dead-code elimination that makes a poisoned instruction harmless when it is guarded
  • The bpf() Syscall — BPF_PROG_LOAD and kernel.unprivileged_bpf_disabled
  • Linux eBPF MOC — the parent map; CO-RE is §7, “CO-RE and BTF — Write Once, Run Everywhere”