BPF CO-RE + libbpf-rs:免重编译的可移植内核追踪深度实战
在生产环境中部署 eBPF 程序时,开发者面临的最大痛点并非 BPF 指令的复杂性,而是可移植性。传统方式下,eBPF 程序需要在目标机器上使用目标内核版本的头文件重新编译,这意味着你需要一个与目标内核版本完全匹配的编译工具链,或者为每一个内核版本单独维护一套二进制产物。在异构集群中,这种做法几乎无法规模化。
BPF CO-RE(Compile Once, Run Everywhere)彻底改变了这一局面。本文将深入 BPF CO-RE 的核心机制——BTF(BPF Type Format)、内核重定位记录(relocations)、以及 libbpf-rs 在 Rust 生态中的实践,并通过多个生产级案例展示如何构建真正"一次编译,到处运行"的 eBPF 工具。
---
一、为什么传统 eBPF 方式在生产环境行不通
在深入 CO-RE 之前,先理解它解决了什么问题。
1.1 传统 BCC 模式的困境
BCC(BPF Compiler Collection)是最早的 eBPF 开发框架之一,它的运行模式是在目标机器上即时编译 BPF 代码:
用户态脚本 (Python) ──→ LLVM/Clang ──→ BPF 字节码 ──→ 内核
└── 需要目标内核的头文件 ──┘
这种模式的问题显而易见:
- 需要内核头文件:
linux-headers-$(uname -r),而很多精简镜像根本不包含 - 需要完整的编译工具链:Clang + LLVM,占用数十 MB 空间
- 编译耗时:大规模部署时,每次启动都要编译 30 秒以上
- ABI 不兼容风险:结构体定义可能随内核版本差异而变化
1.2 预编译模式的局限
如果不能即时编译,那就预编译。但预编译面临另一个核心问题:内核数据结构在不同版本之间会变化字段名、偏移量、甚至整个结构体布局。
举例来说,struct task_struct 在 5.15 内核和 6.1 内核中,pid 字段的偏移可能不同。如果硬编码偏移量编译,换一台机器就会崩溃。
---
二、BPF CO-RE 的核心机制
BPF CO-RE 由四个关键技术组成,它们协同工作,实现了免重编译的目标:
┌─────────────────────────────────────────────────────┐
│ BPF CO-RE Pipeline │
│ │
│ BTF ──→ 类型信息 ──→ 字段重定位 ──→ 运行时适配 │
│ ↑ ↑ ↑ ↑ │
│ │ │ │ │ │
│ 内核 libbpf.so 编译器记录 内核加载时 │
│(vmlinux) (relocation) (field access) │
└─────────────────────────────────────────────────────┘
2.1 BTF(BPF Type Format)
BTF 是 CO-RE 的基石。它是一种紧凑的类型编码格式,描述了内核中所有结构体、联合体、枚举和函数签名。
内核通过配置 CONFIG_DEBUG_INFO_BTF=y 编译时,会自动生成 /sys/kernel/btf/vmlinux 文件。主流发行版(Ubuntu 20.04+、Fedora 32+、CentOS 8+)默认开启此选项。
查看 BTF 信息:
# 查看 vmlinux 中的 BTF 类型数量
bpftool btf dump file /sys/kernel/btf/vmlinux format raw | wc -l
查看特定结构体
bpftool btf dump file /sys/kernel/btf/vmlinux format c | grep -A 5 "struct task_struct"
BTF 中每一条类型记录包含了:类型名称、大小、字段名称、字段偏移、字段类型、bit 偏移(用于位域)等完整信息。
2.2 编译器重定位记录
当使用 clang -g 编译 BPF 程序时,Clang 会在 BPF ELF 重定位段中生成重定位记录。这些记录告诉 libbpf:"在加载时,需要把 task_struct->pid 的偏移量填入指令中"。
# 查看 BPF 程序中的重定位信息
readelf -r bpf_program.o
输出示例:
Offset Type Value Name
00000000 R_BPF_64_ABS btf_str task_struct___pid
R_BPF_64_ABS 类型的重定位表示"该指令需要根据 BTF 信息,在加载时解析为具体的字段偏移"。
2.3 libbpf 的运行时加载
libbpf.so 在加载 BPF 程序时执行以下流程:
1. 从 ELF 文件读取 .BTF 和 .BTF.ext 段
2. 从目标内核读取 /sys/kernel/btf/vmlinux
3. 对比两者的类型信息,计算差异(字段偏移变化)
4. 根据重定位记录,原地修改 BPF 指令中的立即数
5. 通过 bpf(BPF_PROG_LOAD) 提交给内核验证器验证
关键的是第 4 步——重定位不仅修改了字段偏移,还还处理了:
- 字段是否存在(如果字段被内核移除,可以返回错误或跳过)
- 结构体是否被重命名
- 是否跨结构体访问(如访问嵌套结构体的字段)
---
三、libbpf-rs:Rust 生态中的 CO-RE 利器
libbpf-rs 是 libbpf 的 Rust 封装,提供了类型安全的 API。更高层的封装 libbpf-cargo 则可以自动生成 BPF 程序的脚手架。
3.1 项目结构
一个典型的 CO-RE 项目包含两部分:
bpf-core-project/
├── Cargo.toml # 用户态
├── build.rs # 构建脚本,编译 BPF 对象
├── src/
│ └── main.rs # 用户态逻辑
└── bpf/
├── Cargo.toml # BPF 端
└── probe.bpf.c # BPF 程序代码
3.2 创建项目
安装 libbpf-cargo:
cargo install libbpf-cargo
build.rs 中的关键配置:
use libbpf_cargo::SkeletonBuilder;
fn main() {
SkeletonBuilder::new()
.source("bpf/probe.bpf.c")
.build_and_generate("src/probe.skel.rs")
.unwrap();
}
libbpf-cargo 会自动调用 clang -g -O2 编译 BPF 代码,并生成 Rust skeleton 文件,提供类型化的句柄。
---
四、实战案例一:可移植的系统调用追踪
我们从一个实际案例开始:追踪 execve 系统调用的执行,每次调用时记录进程 PID、进程名和可执行文件路径。
4.1 BPF 端代码
// bpf/probe.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
char _license[] SEC("license") = "GPL";
struct event {
u32 pid;
u32 ppid;
char comm[16];
char filename[256];
};
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");
SEC("tp/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx)
{
struct event evt = {};
struct task_struct *task;
task = (struct task_struct *)bpf_get_current_task();
// bpf_core_read 会根据 BTF 自动处理字段偏移
evt.pid = bpf_core_read(&task->tgid, sizeof(u32), &task->tgid);
evt.pid >>= 32;
evt.ppid = bpf_core_read(&task->real_parent->tgid,
sizeof(u32),
&task->real_parent->tgid);
evt.ppid >>= 32;
bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
bpf_core_read_user_str(&evt.filename, sizeof(evt.filename),
(void *)ctx->args[0]);
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&evt, sizeof(evt));
return 0;
}
4.2 用户态 Rust 代码
// src/main.rs
use libbpf_rs::PerfBufferBuilder;
use std::time::Duration;
mod probe;
use probe::*;
#[repr(C)]
#[derive(Clone, Copy)]
struct Event {
pid: u32,
ppid: u32,
comm: [u8; 16],
filename: [u8; 256],
}
impl Event {
fn comm_str(&self) -> &str {
let end = self.comm.iter().position(|&b| b == 0).unwrap_or(15);
std::str::from_utf8(&self.comm[..end]).unwrap_or("?")
}
fn filename_str(&self) -> &str {
let end = self.filename.iter().position(|&b| b == 0).unwrap_or(255);
std::str::from_utf8(&self.filename[..end]).unwrap_or("?")
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let skel_builder = ProbeSkelBuilder::default();
let open_skel = skel_builder.open()?;
let mut skel = open_skel.load()?;
let _link = skel.progs_mut().trace_execve()
.attach_tracepoint("syscalls", "sys_enter_execve")?;
let perf = PerfBufferBuilder::new(skel.maps_mut().events())
.sample_cb(|_cpu, data: &[u8]| {
let evt: &Event = unsafe { &(data.as_ptr() as const Event) };
println!(
"PID: {}; PPID: {}; Comm: {}; File: {}",
evt.pid, evt.ppid, evt.comm_str(), evt.filename_str()
);
})
.build()?;
println!("按 Ctrl+C 退出...");
loop {
perf.poll(Duration::from_millis(100))?;
}
}
4.3 构建与部署
# 本地编译
cargo build --release
编译后的用户态二进制 + BPF 对象打包在一起
只需传输这一个二进制到目标机器
file target/release/bpf-core-project
部署到目标机器后,无需任何安装步骤,直接运行:
sudo ./bpf-core-project
PID: 1743; PPID: 1; Comm: systemd-journal; File: /usr/lib/systemd/systemd-journald
PID: 1744; PPID: 1740; Comm: cat; File: /usr/bin/coreutils
---
五、实战案例二:条件编译与内核版本适配
当 BPF 程序需要根据内核版本调整行为时,CO-RE 提供两种方式:BTF 存在性检查和运行时适配。
5.1 编译时字段存在性检查
// 检查某个字段在当前内核 BTF 中是否存在
if (bpf_core_field_exists(struct task_struct, cpid)) {
// 该字段较新,只在 5.17+ 内核中出现
// 使用 cpid 而不是 pid
}
5.2 运行时内核版本适配
SEC("kprobe/tcp_sendmsg")
int BPF_KPROBE(tcp_sendmsg_probe, struct sock *sk)
{
struct tcp_sock tp = (struct tcp_sock )sk;
u32 srtt_val;
// 适配不同内核版本中 srtt_us 字段位置变化
if (bpf_core_field_exists(struct tcp_sock, srtt_us)) {
bpf_core_read(&srtt_val, sizeof(srtt_val), &tp->srtt_us);
} else {
// 旧内核中该字段位置不同或粒度不同
bpf_core_read(&srtt_val, sizeof(srtt_val), &tp->srtt);
}
bpf_printk("TCP RTT: %u us", srtt_val);
return 0;
}
5.3 vmlinux.h 的生成方式
vmlinux.h 是 CO-RE 程序中引用内核类型的核心头文件。
方式一:从目标系统直接提取:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
方式二:使用 libbpf-cargo 自动生成:
cargo libbpf make
vmlinux.h 包含了目标内核所有结构体定义,编译 BPF 程序时可以安全访问任何内核类型。
---
六、实战案例三:挂载点追踪与容器逃逸检测
这是安全领域的典型用例:监控 unshare 和 mount 调用,检测容器逃逸尝试。
// bpf/ns_monitor.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
struct alert {
u32 pid;
u32 mnt_ns;
char comm[16];
char op[32];
u64 timestamp;
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 20);
} alerts SEC(".maps");
static __always_inline u64 get_task_mnt_ns_id(struct task_struct *task)
{
struct nsproxy *nsproxy;
struct mnt_namespace *mnt_ns;
u64 ns_id = 0;
bpf_core_read(&nsproxy, sizeof(nsproxy), &task->nsproxy);
if (!nsproxy)
return 0;
bpf_core_read(&mnt_ns, sizeof(mnt_ns), &nsproxy->mnt_ns);
if (!mnt_ns)
return 0;
bpf_core_read(&ns_id, sizeof(ns_id), &mnt_ns->ns.inum);
return ns_id;
}
SEC("tp/syscalls/sys_enter_unshare")
int trace_unshare(struct trace_event_raw_sys_enter *ctx)
{
struct alert *alert;
u64 flags = ctx->args[0];
if (!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID)))
return 0;
alert = bpf_ringbuf_reserve(&alerts, sizeof(*alert), 0);
if (!alert)
return 0;
alert->pid = bpf_get_current_pid_tgid() >> 32;
alert->timestamp = bpf_ktime_get_ns();
alert->mnt_ns = get_task_mnt_ns_id(bpf_get_current_task());
bpf_get_current_comm(&alert->comm, sizeof(alert->comm));
__builtin_memcpy(&alert->op, "unshare", 8);
bpf_ringbuf_submit(alert, 0);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
对应的 Rust 用户态处理:
use libbpf_rs::RingBufferBuilder;
#[repr(C)]
struct Alert {
pid: u32,
mnt_ns: u32,
comm: [u8; 16],
op: [u8; 32],
timestamp: u64,
}
let rb = RingBufferBuilder::new()
.add(skel.maps().alerts(), |data| {
let alert: &Alert = unsafe { &(data.as_ptr() as const Alert) };
let c_end = alert.comm.iter().position(|&b| b == 0).unwrap_or(15);
let o_end = alert.op.iter().position(|&b| b == 0).unwrap_or(31);
let comm = std::str::from_utf8(&alert.comm[..c_end])?;
let op = std::str::from_utf8(&alert.op[..o_end])?;
if alert.mnt_ns == INIT_MNT_NS {
log::warn!("[HOST] {} (PID {}) executed {}", comm, alert.pid, op);
} else {
log::info!("[CONTAINER ns={}] {} (PID {}) executed {}",
alert.mnt_ns, comm, alert.pid, op);
}
Ok(0)
})?
.build()?;
---
七、生产环境中的性能与资源管理
CO-RE 不只是编译便利性问题,更关系到生产环境中 BPF 程序的运行效率和资源自治。
7.1 Maps 资源持久化
传统 BCC 模式中,maps 的生命周期依赖 Python 进程。如果进程被 OOM Kill,maps 中的数据永久丢失。CO-RE 程序可以将 maps 委托给 bpffs(BPF 伪文件系统),实现与进程解耦:
let pin_path = "/sys/fs/bpf/my_map";
skel.maps_mut().events().pin(pin_path)?;
7.2 多程序协同:BPF Tail Calls
对于复杂场景,Tail Calls 可以串联多个 BPF 程序:
struct {
__uint(type, BPF_MAP_TYPE_PROG_ARRAY);
__uint(max_entries, 16);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} progs SEC(".maps");
SEC("tp/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx)
{
record_execve(ctx);
bpf_tail_call(ctx, &progs, 1);
return 0;
}
7.3 Ring Buffer vs Perf Buffer
| 特性 | Ring Buffer | Perf Buffer |
|---|---|---|
| API 版本 | Linux 5.8+ | 所有支持 BPF 的系统 |
| 多消费者 | 不支持 | 支持 |
| CPU间隔离 | 按CPU独立 | 共享 |
| 稳定性 | 高(锁安全) | 可能存在竞争 |
| 适用场景 | 单进程消费、告警类 | 多聚合、高吞吐追踪 |
在生产环境中,如果只需要将事件发送到单个用户态进程,优先选择 Ring Buffer。
---
八、调试与排错
CO-RE 程序最常见的失败场景是 BTF 兼容性问题。
8.1 字段不存在错误
libbpf: failed to find field 'xxx' in CO-RE relocation
确认字段是否存在:
bpftool btf dump file /sys/kernel/btf/vmlinux format c | grep -A 20 "struct task_struct"
8.2 内核未开启 BTF
libbpf: failed to find BTF info for kernel
检查 BTF 文件:
ls -la /sys/kernel/btf/vmlinux
如果不存在,可以:
- 重新编译内核开启
CONFIG_DEBUG_INFO_BTF=y - 下载发行版提供的
vmlinux文件(如linux-image-unsigned-dbgsym) - 使用
BTF_CUSTOM_PATH环境变量指定备用 BTF 文件路径
8.3 验证器日志
let _ = libbpf_rs::set_print(Some((
libbpf_rs::PrintLevel::Debug,
level, msg eprintln!("BPF[{}]: {}", level, msg),
)));
---
九、与其他方案的对比
| 维度 | BCC | CO-RE + libbpf | raw bpf() |
|---|---|---|---|
| 编译时依赖 | 需要完整工具链 | 仅需 clang + BPF 目标 | 手动编码字节码 |
| 部署方式 | 携带编译器 | 单一静态二进制 | 单一静态二进制 |
| 跨内核移植 | 否(需重新编译) | 是(BTF 适配) | 否(硬编码偏移) |
| 类型安全性 | 弱(C 字符串拼接) | 强(编译期检查) | 无 |
| 性能开销 | 极高(JIT + 编译) | 极低(直接加载) | 无 |
| 适用场景 | 开发调试 | 生产部署 | 极简嵌入式 |
CO-RE 在几乎所有维度上都优于传统方案,是当前生产环境的唯一合理选择。
---
十、总结
BPF CO-RE 从根本上解决了 eBPF 程序在生产规模化部署中的最大障碍——可移植性。它通过 BTF 类型信息、编译器重定位记录和 libbpf 运行时适配的协同,实现了"一次编译,到处运行"的承诺。
结合 libbpf-rs 这套 Rust 封装,开发者可以:
- 使用类型安全的 BPF 程序加载和管理 API
- 享受
libbpf-cargo自动生成的 skeleton 文件 - 将 BPF 程序编译进单一可执行文件中
对于需要追踪系统行为的运维和安全团队来说,CO-RE + libbpf-rs 是当前最具工程实践价值的方案。
> 文中所有代码示例均基于 libbpf-rs 0.24+、bpftool 7.3+ 和 Linux 5.15+ 内核。

发表评论 取消回复