eBPF CO-RE:跨内核版本可移植 BPF 程序设计实战——从 BTF 到 libbpf 的生产级深度指南
引言:BPF 程序的"一次编写,到处运行"之梦
2019 年之前,编写一个能在生产环境中稳定运行的 eBPF 程序需要解决一个令人头疼的问题:内核数据结构在不同版本之间频繁变化——字段重命名、结构体嵌套调整、类型定义迁移。传统的解决方案是在目标机器上安装完整的内核头文件(kernel-headers),然后通过 bpf() 系统调用加载预编译的 BPF 对象文件。这种方式意味着:每个运行不同内核版本的机器都需要重新编译 BPF 程序。
2019 年,Andrii Nakryiko 提出了 BPF CO-RE(Compile Once, Run Everywhere) 范式,并在此基础上构建了 libbpf 的 CO-RE 支持。CO-RE 的核心思想是:利用内核提供的 BTF(BPF Type Format) 类型元数据,在 BPF 加载时动态适配目标内核的数据结构布局,从而实现编译出的 BPF 二进制文件可以在任意内核版本上运行——无需重新编译。
截至 2026 年,CO-RE 已经成为生产级 eBPF 工具的事实标准:Cilium、Pixie、Falco、Tetragon 等主流项目均基于 CO-RE 构建。本文将从内核源码级别的细节出发,深入解析 CO-RE 的实现机制,并通过多个生产级代码示例展示如何编写可移植的 BPF 程序。
一、BTF:内核数据的类型指纹
BTF(BPF Type Format)本质上是一种紧凑的类型描述格式,类似于 DWARF 调试信息但更轻量。内核在编译时通过 CONFIG_DEBUG_INFO_BTF=y 配置选项,会生成一个 .btf 段嵌入到 vmlinux 镜像中,包含了内核所有类型和函数的精确描述。
1.1 BTF 的本质结构
BTF 采用类型链(type chain)的链表结构,每个类型由一个唯一的 BTF 类型 ID 标识:
// include/uapi/linux/btf.h
struct btf_header {
__u16 magic; // 0xeB9F (little endian)
__u8 version; // 目前为 1
__u8 flags;
__u32 hdr_len;
__u32 type_off;
__u32 type_len;
__u32 str_off;
__u32 str_len; // 字符串表长度
};
// 通用类型描述头
struct btf_type {
__u32 name_off; // 名称在字符串表中的偏移
__u32 info; // 编码了 vlen、kind、kind_flag
union {
__u32 size; // 用于 STRUCT/UNION/ENUM 等
__u32 type; // 指向另一个类型的 ID
};
};
BTF 支持以下类型种类(kind):
| Kind 枚举 | 用途 | 示例 |
|---|---|---|
BTF_KIND_INT |
整型 | int, unsigned long |
BTF_KIND_PTR |
指针类型 | struct task_struct * |
BTF_KIND_ARRAY |
数组 | char comm[16] |
BTF_KIND_STRUCT |
结构体 | struct sock {} |
BTF_KIND_UNION |
联合体 | union bpf_attr {} |
BTF_KIND_ENUM |
枚举 | enum bpf_prog_type |
BTF_KIND_TYPEDEF |
类型别名 | typedef u64 u64_t |
BTF_KIND_FUNC |
函数原型 | bool func(struct pt_regs *) |
BTF_KIND_FUNC_PROTO |
函数签名 | 参数和返回值类型 |
BTF_KIND_DATASEC |
数据段 | .bss, .data, .kconfig |
BTF_KIND_DECL_TAG |
声明标签 | C23 [[reorders]] 语义 |
1.2 查看系统的 BTF 信息
我们可以通过 bpftool 工具查看系统 BTF 信息:
# 检查内核是否支持 BTF
bpftool btf list
# 输出示例:
# 1: name [vmlinux] size 5823160B
# 查看特定结构体的 BTF 描述
bpftool btf dump file /sys/kernel/btf/vmlinux format c | grep -A 30 "struct task_struct"
# 查看内核 BPF 程序关联的 BTF
bpftool prog show
1.3 BTF 读取路径
libbpf 在加载 BPF 程序时,会从以下路径读取目标内核的 BTF:
/sys/kernel/btf/vmlinux— 主内核的 BTF(自 Linux 5.4 起标准提供)/sys/kernel/btf/<module_name>— 模块 BTF(用于跟踪模块内部)- 用户通过
bpf_object__set_kversion()隐式指定的外部 BTF 文件
二、CO-RE 的核心机制:重定位记录
CO-RE 的魔法隐藏在 BPF 对象文件的重定位段(.BTF.ext)中。当 clang 编译启用了 CO-RE 的 BPF 程序时,会生成特殊的重定位记录。libbpf 在加载时读取目标内核的 BTF,然后进行动态匹配和重定位。
2.1 重定位的制作过程
当 BPF 程序中包含一句简单的 task->pid 访问时,编译链路执行以下步骤:
- 源码解析:编译器识别出
task是一个struct task_struct *类型 - 字段访问解析:
->pid被解析为访问task_struct结构体中pid字段的偏移量——但此时不硬编码偏移量 - 生成重定位记录:在
.BTF.ext段中记录一条重定位条目,描述"在指令偏移 X 处需要访问结构体task_struct的字段pid"
重定位记录的核心在于:在编译时只记录"意图"(访问哪个结构体的哪个字段),在加载时通过 BTF 解析"意图"的实际偏移量。
2.2 read_record 与 read_impl
阅读 libbpf 源码可以看到 CO-RE 工程的实现细节:
// libpf/src/bpf_core.c(简化示意)
struct bpf_core_relo {
__u32 insn_idx; // BPF 指令偏移
__u32 type_id; // 本地 BTF 的类型 ID
__u32 access_str_off; // 访问路径字符串(如 "pid")
enum bpf_core_relo_kind relo_kind;
};
// 重定位结果的 enum
enum bpf_core_relo_kind {
BPF_CORE_RELO_FIELD_BYTE_OFFSET, // 字段字节偏移
BPF_CORE_RELO_FIELD_BYTE_SIZE, // 字段字节大小
BPF_CORE_RELO_FIELD_EXISTS, // 字段是否存在
BPF_CORE_RELO_FIELD_SIGNED, // 字段是否有符号
BPF_CORE_RELO_TYPE_ID_LOCAL, // 本地类型 ID
BPF_CORE_RELO_TYPE_ID_TARGET, // 目标类型 ID
BPF_CORE_RELO_TYPE_EXISTS, // 类型是否存在
BPF_CORE_RELO_ENUMVAL_EXISTS, // 枚举值是否存在
BPF_CORE_RELO_ENUMVAL_VALUE, // 枚举值内容
};
三、libbpf CO-RE 编程实践
3.1 基础框架:一个最小的 CO-RE BPF 程序
下面展示一个追踪进程创建的 CO-RE BPF 程序。这个程序可以在 5.4 到任何较新内核上运行,无需重新编译:
// trace_process.bpf.c
#include "vmlinux.h" // 由 bpftool 生成的精简 BTF 头文件
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h> // CO-RE 读取宏
// 定义 ring buffer map
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
// 事件结构体
struct event {
__u32 pid;
__u32 tgid;
char comm[16];
__u64 timestamp;
};
// 跟踪点:进程创建
SEC("tp/sched/sched_process_fork")
int BPF_PROG(trace_sched_fork, struct task_struct *parent,
struct task_struct *child)
{
struct event *e;
// 使用 bpf_core_read() 读取——自动 CO-RE 重定位
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;
e->pid = BPF_CORE_READ(child, pid); // 字段偏移量自动适配
e->tgid = BPF_CORE_READ(child, tgid);
bpf_core_read_str(&e->comm, sizeof(e->comm), child->comm);
e->timestamp = bpf_ktime_get_ns();
bpf_ringbuf_submit(e, 0);
return 0;
}
char _license[] SEC("license") = "GPL";
3.2 vmlinux.h:类型的"圣经"
C-RE 程序的第一行通常是 #include "vmlinux.h"。这个文件由 bpftool btf dump 命令生成:
# 从内核 BTF 生成 vmlinux.h
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
# 文件通常约 100 万行,包含内核所有类型定义
这个头文件是纯类型定义,不包含任何函数实现或宏,因此编译后不占用任何运行时内存。
3.3 BPF_CORE_READ 宏家族详解
libbpf 提供了一组宏用于安全读取内核数据:
// 方式 1:直接字段读取
pid_t pid = BPF_CORE_READ(task, pid);
// 方式 2:指针链式解引用(自动处理 NULL)
unsigned int policy = BPF_CORE_READ(task, signal, sched_policy);
// 方式 3:数组访问
comm_t comm = BPF_CORE_READ(task, group_leader, comm);
// 方式 4:bpf_core_read_str 用于字符串安全拷贝
char comm[16];
bpf_core_read_str(comm, sizeof(comm), &task->comm);
// 方式 5:带错误检查的读取
int ret;
u64 val = bpf_core_read(&ret, sizeof(ret), &task->start_time);
关键区别:BPF_CORE_READ 宏会生成重定位记录,而 bpf_probe_read_kernel() 需要硬编码偏移量,不享受 CO-RE 重定位的好处。
3.4 高级数据结构兼容性处理
生产环境中,内核数据结构在不同版本间可能有严峻变化。CO-RE 提供了几种处理策略:
策略一:字段存在性检查(field_exists)
struct event e = {};
// 字段可能在新内核中存在,旧内核中不存在
BPF_CORE_READ_INTO(&e.cgrp_id, task, cgroup, kn, id);
// 更安全的做法:先检查再读取
if (bpf_core_field_exists(task->sch, band_q))
BPF_CORE_READ_INTO(&e.bw_band_q, task, sch, band_q);
策略二:类型重定义与条件编译
// 在不同内核版本中,cgroup 相关结构体定义不同
#if __has_include("custom_structs.h")
#include "custom_structs.h"
#else
// 定义兼容结构体,按需填充字段
struct my_cgroup_compat {
struct kernfs_node *kn;
int id;
};
#endif
策略三:内核版本适配层
// 获取内核版本
extern __u32 LINUX_KERNEL_VERSION __kconfig;
SEC("tp/sched/sched_process_exec")
int trace_exec(struct trace_event_raw_sched_process_exec *ctx)
{
__u32 pid = bpf_get_current_pid_tgid() >> 32;
if (LINUX_KERNEL_VERSION >= KERNEL_VERSION(5, 15, 0)) {
// 5.15+ 内核的新字段处理
__u32 new_field = BPF_CORE_READ(task, sched, bw);
} else if (LINUX_KERNEL_VERSION >= KERNEL_VERSION(5, 8, 0)) {
// 5.8-5.14 内核的兼容路径
} else {
// 旧内核回退路径
}
}
3.5 重定位失败的优雅处理
CO-RE 最优雅的设计之一是 失败可以是"软"的:如果某个重定位完全无法解析(比如字段被彻底移除),libbpf 可以让加载时返回错误或由程序自己决定行为:
// 使用 BPF_CORE_READ 读取可能不存在的字段时,
// libbpf 会尝试查找匹配字段;若失败,可以捕获:
pid_t BPF_CORE_READ(task, pid); // 旧内核中 pid 可能不存在(改名为 __pid)
// 替代方案:使用 bpf_core_enum_value 和 bpf_core_type_id 进行元编程
long val = bpf_core_enum_value(enum task_state, __TASK_STOPPED);
四、构建系统与 Makefile
生产环境中,CO-RE 项目的构建系统通常如下:
# Makefile
CLANG ?= clang
LLVM_STRIP ?= llvm-strip
BPFTOOL ?= bpftool
ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' | sed 's/aarch64/arm64/')
# 生成 vmlinux.h(仅本地开发时;CI 中使用预生成版本)
vmlinux.h:
$(BPFTOOL) btf dump file /sys/kernel/btf/vmlinux format c > $@
# 编译 BPF 对象文件
%.bpf.o: %.bpf.c vmlinux.h
$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \
-I/usr/include/$(shell uname -m)-linux-gnu \
-c $< -o $@
$(LLVM_STRIP) -g $@
# 生成 skeleton 头文件
%.skel.h: %.bpf.o
$(BPFTOOL) gen skeleton $< > $@
# 编译用户态程序
trace_process: trace_process.c trace_process.skel.h
$(CC) -g -O2 -Wall -I. -o $@ $< -lbpf -lelf -lz
.PHONY: clean
clean:
rm -f *.bpf.o *.skel.h vmlinux.h trace_process
4.1 Skeleton:用户态的自动绑定
bpftool gen skeleton 生成的 skeleton 头文件提供了面向对象的 C API,自动处理 map、program 和 link 的生命周期管理:
// trace_process.c(用户态部分)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "trace_process.skel.h"
static volatile bool exiting = false;
static void sig_handler(int sig) {
exiting = true;
}
static int handle_event(void *ctx, void *data, size_t data_sz) {
struct event *e = data;
printf("[%llu] PID=%d TGID=%d COMM=%s forks\n",
e->timestamp, e->pid, e->tgid, e->comm);
return 0;
}
int main(int argc, char **argv) {
struct trace_process_bpf *skel;
struct ring_buffer *rb = NULL;
int err;
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);
// 打开并加载 skeleton(自动完成 BPF 加载、CO-RE 重定位、attach)
skel = trace_process_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
// Attach 跟踪点(自动创建 bpf_link)
err = trace_process_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}
// 设置 ring buffer 回调
rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
if (!rb) {
err = -1;
goto cleanup;
}
printf("Successfully started! Press Ctrl+C to stop.\n");
while (!exiting) {
err = ring_buffer__poll(rb, 100);
if (err == -EINTR) { err = 0; break; }
if (err < 0) break;
}
cleanup:
ring_buffer__free(rb);
trace_process_bpf__destroy(skel);
return err < 0 ? -err : 0;
}
五、生产级部署中的 CO-RE
5.1 目标机器无 BTF 时的策略
虽然 Linux 5.4+ 标准启用了 BTF,但某些生产环境(尤其是嵌入式或旧发行版)仍可能没有 BTF。此时有两种策略:
策略 A:嵌入外部 BTF 文件
将目标内核对应的 BTF 文件打包分发:
// 使用 libbpf API 指定外部 BTF 文件
struct bpf_object_open_opts opts = {
.sz = sizeof(opts),
.btf_custom_path = "/path/to/target_kernel.btf",
};
struct bpf_object *obj = bpf_object__open_file("prog.bpf.o", &opts);
可以通过以下方式获取特定内核的 BTF 文件:
# 从 system.btf 仓库下载
wget https://github.com/aquasecurity/btfhub/raw/main/ubuntu/22.04/x86_64/5.15.0-1-generic.btf
# 或从磁盘上的 vmlinux 提取
bpftool btf dump file /usr/lib/debug/boot/vmlinux-5.15.0-1-generic format c > kernel.btf
策略 B:预编译多个兼容性变体
对于内核版本差异极大的环境,可以在 CI 中针对每个主要内核版本编译一次,运行时自动选择对应版本。
5.2 重定位调试技巧
当 CO-RE 程序因重定位失败而无法加载时,libbpf 通常会打印详细错误信息:
# 开启 libbpf 调试日志
LIBBPF_LOG_LEVEL=2 ./trace_process
# 输出示例:
# libbpf: prog 'trace_sched_fork': relo #1: patched insn #12 (LDX/ST/STX)
# target 'task_struct->pid' from offset 0x3 -> 0x4a8
也可以使用 bpftool 检查 BPF 对象文件中的重定位需求:
bpftool gen object out.bpf.o trace_process.bpf.o
bpftool prog load out.bpf.o /sys/fs/bpf/trace_process type tracepoint
5.3 性能考量
CO-RE 的性能开销可以忽略不计:
- 加载时:重定位在 BPF 加载阶段(
bpf()系统调用期间)完成,不产生运行时开销 - 运行时:BPF 指令中的偏移量是硬编码的最终值,与手动编译的程序等效
- BTF 解析:libbpf 对 BTF 进行缓存,同一程序集的重定位查找为 O(1) 时间
实测数据:一个典型的 10 指令 CO-RE 程序在加载阶段增加的开销 < 100 微秒。
六、CO-RE 与 BTFHub:边缘节点的终极方案
对于无法修改内核或使用保留 BTF 内核的环境,BTFHub 项目提供了预编译的 BTF 归档文件,覆盖了主流发行版(Ubuntu、Debian、CentOS、Amazon Linux 等)几千个内核版本。
CO-RE 程序在加载时的 BTF 查找顺序为:
bpf_object_open_opts.btf_custom_path指定的自定义路径/sys/kernel/btf/vmlinux(内核内置)/boot/vmlinux-$(uname -r)(可调试内核镜像)/usr/lib/debug/boot/vmlinux-$(uname -r)(调试符号包)/usr/lib/modules/$(uname -r)/build/vmlinux(kernel-headers)
如果上述所有路径都失败,且程序无法容忍,就需要外部 BTF 文件作为兜底。
七、进阶主题:条件编译与类型重映射
7.1 使用 __builtin_preserve_access_index
clang 扩展了属性 __attribute__((preserve_access_index)),使得对结构体成员的访问自动产生 CO-RE 重定位记录:
// 类型定义时添加属性,所有访问自动产生重定位
struct task_struct __attribute__((preserve_access_index));
// 这样常规解引用访问也能自动重定位
pid_t pid = task->pid; // 自动产生重定位,无需 BPF_CORE_READ
7.2 bpf_core_cast 与类型变换
当目标内核的数据类型与本地定义不同时,可以使用 bpf_core_cast() 进行安全类型转换:
// 假设本地 BTF 中 sock_common 的定义与目标内核不同
struct sock_common *skc = bpf_core_cast(sk, struct sock_common);
7.3 bpf_core_enum_value 与动态枚举获取
// 安全获取可能不存在的枚举值
long val = bpf_core_enum_value(enum task_state, __TASK_STOPPED);
if (val < 0) {
val = 0; // 回退默认值
}
八、最佳实践总结
编写健壮 CO-RE 程序的核心原则:
- 始终使用
BPF_CORE_READ宏家族,避免bpf_probe_read_kernel()直接读取 - 将自定义结构体限制到最小,只在
vmlinux.h中添加 BPF 程序实际需要的类型 - 对"可选"字段使用
bpf_core_field_exists()包装,提供内核版本回退路径 - 在 CI 中集成多个内核版本的重定位验证,确保跨版本兼容性
- 将 BTF 文件纳入 CI 制品,与 BPF 二进制一起的分发包
- 避免在 BPF C 代码中使用内核头文件宏——宏不能跨版本重定位,直接使用 BTF 读取的整数值
- 对于数据类型变化剧烈的结构体(如
struct tcp_sock),考虑使用bpf_core_read_str()配合字段偏移量硬编码的 fallback 方案
总结
CO-RE 的出现标志着 eBPF 开发从"工匠模式"(手工计算offset、手动适配版本)向"工程化模式"(声明式跨版本兼容)的范式转变。借助 BTF 提供的精确类型信息和 libbpf 的重定位引擎,今天的 eBPF 开发者可以真正做到"编写一次,到处运行"。
对于正在建设 eBPF 基础设施的团队来说,CO-RE 不仅大幅降低了维护和部署成本,还带来了更可靠的安全保证——不再有因为内核升级导致字段偏移变化而静默读取错误数据的风险。配合 BTFHub 的广泛 BTF 覆盖和 libbpf 日趋成熟的多内核适配能力,CO-RE 已经成为生产级 eBPF 项目不可动摇的基石。
未来,随着 BPF 类型格式进一步演进(如 BPF Type Format v2 的讨论)和 Linux 内核持续提供更丰富的 BTF 注解(如 __builtin_preserve_field_info),CO-RE 的能力边界还将进一步扩展,可能实现跨子系统、跨架构的完全透明适配。

发表评论 取消回复