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:

  1. /sys/kernel/btf/vmlinux — 主内核的 BTF(自 Linux 5.4 起标准提供)
  2. /sys/kernel/btf/<module_name> — 模块 BTF(用于跟踪模块内部)
  3. 用户通过 bpf_object__set_kversion() 隐式指定的外部 BTF 文件

二、CO-RE 的核心机制:重定位记录

CO-RE 的魔法隐藏在 BPF 对象文件的重定位段(.BTF.ext)中。当 clang 编译启用了 CO-RE 的 BPF 程序时,会生成特殊的重定位记录。libbpf 在加载时读取目标内核的 BTF,然后进行动态匹配和重定位。

2.1 重定位的制作过程

当 BPF 程序中包含一句简单的 task->pid 访问时,编译链路执行以下步骤:

  1. 源码解析:编译器识别出 task 是一个 struct task_struct * 类型
  2. 字段访问解析:->pid 被解析为访问 task_struct 结构体中 pid 字段的偏移量——但此时不硬编码偏移量
  3. 生成重定位记录:在 .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 查找顺序为:

  1. bpf_object_open_opts.btf_custom_path 指定的自定义路径
  2. /sys/kernel/btf/vmlinux(内核内置)
  3. /boot/vmlinux-$(uname -r)(可调试内核镜像)
  4. /usr/lib/debug/boot/vmlinux-$(uname -r)(调试符号包)
  5. /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 程序的核心原则:

  1. 始终使用 BPF_CORE_READ 宏家族,避免 bpf_probe_read_kernel() 直接读取
  2. 将自定义结构体限制到最小,只在 vmlinux.h 中添加 BPF 程序实际需要的类型
  3. 对"可选"字段使用 bpf_core_field_exists() 包装,提供内核版本回退路径
  4. 在 CI 中集成多个内核版本的重定位验证,确保跨版本兼容性
  5. 将 BTF 文件纳入 CI 制品,与 BPF 二进制一起的分发包
  6. 避免在 BPF C 代码中使用内核头文件宏——宏不能跨版本重定位,直接使用 BTF 读取的整数值
  7. 对于数据类型变化剧烈的结构体(如 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 的能力边界还将进一步扩展,可能实现跨子系统、跨架构的完全透明适配。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部