CO-RE Relocations and Field Access
A CO-RE relocation is a tiny metadata record, emitted by Clang into a BPF object’s
.BTF.extELF 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 astruct bpf_core_relo—{ insn_off, type_id, access_str_off, kind }— andkindis one of twelve values inenum bpf_core_relo_kind, ranging fromBPF_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 itsofffield for memory loads or itsimmfield for ALU ops. This note is the mechanism in full; the motivation and the bigger picture are in CO-RE (Compile Once Run Everywhere).
Version context
Kernel-side definitions (
enum bpf_core_relo_kind,struct bpf_core_relo,relo_core.cbehavior, the poison value) are pinned to Linux 6.12 LTS (2024-11-17) and verified against the raw source at tagv6.12. Thebpf_core_read.hmacros and the smallbpf_field_info_kind/bpf_type_info_kind/bpf_enum_value_kindenums are as of libbpf 1.5.0 (2024). The Clang builtins (__builtin_preserve_access_index,__builtin_preserve_field_info,__builtin_btf_type_id,__builtin_preserve_enum_value) are LLVM/Clang features dated where relevant.
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):
struct bpf_core_relo {
__u32 insn_off;
__u32 type_id;
__u32 access_str_off;
enum bpf_core_relo_kind kind;
};Walking the fields, per the kernel’s own comment block:
insn_off— the byte offset, within the program’s code section, of the instruction whoseimmorofffield this relocation will patch. (libbpf divides this byBPF_INSN_SZ= 8 to get an instruction index — seebpf_object__relocate_coreinlibbpf.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 thetype_id“will capture BTF type id ofstruct sample,” i.e. the root, not the leaf field.access_str_off— an offset into the.BTFstring 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 ofenum 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 ofs” (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) instruct sample.”s->b→"0:1"— element 0, field index 1 (b).s[1].c[5]→"1:2:0:5"—1= second element ofs;2= the anonymous-struct field (it is field index 2 instruct sample);0= fieldcinside 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.
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.
The Twelve Relocation Kinds
enum bpf_core_relo_kind, verbatim from include/uapi/linux/bpf.h, v6.12:
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_OFFSETis the common case — patch the load’s offset.BYTE_SIZEandSIGNEDdescribe the field’s storage;EXISTSyields 1 or 0 depending on whether the field is present;LSHIFT_U64/RSHIFT_U64are 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_TARGETproduce a BTF type ID (in the local object, or in the target kernel) — used to pass a type to helpers likebpf_core_type_id_kernel.TYPE_EXISTS/TYPE_SIZEanswer whether a type is present and how big it is on the target.TYPE_MATCHES(added later than the original set — verify the exact version) 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_VALUEanswer 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.
Uncertain
Verify: the exact kernel/libbpf/Clang version that introduced
BPF_CORE_TYPE_MATCHES = 12(and theBPF_TYPE_MATCHES = 2compiler-facing kind inbpf_core_read.h). Reason: it is present and value-stable in the v6.12 source I read, but I did not pin the introducing release. To resolve:git log -S BPF_CORE_TYPE_MATCHESagainst the kernel and libbpf trees. The enum values 0–11 are long-standing and the v6.12 numbering above is verified.#uncertain
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.5.0), which are the literal integers passed to the Clang builtins:enum bpf_field_info_kind { BPF_FIELD_BYTE_OFFSET = 0, BPF_FIELD_BYTE_SIZE = 1, BPF_FIELD_EXISTS = 2, BPF_FIELD_SIGNED = 3, BPF_FIELD_LSHIFT_U64 = 4, BPF_FIELD_RSHIFT_U64 = 5, }; enum bpf_type_info_kind { BPF_TYPE_EXISTS = 0, BPF_TYPE_SIZE = 1, BPF_TYPE_MATCHES = 2 }; enum bpf_enum_value_kind { BPF_ENUMVAL_EXISTS = 0, BPF_ENUMVAL_VALUE = 1 };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 value0. -
The unified
bpf_core_relo_kind(0–12, above) that is serialized into the.BTF.extrecord 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.5.0).
The primitive — wrap a probe-read so the address expression carries relocations:
#define bpf_core_read(dst, sz, src) \
bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))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->pid and 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:
#define bpf_core_field_exists(field...) \
__builtin_preserve_field_info(___bpf_field_ref(field), BPF_FIELD_EXISTS)
#define bpf_core_field_size(field...) \
__builtin_preserve_field_info(___bpf_field_ref(field), BPF_FIELD_BYTE_SIZE)
#define bpf_core_type_exists(type) \
__builtin_preserve_type_info(*___bpf_typeof(type), BPF_TYPE_EXISTS)
#define bpf_core_type_size(type) \
__builtin_preserve_type_info(*___bpf_typeof(type), BPF_TYPE_SIZE)
#define bpf_core_type_id_kernel(type) \
__builtin_btf_type_id(*___bpf_typeof(type), BPF_TYPE_ID_TARGET)
#define bpf_core_enum_value_exists(enum_type, enum_value) \
__builtin_preserve_enum_value(*(typeof(enum_type) *)enum_value, BPF_ENUMVAL_EXISTS)
#define bpf_core_enum_value(enum_type, enum_value) \
__builtin_preserve_enum_value(*(typeof(enum_type) *)enum_value, BPF_ENUMVAL_VALUE)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.
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:
00 <alpha>:
0: r3 = *(s32 *)(r1 + 0x0)
00: CO-RE <byte_off> [2] struct foo::a (0:0)
1: *(u64 *)(r2 + 0x0) = r3
2: *(u32 *)(r1 + 0x0) = 0x1
10: CO-RE <byte_off> [2] struct foo::a (0:0)
3: exit
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 own byte_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 brief’s phrase “rewrites immediates” is actually imprecise — the kernel/libbpf source shows two distinct cases in bpf_core_patch_insn (relo_core.c, v6.12):
- For memory-access instruction classes —
BPF_LDX,BPF_ST,BPF_STX(ourr3 = *(s32 *)(r1 + 0x0)isLDX) — the offset lives in the instruction’sofffield, so libbpf doesinsn->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_existsor a size) — the value lives in theimmfield, so libbpf doesinsn->imm = new_val:orig_val = insn->imm; insn->imm = new_val;
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 its off.
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.
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:
insn->code = BPF_JMP | BPF_CALL;
/* ... */
insn->imm = 195896080; /* => 0xbad2310 => "bad relo" */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.
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
invalid func unknown#195896080at 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 withbpf_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_readwithout__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_adjustpoison.bpf_core_patch_insnalso 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. Directs->fieldreads in a plain program do not emit relocations unless the struct (or the whole file via#pragma clang attribute push (__attribute__((preserve_access_index)), ...), whichvmlinux.happlies) carries the attribute.vmlinux.happlies 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 writet->mm->exe_file->...directly and the compiler still emits CO-RE relocations (becausevmlinux.happliespreserve_access_index), but you skip thebpf_probe_readwrapping — “fast, convenient and simple” (reference guide). UseBPF_CORE_READonly 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 usingbpf_probe_read_userfor 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 handleCONFIG-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 shared relo_core.c between libbpf and the kernel is itself a notable engineering decision: it guarantees the userspace loader and the kernel-side light-skeleton loader compute bit-identical relocations, so a program relocates the same way regardless of which loader path resolved it.
See Also
- CO-RE (Compile Once Run Everywhere) — the motivation, the BCC contrast, and the big-picture pipeline this note is the mechanism for
- BTF (BPF Type Format) — the type encoding the access strings and
type_ids index into - BTF and vmlinux.h — where
preserve_access_indexgets applied globally so CO-RE “just works” - libbpf and the BPF Loader —
bpf_object__relocate_coreand the load-time resolution loop - BPF Instruction Set — the
off/imminstruction fields that relocations patch - Linux eBPF MOC — parent map (§7, CO-RE and BTF)