A CO-RE relocation is a tiny metadata record, emitted by Clang into a BPF object’s .BTF.ext ELF section, that says: “the BPF instruction at offset X accesses this field (or this type, or this enum value); when you load me on a real kernel, look up where that thing actually lives and patch this instruction accordingly.” Each record is a struct bpf_core_relo — { insn_off, type_id, access_str_off, kind } — and kind is one of thirteen values in enum bpf_core_relo_kind, ranging from BPF_CORE_FIELD_BYTE_OFFSET (the workhorse: rewrite a field’s byte offset) through field-existence, field-size, signedness, bitfield shifts, type existence/size/id, and enum-value existence/value (include/uapi/linux/bpf.h, v6.12). At load time the loader (libbpf, in userspace, in the normal path) matches each record’s local type against the target kernel’s BTF, computes the right value, and patches the instruction — rewriting its off field for memory loads or its imm field for ALU ops. This note is the mechanism in full, and its centre is the matching algorithm: given a local type, an access string, and a target BTF describing a kernel nobody compiled against, how the corresponding member is actually found — candidate search by essential name, recursive descent through anonymous members, modifier stripping, the ambiguity rule when several candidates match, and what happens when none do. The motivation and the bigger picture are in CO-RE (Compile Once Run Everywhere); the loader’s side of the same transaction is in libbpf and the BPF Loader.
Version context and measurement host
Kernel-side definitions (enum bpf_core_relo_kind, struct bpf_core_relo, relo_core.c behavior, the poison value) are pinned to Linux 6.12 LTS (2024-11-17) and verified against the raw source at tag v6.12. Where v6.12 and current libbpf differ, the note says so — the two copies of relo_core.c are not eternally identical, and one real divergence is documented under Production Notes below. libbpf’s own latest release is v1.7.0 (read from libbpf/libbpftags.atom, 2026-09-12), and src/relo_core.c at that tag is what is compared against.
Every measured figure in this note was produced on one host and is labelled as such: Linux 7.1.13-200.fc44.x86_64, clang version 22.1.8 (Fedora 22.1.8-4.fc44) with a working bpf target, and a world-readable /sys/kernel/btf/vmlinux (6.7 MB, CONFIG_DEBUG_INFO_BTF=y). Neither bpftool nor perf is installed there, so every byte of BTF quoted below was parsed by a hand-written reader over the raw .BTF/.BTF.ext ELF sections and over /sys/kernel/btf/vmlinux. bpf(2)BPF_PROG_LOAD returns EPERM for uid 1000 because kernel.unprivileged_bpf_disabled = 2, so nothing here was actually loaded into the kernel; resolving that needs sudo sysctl kernel.unprivileged_bpf_disabled=0 (or running the loader as root). Relocation values shown as “target” are computed by a Python re-implementation of bpf_core_spec_match() that is independently cross-checked against Clang’s own placeholder immediates (see Validating the re-implementation).
What this note owns, and what it does not
Three notes cover CO-RE and they divide the subject cleanly. CO-RE (Compile Once Run Everywhere) owns the concept: why kernel struct layouts move, what BCC did instead, what CO-RE can and cannot insulate a program from, and the deployment story. libbpf and the BPF Loader owns the loader’s half: where relocation sits in the bpf_object pipeline, the physical 16-byte layout of a bpf_core_relo record in the ELF, and which instruction field libbpf writes. This note owns the middle: the access-string grammar, the thirteen relocation kinds as queries, and above all the matching algorithm — how libbpf finds a member in a target struct whose layout it has never seen. BTF (BPF Type Format) owns BTF itself (the kinds, the encoding, the type graph); what appears here is only what the matcher does with those kinds.
Mental Model: a Relocation Is a Deferred Question
The right way to think about a CO-RE relocation is as a deferred question that the compiler asks but does not answer. When you write a field access, Clang does not bake in an offset; it leaves a placeholder in the instruction and writes a note saying, “the answer to what offset does field pid of struct task_struct have goes here.” The loader answers the question later, against the kernel it is about to run on.
flowchart LR
subgraph COMPILE["Compile time (Clang)"]
EXPR["s->pid<br/>(preserve_access_index)"]
INSN["emit LDX with<br/>placeholder offset"]
RELO["emit bpf_core_relo:<br/>insn_off, type_id=struct,<br/>access_str='0:N', kind=BYTE_OFFSET"]
EXPR --> INSN
EXPR --> RELO
end
subgraph LOAD["Load time (libbpf)"]
LOCAL["local BTF:<br/>find field via access_str<br/>= local offset"]
TARGET["target BTF:<br/>find SAME field by name<br/>= target offset"]
PATCH["patch insn->off<br/>:= target offset"]
LOCAL --> PATCH
TARGET --> PATCH
end
RELO --> LOCAL
RELO --> TARGET
INSN --> PATCH
A relocation as a deferred question. What it shows: at compile time Clang emits the instruction with a placeholder and a separate relocation record describing which field; at load time libbpf walks the access string against both the local BTF (the layout Clang saw) and the target BTF (the running kernel), and rewrites the instruction’s offset to the target value. The insight to take: the record is symbolic — it names a field by its position in a type, not by a number — which is exactly what lets the same record resolve to different offsets on different kernels.
The Relocation Record: struct bpf_core_relo
Every CO-RE relocation is serialized as this struct, defined in the kernel uapi and shared all the way from LLVM to libbpf to the kernel (include/uapi/linux/bpf.h, v6.12):
Walking the fields, per the kernel’s own comment block:
insn_off — the byte offset, within the program’s code section, of the instruction whose imm or off field this relocation will patch. (libbpf divides this by BPF_INSN_SZ = 8 to get an instruction index — see bpf_object__relocate_core in libbpf.c, v6.12.)
type_id — the BTF type ID of the “root” (containing) entity — the outermost struct/union/type the access starts from. The comment is explicit: for a chain of accesses the type_id “will capture BTF type id of struct sample,” i.e. the root, not the leaf field.
access_str_off — an offset into the .BTF string section pointing at the access string, which encodes which field (or array element, or enum value) inside the root type is being referenced. The encoding is the heart of how a relocation names a field symbolically — detailed next.
kind — one of enum bpf_core_relo_kind. This tells the loader what to compute (an offset? a size? whether the field exists?) and what to patch.
The kernel comment also nails when such a record is emitted: “Such relocation is emitted when using __builtin_preserve_access_index() Clang built-in, passing expression that captures field address, e.g.: bpf_probe_read(&dst, sizeof(dst), __builtin_preserve_access_index(&src->a.b.c));” (uapi bpf.h, v6.12).
The Access String: Naming a Field Symbolically
The access string is what makes a relocation portable: it identifies a field by its path of indices through the type, not by any byte offset. Per the kernel’s LLVM reloc doc, v6.12, “string encodes an accessed field using a sequence of field and array indices, separated by colon (:). It’s conceptually very close to LLVM’s getelementptr instruction’s arguments.” Concretely, for:
struct sample { int a; int b; struct { int c[10]; };} __attribute__((preserve_access_index));struct sample *s;
the encodings are:
s->a → "0:0" — 0 = “first element of s” (the leading index always treats the root pointer as if it were an array, so a plain -> is element 0); 0 = “field index 0 (a) in struct sample.”
s->b → "0:1" — element 0, field index 1 (b).
s[1].c[5] → "1:2:0:5" — 1 = second element of s; 2 = the anonymous-struct field (it is field index 2 in struct sample); 0 = field c inside the anonymous struct; 5 = array element 5.
The leading index is why even s->a starts with 0: — the relocation machinery models the root access as array-style, so s->field is “element 0, then field N.” The kernel’s uapi comment gives the same example with s[10]->b encoded as "10:1", confirming the leading index is a real array subscript when you write one.
The grammar, decoded
Stated as a grammar rather than by example, an access string is:
access_str ::= index ( ":" index )* # field-based relocations
| "0" # type-based relocations
| index # enum-value-based: the enumerator's index
index ::= non-negative decimal integer
The first index is always an array subscript applied to the root pointer — s->a is s[0].a, so it is 0:…, while s[10]->b is 10:1. Every subsequent index is interpreted against whatever type the walk has reached after stripping modifiers and typedefs: a member index if that type is a STRUCT or UNION, an element index if it is an ARRAY, and an error otherwise (relo for [%u] %s (at idx %d) captures type [%d] of unexpected kind %s, relo_core.c L386). The list is capped at BPF_CORE_SPEC_MAX_LEN entries; overflowing it returns -E2BIG.
The table below decodes every access string measured on the host described above. Each row is a real record read out of a real .BTF.ext section — none is reconstructed from the documentation:
Source expression
Local type as declared
access_str
Decoded
Local byte offset (what Clang emitted)
t->pid
struct task_struct { int pid; }
0:0
elem 0 → member #0 pid
0x0
t->pid
struct task_struct { int prio; int pid; … }
0:1
elem 0 → member #1 pid
0x4
t->pid
…; struct { int nice; int pid; }; } (anonymous)
0:4:1
elem 0 → member #4 (anonymous struct) → member #1 pid
0x2c
t->tgid
four-member task_struct
0:2
elem 0 → member #2 tgid
0x8
t->real_parent
four-member task_struct
0:3
elem 0 → member #3 real_parent
0x10
a->items[3].prog
struct bpf_prog_array { long rcu; struct …_item items[]; }
enumerator index 1 inside the local enum (not its value, 2)
—
Decoded access strings, every row measured. What it shows: the same source line t->pid produces three different access strings and three different placeholder offsets purely because the local struct was declared differently — and the anonymous-struct case (0:4:1) proves the string is a path, not a single member number. The insight to take: the enum row is the one that catches people. For ENUMVAL_* relocations the string holds the enumerator’s position in the local enum, not its numeric value; the value is looked up from that position, which is exactly why an enum whose members get renumbered still relocates correctly.
Why indices and not field names? Because indices survive renames-with-the-same-position and are unambiguous for anonymous members, and because the loader’s job is to walk the target type by the same path and read off the target offset. The string is the recipe; the loader runs the recipe against two different type trees (local and target) and compares the answers.
enum bpf_core_relo_kind { BPF_CORE_FIELD_BYTE_OFFSET = 0, /* field byte offset */ BPF_CORE_FIELD_BYTE_SIZE = 1, /* field size in bytes */ BPF_CORE_FIELD_EXISTS = 2, /* field existence in target kernel */ BPF_CORE_FIELD_SIGNED = 3, /* field signedness (0 - unsigned, 1 - signed) */ BPF_CORE_FIELD_LSHIFT_U64 = 4, /* bitfield-specific left bitshift */ BPF_CORE_FIELD_RSHIFT_U64 = 5, /* bitfield-specific right bitshift */ BPF_CORE_TYPE_ID_LOCAL = 6, /* type ID in local BPF object */ BPF_CORE_TYPE_ID_TARGET = 7, /* type ID in target kernel */ BPF_CORE_TYPE_EXISTS = 8, /* type existence in target kernel */ BPF_CORE_TYPE_SIZE = 9, /* type size in bytes */ BPF_CORE_ENUMVAL_EXISTS = 10, /* enum value existence in target kernel */ BPF_CORE_ENUMVAL_VALUE = 11, /* enum value integer value */ BPF_CORE_TYPE_MATCHES = 12, /* type match in target kernel */};
These group into three families, and relo_core.c classifies each kind into exactly one via core_relo_is_field_based(), core_relo_is_type_based(), and core_relo_is_enumval_based() (relo_core.c, v6.12):
Field-based (0–5). Resolved by walking the access string into the target type to find the target field. BYTE_OFFSET is the common case — patch the load’s offset. BYTE_SIZE and SIGNED describe the field’s storage; EXISTS yields 1 or 0 depending on whether the field is present; LSHIFT_U64/RSHIFT_U64 are the shift amounts needed to extract a bitfield of arbitrary width and bit position (see the bitfield section below).
Type-based (6–9, 12).TYPE_ID_LOCAL/TYPE_ID_TARGET produce a BTF type ID (in the local object, or in the target kernel) — used to pass a type to helpers like bpf_core_type_id_kernel. TYPE_EXISTS/TYPE_SIZE answer whether a type is present and how big it is on the target. TYPE_MATCHES asks whether the target type structurally matches the local one. For type-based relocations the access string is just "0".
Enum-value-based (10–11).ENUMVAL_EXISTS/ENUMVAL_VALUE answer whether an enumerator exists on the target and what integer value it has — crucial because enum values get renumbered between kernels. For these, the access string holds the index of the enum value within its enum type.
BPF_CORE_TYPE_MATCHES = 12 is the only late addition, and it can be dated exactly. It is absent from include/uapi/linux/bpf.h at tag v5.19 and present at v6.0-rc1 (existence-checked by fetching both files), so it shipped in Linux 6.0; the adding commit is 3c660a5d86f4 “bpf: Introduce TYPE_MATCH related constants/macros” by Daniel Müller, dated 2022-06-28, which adds the enumerator to the kernel uapi, the tools copy, and bpf_core_read.h in one three-line patch and says plainly that it “require[s] toolchain support … Recent LLVM/Clang versions support a new value for the last argument to the __builtin_preserve_type_info builtin.” The companion macro bpf_core_type_matches() landed in b8a195dc2993 and names the Clang review that added compiler support (reviews.llvm.org/D126838). The semantics live in ec6209c8d42f “bpf, libbpf: Add type match support” and are described in the Two Notions of “Compatible” section below.
The thirteen kinds as queries against target BTF
The sibling note libbpf and the BPF Loader tabulates these kinds by what the loader patches (off versus imm). The table here is the other axis, the one this note owns: for each kind, what the matcher has to find in the target BTF, and — the part that is routinely misunderstood — what happens when it finds nothing. Enumerated from include/uapi/linux/bpf.h L7427–L7441 at v6.12, with the no-match column read from bpf_core_calc_field_relo, bpf_core_calc_type_relo and bpf_core_calc_enumval_relo in relo_core.c:
#
enum bpf_core_relo_kind
Family
What the matcher must find in target BTF
access_str
Emitted by
If no candidate matches
0
BPF_CORE_FIELD_BYTE_OFFSET
field
the member, then its accumulated bit offset ÷ 8
field path
any relocatable ->/. read, bpf_core_read()
poison (-EUCLEAN)
1
BPF_CORE_FIELD_BYTE_SIZE
field
the member’s resolved size in bytes
field path
bpf_core_field_size()
poison
2
BPF_CORE_FIELD_EXISTS
field
nothing — the search result itself is the answer
field path
bpf_core_field_exists()
0 (never poisons)
3
BPF_CORE_FIELD_SIGNED
field
the member’s type: signed INT, or an ENUM/ENUM64 with kflag set
field path
bpf_core_field_size()’s siblings; emitted implicitly for narrow signed loads
poison
4
BPF_CORE_FIELD_LSHIFT_U64
field
the member’s bit offset and the enclosing load’s chosen width
field path
bitfield reads (BPF_CORE_READ_BITFIELD)
poison
5
BPF_CORE_FIELD_RSHIFT_U64
field
the member’s bit size
field path
bitfield reads
poison
6
BPF_CORE_TYPE_ID_LOCAL
type
nothing — no candidate search is performed at all
"0"
bpf_core_type_id_local()
n/a: short-circuited before the search
7
BPF_CORE_TYPE_ID_TARGET
type
a type whose essential name and kind match
"0"
bpf_core_type_id_kernel()
0
8
BPF_CORE_TYPE_EXISTS
type
ditto, under the loose compatibility relation
"0"
bpf_core_type_exists()
0
9
BPF_CORE_TYPE_SIZE
type
the matched type’s size
"0"
bpf_core_type_size()
0
10
BPF_CORE_ENUMVAL_EXISTS
enumval
an enumerator of the same essential name in a same-named enum
enumerator index
bpf_core_enum_value_exists()
0
11
BPF_CORE_ENUMVAL_VALUE
enumval
that enumerator’s integer value on the target
enumerator index
bpf_core_enum_value()
poison
12
BPF_CORE_TYPE_MATCHES
type
a type satisfying the strict match relation (6.0+)
"0"
bpf_core_type_matches()
0
The thirteen kinds by what they ask and what happens on failure. What it shows: the last column splits the enum cleanly in two. Six kinds (0, 1, 3, 4, 5, 11) poison the instruction when nothing matches, because there is no sensible number to substitute — “the offset of a field that does not exist” is not a question with an answer. The other seven resolve to 0 or 1, which is precisely what makes them usable as guards. The insight to take:bpf_core_field_exists() is not a convenience wrapper around a failure; it is a structurally different kind whose failure path is a value rather than a trap, and that asymmetry is the entire basis of writing one .o that runs on kernels with and without a field.
Two of these rows are unusual enough to call out. BPF_CORE_TYPE_ID_LOCAL (#6) never searches: bpf_core_calc_relo_insn() short-circuits it before the candidate loop, writing the local BTF id into both orig_val and new_val and setting validate = false — “bpf_insn’s imm value could get out of sync during linking” (relo_core.c L1319–L1328). And BPF_CORE_FIELD_EXISTS (#2) is special-cased at the very top of bpf_core_calc_field_relo() as *val = spec ? 1 : 0 — it is answered before any offset arithmetic is attempted, which is why a missing field costs nothing. Note the converse, which is easy to get backwards: a field that is present on the target but whose type fails bpf_core_fields_are_compat() makes the whole candidate non-matching, so spec is NULL and FIELD_EXISTS reports 0, not 1. “Exists” means “exists and is type-compatible”, not “a member of that name is present”. Measured: declaring struct task_struct { void *pid; } — the right name, the wrong BTF kind (PTR where the kernel has TYPEDEF pid_t → INT) — makes bpf_core_field_exists(t->pid) resolve to 0 on a kernel whose task_struct plainly has a pid.
All ten kinds, measured on one object
Ten of the thirteen kinds can be emitted from one small program compiled without libbpf headers at all — the two Clang-facing enums are copied in by hand and the builtins called directly. Compiled at -O0 so each result stays in its own instruction:
struct task_struct { int pid; int no_such_field_anywhere; /* deliberately absent from any kernel */} __attribute__((preserve_access_index));enum bpf_prog_type { BPF_PROG_TYPE_UNSPEC = 0, BPF_PROG_TYPE_KPROBE = 2 };/* ... r += __builtin_preserve_field_info(t->pid, BPF_FIELD_EXISTS); etc. ... */
Parsing the resulting .BTF.ext and resolving each record against /sys/kernel/btf/vmlinux gives, in one table, both halves of every kind — the placeholder Clang baked in (which is always the answer for the local types, and is what libbpf validates against before overwriting) and the value the target kernel produces:
Relocation record
access_str
Clang’s placeholder imm (local answer)
Resolved against this kernel
What changed
field_exists on task_struct::pid
0:0
0x1
1
nothing — the field exists on both sides
field_exists on task_struct::no_such_field_anywhere
0:1
0x1
0
the guard flips; the if becomes if (0)
byte_sz on pid
0:0
0x4
4
unchanged, but only by luck
signed on pid
0:0
0x1
1
local int and target typedef pid_t → int are both signed
type_exists on struct task_struct
0
0x1
1
—
type_size on struct task_struct
0
0x8
10176
the whole point: 8-byte fiction versus a 10 KB reality
target_type_id
0
0x5 (ldimm64)
145856
the local id is meaningless on the target
local_type_id
0
0x5 (ldimm64)
5
never searched, by design
enumval_valueBPF_PROG_TYPE_KPROBE
1
0x2 (ldimm64)
2
value identical, index differs: local #1, target #2
enumval_existsBPF_PROG_TYPE_KPROBE
1
0x1 (ldimm64)
1
—
Ten relocation kinds resolved against a live kernel’s BTF. What it shows: Clang genuinely computes a complete, self-consistent answer using only the local type definitions, then hands it over to be overwritten. type_size going 8 → 10,176 is the dramatic row; enumval_value is the subtle one — the enumerator sits at index 1 locally and index 2 in the kernel’s enum bpf_prog_type, and the relocation resolves by name across that gap even though the numeric value happens to coincide. The insight to take: the placeholders are not garbage. libbpf recomputes them from the local BTF and refuses to patch when the instruction disagrees (unexpected insn #%d (ALU/ALU64) value: got %u, exp %llu -> %llu), so a mismatch between what Clang emitted and what libbpf derives is caught rather than silently accepted.
Two Enum Representations — Don’t Conflate Them
A subtle but load-bearing point: there are two different enumerations in play, and they are not the same enum.
The compiler-facing small enums in libbpf’s bpf_core_read.h (v1.7.0), which are the literal integers passed to the Clang builtins:
These are small, per-family and restart at 0 in each family. The builtin __builtin_preserve_field_info(expr, BPF_FIELD_BYTE_OFFSET) takes the field-family value 0.
The unified bpf_core_relo_kind (0–12, above) that is serialized into the .BTF.ext record and that travels to libbpf and the kernel.
Clang is the translator: it consumes the small-enum value at the builtin call site and emits the corresponding unified bpf_core_relo_kind into the relocation record. So __builtin_preserve_field_info(s->b, 0) (field-family BYTE_OFFSET) becomes a record with kind = BPF_CORE_FIELD_BYTE_OFFSET (0); a bpf_core_type_exists call uses __builtin_preserve_type_info(*..., BPF_TYPE_EXISTS=0) and Clang emits kind = BPF_CORE_TYPE_EXISTS (8). Treating the small enums and the unified enum as one thing produces subtly wrong numbers — they only coincide for the field family because that family is the prefix of the unified enum.
The Macros: From C You Write to Relocations Clang Emits
You almost never call __builtin_preserve_access_index directly. libbpf’s bpf_core_read.h wraps the builtins in ergonomic macros (v1.7.0).
The primitive — wrap a probe-read so the address expression carries relocations:
The header explains: __builtin_preserve_access_index() “takes as an argument an expression of taking an address of a field within struct/union. It makes compiler emit a relocation.” So bpf_core_read(&pid, sizeof(pid), &task->pid) reads task->pidand emits a BYTE_OFFSET relocation for the task->pid access, so the offset baked into the read is patched per-kernel.
The chain reader — BPF_CORE_READ turns a nested pointer chase into a single relocated expression:
#define BPF_CORE_READ(src, a, ...) ({ \ ___type((src), a, ##__VA_ARGS__) __r; \ BPF_CORE_READ_INTO(&__r, (src), a, ##__VA_ARGS__); \ __r; \})
Per Nakryiko’s reference guide, BPF_CORE_READ(t, mm, exe_file, path.dentry, d_name.name) mirrors the natural C t->mm->exe_file->path.dentry->d_name.name — “each pointer dereference turns into a comma in the macro invocation. Each sub-struct access is kept as is.” Each dereference becomes a separate bpf_probe_read_kernel with its own relocation, because each intermediate pointer is a field whose offset can move independently between kernels. This is why you cannot just t->mm->exe_file->... directly in a classic (non-BTF-aware) program — every hop needs a probe-read and a relocation.
Introspection macros — these emit the non-BYTE_OFFSET kinds:
Each resolves, at load time, to a constant (offset, size, 1/0 existence, type ID, or enum value) that the loader patches into an ALU instruction’s immediate — so by the time the verifier runs, these are plain compile-time-looking constants.
One detail in the v1.7.0 header is worth noticing because it is invisible in older write-ups: every one of these macros now has two definitions, selected by #ifdef __clang__. GCC’s BPF backend spells the same builtins differently — __builtin_preserve_enum_value takes three arguments there (a type expression, the enumerator, and the kind) rather than Clang’s two, and ___bpf_typeof(type) expands to a cast of 0 under Clang but to an extern declaration of a throwaway pointer under GCC, because GCC “needs type information in the builtin expansion” (bpf_core_read.h L153–L163, L272–L293 at v1.7.0). The relocation records the two compilers emit are identical — they have to be, since the format is kernel ABI — but the source-level spelling of the builtins is not portable between them, which is why the macros exist rather than being inlined into user code.
The Matching Algorithm: Finding a Field in a Struct You Have Never Seen
Everything above is bookkeeping. The substance of CO-RE is a single question: given a local BTF type, a local access string, and a target BTF blob describing a kernel nobody compiled against, how is the corresponding member found? The answer is a four-stage pipeline, all of it in relo_core.c except the first stage, which lives in libbpf.c because it needs access to the loaded module BTFs.
flowchart TB
R["bpf_core_relo record<br/>type_id + access_str + kind"]
PS["bpf_core_parse_spec<br/>split access_str into raw_spec[]<br/>walk LOCAL BTF<br/>accumulate local bit_offset"]
TIDL{"kind ==<br/>TYPE_ID_LOCAL?"}
SHORT["answer = local type id<br/>validate = false<br/>NO candidate search"]
FC["bpf_core_find_cands<br/>scan every type in vmlinux BTF"]
NAME{"kind-compatible AND<br/>essential names equal?"}
SKIP["skip this type"]
CAND["append to candidate list"]
MOD{"candidate list<br/>still empty?"}
MODBTF["load module BTFs<br/>and scan those too"]
LOOP["for each candidate:<br/>bpf_core_spec_match"]
MM["bpf_core_match_member<br/>linear scan of target members<br/>compare by NAME"]
ANON{"member has<br/>empty name?"}
DESC["recurse into the<br/>anonymous struct/union"]
FCOMP{"bpf_core_fields_are_compat<br/>local vs target member type?"}
DROP["candidate does not match<br/>- dropped from the list"]
ACC["accept: record accessor,<br/>add member bit_offset"]
AGREE{"do all matching candidates<br/>agree on bit_offset<br/>and on the computed value?"}
AMB["-EINVAL<br/>'field offset ambiguity'"]
NONE{"zero matching<br/>candidates?"}
POIS["EXISTS-style kinds -> 0<br/>value kinds -> poison insn"]
OK["patch the instruction"]
R --> PS --> TIDL
TIDL -- yes --> SHORT
TIDL -- no --> FC --> NAME
NAME -- no --> SKIP
NAME -- yes --> CAND
CAND --> MOD
SKIP --> MOD
MOD -- yes --> MODBTF --> LOOP
MOD -- no --> LOOP
LOOP --> MM --> ANON
ANON -- yes --> DESC --> MM
ANON -- "no, name matches" --> FCOMP
FCOMP -- no --> DROP
FCOMP -- yes --> ACC
ACC --> AGREE
DROP --> NONE
AGREE -- no --> AMB
AGREE -- yes --> OK
NONE -- yes --> POIS
NONE -- no --> OK
The full CO-RE matching walk. What it shows: the algorithm is name-driven at every level — the access string supplies indices into the local type, but every comparison against the target is a string comparison of member names, with fields_are_compat acting as a type-level veto. The two exits on the right are the interesting ones: disagreement between candidates is a hard error, while no candidate at all is a routine outcome whose consequence depends entirely on the relocation kind. The insight to take: there is no offset arithmetic anywhere in the matching phase. Offsets are accumulated as a side effect of a successful name walk; if the walk fails there is nothing to compute, which is why “field missing” and “field moved” are handled by completely different code paths.
Stage 1 — candidate search: essential names
Before any member can be matched, libbpf must decide which target types are even plausible. bpf_core_find_cands() (libbpf.c L5674–L5732 at v6.12) walks the entire target type section — on the measurement host, all 159,359 types in /sys/kernel/btf/vmlinux — and keeps a type as a candidate only if all three of these hold, per bpf_core_add_cands() (L5525–L5573):
btf_kind_core_compat(t, local_t) — same BTF kind, except that ENUM and ENUM64 are interchangeable (btf.h L493–L498). A local struct never matches a target union.
The target type has a non-empty name. Anonymous types are never candidates; a relocation rooted at an anonymous type is rejected outright with relocation doesn't support anonymous types (-EOPNOTSUPP).
The two essential names are equal.
The essential name is where the famous ___ flavour convention lives. bpf_core_essential_name_len() (libbpf.c L5504–L5515) scans backwards for the last occurrence of the pattern X___Y where neither X nor Y is an underscore, and returns the length of the prefix before it:
static bool bpf_core_is_flavor_sep(const char *s){ /* check X___Y name pattern, where X and Y are not underscores */ return s[0] != '_' && /* X */ s[1] == '_' && s[2] == '_' && s[3] == '_' && /* ___ */ s[4] != '_'; /* Y */}
So task_struct___old has essential length 11 (task_struct) and matches the kernel’s task_struct, while ____fput — four leading underscores — has essential length 8, because at every candidate position s[0] is itself an underscore. That guard is what stops the kernel’s many __-prefixed names from being mangled. Nakryiko calls this the “ignored suffix rule” and states it in full generality: “For any type, field, enum, or enumerator, if the entity’s name contains a suffix of the form ___something … such name suffix is ignored for the purposes of CO-RE relocation as if it was never there” (reference guide).
It works in both directions, and the kernel itself uses it. Scanning the 159,359 types in /sys/kernel/btf/vmlinux on the measurement host for names whose essential length is shorter than their full length turns up 16 hits, including nf_conn___init (essential name nf_conn), class___task_rq_lock_t, convert___skb_to_skb and a family of rust_helper___* symbols. A BPF program declaring struct nf_conn therefore has nf_conn___init as a second candidate whether it wants one or not — which is exactly the setup for the ambiguity rule below.
The search order matters for correctness, not just speed: vmlinux BTF is scanned first, and module BTFs are consulted only if vmlinux produced no candidates at all (“if vmlinux BTF has any candidate, don’t go for module BTFs”). If btf_custom_path / btf_vlinux_override was set by the caller, module BTFs are never consulted. The whole result is memoised in a cand_cache hashmap keyed by local type id, so a program with two hundred task_struct relocations pays for the 159,359-type scan exactly once.
Stage 2 — parsing the local spec: raw versus high-level
bpf_core_parse_spec() (L263–L390) turns the access string into two parallel representations, and the distinction between them is the single most useful thing to understand about the matcher. The kernel’s own comment sets it out:
Low-level spec has 1:1 mapping with each element of access string (it’s just a parsed access string representation): [0, 1, 2, 3]. High-level spec will capture only 3 points: initial zero-index access by pointer; field 'a' access; array element #3 access.
The raw spec (raw_spec[]) is the literal list of indices. The high-level spec (spec[]) records only semantically meaningful turning points: named members and array subscripts. Anonymous members are walked through — their bit offsets are accumulated — but they never become accessors, because the matcher must not require the target to have its anonymous members arranged the same way. That single design decision is what makes the two measured cases below symmetric opposites of each other.
Along the way parse_spec accumulates spec->bit_offset, and the leading index is treated as a genuine array subscript against the root: bit_offset = access_idx * sizeof(root) * 8. For a plain s->field that index is 0 and contributes nothing, which is why the arithmetic is invisible in the common case — but it is exactly what makes s[10]->b encode as 10:1 and resolve correctly.
Stage 3 — bpf_core_match_member: recursive descent by name
With a local high-level spec in hand, bpf_core_spec_match() (L557–L677) walks it accessor by accessor against one candidate. For each named accessor it calls bpf_core_match_member(), whose contract the source states directly:
Searching is performed through recursive exhaustive enumeration of all fields of a struct/union. If there are any anonymous (embedded) structs/unions, they are recursively searched as well. If field with desired name is found, check compatibility between local and target types, before returning result.
Mechanically it is a speculative linear scan. For each target member i it optimistically adds that member’s bit offset and pushes i onto the target raw spec, then either (a) recurses if the member is anonymous, (b) accepts if the name matches, or (c) undoes both the offset addition and the push and moves on:
/* speculate this member will be the good one */spec->bit_offset += bit_offset;spec->raw_spec[spec->raw_len++] = i;.../* member turned out not to be what we looked for */spec->bit_offset -= bit_offset;spec->raw_len--;
Two properties follow that are easy to miss. First, the search is name-first and type-second: a target member whose name matches but whose type is incompatible causes the whole match_member call to return 0 immediately — it does not keep looking for a second member of the same name. Second, the scan is depth-first in declaration order, so if an anonymous union at index 3 contains a member named x and a plain member named x also exists at index 9, the anonymous one wins.
Here is the recursion happening for real. struct inode on the measurement kernel keeps i_nlink inside an anonymous union at member index 12, so a program that declares the obvious flat local view forces the matcher to descend:
Compiling that emits access_str = "0:0" for both reads. Running the matcher and printing each step:
--- relo: local [2] struct 'inode' access='0:0' kind=byte_off
parse_spec: raw=[0, 0]; root [2] inode; leading index 0 x sizeof=4 -> bit_offset=0
parse_spec: member #0 'i_nlink' of [2] inode +0 bits -> bit_offset=0
find_cands: essential name 'inode' (len 5) -> 1 candidate: [114917] inode
spec_match: leading index 0 x sizeof([114917])=568 -> target bit_offset=0
match_member: looking for 'i_nlink' among 55 members of [114917] struct inode
#12 <anonymous union> at +576 bits -> DESCEND
match_member: looking for 'i_nlink' among 2 members of [847] union <anon>
#0 'i_nlink' NAME MATCH at +0 bits (bit_offset now 576);
fields_are_compat(local [3] INT, target [7] INT) = 1
MATCHING: target raw spec [0, 12, 0], high-level [(0, -), (0, 'i_nlink')]
-> local off 0 PATCHED TO 72
The target raw spec has three entries — it recorded the descent through member 12 — while the high-level spec still has two, identical in shape to the local one. struct file::f_path behaves the same way (anonymous union at index 10, f_path at sub-index 0, offset 0 → 64). And the mirror image is just as instructive: declare the local type with an anonymous struct and the kernel’s flat, and the asymmetry reverses.
Local declaration of task_struct
access_str
Local raw spec
Local high-level spec
Target raw spec
Placeholder → patched
{ int pid; }
0:0
[0, 0]
[(0, —), (0, pid)]
[0, 95]
0x0 → 2864
{ int prio; int pid; int tgid; struct task_struct *real_parent; }
0:1
[0, 1]
[(0, —), (1, pid)]
[0, 95]
0x4 → 2864
{ …pad…; char comm[16]; struct { int nice; int pid; }; }
0:4:1
[0, 4, 1]
[(0, —), (1, pid)]
[0, 95]
0x2c → 2864
struct inode { unsigned int i_nlink; }
0:0
[0, 0]
[(0, —), (0, i_nlink)]
[0, **12**, 0]
0x0 → 72
The same field reached from four different local shapes. What it shows: rows 1–3 are the identical source linet->pid compiled against three different local declarations of struct task_struct; the access strings and placeholder offsets all differ, and all three converge on target byte 2864. Row 4 runs the anonymity in the other direction — the local type is flat and the target nests. The insight to take: the raw spec is allowed to change length across the match ([0,0] → [0,12,0]) while the high-level spec is not. The high-level spec is the invariant the two sides must agree on; the raw spec is just the route each side took to satisfy it.
flowchart TB
subgraph LOC["LOCAL side: struct inode { unsigned int i_nlink; }"]
L0["access_str '0:0'"]
L1["raw_spec [0, 0]<br/>literal parse of the string"]
L2["high-level spec<br/>[elem 0] [member 'i_nlink']<br/>2 turning points"]
L0 --> L1 --> L2
end
subgraph TGT["TARGET side: [114917] struct inode, 568 B, 55 members"]
T1["member 0..11 scanned by name — no hit"]
T2["member 12 is ANONYMOUS union [847]<br/>+576 bits — descend"]
T3["sub-member 0 named 'i_nlink'<br/>+0 bits — NAME MATCH"]
T4["raw_spec [0, 12, 0]<br/>THREE entries — records the descent"]
T5["high-level spec<br/>[elem 0] [member 'i_nlink']<br/>still 2 turning points"]
T1 --> T2 --> T3 --> T4 --> T5
end
L2 ==>|"the invariant both sides must agree on"| T5
T3 -.->|"accumulated bit_offset 576 / 8"| OUT["patch off: 0 -> 72"]
Why the matcher keeps two representations of the same walk. What it shows: the low-level raw_spec grew from two entries to three because the target nests i_nlink inside an anonymous union that the local declaration does not have; the high-level spec, which records only named members and array subscripts, is identical on both sides. The insight to take: the high-level spec is the contract between local and target — two entries here, two entries there — while the raw spec is merely the route each side took. If the matcher compared raw specs, no program could ever survive a field being wrapped in or unwrapped from an anonymous union, which the kernel does routinely.
Stage 3a — modifier stripping and fields_are_compat
Both sides of every comparison are first passed through skip_mods_and_typedefs() (libbpf.c L2337–L2351), a loop that follows ->type while the current type is a modifier or a typedef. btf_is_mod() defines “modifier” as exactly four kinds (btf.h L443–L451). That is why the measured pid match succeeds at all: the local member is a bare int (BTF id 3) while the kernel’s is typedef pid_t (id 131231) resolving to int (id 115271), and the typedef simply evaporates before the comparison.
BTF kind
Stripped by skip_mods_and_typedefs?
How the matcher treats it
BTF_KIND_TYPEDEF (8)
yes
transparent; pid_t and int compare equal
BTF_KIND_VOLATILE (9)
yes
transparent
BTF_KIND_CONST (10)
yes
transparent
BTF_KIND_RESTRICT (11)
yes
transparent
BTF_KIND_TYPE_TAG (18)
yes
transparent (this is how __user/__rcu annotations survive without breaking matching)
BTF_KIND_STRUCT (4)
no
composite: any struct/union pair is compatible; members searched by name
BTF_KIND_UNION (5)
no
composite: interchangeable with STRUCT for field relocations
BTF_KIND_PTR (2)
no
any two pointers are compatible — pointee types are not checked
BTF_KIND_ARRAY (3)
no
dimensionality ignored; element types recursed into
BTF_KIND_INT (1)
no
size and signedness ignored; only a non-zero int_offset (a deprecated bitfield encoding) is rejected
BTF_KIND_FLOAT (16)
no
any two floats are compatible
BTF_KIND_ENUM (6) / ENUM64 (19)
no
interchangeable with each other; names must match modulo flavour, or one must be anonymous
BTF_KIND_FWD (7)
no
compatible with another FWD of the same essential name
anything else
no
“shouldn’t ever be a target of relocation” — returns 0
How bpf_core_fields_are_compat() treats each BTF kind, transcribed from its comment block and body at relo_core.c L392–L462. What it shows: the relation is deliberately loose. Two structs are compatible without any member-level check; two ints are compatible regardless of width or signedness; two pointers are compatible regardless of what they point at. The insight to take: this looseness is the whole reason a four-member fictional task_struct relocates against a 272-member real one. The comment ends with an unusual admission — “These rules are not set in stone and probably will be adjusted as we get more experience with using BPF CO-RE relocations” — so treat the relation as policy, not as a law of the format.
There is one important sharpening applied after the match, in bpf_core_calc_relo(): if the local and target field sizes differ, libbpf will only adjust the load width when both sides are pointers, or both are unsigned integers — because in both cases zero-extension preserves the value. Anything else sets fail_memsz_adjust, and the instruction is poisoned with the message accesses field incorrectly. Make sure you are accessing pointers, unsigned integers, or fields of matching type and size. So fields_are_compat ignoring integer size does not mean size is ignored; it means the size check is deferred to a point where the instruction’s width is known.
Stage 3b — flexible arrays
Arrays get one special case. is_flex_arr() (L71–L84) returns true when an array has nelems == 0, is inside a struct, and is that struct’s last member — the C flexible-array-member idiom. For such an array the normal access_idx >= a->nelems bound check is skipped, on both the local and the target side. Without that, no CO-RE program could index a trailing variable-length array at all, since nelems is 0 and every index is out of range. There are 1,394 structs ending in a zero-length array in this kernel’s BTF, so this is not an exotic case.
struct bpf_prog_array is one of them, and it doubles as a demonstration that array element size is relocated too:
struct bpf_prog_array_item { void *prog; long cgroup_storage; }; /* 16 bytes locally */struct bpf_prog_array { long rcu; /* the kernel has a 16-byte callback_head here */ struct bpf_prog_array_item items[]; /* flexible array member */} __attribute__((preserve_access_index));/* ... a->items[3].prog ... */
Clang encodes this as access_str = "0:1:3" and emits r0 = *(u64 *)(r1 + 0x38) — offset 56, being 8 bytes of rcu plus 3 × 16. The matcher finds items at target member #1, byte 16, with a target element size of 24 bytes, and computes 16 + 3 × 24 = 88. Note also that the final accessor here has name == NULL because it is an array subscript, and bpf_core_calc_field_relo() handles that case separately at the top of the function: only BYTE_OFFSET and BYTE_SIZE are legal for an array accessor, and anything else returns relo %d at insn #%d can't be applied to array access with -EINVAL.
Stage 4 — several candidates, or none
bpf_core_calc_relo_insn() (L1280–L1416) runs the match against every candidate and then reconciles the answers. Non-matching candidates are quietly dropped — the surviving ones are compacted to the front of the list and cands->len shrunk, so later relocations on the same type get a pre-pruned list. The surviving candidates must then agree on two things, or libbpf refuses:
} else if (cand_spec->bit_offset != targ_spec->bit_offset) { /* if there are many field relo candidates, they * should all resolve to the same bit offset */ pr_warn("prog '%s': relo #%d: field offset ambiguity: %u != %u\n", ...); return -EINVAL;} else if (cand_res.poison != targ_res->poison || cand_res.new_val != targ_res->new_val) { pr_warn("prog '%s': relo #%d: relocation decision ambiguity: %s %llu != %s %llu\n", ...); return -EINVAL;}
This is not hypothetical. The measurement kernel’s BTF contains 31 duplicated composite type names, and struct irq_info exists three times with wildly different shapes. A program declaring struct irq_info { int irq; } triggers all three outcomes at once:
Candidate
Size / members
Member named irq
Verdict
[118255]
136 B, 18 members
unsigned int at byte 96
matches
[118256]
32 B, 4 members
int at byte 16
matches
[118257]
16 B, 5 members
unsigned char irq[4] — kind ARRAY, not INT
dropped: fields_are_compat fails on kind
Three candidates, two survivors, one hard error. What it shows:[118257] is eliminated cleanly by the type veto even though its member name matches perfectly; the remaining two both match and disagree (bit offsets 128 and 768), which produces relo #N: field offset ambiguity: 128 != 768 and -EINVAL. The insight to take: ambiguity is a load failure, not a silent coin-flip. libbpf would rather refuse the program than guess which irq_info you meant — and the fix on the programmer’s side is not to disambiguate the search but to rename the local type with a ___flavour suffix that narrows it, or to move to a program type whose context pointer is typed.
flowchart LR
L["local: struct irq_info { int irq; }<br/>essential name 'irq_info'"]
C1["[118255] 136 B, 18 members"]
C2["[118256] 32 B, 4 members"]
C3["[118257] 16 B, 5 members"]
R1["member #9 'irq' is INT<br/>compat -> MATCH at bit 768"]
R2["member #1 'irq' is INT<br/>compat -> MATCH at bit 128"]
R3["member #2 'irq' is ARRAY[4]<br/>kind veto -> NON-MATCH"]
D["drop from candidate list"]
CMP{"768 == 128 ?"}
ERR["-EINVAL<br/>'field offset ambiguity: 128 != 768'<br/>the program does not load"]
L --> C1 --> R1 --> CMP
L --> C2 --> R2 --> CMP
L --> C3 --> R3 --> D
CMP -- no --> ERR
Candidate reconciliation on a real duplicated type name. What it shows: three same-named target types, resolved three different ways — one vetoed on type kind and silently dropped, two accepted and then found to disagree. The insight to take: dropping a non-matching candidate is routine and produces no diagnostic at default log level, but two matching candidates that disagree is a hard refusal. The distinction is worth internalising, because the two situations look identical from the source and only one of them stops the load.
The contrast case is struct console, which exists twice on the same kernel — [94538] at 280 bytes with 29 members and [94539] at 40 bytes with 4. A local struct console { void *write; } finds two candidates, but only [94538] has a member called write; [94539] matches nothing, is dropped, and the relocation resolves unambiguously to byte 16. Two candidates is not an error. Two matching candidates that disagree is.
When the survivor count reaches zero, libbpf logs relo #%d: no matching targets found at debug level, leaves the candidate list untouched, and calls bpf_core_calc_relo() one last time with a NULL target spec. That is the entry point to the poison-or-zero split tabulated earlier, and the source comment is explicit that this is an expected state rather than an error: “it might be both a programmer error, as well as expected case, depending whether instruction w/ relocation is guarded in some way that makes it unreachable (dead code).”
Two notions of “compatible”
Field relocations use bpf_core_fields_are_compat(). Type-based relocations use one of two other relations, and conflating them is a real source of confusion:
Relation
Used by
Structs/unions
Integers
Enums
Pointers
bpf_core_fields_are_compat
field relocations (kinds 0–5)
any struct ≡ any union
size and signedness ignored
names must match modulo flavour
always compatible; pointee ignored
__bpf_core_types_are_compat
TYPE_EXISTS, TYPE_SIZE, TYPE_ID_TARGET (8, 9, 7)
any two STRUCTs/UNIONs/FWDs/ENUMs/INTs of the same kind
size and signedness ignored
compatible if kind matches
recursed into (pointee is checked)
__bpf_core_types_match
TYPE_MATCHES (12)
every local member must exist in the target by name and recursively match
must match in size and signedness
every local enumerator must exist in target by name; sizes must match
recursed; behind a pointer a struct may match a FWD
Three compatibility relations, ordered from loosest to strictest. What it shows:TYPE_MATCHES is the only relation that actually inspects members, and even it is deliberately one-directional — the target is allowed to have members the local type does not (if (local_vlen > targ_vlen) return 0; is the only cardinality check). The insight to take:bpf_core_type_exists() answering 1 tells you almost nothing about layout; bpf_core_type_matches() answering 1 tells you the local view is a faithful subset. That is the distinction the 6.0 feature was added to express, and it is why bpf_core_type_exists() is a poor feature-detection primitive on its own.
The strict relation has one more subtlety worth stating, because it is stated in the commit message that introduced it: when recursion passes behind a pointer, member-by-member checking stops and only names and kinds are compared. Without that, matching struct task_struct would transitively pull in most of the kernel’s type graph.
Validating the re-implementation
Every “target” number in this note comes from a Python re-implementation of bpf_core_parse_spec, bpf_core_find_cands, bpf_core_match_member, bpf_core_spec_match and bpf_core_calc_field_relo, written against the v6.12 source, because bpftool is absent and BPF_PROG_LOAD is unavailable to an unprivileged user on the measurement host. That would be worth little on its own — so it is checked against an independent oracle: Clang’s own placeholder immediates.
For the non-BYTE_OFFSET field kinds, LLVM computes the answer for the local types and bakes it into the instruction, and libbpf recomputes exactly the same quantity as orig_val before overwriting it. Running the re-implementation over the local BTF must therefore reproduce Clang’s immediates bit for bit. For the struct tcphdr bitfield object below it does: predicted local values byte_off=4, byte_sz=2, lshift=60, rshift=60, signed=0 against Clang’s emitted 0x4, 0x2, 0x3c, 0x3c, 0x0. Five independent agreements across two compilers’ worth of logic is decent evidence the walk is faithful; it is not a substitute for observing a real load.
Uncertain
Verify: that the target-side values in this note (pid 4 → 2864, i_nlink 0 → 72, items[3].prog 56 → 88, type_size 8 → 10176) are what libbpf would actually write, not merely what a correct implementation should write. Reason: the local BTF, the .BTF.ext records and the kernel’s BTF were all read directly, and the local half of the arithmetic is cross-checked against Clang’s own immediates — but no patch was ever observed, because kernel.unprivileged_bpf_disabled = 2 makes BPF_PROG_LOAD return EPERM for uid 1000 on the measurement host and neither bpftool nor a root shell was available. To resolve: run these objects through bpf_object__open_mem() + bpf_object__prepare() as root with libbpf_set_print() accepting LIBBPF_DEBUG, and diff the printed patched insn #N ... off X -> Y lines against the tables. #uncertain
End to End: One Field-Offset Relocation, Walked
This is the canonical example from the kernel’s LLVM reloc doc, v6.12, which I’ll trace symbol by symbol. The source:
struct foo { int a; int b; unsigned c:15;} __attribute__((preserve_access_index));void alpha(struct foo *s, volatile unsigned long *g) { *g = s->a; s->a = 1;}
The relevant BTF (from the same doc) describes struct foo as type [2], with field a at bits_offset=0, b at bits_offset=32, and bitfield c at bits_offset=64.
Step 1 — Clang compiles, emitting placeholder instructions + relocation records. The disassembly the doc shows:
Read this carefully. Instruction 0 is r3 = *(s32 *)(r1 + 0x0) — load the signed 32-bit value at r1 + 0 (where r1 holds s). The 0x0 is the placeholder offset. Attached to it is the relocation record annotated CO-RE <byte_off> [2] struct foo::a (0:0): kind BYTE_OFFSET, root type_id = 2 (struct foo), access string 0:0 (element 0, field 0 = a). Instruction 2 (*(u32 *)(r1 + 0x0) = 0x1, the store s->a = 1) gets its ownbyte_off relocation — note insn_off = 0x10, i.e. byte offset 16 = instruction index 2, because each BPF instruction is 8 bytes.
Step 2 — At load, libbpf finds the local offset. In bpf_object__relocate_core, libbpf walks the access string 0:0 against the local BTF (struct foo as Clang saw it): element 0, then field index 0 = a, at bits_offset=0 → local byte offset 0. This is res->orig_val.
Step 3 — libbpf finds the target offset. It finds struct foo in the target kernel’s BTF (matched by name), walks the same access string 0:0, and reads off field a’s offset on the target. If on the target a were still first, the answer is 0; if foo had grown a leading field and a moved, the answer would be the new offset. This is res->new_val.
Step 4 — libbpf patches the instruction. This is the crucial mechanical detail, and the common shorthand “CO-RE rewrites immediates” is imprecise — the source shows three distinct cases in bpf_core_patch_insn (relo_core.c, v6.12):
For memory-access instruction classes — BPF_LDX, BPF_ST, BPF_STX (our r3 = *(s32 *)(r1 + 0x0) is LDX) — the offset lives in the instruction’s off field, so libbpf does insn->off = new_val:
orig_val = insn->off;insn->off = new_val;
For ALU instruction classes — BPF_ALU, BPF_ALU64 (used when a relocation result is a computed constant, e.g. bpf_core_field_exists or a size) — the value lives in the imm field, so libbpf does insn->imm = new_val:
orig_val = insn->imm;insn->imm = new_val;
For ldimm64 — the two-slot BPF_LD | BPF_IMM | BPF_DW form — the 64-bit value is split across insn[0].imm and insn[1].imm, so libbpf writes both halves and reassembles the original for validation:
This is the path taken by bpf_core_type_id_kernel() and bpf_core_enum_value(), whose results can exceed 32 bits. It is also why poisoning an ldimm64 poisons two instructions — bpf_core_patch_insn() poisons insn + 1 first, “to avoid confusing error from verifier about unknown opcode 00”. Measured: a target_type_id relocation compiles to 18 01 00 00 05 00 00 00 … (r1 = 0x5 ll), carrying the local type id 5, and resolves to 145856 on this kernel.
So in our walk, libbpf rewrites insn->off of instruction 0 from 0 to the target offset of foo::a. (It also validates: when res->validate is set, it checks the existing off/imm equals the expected orig_val before patching, catching mismatched local-vs-record assumptions.) For the second relocation, the store at instruction 2 gets the same target offset written into itsoff.
Step 5 — submit. The patched, fully-resolved bytecode goes to BPF_PROG_LOAD. The kernel verifier sees ordinary loads/stores at concrete offsets — it has no idea a relocation ever happened.
That is one offset relocation, end to end: a symbolic access string, resolved against two BTF trees, producing a delta that is written into the instruction’s off field before the program ever reaches the kernel.
Bitfields: Why Four Relocation Kinds, Not One
A plain field needs one number — its byte offset. A bitfield needs four, and that is why enum bpf_core_relo_kind spends four of its thirteen slots on one feature. The reason is that BPF has no bit-addressed load: the instruction set can load 1, 2, 4 or 8 bytes at a byte offset and nothing else. Extracting unsigned doff:4 therefore takes three decisions plus a size, and every one of them can change between kernels independently of the others.
Kind
Value for struct tcphdr::doff on this kernel
What it is
Why it must be relocated separately
BPF_CORE_FIELD_BYTE_OFFSET (0)
12
the byte offset of the containing word to load, not of the bitfield
the enclosing word moves when preceding members change
BPF_CORE_FIELD_BYTE_SIZE (1)
2
the width of that load (1/2/4/8)
the compiler may need a wider load if the field straddles
BPF_CORE_FIELD_LSHIFT_U64 (4)
56
shift left to push the field’s top bit to bit 63
depends on the bit position within the loaded word
BPF_CORE_FIELD_RSHIFT_U64 (5)
60
shift right to bring the field down to bit 0
depends only on the field’s width, which can also change
BPF_CORE_FIELD_SIGNED (3)
0
whether the final shift-right is arithmetic or logical
a field can change signedness across kernels
The arithmetic is in bpf_core_calc_field_relo() (L729–L795) and is worth walking symbol by symbol. Let bit_off be the field’s absolute bit offset in the struct, bit_sz its width in bits, and byte_sz the size of its declared base type. libbpf starts with a load aligned to the base type and doubles the load width until the field fits inside it:
byte_sz = mt->size;byte_off = bit_off / 8 / byte_sz * byte_sz; /* round down to a byte_sz boundary */while (bit_off + bit_sz - byte_off * 8 > byte_sz * 8) { if (byte_sz >= 8) /* can't be read with a 64-bit read */ return -E2BIG; byte_sz *= 2; byte_off = bit_off / 8 / byte_sz * byte_sz;}
bit_off + bit_sz - byte_off * 8 is “how many bits from the start of the loaded word up to the end of the field”. Subtracting from 64 gives the left shift that lands that end bit at bit 63; the right shift of 64 - bit_sz then brings bit_sz bits down to bit 0. Note the comment on the preceding lines: for bitfields *validate = !bitfield — “all the relocatable aspects are ambiguous and we might disagree with compiler, so turn off validation of expected value, except for signedness”. Signedness and the right shift are the two quantities the source calls “never ambiguous”, and they keep validation on.
Decoded for TCP’s four-bit data-offset field, measured on this host. The local declaration deliberately puts doff in the wrong place — after two unsigned shorts rather than after seq and ack_seq — so every number moves:
flowchart LR
subgraph L["Local: doff is member #2, bit_off=32, bit_sz=4, base u16"]
L1["byte_off = 32/8/2*2 = 4"]
L2["byte_sz = 2"]
L3["lshift = 64 - (32+4-32) = 60"]
L4["rshift = 64 - 4 = 60"]
end
subgraph T["Target: doff is member #6, bit_off=100, bit_sz=4, base u16"]
T1["byte_off = 100/8/2*2 = 12"]
T2["byte_sz = 2"]
T3["lshift = 64 - (100+4-96) = 56"]
T4["rshift = 64 - 4 = 60"]
end
subgraph X["Extraction sequence the program runs"]
X1["r1 = *(u16 *)(skb + byte_off)"]
X2["r1 <<= lshift"]
X3["r1 >>= rshift (logical: signed = 0)"]
X4["r1 now holds the 4-bit doff"]
X1 --> X2 --> X3 --> X4
end
L1 -. "4 -> 12" .-> T1
L3 -. "60 -> 56" .-> T3
L4 -. "60 = 60" .-> T4
T1 --> X1
T3 --> X2
T4 --> X3
The bitfield relocation quartet for struct tcphdr::doff, all four values measured. What it shows: three of the four numbers are needed to turn one aligned 2-byte load into a 4-bit value, and two of them changed when the field moved — byte_off 4 → 12 and lshift 60 → 56 — while rshift stayed at 60 because it depends only on the field’s width, which did not change. The insight to take: this is why a single “bitfield offset” relocation could not work. The byte offset and the shifts are not derivable from one another without knowing the base type’s size and the field’s bit position, and each of those can move independently, so each gets its own record.
Sanity-check the target numbers against the wire format: a TCP header’s data-offset field sits in the high nibble of byte 12, immediately after the 4-byte sequence number and 4-byte acknowledgement number that follow the 2-byte source and destination ports. Bits 100–103 of the struct are bits 4–7 of byte 12. A little-endian 2-byte load at offset 12 puts byte 12’s bit 0 at register bit 0, so doff lands at register bits 4–7; << 56 moves bit 7 to bit 63 and bit 4 to bit 60; >> 60 brings those four bits to 0–3. The relocation is correct, and it is correct by construction rather than by the compiler having guessed the kernel’s layout.
Graceful Handling: When a Field Is Absent
The hard case is a field that exists on some kernels but not the target. If you read it unconditionally and it is missing, the relocation cannot be resolved, and CO-RE handles this not by silently zeroing but by poisoning the instruction. From bpf_core_poison_insn in relo_core.c, v6.12, the failed instruction is rewritten into a deliberately-invalid call:
The magic immediate 195896080 is 0xbad2310 — read as leetspeak, “bad relo.” The comment explains the consequence: “if this instruction is reachable (not a dead code), verifier will complain with the following message: invalid func unknown#195896080.” This is the cryptic error every CO-RE developer eventually hits (Nakryiko, reference guide). The genius of it is the reachability clause: a poisoned instruction is only fatal if the verifier can reach it. So the idiom for portable code is to guard the read with a relocation that resolves to a constant the verifier can fold:
if (bpf_core_field_exists(t->__state)) { state = BPF_CORE_READ(t, __state);} else { struct task_struct___old *t_old = (void *)t; state = BPF_CORE_READ(t_old, state); /* old kernels: field was 'state' */}
bpf_core_field_exists(t->__state) emits a BPF_CORE_FIELD_EXISTS relocation, which the loader patches to the constant 1 or 0 depending on whether __state exists on the target. On a new kernel that branch is if (1), the else is dead code — including its now-poisoned t_old->state read — and the verifier eliminates it before it can complain. On an old kernel it is if (0), the then branch (with its poisoned __state read) is dead, and the else runs. As Nakryiko puts it, the verifier “will know that such code path is impossible to hit” and prunes it. The poison instruction is harmless precisely because it is unreachable.
The guarded idiom, compiled and resolved
The idiom above is easy to state and easy to disbelieve, so here it is measured. The same __state/state rename, compiled with no libbpf headers at all — just the two Clang-facing enumerators and the builtin:
The complete graceful-degradation idiom, measured end to end. What it shows: instruction 0 is an ordinary mov whose immediate is a relocation target; on this kernel it is patched from the local answer 1 to the target answer 1, the branch at instruction 1 becomes statically false, and instruction 4 — which is poisoned, because no member named state exists in this kernel’s 272-member task_struct — becomes unreachable. The insight to take: the poison is real and it is emitted regardless. The program loads only because the verifier proves the poisoned instruction is dead. Delete the if and the identical object stops loading with invalid func unknown#195896080, with no other change to the source.
Note also that the flavour suffix did its job in the other direction here: task_struct___old produced a candidate search on the essential name task_struct (11 characters of 17) and found the kernel’s real [145856] struct task_struct — the type matched perfectly and only the member search failed. A relocation can fail at the member level while the type search succeeds, and the two failures are reported differently in libbpf’s debug log (no matching targets found versus non-matching candidate #N).
This pairs with struct flavors — the task_struct___old above. Per the reference guide, “for any type, field, enum, or enumerator, if the entity’s name contains a suffix of the form ___something (three underscores plus some text after it), such name suffix is ignored for the purposes of CO-RE relocation.” So struct task_struct___old is treated by CO-RE as struct task_struct, letting you define an alternate view of the same kernel type (with the old field layout/name) and cast to it conditionally. The ___ flavor convention is the mechanism that makes the else branch above even expressible.
Failure Modes and Common Misunderstandings
Every message below is a literal string from relo_core.c or libbpf.c at v6.12, so they can be grepped for directly:
Symptom you see
Where it comes from
What it actually means
Fix
invalid func unknown#195896080 (from the verifier, at load)
bpf_core_poison_insn() wrote imm = 0xbad2310 and the verifier reached the instruction
a relocation could not be resolved and the instruction was live
guard the read with bpf_core_field_exists() / bpf_core_type_exists() / bpf_core_enum_value_exists() so the branch becomes dead code
relo #N: no matching targets found (libbpf, LIBBPF_DEBUG)
bpf_core_calc_relo_insn(), candidate list empty after matching
the field/type/enumerator is absent from target BTF
normal for EXISTS kinds; for value kinds it is the cause of the poison above
relo #N: field offset ambiguity: X != Y
bpf_core_calc_relo_insn()
≥2 candidates matched at different offsets — a duplicated type name in the target BTF
rename the local type with a ___flavour suffix, or use a typed program context
relo #N: relocation decision ambiguity: <s> X != <s> Y
same function
candidates agreed on offset but disagreed on the computed value or on whether to poison
same
insn #N (LDX/ST/STX) accesses field incorrectly. Make sure you are accessing pointers, unsigned integers, or fields of matching type and size.
fail_memsz_adjust in bpf_core_calc_relo()
field size changed and the change is not zero-extension-safe (e.g. a signed 32→64 widening)
read into a correctly-typed variable, or use bpf_core_read() with an explicit size
insn #N (LDX/ST/STX) value too big: X
bpf_core_patch_insn(), new_val > SHRT_MAX
the target offset exceeds 32,767 and will not fit the instruction’s signed 16-bit off
unavoidable for very deep offsets; use bpf_core_read() with a computed pointer instead of a direct load
unexpected insn #N (ALU/ALU64) value: got X, exp Y -> Z
bpf_core_patch_insn() validation
what Clang baked in disagrees with what libbpf derived from the local BTF
usually a mismatched .BTF and code section, or a hand-edited object
relo #N: unrecognized CO-RE relocation <kind> (K) at insn #N
bpf_core_calc_relo()
the object was built by a compiler newer than the loading libbpf
upgrade libbpf
relocation doesn't support anonymous types
bpf_core_calc_relo_insn()
the relocation’s root type has no name, so no candidate search is possible
give the type a name; anonymous members are fine, anonymous roots are not
relo for [T] <str> (at idx N) captures type [T] of unexpected kind <kind>
bpf_core_parse_spec()
the access string tried to index into something that is neither composite nor array
almost always a compiler/libbpf version skew
Every CO-RE load-time diagnostic in one place. What it shows: exactly one of these ten is produced by the kernel (invalid func unknown#195896080); the other nine come from libbpf and only appear if logging is turned up. The insight to take: the kernel’s message is the least informative of the set and is the one everyone sees, because libbpf’s messages are silent at default log level. Turning on LIBBPF_DEBUG converts an unreadable verifier complaint into a precise statement of which relocation failed and why — it is the first thing to do, not the last.
invalid func unknown#195896080 at load. A CO-RE relocation failed and the instruction was reachable. Almost always a field/type/enum your program reads unconditionally does not exist on the target. Fix: guard with bpf_core_field_exists() / bpf_core_type_exists() / bpf_core_enum_value_exists() so the failing path becomes dead code.
Wrong data, no error. If you bypass CO-RE (a raw bpf_probe_read without __builtin_preserve_access_index, or a hand-coded offset), there is no relocation and the offset is frozen — the silent-wrong-field bug from CO-RE (Compile Once Run Everywhere). The presence of a relocation is what protects you.
fail_memsz_adjust poison.bpf_core_patch_insn also poisons when a field’s size changed in a way it cannot safely adjust the load width for, warning “accesses field incorrectly. Make sure you are accessing pointers, unsigned integers, or fields of matching type and size.” CO-RE can widen/narrow a load when the field’s integer size changed, but only within safe rules.
Forgetting preserve_access_index. Direct s->field reads in a plain program do not emit relocations unless the struct (or the whole file via #pragma clang attribute push (__attribute__((preserve_access_index)), ...), which vmlinux.h applies) carries the attribute. vmlinux.h applies it globally, which is why CO-RE “just works” when you use it.
Alternatives and When to Choose Them
Direct (BTF-aware) reads — no bpf_core_read. For program types where the verifier knows the type of context pointers (fentry, fexit, tp_btf, lsm), you can write t->mm->exe_file->... directly and the compiler still emits CO-RE relocations (because vmlinux.h applies preserve_access_index), but you skip the bpf_probe_read wrapping — “fast, convenient and simple” (reference guide). Use BPF_CORE_READ only where the verifier cannot prove the pointer is safe to dereference (classic kprobe/tracepoint contexts), where every hop genuinely needs a probe-read.
bpf_core_read_user / BPF_CORE_READ_USER. The same machinery but using bpf_probe_read_user for userspace pointers in kernel structs — relocations still apply to the field offsets.
Hard-coded offsets + version checks. The pre-CO-RE manual approach (read LINUX_VERSION_CODE, pick an offset). Brittle, doesn’t handle CONFIG-driven layout differences within one version, and is exactly what CO-RE obsoletes.
Production Notes
The libbpf-tools rewrites of the classic BCC tools are the reference corpus for idiomatic CO-RE: nearly every one uses BPF_CORE_READ for pointer chases and guards kernel-version-sensitive fields with bpf_core_field_exists. The recurring real-world bug is the unguarded read of a CONFIG-gated or version-new field manifesting as invalid func unknown#195896080 on exactly the subset of a fleet running the kernel that lacks the field — a problem that is invisible in CI (which runs one kernel) and only appears in heterogeneous production, which is why disciplined existence-guarding is the operational lesson.
The two copies of relo_core.c, and what “shared” actually means
relo_core.c is often described as “shared between libbpf and the kernel”, which is true in a stronger and a weaker sense than people usually mean. The strong sense: inside one kernel tree the kernel does not have its own copy at all.kernel/bpf/relo_core.c at v6.12 is two lines long —
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)#include "../../tools/lib/bpf/relo_core.c"
— and kernel/bpf/Makefile builds it with obj-$(CONFIG_BPF_SYSCALL) += relo_core.o. The in-kernel matcher is textually the same file as the in-tree libbpf matcher; they cannot drift. The kernel’s entry point into it is bpf_core_apply() in kernel/bpf/btf.c L8949, called from check_core_relo() in the verifier.
The weak sense is the one that bites. The standalone libbpf/libbpf repository carries its own mirrored copy, synced periodically from the kernel, and between syncs the two drift. Diffing tools/lib/bpf/relo_core.c at kernel v6.12 against src/relo_core.c at libbpf v1.7.0 on 2026-09-12 gives 36 differing lines, and one of them is a behaviour change in field-size calculation, not a cosmetic difference. So a program built and loaded on a v6.12 kernel by a distro-packaged libbpf v1.7.0 really is relocated by a slightly newer algorithm than the one that kernel would apply on the light-skeleton path. Pin your claims to a tag, and say which.
A real relocation bug: array element sizes, found by sched_ext
Libbpf has a somewhat obscure feature of automatically adjusting the “size” of LDX/STX/ST instruction … based on originally recorded access size (u8, u16, u32, or u64) and the actual size of the field on target kernel. This is meant to facilitate using BPF CO-RE on 32-bit architectures (pointers are always 64-bit in BPF, but host kernel’s BTF will have it as 32-bit type), as well as generally supporting safe type changes.
And the bug: “One issue that surfaced only now, 5 years after this logic was implemented, is how this all works when dealing with fields that are arrays … libbpf used to calculate entire array size, instead of making sure to only calculate array’s element size. But it’s the element that is loaded by LDX/STX/ST instructions.” It was found by a sched_ext BPF program “with innocent looking loop”, reported by Emil Tsalapatis. The fix walks down through nested ARRAY types to the element before calling btf__resolve_size(). Two lessons: the size-adjustment machinery is the least-exercised part of CO-RE, and a relocation bug presents as a verifier complaint about a load, never as a relocation error.
A second, narrower example from the same file: f3bdb54f09ab (2023-08-23) fixed BPF_CORE_FIELD_SIGNED reading btf_int_encoding() on types that are not BTF_KIND_INT at all — “extracting btf_int_encoding() is only meaningful for BTF_KIND_INT, so we need to check that first before inferring signedness”. Reported by Lorenz Bauer against a real libbpf issue. Both fixes are one line of guard each; both silently corrupted a relocation result for years.
The kernel-side parser is attack surface
Because relo_core.c also runs inside the kernel — reached via check_core_relo() in the verifier and bpf_core_apply() in kernel/bpf/btf.c when a program is loaded with CO-RE relocations attached, which is how the light skeleton works — bugs in the matcher are kernel bugs. 1c22483a2c4b “bpf: reject negative CO-RE accessor indices in bpf_core_parse_spec()” (Weiming Shi, reported by Xiang Mei, April 2026) is the clearest illustration:
bpf_core_parse_spec() parses each component with sscanf("%d"), so negative values like -1 are silently accepted. The subsequent bounds checks (access_idx >= btf_vlen(t)) only guard the upper bound and always pass for negative values because C integer promotion converts the __u16btf_vlen result to int … When -1 reaches btf_member_bit_offset() it gets cast to u32 0xffffffff, producing an out-of-bounds read far past the members array. A crafted BPF program with a negative CO-RE accessor on any struct that exists in vmlinux BTF (e.g. task_struct) crashes the kernel deterministically during BPF_PROG_LOAD on any system with CONFIG_DEBUG_INFO_BTF=y (default on major distributions). The bug is reachable with CAP_BPF.
Its Fixes: tag points at ddc7c3042614 ("libbpf: implement BPF CO-RE offset relocation algorithm") — the original 2019 commit — so the flaw was in the algorithm from the day it was written and survived being copied into the kernel. The practical takeaway for anyone reasoning about BPF’s security model: a relocation record is attacker-controlled input to kernel code on the light-skeleton path, in a way that a relocation resolved in userspace by libbpf is not. That is one more reason the default libbpf path — resolve in userspace, submit plain bytecode — is the conservative choice.
See Also
The three-way split is stated in the ownership box at the top of this note; briefly, in link form:
CO-RE (Compile Once Run Everywhere) — the concept: why layouts move, what BCC did instead, what CO-RE does and does not insulate you from. That note frames the problem; this one is the machinery.
libbpf and the BPF Loader — the loader’s half: where relocation sits in the bpf_object pipeline, the 16-byte on-disk record, and which instruction field gets written. That note explicitly defers the matching algorithm to here, and this note does not re-derive the pipeline.
BTF (BPF Type Format) — BTF itself: the kinds, the encoding, the type graph. This note only covers what the matcher does with those kinds (stripping modifiers, treating ENUM/ENUM64 as interchangeable, refusing anonymous roots).
BTF and vmlinux.h — where preserve_access_index gets applied globally via #pragma clang attribute, so CO-RE “just works” without per-struct annotation
BPF Skeletons and bpftool — the light-skeleton / gen_loader path on which relo_core.c runs inside the kernel
BPF Instruction Set — the off/imm/ldimm64 instruction fields that relocations patch, and the 16-bit signed off limit behind value too big
eBPF Verifier — the dead-code elimination that makes a poisoned instruction harmless
The bpf() Syscall — BPF_PROG_LOAD and kernel.unprivileged_bpf_disabled, which is why nothing here could be loaded