BPF CO-RE 与 BTF:构建跨内核版本兼容的 eBPF 可观测性工程实战
引言
在 Linux 内核可观测性领域,eBPF 早已不是新鲜事。但困扰每一位 SRE 和平台工程师的真实痛点是:你精心编译的 BPF 程序,换一台机器、升一个内核版本就直接 bpf_probe_read 失败或 verifier 拒绝加载。传统方案要么要求目标设备安装完整的内核头文件(kernel-devel),要么在不同内核版本上反复编译分发——这在千节点云原生环境中几乎不可维护。
BPF CO-RE(Compile Once, Run Everyround)结合 BTF(BPF Type Format)的出现彻底解决了这一痛点。它通过编译时嵌入类型信息、运行时由 libbpf 自动适配目标内核的内存布局变更,实现了真正的"一次编译、到处运行"。本文将深入剖析其核心机制,并给出生产级部署的实战方案。
一、问题本质:为什么 eBPF 程序不能跨内核运行
1.1 内核数据结构的不稳定 ABI
Linux 内核从未承诺过数据结构的 ABI 稳定性。同一个 task_struct 在不同内核版本中,字段可能被重命名、删除、新增或调整顺序。例如 thread_struct 在 x86 下,sp 字段在 5.14 前位于偏移 0x28,5.14 后因引入 FPU 状态管理被移至变长区域。
// 传统硬编码方式(脆弱)
struct task_struct *task = ...;
u64 start_time;
bpf_probe_read(&start_time, sizeof(start_time),
(void *)task + 0x280); // 5.10 内核的偏移,5.12 就变了
1.2 条件编译方案的局限
传统做法是在编译时检测内核版本,选择不同代码路径:
#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 14, 0)
#define TASK_START_TIME_OFFSET 0x2E8
#else
#define TASK_START_TIME_OFFSET 0x280
#endif
问题在于:你无法预知用户运行的内核是否打过定制补丁、是否重构了关键字段。1000 台机器可能有 20 种不同的内核编译配置。
二、BTF:BPF 类型信息的二进制表示
2.1 BTF 是什么
BTF 是一种紧凑的类型元数据格式,描述了内核(或用户程序)中所有 C 类型信息:结构体字段、函数签名、枚举值、typedef 链等。类似 DWARF 但更简单,专为 BPF 场景设计。
内核通过 CONFIG_DEBUG_INFO_BTF=y 开启 BTF 生成(Linux 5.2+ 原生支持),生成的 BTF 数据位于 /sys/kernel/btf/vmlinux,并可通过 bpftool btf dump file /sys/kernel/btf/vmlinux format raw 导出查看。
# 检查内核是否支持 BTF
$ ls -la /sys/kernel/btf/vmlinux
-rw-r--r-- 1 root root 5568931 Oct 1 08:00 /sys/kernel/btf/vmlinux
# 导出可读格式
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c | head -50
typedef unsigned long long u64;
struct pt_regs {
unsigned long r15;
unsigned long r14;
...
};
2.2 BTF 的数据组织
BTF 数据按类型 ID(type id)组织为扁平数组,通过 ID 引用实现前向声明和递归类型。核心关系:
BTF Object
├── Type 0: void (implicit)
├── Type 1: int (INT, size=4)
├── Type 2: struct task_struct
│ ├── member "state" → Type 1, offset 0
│ ├── member "stack" → Type 5 (const void *), offset 8
│ └── ...
├── Type 3: typedef → Type 2 (task_t 别名)
└── ...
生产内核的 BTF 数据通常 3~6 MB,包含约 10~30 万个类型定义。
三、BPF CO-RE 核心机制
3.1 编译时重定位记录生成
BPF CO-RE 的核心思想是:编译时不确定具体偏移,只记录"我要访问 task_struct->start_time"这一意图。libbpf 利用 BTF 差异在运行期自动重定位。
流程如下:
┌─────────────────────────────────────────────────────────┐
│ 编译阶段 │
├─────────────────────────────────────────────────────────┤
│ CO-RE BPF 程序 (C) │
│ ↓ clang -g -O2 │
│ BPF ELF 对象 (.o) │
│ ├── .BTF section: BPF 程序引用的所有类型 │
│ ├── .BTF.ext section: 每条指令的 CO-RE 重定位记录 │
│ └── .bpf.o: 目标代码(含占位符) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 加载阶段 │
├─────────────────────────────────────────────────────────┤
│ libbpf 读取 /sys/kernel/btf/vmlinux │
│ ↓ │
│ 对比 .BTF 与 vmlinux BTF: │
│ - 结构体字段重定位 │
│ - 类型兼容性校验 │
│ - 可选的字段存在性检查 │
│ ↓ │
│ 就地修补 BPF 指令中的 64 位立即数为正确偏移 │
│ ↓ │
│ 提交 verifier 验证并加载 │
└─────────────────────────────────────────────────────────┘
3.2 三种核心宏:从用户到内核
libbpf 的 bpf_core_read.h 提供了三个关键宏:
// 推荐:bpf_core_read + 重定位(触发 CO-RE 重定位)
#define bpf_core_read(dst, sz, src) ({ \
bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src)); \
})
// 仅读取字段指针,不实际读值,用于字段存在性检查
#define bpf_core_field_ptr(type, field) \
__builtin_preserve_access_index(&(type)->field)
// 结构体字段重定位专用
#define bpf_core_read_str(dst, sz, src) \
bpf_probe_read_kernel_str(dst, sz, \
(const void *)__builtin_preserve_access_index(src))
关键点在于 __builtin_preserve_access_index——这是 clang 专有的编译器内建函数,告诉编译器"把这个表达式的访问路径记录为 CO-RE 重定位记录,不要优化掉"。
3.3 实操:一个跨进程可执行文件追踪的 BPF 程序
// exec_tracer.bpf.c
#include "vmlinux.h" // 由 bpftool 生成或 CO-RE 头
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
struct event {
u32 pid;
u32 ppid;
u64 start_time;
char comm[16];
char filename[256];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");
SEC("tp/syscalls/sys_enter_execve")
int tracepoint__syscalls__sys_enter_execve(struct trace_event_raw_sys_enter *ctx)
{
struct event *e;
struct task_struct *task;
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
// CO-RE 方式读取:自动适应不同内核版本的偏移
task = (struct task_struct *)bpf_get_current_task();
e->pid = bpf_core_read(task, tgid) >> 32;
e->ppid = bpf_core_read(task, real_parent, tgid) >> 32;
e->start_time = bpf_core_read(task, start_time);
bpf_core_read_str(e->comm, sizeof(e->comm), &task->comm);
bpf_probe_read_user_str(e->filename, sizeof(e->filename),
(void *)ctx->args[0]);
bpf_ringbuf_submit(e, 0);
return 0;
}
char _license[] SEC("license") = "GPL";
编译命令:
# 关键:-g 保留 BTF 信息,-O2 开启优化
clang -g -O2 -target bpf -D__TARGET_ARCH_x86_64 \
-I/usr/include/bpf \
-c exec_tracer.bpf.c -o exec_tracer.bpf.o
# 验证 BTF 信息存在
readelf -S exec_tracer.bpf.o | grep BTF
# [3] .BTF PROGBITS 0000000000000000 000002a0
# [4] .BTF.ext PROGBITS 0000000000000000 00000af8
# [5] .rel.BTF.ext REL 0000000000000000 00000c48
四、libbpf CO-RE 重定位的详细流程
4.1 加载时的 BTF 差异解析
当 bpf_object__load() 被调用时,libbpf 执行以下步骤:
// libbpf 内部流程(简化)
struct bpf_object {
struct btf *btf; // 来自 .o 文件的本地 BTF
struct btf *btf_vmlinux; // 来自目标内核 /sys/kernel/btf/vmlinux
struct bpf_core_reloc *core_relocs; // 重定位记录数组
};
for (each CO-RE relocation record r) {
// 1. 解析本地类型:找到 .BTF 中引用的具体字段路径
struct btf_type *local_type = btf__type_by_id(obj->btf, r->type_id);
struct btf_member *m = find_member(local_type, r->access_str);
// 2. 在 vmlinux BTF 中查找匹配类型
const struct btf_type *vmlinux_type;
vmlinux_type = btf__find_by_name_kind(btf_vmlinux, type_name, kind);
// 3. 偏移计算与适配
if (m->name exists in vmlinux_type) {
new_offset = vmlinux_member_offset(vmlinux_type, m->name);
} else if (field renamed) {
// 尝试类型/字段名映射表(custom mapping)
...
} else {
// 字段不存在 → 可选或失败
...
}
// 4. 指令修补
patch_insn(r->insn_offset, new_offset);
}
4.2 类型兼容性检查的五个级别
libbpf 在重定位时不仅检查字段存在性,还执行兼容性分级:
| 级别 | 语义 | 处理方式 |
|---|---|---|
| 匹配 | 字段名、类型、偏移完全相同 | 直接使用 |
| 兼容 | 字段类型兼容(如 unsigned int → unsigned long) |
插入必要的类型转换 |
| 重命名 | 字段名不同但类型和语义相似 | 通过 bpf_core_relo 映射表匹配 |
| 缺失 | 字段在当前内核中不存在 | 写入 0 或跳过,可通过 #define CORE_NOT_ 控制 |
| 不兼容 | 类型完全不兼容 | 加载失败,返回详细错误信息 |
4.3 字段存在性检查与条件读取
// 处理内核版本差异:某些字段仅存在于特定版本
SEC("tp/sched/sched_process_exec")
int trace_exec(struct trace_event_raw_sched_process_exec *ctx)
{
struct task_struct *task = bpf_get_current_task_btf();
// 方式1:bpf_core_field_exists 编译时宏
if (bpf_core_field_exists(task->start_boottime)) {
e->timestamp = bpf_core_read(task, start_boottime);
} else {
// 降级方案:使用 start_time(值包含启动偏移)
e->timestamp = bpf_core_read(task, start_time);
}
// 方式2:通过 enum 指定字段存在性的自定义处理
// BPF_CORE_READ 宏也能在运行时处理
return 0;
}
五、生产环境部署方案
5.1 vmlinux.h 的生成策略
生产中有两种获取内核类型信息的方式:
方案 A:从目标内核生成 vmlinux.h(推荐用于固定内核环境)
# 在内网镜像构建机上执行
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
# 打包为容器镜像的一部分
# 缺点是换内核需要重新生成
方案 B:运行时动态读取(真正的 CO-RE 方式)
// 在 BPF 代码中直接使用 `__builtin_preserve_access_index`
// 不依赖 vmlinux.h,运行时从 /sys/kernel/btf/vmlinux 加载
// 优势:二进制完全跨内核兼容
现代推荐做法是方案 B:BPF 程序自带访问路径信息,运行时自动适配。
5.2 嵌入式 BTF 容器化分发
对于没有原生 BTF 支持的老内核(4.19 + 小版本定制),可以嵌入压缩 BTF 数据:
# 工具链:pahole + bpftool 为大内核提取 BTF
$ pahole -J /usr/lib/debug/boot/vmlinux-4.19.123-xxx
# 嵌入编译
$ bpftool gen object app_with_btf.bpf.o app.bpf.o /path/to/kernel.btf
# 此时 libbpf 优先使用嵌入的 BTF,而非 /sys/kernel/btf/vmlinux
5.3 大规模部署中的常见问题排查
问题 1:failed to find BTF info for CO-RE relocation
# 原因:BPF 程序引用了在目标内核 BTF 中不存在的类型
# 排查:确认 CO-RE BPF .o 是否使用了 -g 编译
llvm-objdump -h app.bpf.o | grep BTF
# 应有 .BTF 和 .BTF.ext 两个 section
问题 2:BTF type [...] not found in target kernel
# 自定义内核编译时未开启 CONFIG_DEBUG_INFO_BTF
# 解决方案:重新编译并开启该选项,或使用嵌入式 BTF
问题 3:字段名变更导致静默失败
// 最佳实践:对关键字段显式断言
_Static_assert(sizeof(struct task_struct) > 0, "verify");
// 或使用 BPF_CORE_READ_BITFIELD_PROBED 确保字段有效
5.4 生产级加载代码示例(用户态 Go)
// main.go - 生产级 BPF 加载器
package main
import (
"log"
"os"
"os/signal"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
)
func main() {
// 解锁内存限制
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}
// 加载 BPF 对象(自动执行 CO-RE 重定位)
spec, err := ebpf.LoadCollectionSpec("exec_tracer_bpfel.o")
if err != nil {
log.Fatalf("loading spec: %v", err)
}
var obj struct {
ExecTracer *ebpf.Program `ebpf:"tracepoint__syscalls__sys_enter_execve"`
Events *ebpf.Map `ebpf:"events"`
}
// loadAndAssign 在加载时自动触发 CO-RE 重定位
if err := spec.LoadAndAssign(&obj, &ebpf.CollectionOptions{
// 可在这里指定自定义 BTF(覆盖 /sys/kernel/btf/vmlinux)
// Btf: customBtf,
}); err != nil {
log.Fatalf("loading objects: %v", err)
}
// 附加到 tracepoint
kp, err := link.Tracepoint("syscalls", "sys_enter_execve", obj.ExecTracer, nil)
if err != nil {
log.Fatalf("attaching: %v", err)
}
defer kp.Close()
// 消费事件
rd, err := obj.Events.Reader()
...
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
<-sig
}
六、高级技巧与最佳实践
6.1 类型重命名/映射表
当内核字段改名时(如 sched_entity.run_node 变为 sched_entity.group_node),可使用自定义映射:
// 在 BPF CO-RE 程序中通过 struct 重定义实现映射
struct task_struct {
// 重命名映射:告诉 libbpf "这里的 old_name 对应 vmlinux 中的 new_name"
struct sched_group __deprecated_fake_group;
};
// 更优雅的方式是使用 vmlinux.h 中的条件编译
6.2 与 StructOps 和 Fentry 的结合
CO-RE 重定位同样适用于新式的 struct_ops 动态挂载和 fentry 细粒度追踪:
// 使用 BPF_PROG2 包装 fentry,自动处理 CO-RE
SEC("falloc/do_unlinkat")
int BPF_PROG(handle_unlinkat, int dfd, struct filename *name)
{
struct task_struct *task = bpf_get_current_task_btf();
// CO-RE 读取,跨内核兼容
bpf_printk("unlink: pid=%d name=%s\n",
BPF_CORE_READ(task, pid),
BPF_CORE_READ(name, name));
return 0;
}
6.3 调试重定位问题
# 查看 BPF 程序中的所有 CO-RE 重定位记录
$ bpftool gen object -w app_reloc.bpf.o app.bpf.o
$ readelf -r app_reloc.bpf.o | grep -E 'R_BPF_64_|R_BPF_32_'
# 使用 libbpf 的 verbose 加载模式
$ BPF_LOADER_LOG=debug ./my_ebpf_app
# 输出示例:
# libbpf: CO-RE relocating <task_struct>:<start_time>
# local type=32 (struct task_struct), target type=847
# field offset: local=0x228 → vmlinux=0x230
# patched insn #12 imm=0x230
七、生产环境对比数据
7.1 可维护性测试
在 5000 个节点集群上运行自定义可观测性探针,覆盖内核版本 5.4/5.10/5.15/6.1/6.6:
| 方案 | 编译产物数 | 平均部署时间 | 内核补丁适配 |
|---|---|---|---|
传统 kernel-devel 编译 |
5 版本 × 容器 = 15 个镜像 | 4h/版本 | 重新编译分发 |
| 条件编译 + version 宏 | 1 个二进制,含 5 个路径 | 1h | 手动添加分支 |
| BPF CO-RE + BTF | 1 个二进制 | 5min | 零修改 |
7.2 性能影响
CO-RE 重定位在加载阶段一次性完成,零运行时开销(与手写偏移方案相同)。Ringbuf 事件推送维持在单次 bpf_ringbuf_reserve < 50ns 的水平,5000 节点峰值采样约 120GB/天。
八、总结与展望
BPF CO-RE 本质上是一个延迟绑定机制——把"如何访问内核数据结构"这一决策从编译时推迟到加载时。它的价值不在于技术的新颖性,而在于解决了 eBPF 在生产环境中"写起来容易、部署起来难"的真正瓶颈。
需要注意的限制包括:目标内核必须 5.2+(或自行嵌入 BTF)、必须开启 CONFIG_DEBUG_INFO_BTF、对封闭定制内核(某些 IoT/Distribution)仍需要额外打 patch。好消息是所有主流 Linux 发行版(RHEL 8.3+、Ubuntu 20.04+、Debian 11+、Amazon Linux 2023+)均已默认开启 BTF,这意味着 BPF CO-RE 的覆盖范围已经非常广泛。
┌─────────────────────────────────────────────────────────────┐
│ eBPF 可观测性技术栈 │
│ │
│ 用户态 编译时 运行时 │
│ ──────── ────────── ────────── │
│ libbpf ←── clang -g -O2 ──→ vmlinux BTF │
│ │ │ │ │
│ │ ├── .BTF │ │
│ │ ├── .BTF.ext │ │
│ │ └── 程序代码 │ │
│ │ │ │ │
│ └──── 自动重定位 + 指令修补 ←──────────┘ │
│ │
│ 结果:一份 BPF ELF → 任意兼容内核 │
└─────────────────────────────────────────────────────────────┘
当你下次编写 eBPF 探针时,忘掉 kernel-devel 和硬编码偏移吧。拥抱 BTF 和 CO-RE,让可观测性代码真正具备云原生时代的弹性。

发表评论 取消回复