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 BufferPerf 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, msgeprintln!("BPF[{}]: {}", level, msg),

)));

---

九、与其他方案的对比

维度BCCCO-RE + libbpfraw 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+ 内核。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部