Day 3 — CO-RE: read task->real_parent->tgid and survive a kernel upgrade
Today’s mission: read the parent PID of every process that calls
unlink. Do it in a way that runs unchanged on the kernel you compiled against and on next year’s kernel after the layout drifts. Total time: ~95 minutes.
The fragile pre-CO-RE world
Before CO-RE existed, BPF tracing programs had two ugly choices:
-
bcc-style runtime compilation. Ship the BPF source as a string. The userspace tool detects the running kernel, calls Clang at runtime against the current kernel’s headers, compiles the program then and there, loads it. Slow startup (Clang takes ~seconds), needs Clang+headers on every target machine, and breaks subtly when headers don’t match a running kernel built from a slightly different config.
-
Hardcoded offsets. Read kernel struct fields with
bpf_probe_read_kernel(&out, sizeof(out), (char *)task + 1872). Works on exactly the kernel you derived1872from. Breaks the moment that struct grows a field.
Both options are why pre-2020 BPF tooling felt brittle. CO-RE killed both.
And the structure that breaks hardcoded-offset tools worst is the one we’re going to read today: struct task_struct. So before we touch CO-RE, let’s meet it — because half of why CO-RE exists is to read this one struct safely.
The structure that motivates everything: task_struct
Every runnable thread in the kernel — every process, every thread inside a process, every kernel worker — is described by exactly one struct task_struct. It is the kernel’s process/thread descriptor: the big bag of state the scheduler, the signal code, the VFS, and the credential machinery all hang off of. It is genuinely large — hundreds of fields, well over a kilobyte — holding scheduling state, the open-file table, credentials, parent/child links, and a short human-readable name.
A few fields are all we need today. In v7.1 (include/linux/sched.h):
pid_t pid; /* sched.h:1063 — per-thread ID */
pid_t tgid; /* sched.h:1064 — thread-group ID */
struct task_struct __rcu *real_parent; /* sched.h:1077 — the (original) parent */
struct task_struct __rcu *parent; /* sched.h:1080 — recipient of SIGCHLD */
char comm[TASK_COMM_LEN]; /* sched.h:1173 — 16-byte thread name */
current: the task that’s running right now
You never get a task_struct out of thin air — you ask “who is running on this CPU at this instant?” The kernel’s answer is the macro current. It’s a per-CPU/arch construct that yields the task_struct * of whatever thread is executing right now (on x86_64 it’s read from a per-CPU variable; other arches stash it in a register). Whenever kernel code says current->pid, it means “the PID of the thread that triggered this code path.”
BPF gets at it through a helper. bpf_get_current_task_btf() literally returns current — its kernel implementation is one line:
/* kernel/trace/bpf_trace.c:766 */
BPF_CALL_0(bpf_get_current_task_btf)
{
return (unsigned long) current; /* :768 */
}
So when your program calls bpf_get_current_task_btf() inside the unlink hook, you get the task_struct of the process that called unlink. That’s the anchor for everything else today.

pid vs tgid: the kernel’s PID is not userspace’s PID
Here’s the trap that bites everyone once. In the kernel, pid is the per-thread ID and tgid (thread-group ID) is the leader thread’s pid. A single-threaded process has pid == tgid. A process with four threads has four distinct pids but one shared tgid.
Userspace getpid() returns the tgid. So:
- The user-visible PID of a task is its
tgid. - The user-visible PPID is the parent’s
tgid— i.e.real_parent->tgid.
(Day 1 already noted “userspace PID = kernel TGID” in passing when we used bpf_get_current_pid_tgid; this is why. That helper packs tgid in the high 32 bits and pid in the low 32 — >> 32 gives you the user-visible PID.)
That single fact is why the lab reads real_parent->tgid and not real_parent->pid: we want the PPID a human would recognize from ps, which is the parent’s thread-group ID.
real_parent vs parent
There are two parent pointers, and the kernel comment block at sched.h:1071 explains why:
real_parent(sched.h:1077) — the original/biological parent. Used for reparenting and ptrace bookkeeping. This is the “who actually forked me” link.parent(sched.h:1080) — the task that receivesSIGCHLDandwait4()reports. Usually identical toreal_parent, but underptrace(a debugger,strace) theparentbecomes the tracer whilereal_parentstays the true parent.
The lab deliberately uses real_parent so the true parent shows up even if something is tracing the process.
comm[16]: the thread name
comm is a fixed 16-byte (TASK_COMM_LEN, sched.h:325) NUL-padded thread name — "bash", "rm", "sshd". It’s the same buffer Day 1’s bpf_get_current_comm helper copied out for you. Today we read it directly via CO-RE instead, which is the whole point: we’re going to walk into task_struct by hand.
And this is why CO-RE matters. task_struct grows and reshuffles constantly between kernel versions — a new scheduler field here, a reordered block there. Every one of those changes shifts the byte offset of comm, tgid, real_parent. A tool that hardcoded (char *)task + 1872 is broken by the next -rc. CO-RE is the fix.
What CO-RE actually does
You write field accesses by name:
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
The compiler emits an instruction that loads a value at offset 0xC0RE0001 (a placeholder — Clang fills the actual relocation index there). Alongside the instruction, it emits a CO-RE relocation record that says: “the offset I just used is bogus; at load time, look up the byte offset of task_struct.real_parent in the target kernel’s BTF and patch this instruction with the real value.”
When libbpf loads the program, it walks every CO-RE relocation, looks up the field by name in /sys/kernel/btf/vmlinux, computes the offset, and patches the instruction. Same .o file, different patched offsets per kernel.

This is why your Day 1 program’s BPF_PROG(on_unlink, int dfd, struct filename *name) worked without you knowing the byte offset of anything — every kernel-type access went through this name-based relocation.
There are no Dumb Questions
Q: Why doesn’t C just do this normally? My userspace programs include kernel headers and read fields by name.
A: Userspace
#include <linux/sched.h>reads the headers from when you compiled. If the running kernel has a different layout, you crash or read wrong bytes — that’s why people warn against using kernel headers from outside/proc. CO-RE inverts the model: you build BPF programs as if struct layouts are unknown until load time, then libbpf resolves them against ground truth.Q: What if a field doesn’t exist on the target kernel?
A: There are CO-RE primitives for that:
bpf_core_field_exists(task->real_parent)— returns 1 if the field exists, 0 otherwise.bpf_core_type_exists(struct foo)— same for whole types.bpf_core_enum_value_exists(...)— same for enum members.If you check existence and skip access, libbpf will also patch the access instruction to a no-op when the field is missing. You can ship one program that gracefully degrades across kernel versions.
Q: Are direct typed-pointer derefs the same as
BPF_CORE_READ?A: Almost. With BTF and the right program type, the Verifier accepts
task->real_parent->tgidand the compiler emits CO-RE-relocated instructions. Both are memory-safe:BPF_CORE_READexpands tobpf_probe_read_kernel(a helper call that zero-fills on a bad pointer), while a direct deref of a walkedPTR_TO_BTF_IDis rewritten by the verifier to a fault-protectedBPF_PROBE_MEMload that also zero-fills. The real difference is speed and applicability: direct deref is an inline load (no call) but needs a verifier-typed pointer, whileBPF_CORE_READis slower but works even when the verifier can’t type the pointer (kprobept_regscasts, non-BTF program types). For arguments handed to you by the kernel (fentry params,bpf_get_current_task_btf()), direct deref is ideal. (We unpack exactly why one is faster two sections down.)
Sharpen your pencil
Your Day 1 and Day 2 programs read
bpf_get_current_pid_tgid() >> 32to get the PID. That doesn’t go through CO-RE — there’s no struct field involved. But the BPF program still has to know it’s running on Linux x86_64 vs ARM64. What handles that abstraction?.
.
.Answer: the helper itself.
bpf_get_current_pid_tgidis implemented inkernel/bpf/helpers.cand is called the same way regardless of arch. Architecture-specific work happens inside the kernel implementation. CO-RE is for accessing kernel data structures whose layout differs across kernel versions, not for arch-portable helper calls.
Why BPF can’t just dereference a pointer: bpf_probe_read_kernel
We keep saying “BPF_CORE_READ fault-handles each hop” and “direct deref is faster but assumes the pointer is valid.” To make that concrete you need to know what’s underneath — and the answer is a helper called bpf_probe_read_kernel.
Here’s the problem. A BPF program runs in kernel context. If it blindly dereferenced an arbitrary kernel pointer and that pointer were NULL, stale, or just wrong, it would fault in kernel context — and a fault in the kernel is not a friendly segfault, it’s an oops. BPF is supposed to be safe to load on a production box. So the kernel does not let a BPF program follow just any pointer. It offers exactly two sanctioned ways to touch kernel memory.
Way 1 — the bpf_probe_read_kernel(dst, size, src) helper. It copies size bytes from src into dst, but it does the copy through copy_from_kernel_nofault, the kernel’s “try this load, but catch the fault instead of crashing” primitive. If the read faults, the helper doesn’t crash — it zero-fills dst and returns -EFAULT:
/* include/linux/bpf.h:3387 — bpf_probe_read_kernel_common, the plain kernel read path */
ret = copy_from_kernel_nofault(dst, unsafe_ptr, size); /* :3392 */
if (unlikely(ret < 0))
memset(dst, 0, size); /* :3394 — the zero-fill-on-fault path */
return ret;
(The string and user variants live in kernel/trace/bpf_trace.c and use a different primitive: bpf_probe_read_user_common memsets at :179, bpf_probe_read_user_str_common at :216, bpf_probe_read_kernel_str_common at :266. Only the non-string kernel read shown above is in bpf.h.)
/* kernel/trace/bpf_trace.c:235 */
BPF_CALL_3(bpf_probe_read_kernel, void *, dst, u32, size,
const void *, unsafe_ptr)
{
return bpf_probe_read_kernel_common(dst, size, unsafe_ptr); /* :238 */
}
That memset(dst, 0, size) is the source of the chapter’s “BPF_CORE_READ returns a default 0 on bad pointers” behavior. The zero doesn’t come from CO-RE — it comes from this helper’s error path. A bad hop gives you a clean 0, not a crash.
Way 2 — verifier-trusted typed pointers. When the Verifier can prove a pointer’s type and trustworthiness, it lets the JIT emit a plain hardware load with no helper call at all. No copy_from_kernel_nofault, no fault net — just a ldx. This is the fast path, and it’s why direct deref (task->real_parent->tgid) beats the macro. We meet the register type that unlocks it in the next section.
So now the two forms decompose cleanly:
BPF_CORE_READ= (CO-RE relocation to patch the offset) + (bpf_probe_read_kernelto fault-protect the load), chained once per pointer hop.- Direct deref = (CO-RE relocation to patch the offset) + (plain trusted load, no fault net).
Both keep the relocation. Only BPF_CORE_READ keeps the safety net. You can see the marriage of relocation-and-probe-read right in the header:
/* tools/lib/bpf/bpf_core_read.h:312 */
bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))
The __builtin_preserve_access_index(src) is the CO-RE relocation (it records “this offset must be patched against target BTF”); the bpf_probe_read_kernel(...) wrapped around it is the fault protection. One macro, both jobs.

One licensing consequence.
bpf_probe_read_kernel’s proto is GPL-only:/* kernel/trace/bpf_trace.c:241 */ const struct bpf_func_proto bpf_probe_read_kernel_proto = { .func = bpf_probe_read_kernel, .gpl_only = true, /* :243 */ ... };This ties straight back to Day 1’s LICENSE gate (Day 1, Break 3): the instant your program uses
BPF_CORE_READ, it routes through this GPL-only helper, so it genuinely needschar LICENSE[] SEC("license") = "GPL";. Drop the GPL string and the loader rejects the program with “helper call is not allowed in non-GPL program.” That’s not bureaucracy — it’s this proto’s.gpl_onlyflag.
PTR_TO_BTF_ID: the trusted, typed pointer that lets you skip the safety net
The fast path above hinges on one Verifier concept, so let’s name it.
Refresher (Day 2): the Verifier tags every register with a type and tracks what you may legally do with it. Recall PTR_TO_MAP_VALUE_OR_NULL from yesterday — the type a bpf_map_lookup_elem result carries until you NULL-check it. You couldn’t dereference it until you proved it non-NULL.
New today: PTR_TO_BTF_ID is a register type that says “this pointer points at a specific kernel type, identified by a BTF type id.” Because the register carries the type, the Verifier can type-check field accesses against the real struct layout and permit a direct load of a field at the relocated offset. No helper call needed — that’s the plain ldx from the previous section.
The _TRUSTED variant adds one more guarantee: the pointer is non-NULL and not stale. So no NULL check is required before deref — unlike a map-value pointer, which always might be NULL.
This is exactly why bpf_get_current_task_btf() is special. Look at its proto:
/* kernel/trace/bpf_trace.c:771 */
const struct bpf_func_proto bpf_get_current_task_btf_proto = {
.func = bpf_get_current_task_btf,
.gpl_only = true,
.ret_type = RET_PTR_TO_BTF_ID_TRUSTED, /* :774 */
.ret_btf_id = &btf_tracing_ids[BTF_TRACING_TYPE_TASK], /* :775 */
};
RET_PTR_TO_BTF_ID_TRUSTED + a ret_btf_id pointing at the task_struct BTF id means: the Verifier marks the returned register a trusted, non-NULL PTR_TO_BTF_ID of type task_struct. That’s why the lab can write task->real_parent->tgid with zero NULL checks on task — the Verifier already knows it’s a valid task pointer.
Contrast the older helper, bpf_get_current_task (bpf_trace.c:755), whose proto has .ret_type = RET_INTEGER — it hands back an opaque u64 you have to cast yourself, with no type information for the Verifier. That’s the “older variant that returned an opaque u64 you had to cast” the cast notes mention. The _btf version exists precisely to give the Verifier the type.
This is the first of Day 1’s “four things BTF powers” (Day 1: “Verifier type-checking of typed pointers (PTR_TO_BTF_ID)”) finally made concrete. Forward pointer: Day 4 formalizes the Verifier’s full type lattice. For today, hold one sentence: PTR_TO_BTF_ID = a typed, trusted kernel pointer the Verifier lets you dereference directly.
Meet the cast
bpf_get_current_task_btf — typed access to current
Returns a struct task_struct * with the BTF type tag attached (RET_PTR_TO_BTF_ID_TRUSTED, as we just saw). The Verifier knows it’s a valid, non-NULL kernel pointer. You can deref fields directly. Cheaper than bpf_get_current_task (the older variant that returned an opaque u64 you had to cast).
BPF_CORE_READ(task, a, b, c) — chained CO-RE-aware reads
Expands to a series of bpf_probe_read_kernel calls walking each pointer hop, with CO-RE relocations on each field access. Equivalent to:
struct task_struct *p1 = task;
struct task_struct *p2;
__u32 result;
bpf_probe_read_kernel(&p2, sizeof(p2), &p1->real_parent); // CO-RE: real_parent offset
bpf_probe_read_kernel(&result, sizeof(result), &p2->tgid); // CO-RE: tgid offset
The macro saves you that boilerplate and fault-handles each hop, as the bpf_probe_read_kernel section showed — a bad pointer anywhere in the chain yields a clean 0 instead of a crash.

BPF_CORE_READ_INTO(dst, src, a, b, c)
Same thing but writes the final value into *dst instead of returning it. Use when the field is larger than 8 bytes (e.g., reading task->comm[16] into a buffer).
BPF_CORE_READ_STR_INTO(dst, src, a, b, c)
Reads a NUL-terminated string. Bounds the read at sizeof(dst). Returns the actual length copied.
The lab
Continue in the repository’s ebpf/labs directory. make parent compiles the exact anchored listings shown below.
parent.h — the shared event record
#ifndef PRACTICAL_EBPF_PARENT_H
#define PRACTICAL_EBPF_PARENT_H
struct parent_event {
unsigned int pid;
unsigned int ppid;
char comm[16];
char parent_comm[16];
};
#endif
parent.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "parent.h"
char LICENSE[] SEC("license") = "GPL";
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
SEC("fentry/filename_unlinkat")
int BPF_PROG(on_unlink)
{
struct task_struct *task;
struct parent_event *event;
task = (struct task_struct *)bpf_get_current_task_btf();
event = bpf_ringbuf_reserve(&rb, sizeof(*event), 0);
if (!event)
return 0;
__builtin_memset(event, 0, sizeof(*event));
event->pid = bpf_get_current_pid_tgid() >> 32;
event->ppid = BPF_CORE_READ(task, real_parent, tgid);
BPF_CORE_READ_STR_INTO(&event->comm, task, comm);
BPF_CORE_READ_STR_INTO(&event->parent_comm, task, real_parent, comm);
bpf_ringbuf_submit(event, 0);
return 0;
}
What’s new:
bpf_get_current_task_btf()returns a typedstruct task_struct *. Its proto’s return type isRET_PTR_TO_BTF_ID_TRUSTED, so the Verifier marks the register a trusted, non-NULLPTR_TO_BTF_ID— you can deref its fields directly with no NULL check.__builtin_memset(event, 0, sizeof(*event))initializes the whole reserved record before helper reads. If a fault-tolerant string read fails, userspace receives an empty string instead of stale ringbuf bytes.BPF_CORE_READ(task, real_parent, tgid)walkstask → real_parent → tgid(recall:tgidis the user-visible PID, so the parent’stgidis the PPID). Each hop is a CO-RE-relocatedbpf_probe_read_kernel.BPF_CORE_READ_STR_INTO(&event->comm, task, comm)readstask->comm(a 16-byte char array, not a pointer). Notecommis the last arg; for non-pointer fields you don’t need a final*deref hop.- Unlike Day 1,
BPF_PROG(on_unlink)takes no extra params — we don’t need the unlink arguments (dfd/name) today, becausebpf_get_current_task_btf()hands us the task directly.
Userspace parent.c
The loader mirrors Day 1’s checked error and signal paths, validates the shared struct parent_event, and releases both ringbuf and skeleton on exit:
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <bpf/libbpf.h>
#include "parent.h"
#include "parent.skel.h"
static volatile sig_atomic_t exiting;
static void handle_signal(int signal_number)
{
(void)signal_number;
exiting = 1;
}
static int install_signal_handlers(void)
{
const struct sigaction action = {
.sa_handler = handle_signal,
};
if (sigaction(SIGINT, &action, NULL) != 0 ||
sigaction(SIGTERM, &action, NULL) != 0) {
fprintf(stderr, "failed to install signal handlers: %s\n",
strerror(errno));
return -errno;
}
return 0;
}
static int handle_event(void *ctx, void *data, size_t size)
{
const struct parent_event *event = data;
(void)ctx;
if (size != sizeof(*event)) {
fprintf(stderr, "unexpected ringbuf record size: %zu\n", size);
return 0;
}
printf("PID %u (%.*s) ppid %u (%.*s) deleted a file\n",
event->pid, (int)sizeof(event->comm), event->comm,
event->ppid, (int)sizeof(event->parent_comm), event->parent_comm);
return 0;
}
int main(void)
{
struct parent_bpf *skel = NULL;
struct ring_buffer *ringbuf = NULL;
int err;
setvbuf(stdout, NULL, _IOLBF, 0);
err = install_signal_handlers();
if (err)
return 1;
skel = parent_bpf__open();
if (!skel) {
fprintf(stderr, "failed to open parent BPF skeleton\n");
return 1;
}
err = parent_bpf__load(skel);
if (err) {
fprintf(stderr, "failed to load parent BPF programs: %s\n",
strerror(-err));
goto cleanup;
}
err = parent_bpf__attach(skel);
if (err) {
fprintf(stderr, "failed to attach parent BPF programs: %s\n",
strerror(-err));
goto cleanup;
}
ringbuf = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event,
NULL, NULL);
err = libbpf_get_error(ringbuf);
if (err) {
ringbuf = NULL;
fprintf(stderr, "failed to create ringbuf consumer: %s\n",
strerror(-err));
goto cleanup;
}
puts("Tracing unlink callers and parents; press Ctrl-C to stop.");
while (!exiting) {
err = ring_buffer__poll(ringbuf, 100);
if (err == -EINTR) {
err = 0;
continue;
}
if (err < 0) {
fprintf(stderr, "ringbuf poll failed: %s\n", strerror(-err));
goto cleanup;
}
}
err = 0;
cleanup:
ring_buffer__free(ringbuf);
parent_bpf__destroy(skel);
return err != 0;
}
Run it
In terminal 1:
make parent
sudo ./.output/day03/parent
In terminal 2:
scratch=$(mktemp -d /tmp/ebpf-day03.XXXXXX)
touch "$scratch/x"
rm "$scratch/x"
rmdir "$scratch"
Expected:
PID 24501 (rm) ppid 24450 (bash) deleted a file
That parent is real_parent->tgid and real_parent->comm working exactly as the task_struct section promised: rm’s real parent is the shell that forked it, and its tgid is the PPID ps would show.
Press Ctrl-C in terminal 1 when the event appears. The loader frees the ringbuf and destroys the skeleton, detaching its fentry link without a broad pkill.
Inspect what CO-RE actually emitted
This is the most important step today. You need to see the relocations to believe they exist.
Disassemble the object with relocations interleaved (the -r flag is what makes the CO-RE records appear — -d alone never prints them):
llvm-objdump -dr .output/day03/parent.bpf.o | grep CO-RE
(If llvm-objdump isn’t found, your distro may only ship versioned binaries — try llvm-objdump-21 or llvm-objdump-18. Either works; the -dr flags and output are identical.)
Recent LLVM annotates the BPF disassembly with the CO-RE relocation records read from the object’s .BTF.ext section. You’ll see one line per kernel-field access:
0000000000000060: CO-RE <byte_off> [13] struct task_struct::real_parent
00000000000000a0: CO-RE <byte_off> [13] struct task_struct::tgid
00000000000000e8: CO-RE <byte_off> [13] struct task_struct::comm
(addresses and the type index [13] vary by build). Drop the | grep CO-RE to see the full disassembly: the load instruction just above each relocation carries the compile-time byte offset Clang baked in — e.g. r1 = 0xae0 for real_parent — not a placeholder like 0x0. libbpf rewrites that immediate at load time if the running kernel’s layout differs. CO-RE relocation records live in the ELF section .BTF.ext (the object also has .BTF, .rel.BTF, and .rel.BTF.ext); there is no .relo.btf section.
To inspect the embedded types instead:
./.output/bpftool/bootstrap/bpftool btf dump file .output/day03/parent.bpf.o
You’ll see your maps (VAR 'rb' and the .maps DATASEC), your on_unlink program function, and references to kernel types like task_struct.real_parent. (struct parent_event is used only inside function code and is not reachable from a map, global, or function prototype, so the compiler does not emit it into the object’s BTF.)
Aside — shipping minimal BTF.
./.output/bpftool/bootstrap/bpftool gen min_core_btf /sys/kernel/btf/vmlinux min.btf .output/day03/parent.bpf.owrites a minimal BTF file containing only the types your program references (it prints nothing to stdout). That’s for portability — shipping a tiny BTF alongside your.ofor kernels without/sys/kernel/btf/vmlinux— not for inspecting relocations.
To watch the relocations get applied at load time, you need libbpf’s own debug log, not the kernel verifier log. The two are different: .kernel_log_level feeds bpf_attr.log_level, which controls the in-kernel verifier log (a disassembled, post-relocation instruction dump plus verifier state). CO-RE patching happens in libbpf userspace before the BPF_PROG_LOAD syscall, so the verifier log contains no CO-RE string and no relocation provenance — grepping it for CO-RE finds nothing.
The patching messages come out of libbpf’s print callback at the LIBBPF_DEBUG level. Easiest from the shell — load with bpftool’s -d flag (which sets libbpf to LIBBPF_DEBUG) and grep for the relocation lines:
sudo ./.output/bpftool/bootstrap/bpftool -d prog load .output/day03/parent.bpf.o /sys/fs/bpf/parent 2>&1 | grep relo
sudo rm -f /sys/fs/bpf/parent # clean up the pin
You’ll see lines like prog 'on_unlink': relo #N: ... patched insn .... From C, register a print callback and raise the level before open/load:
static int dbg(enum libbpf_print_level lvl, const char *fmt, va_list ap)
{
return vfprintf(stderr, fmt, ap);
}
// in main(), before parent_bpf__open():
libbpf_set_print(dbg);
Note libbpf’s default print callback only emits WARN to stderr; the INFO/DEBUG relocation lines are suppressed unless you install your own callback or use a debug-enabled loader.
What to break, in order
Break 1 — Use a non-existent field
Add this line:
__u32 fake = BPF_CORE_READ(task, this_field_does_not_exist);
You might expect this to compile and only blow up at load time. It doesn’t — the build fails immediately:
error: no member named 'this_field_does_not_exist' in 'struct task_struct'
Here’s why: BPF_CORE_READ doesn’t reference fields by an opaque name string. It expands to the real C member-access expression (task->this_field_does_not_exist) wrapped in __builtin_preserve_access_index, and Clang type-checks that member against the struct task_struct in your vmlinux.h. A name that exists in no BTF at all is a plain C error. (bpf_core_field_exists(task->this_field_does_not_exist) fails the same way — it also emits the member-access expression.)
So to exercise the genuine load-time relocation-failure path — the one that prints
libbpf: prog 'on_unlink': relo #N: failed to relocate ...
you need a field that exists in your build’s BTF/vmlinux.h but is absent on the target kernel. The realistic way to trigger it is to compile against a newer vmlinux.h and load on an older kernel that lacks the field.
For the graceful-degradation demo, use a field that actually exists so the program compiles, and gate it on bpf_core_field_exists:
__u32 fake = 0;
if (bpf_core_field_exists(task->pid))
fake = BPF_CORE_READ(task, pid);
The point of bpf_core_field_exists is fields that appear or disappear across kernel versions: libbpf evaluates the check at load time against the target kernel’s BTF and patches the branch to a no-op when the field is absent, so a single .o runs on kernels that have or lack the field. It is not a way to reference a name that exists in no BTF at all.
Break 2 — Direct deref vs BPF_CORE_READ
Replace the macro form with direct deref:
event->ppid = task->real_parent->tgid;
Compiles. Verifier accepts. Runs. Faster than BPF_CORE_READ because no bpf_probe_read_kernel call — this is the trusted-PTR_TO_BTF_ID plain-load fast path we dissected earlier. The Verifier already knows task is a non-NULL task_struct, so it lets the JIT emit a bare load with no fault net.
But this is not a safety downgrade, and that’s the subtle part. When the verifier accepts task->real_parent->tgid, it doesn’t emit a bare load for the walked hop. real_parent is reached by following a pointer, so the resulting register is an untrusted PTR_TO_BTF_ID, and the verifier rewrites that tgid load into a BPF_LDX | BPF_PROBE_MEM with an exception-table entry (kernel/bpf/fixups.c). On a fault the load is skipped and the destination register is zero-filled — the same 0 you’d get from BPF_CORE_READ. So if real_parent were ever NULL, ppid comes back 0 and the event still fires; it does not crash, terminate, or lose the event. Only loads through genuinely trusted pointers (like task itself) stay bare — and those are exactly the pointers the verifier has proven non-NULL, so the NULL-deref scenario can’t arise for them.
So both forms are memory-safe and both zero-fill on a bad pointer. The real reasons to choose between them:
BPF_CORE_READemits abpf_probe_read_kernelhelper call (slower), but works even when the verifier can’t hand you a typed pointer — e.g. a kprobe’spt_regscast, or a non-BTF program type. It also keeps the whole chaineda, b, cexpression portable.- Direct deref of a verifier-typed
PTR_TO_BTF_IDemits an inline load (faster, no call). The verifier auto-rewrites the walked/untrusted hops toBPF_PROBE_MEMso they fault-protect and zero-fill too; only the proven-non-NULL trusted hop stays bare.
Break 3 — Forget #include <bpf/bpf_core_read.h>
BPF_CORE_READ is undefined; compile fails. Trivial, but worth noting: BPF helper headers are split across bpf_helpers.h (general), bpf_tracing.h (BPF_PROG, ctx unpacking, PT_REGS_*), bpf_core_read.h (CO-RE macros). Forget any of them and the failure mode is “macro X not defined.”
What to read in the kernel
tools/lib/bpf/relo_core.c— the CO-RE engine in userspace.bpf_core_calc_relo_insn(relo_core.c:1297) computes the relocation value from target-kernel BTF, andbpf_core_patch_insn(relo_core.c:1041) writes it into the instruction’s offset/immediate. ~300 lines, accessible if you know what to look for.tools/lib/bpf/btf.c— readbtf__find_by_name_kind(btf.c:1166). This is how libbpf finds types in BTF by name. CO-RE is built on top of it.tools/lib/bpf/bpf_core_read.h— searchBPF_CORE_READ. The macros expand to a chain ofbpf_core_read(kernel-side) calls;bpf_core_field_existsis at bpf_core_read.h:187-188. Reading them once eliminates magic.include/uapi/linux/btf.h— theBTF_KIND_*enums. BTF has ~19 kinds (int, ptr, array, struct, union, enum, fwd, typedef, volatile, const, restrict, func, func_proto, var, datasec, float, decl_tag, type_tag, enum64).BTF_KIND_ENUM64 = 19at uapi/btf.h:92, withNR_BTF_KINDSat :94 andBTF_KIND_MAXat :95.struct btf_typeis also defined here (at :43);include/linux/btf.honly forward-declares it (at :113). Skim.include/linux/sched.h— thestruct task_structwe read today:pid(1063),tgid(1064),real_parent(1077),parent(1080),comm(1173). Skim the field comments to feel how much state hangs off one task.
Bullet Points
- CO-RE = Compile Once, Run Everywhere. Field offsets are resolved at load time using target-kernel BTF.
task_structis the per-thread descriptor;currentis the running one;bpf_get_current_task_btf()returns it. User-visible PID =tgid, user-visible PPID =real_parent->tgid;comm[16]is the thread name.- BPF can’t blindly deref kernel pointers.
bpf_probe_read_kernelcopies viacopy_from_kernel_nofaultand zero-fills on fault (the source of “default 0”); it’s GPL-only, which is why CO-RE programs needSEC("license")="GPL". PTR_TO_BTF_ID(_TRUSTED) = a typed, non-NULL kernel pointer the Verifier lets you deref directly with no NULL check and no helper — the fast path behind direct deref.- Use
BPF_CORE_READ(a, b, c, d)to chain field accesses through pointer hops with fault-handling on each hop. - Use direct deref (
task->real_parent->tgid) when the pointer chain is trusted and you want speed. - Handle missing fields with
bpf_core_field_exists— libbpf will patch the access to a no-op when the field is absent. - BTF kinds you’ll meet:
STRUCT,UNION,ENUM,INT,PTR,ARRAY,FUNC,TYPEDEF. About 19 total.
Check question
You compile your program against kernel A. Field task_struct.real_parent is at offset 1872. You ship the .o to a machine running kernel B where the same field is at offset 1920. Your program does BPF_CORE_READ(task, real_parent, tgid). Walk through what happens at load time.
Click to reveal answer
Answer: libbpf opens /sys/kernel/btf/vmlinux on kernel B, parses it, finds the type task_struct and its field real_parent, computes byte offset 1920. It walks every CO-RE relocation in your .o. For the real_parent access, it overwrites the placeholder offset (the 0xC0RE... immediate the compiler emitted) with 1920. Same for tgid. Then BPF_PROG_LOAD is called with the patched instructions. The Verifier accepts. The program runs, reading the correct fields on kernel B. At runtime each hop goes through bpf_probe_read_kernel, so even if a pointer were bad the program gets a 0 rather than crashing.
Tomorrow
Day 4: meet the Verifier properly. We’ll spend a whole day deliberately tripping over PTR_TO_MAP_VALUE_OR_NULL rejections in five different shapes — because every BPF programmer, no matter how senior, hits this error monthly. The goal: stop being surprised by the rejection and start reading the log fluently. (Today’s PTR_TO_BTF_ID was a preview of the type lattice we formalize there.)