Before the advent of CO-RE (Compile Once – Run Everywhere), writing eBPF tracing programs was a logistical nightmare for enterprise deployments. Because eBPF programs read internal kernel data structures (like task_struct or sk_buff), they are deeply tied to the exact memory layout of the kernel they are running on. If you compiled an eBPF tool on Ubuntu 20.04 (Kernel 5.4), and deployed the exact same binary to Ubuntu 22.04 (Kernel 5.15), it would immediately crash because the internal struct offsets had changed.
Historically, tools like BCC (BPF Compiler Collection) solved this by embedding a massive Clang/LLVM compiler compiler inside the tool itself. Every time you ran the tool on a new server, it would compile the C code on the fly against the local kernel headers. This consumed massive CPU resources, took several seconds to start, and required installing heavy compiler toolchains on production servers—a massive security violation.
eBPF CO-RE eliminates this. Leveraging BPF Type Format (BTF) data embedded in modern Linux kernels, developers can compile an eBPF C program exactly once. The resulting lightweight binary can be deployed to any modern Linux distribution and will automatically, dynamically adjust its memory offsets at runtime to match the host kernel.
Understanding CO-RE Architecture
CO-RE relies on three interlocking components:
- BTF (BPF Type Format): Modern Linux kernels (typically 5.8+) are compiled with
CONFIG_DEBUG_INFO_BTF=y. This embeds a highly compressed map of every single struct, union, and typedef directly into the kernel image (viewable at/sys/kernel/btf/vmlinux). - Clang/LLVM BPF Backend: When you compile your eBPF C code, Clang records “relocations”—essentially leaving placeholders in the byte code saying, “I need to read field X of struct Y, but I don’t know the exact memory offset yet.”
- libbpf (The Loader): When your user-space Go or C application loads the eBPF byte code into the kernel,
libbpfreads the local/sys/kernel/btf/vmlinuxfile, calculates the exact memory offsets for that specific machine, and patches the byte code on the fly before injecting it into the kernel.
Step 1: Generating the vmlinux.h Header
To write CO-RE eBPF code, you do not use standard Linux header files (which are version-specific). Instead, you generate a massive, single header file containing all the BTF data from your kernel.
Using the bpftool utility, dump the BTF definitions into a header file:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
You will include this single vmlinux.h file in your eBPF C code instead of traditional headers like <linux/sched.h>.
Step 2: Writing the CO-RE eBPF C Code
Let’s write a simple eBPF program that hooks into the execve system call to trace process executions. Notice how we use the BPF_CORE_READ macro provided by libbpf.
Create a file named trace_exec.bpf.c:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
// Define a map to send data to user-space
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(int));
__uint(value_size, sizeof(int));
} events SEC(".maps");
// Define the data structure we will send
struct event_t {
pid_t pid;
char comm[16];
};
SEC("tracepoint/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx) {
struct event_t event = {};
// Get the current task structure
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
// Extract the PID and command name safely using CO-RE macros
event.pid = BPF_CORE_READ(task, pid);
bpf_get_current_comm(&event.comm, sizeof(event.comm));
// Send the event to user-space
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}
char LICENSE[] SEC("license") = "GPL";
The magic happens at BPF_CORE_READ(task, pid). Clang does not hardcode the offset of pid. It records a BTF relocation.
Step 3: Compiling the eBPF Byte Code
Compile the C code into an eBPF object file (.o) using Clang. You must include the -g flag to embed BTF debug information, and specify the bpf target.
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I/usr/include/bpf -c trace_exec.bpf.c -o trace_exec.bpf.o
This trace_exec.bpf.o file is now fully portable. You can copy it to any Linux server running kernel 5.8+ (even completely different distributions) and it will run successfully.
Step 4: Loading the Program via libbpf (User-Space)
To run the program, you need a user-space loader written in C, Go, or Rust that links against libbpf.
A rudimentary C loader looks like this (error checking omitted for brevity):
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include <stdio.h>
int main(int argc, char **argv) {
struct bpf_object *obj;
struct bpf_program *prog;
struct bpf_link *link;
// Open and load the CO-RE byte code
// libbpf handles all the BTF relocations automatically here
obj = bpf_object__open_file("trace_exec.bpf.o", NULL);
bpf_object__load(obj);
// Find the specific program and attach it to the tracepoint
prog = bpf_object__find_program_by_name(obj, "trace_execve");
link = bpf_program__attach(prog);
printf("Successfully loaded CO-RE eBPF program. Tracing execve...\\n");
// Loop infinitely, waiting for events (in a real app, you would read the perf buffer here)
while (1) {
sleep(1);
}
return 0;
}
Conclusion
eBPF CO-RE fundamentally transforms how security and observability tools are distributed on Linux. By eliminating the need for runtime compilation and massive LLVM toolchains on production servers, developers can compile lightweight, highly performant eBPF binaries once, and confidently deploy them across massive, heterogeneous server fleets, knowing libbpf will flawlessly map kernel structures at runtime.