UB Chrinovic

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

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:

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.