CO-RE doesn’t make eBPF programs portable by magically translating them; it uses BTF to describe the kernel’s data structures, allowing your eBPF program to adapt to different kernel versions on the fly.

Let’s see what this looks like in practice. Imagine we want to trace a specific field within the task_struct in the Linux kernel, like the process name (comm). Without CO-RE, you’d typically write a program that directly accesses task->comm. This works fine on your kernel, but if you try to run it on a kernel where task_struct has changed (e.g., a new field added, or comm moved), your program will likely crash or produce garbage.

Here’s a simplified eBPF program using the cilium/ebpf Go library, demonstrating the CO-RE approach. We’ll use bpf_core_read to safely access task->comm.

package main

import (
	"bytes"
	"encoding/binary"
	"fmt"
	"log"

	"github.com/cilium/ebpf"
	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf.examples/resources/bpf"
)

// $BPF_CLANG and $BPF_FLAGS are set by the Makefile.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf ./bpf/hello.c -- -I./bpf

func main() {
	// Load the eBPF program.
	objs := bpf.MustLoadCollectionObjects()
	defer objs.Close()

	// This is the eBPF program itself (from hello.c).
	// It attaches to kprobes, specifically the tracepoint:syscalls:sys_enter_execve.
	// Inside the eBPF program, we'll use bpf_core_read to get the task_struct's comm field.

	// Open a link to the kernel tracepoint.
	// The tracepoint is defined in the C code and is usually something like:
	// SEC("tp/syscalls/sys_enter_execve")
	// int tp_syscalls_sys_enter_execve(struct trace_event_raw_execve *ctx) { ... }
	kp, err := link.Tracepoint("syscalls", "sys_enter_execve", objs.ProgramByName("tp_syscalls_sys_enter_execve"), nil)
	if err != nil {
		log.Fatalf("Could not create tracepoint: %v", err)
	}
	defer kp.Close()

	fmt.Println("Successfully loaded eBPF program and attached to tracepoint. Press Enter to quit.")

	// Read events from the eBPF map.
	// The eBPF program writes the process name to a map.
	// We'll read it here.
	var (
		key   uint32
		value bytes.Buffer
	)
	iter := objs.MapByName("events").Iterate()
	for iter.Next(&key, &value) {
		fmt.Printf("Event %d: Process name: %s\n", key, value.String())
	}
	if err := iter.Err(); err != nil {
		log.Fatalf("Map iteration failed: %v", err)
	}

	// Wait for user input to exit.
	fmt.Scanln()
}

And here’s the corresponding eBPF C code (bpf/hello.c):

#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "GPL";

struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 256 * 1024);
} events SEC(".maps");

// This is the function that will be attached to the tracepoint.
// We're using the tracepoint for syscalls:sys_enter_execve for demonstration.
SEC("tp/syscalls/sys_enter_execve")
int tp_syscalls_sys_enter_execve(struct trace_event_raw_execve *ctx) {
	struct task_struct *task;
	unsigned int pid;

	// Get the current PID.
	pid = bpf_get_current_pid_tgid() >> 32;

	// Safely read the task_struct pointer.
	// bpf_get_current_task() is a helper that returns a pointer to the current task_struct.
	task = (struct task_struct *)bpf_get_current_task();
	if (!task) {
		return 0;
	}

	// Use bpf_core_read to access the 'comm' field.
	// This is the CO-RE magic. bpf_core_read knows how to find 'comm'
	// even if its offset within task_struct changes across kernel versions.
	char comm[TASK_COMM_LEN];
	bpf_core_read(&comm, sizeof(comm), &task->comm);

	// Submit the process name to the ring buffer.
	struct {
		__u64 pid;
		char comm[TASK_COMM_LEN];
	} event = {};

	event.pid = pid;
	__builtin_memcpy(&event.comm, comm, sizeof(comm));

	bpf_ringbuf_output(&events, &event, sizeof(event), 0);

	return 0;
}

The key here is bpf_core_read(&comm, sizeof(comm), &task->comm);. This function, when compiled with CO-RE support, doesn’t hardcode the offset of comm within task_struct. Instead, it relies on BTF (BPF Type Format) information embedded in the kernel. When your eBPF program loads, the CO-RE loader consults the BTF data for the running kernel to find the correct location of task->comm. If the kernel has changed, and comm is now at a different offset, CO-RE will discover this via BTF and adjust how bpf_core_read accesses the memory, ensuring your program continues to work without recompilation.

This mechanism is powered by two main components:

  1. BTF (BPF Type Format): This is a compact, efficient representation of C type information, embedded directly within the kernel image (or as a separate module). It describes data structures, their fields, and their types. vmlinux.h (generated by tools like bpftool) provides eBPF programs with access to these kernel types.

  2. CO-RE (Compile Once - Run Everywhere): This is a set of compiler (Clang) and loader (libbpf, or Go libraries like cilium/ebpf) features. The compiler generates eBPF bytecode that uses generic accessors (like bpf_core_read). The loader, at runtime, uses BTF information from the target kernel to resolve these generic accesses to the specific memory locations for that kernel version.

How it Works Internally (The Magic):

When you compile your eBPF program with bpftool or a similar tool that leverages Clang’s CO-RE support, the generated eBPF bytecode doesn’t contain direct memory offsets for kernel data structures. Instead, it contains instructions that mark specific data accesses as needing "relocation." For example, bpf_core_read(&comm, sizeof(comm), &task->comm); generates an instruction that says, "read sizeof(comm) bytes from the memory location pointed to by task->comm."

When this eBPF program is loaded into a kernel, the eBPF loader (often libbpf or its equivalents in other languages) inspects the program’s relocation entries. For each relocation, it queries the kernel’s BTF information. It looks up the definition of struct task_struct and then finds the specific field comm. BTF provides the byte offset of comm within task_struct. The loader then patches the eBPF program’s bytecode in memory by replacing the generic access with an instruction that includes the correct, kernel-specific byte offset. This means that for every kernel you run your program on, the eBPF loader dynamically adjusts the program’s instructions to match that kernel’s data structure layout.

What most people don’t realize is that BTF isn’t just for eBPF programs. It’s a general-purpose type description format that can be used for other kernel introspection tasks as well. eBPF, through CO-RE, is the primary consumer of BTF that allows userspace applications to leverage this rich type information in a portable way, making the eBPF ecosystem significantly more robust against kernel churn.

The next step after mastering CO-RE and BTF is exploring how to use these mechanisms for more complex data structure interactions, like tracing events involving network packets (struct sk_buff) or file system operations, where the data structures can be significantly more intricate.

Want structured learning?

Take the full Ebpf course →