A look at eBPF's CO-RE
BPF CO-RE(Compile once , Run Everywhere) is a mechanism that allows eBPF programs to work across different kernel version and configurations without recompiling
But How?
The secret to this is BTF(BPF Type Format) of course. BTF is a compact, binary-encoded type information format used by the Linux kernel and eBPF ecosystem. Think of it as a lightweight , space-efficient version of DWARF debug info (used in normal binaries). It describes
- Structs
- Unions
- Enums
- Typedefs
- Function prototypes
so that eBPF programs can understand kernel data structures definition of the target kernel version at runtime. It came about about because internal types such as struct task_struct would change implementations in different kernel versions.
.
Here's a simplified example
Linux 5.4
struct task struct {
pid_t pid;
pid_t tgid;
struct files_struct *files;
struct mm_struct *mm;
...
};
Linux 6.1
struct task_struct {
pid_t pid;
pid_t tgid;
struct mm_struct **mm;
struct files_strcut *files*
...
};
As you can see, the order of mm and files changes, resulting in the offsets of the fields changing too. Without CO-RE , an eBPF program compiled for 5.4 would read the wrong memory location on kernel 6.1, possibly crashing or giving garbage data.
The relocation steps
Step 1: Compilation
The compiler emits two sections:
.BTF section describing type used by your program.
.BTF.ext section containing CO-RE relocation entries that describe which fields and types your code depends on.
Each CO-RE relocation entries is defined in the Linux kernel as follows
struct bpf_core_relo{
__u32 insn_off; // instruction offset needing fix
__u32 type_id; // which type this refers to (from .BTF)
__uu32 access_str_off; //string diescribing field access
enum bpf_core_relo_kind kind; // what kind of relocation
}
insn_off : Where in your eBPF program this relocation applies(which instruction).
type_id : The BTF type ID of the type being accessed (e.g struct task_struct).
access_str_off : String offset describing what field or member is being accessed (e.g "->pid" or "->files>f_inode->i_ino").
kind : what is being relocated: field offset, field size, existence, type match , etc.
Step 2: Loading into kernel
The kernel also has it's own BTF in /sys/kernel/btf/vmlinux, describing the kernel's structs and types.
BPF loader such as libbpf compares your program's BTF(from .BTF) to the kernel's own BTF and uses CO-RE relocations(from .BTF.ext)to adapt your code.
So basically BTF provides field offsets, sizes and type IDs for the current kernel and CO-RE uses those offset to patch you program's load instructions so they access correct kernel memory locations.
Practical example of CO-RE
Suppose your program:
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
pid_t pid = BPF_CORE_READ(task, pid);
Step 1 : Program BTF
When you compile, Clang emits BTF describing your struct (from headers).
[1] STRUCT 'task_struct' size=7488 vlen=5
'pid' type_id=2 bits_offset=48
[2] INT 'pid_t' size=4 bits_offset=0
This means your program knows that task struct has a field named pid.
Step 2: CO-RE relocation
Clang also emits a relocation entry like this.
struct bpf_core_relo{
.ins_off = 0x20, //isntruction to fix
.type_id = 1, // refers to task_struct
.access_str_off = "pid", // field to access
.kind = BPF_CORE_FIELD_BYTE_OFFSET // what to relocate
};
So the compiler is saying:
"At instruction 0x20, I'm accessing task_struct.pid.pid.Replace assume offset with actual offset in current kernel."
Step 3 - kernel BTF (/sys/kernel/btf/vmlinux))
In the running kernel the struct might look like this:
[500] STRUCT "task_struct" size=800 vlen=5
'pid' type_id=100 bits_offset=64
Here , the pid field is at offset 64 bits (not 48 anymore).
STEP 4 - CO-RE applies relocation
When libbpf loads a program:
- It reads
.BTFand.BTF.extsections from the program. - It reads the kernel's BTF from
/sys/kernel/btf/vmlinux. - It matches the
task_structby name and layout. - It finds
pid's real offset = 64 bits = 8 bytes. - It patched the instruction at
insn_off=0x20with new offset8. So your code:
BPF_CORE_READ(task, pid)
becomes equivalent to :
*(pid_t *)((char *)task + 8)
even thought the local kernel headers said the offset was different.
In essence, BPF CO-RE and BTF bridge the gap between kernel evolution and eBPF portability — allowing developers to write once, and run safely across countless kernel versions without worrying about changing data layouts.