引言:为什么eBPF开发者需要CO-RE

在2019年之前,编写一个能跨不同内核版本运行的eBPF程序是一场技术上的噩梦。开发者需要为每个目标内核版本手动预编译eBPF字节码,通过defines宏条件编译处理内核结构体字段变更,最终将数十个版本的BPF对象文件打包进应用分发包。知名的BCC (BPF Compiler Collection) 项目虽然提供了Python前端和即时编译能力,但代价是需要在目标机器上部署完整的内核头文件、LLVM/Clang工具链——在生产环境中,这等于给每台宿主机增加了400MB+的依赖和每次加载程序的编译等待。

BPF CO-RE (Compile Once – Run Everywhere) 的诞生彻底解决了这个痛点。它让开发者只需编译一次eBPF程序,就能在不同内核版本、不同内核配置的目标机器上直接运行。其核心技术基石是BPF Type Format (BTF)、libbpf重定位框架和bpftool gen skeleton。今天CO-RE已经成为eBPF生态的事实标准 -- Katran、Cilium、Falco、Tetragon等生产级项目都基于CO-RE架构构建。

本文将系统性拆解BPF CO-RE的完整技术栈:从BTF的底层编码格式,到libbpf重定位的内部机制,再到跨内核兼容的工程化策略,最终给出生产级CO-RE项目的标准开发流程。

一、BPF Type Format (BTF):CO-RE的信息基石

1.1 BTF是什么

BTF (BPF Type Format) 是一种用于描述C语言类型信息的编码格式,它将结构体定义、函数签名、枚举值、变量位置等元数据以紧凑的二进制格式存储。当内核被编译时开启 CONFIG_DEBUG_INFO_BTF=y,Clang/LLVM会在构建内核的同时生成一个名为 vmlinux 的BTTSection ELF对象,它包含了整个内核所有类型信息的完整描述。

BTF的核心价值在于:用户态程序可以在运行时读取目标内核的BTF数据,获知该内核中各种结构体的布局、字段的偏移量、类型的字节大小,这正是CO-RE实现跨版本运行的关键 -- 我们不再需要在编译时假定某个字段的偏移量是在编译时确定的常量,而是通过renamed实际硬件在运行时据此BTF动态resolve。

1.2 BTF的编码模型

BTF的编码基于"type-based" 模式:每个类型被分配一个唯一ID,后续类型通过引用ID来建立关系。BTF Section中可以包含17种主要的Kind,可以分为以下几类:

基础类型:
   BTF_INT      - 整数类型 (编码字节序、位宽、符号)
   BTF_PTR      - 指针类型 (引用其他类型)
   BTF_ARRAY    - 数组类型 (元素类型 + 下标类型 + 长度)
   BTF_STRUCT   - 结构体类型 (成员列表:名称+类型+偏移)
   BTF_UNION    - 联合体类型 (成员列表:名称+类型)
   BTF_ENUM     - 枚举类型 (成员列表:名称+整数值)
   BTF_FWD      - 前向声明 (struct/union声明,无完整定义)
   BTF_TYPEDEF  - 类型别名 (typedef)
   BTF_VOLATILE - volatile修饰
   BTF_CONST    - const修饰
   BTF_RESTRICT - restrict修饰
   BTF_FUNC     - 函数类型 (原型)
   BTF_FUNC_PROTO - 函数原型 (参数列表)
   BTF_VAR      - 全局变量 (类型+链接属性)
   BTF_DATASEC  - 数据节 (变量在节内的布局)
   BTF_FLOAT    - 浮点类型

对于CO-RE来说,最核心的是 BTF_STRUCT 的成员偏移信息。CO-RE通过读取BTF中struct task_struct的字段定义,可以知道在目标内核中pid字段相对于结构体起始地址的偏移是2368字节而不是hs表中记录的某个固定值 -- 这种动态发现就是可移植性的关键。

1.3 vmlinux.h:内核全量类型头文件

vmlinux.h 是由 bpftool btf dump file /sys/kernel/btf/vmlinux format c 命令生成的C头文件,包含了内核所有导出和非导出的结构体、联合体、枚举类型的完整定义。使用它,eBPF程序可以直接引用任何内核struct -- 这是CO-RE的前置依赖。

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
$ wc -l vmlinux.h
   21023 vmlinux.h
$ wc -c vmlinux.h
  836081 vmlinux.h  (约836KB)

vmlinux.h 的好处是消除了对内核头文件的编译依赖。在不使用CO-RE的传统开发模式中,开发者需要安装目标内核版本对应的 linux-headers-* 包。但在生产环境中,内核头文件常常不存在或版本不匹配。而BTF导出的vmlinux.h包含了该内核实际编译时的所有类型信息 -- 无论是公开的还是私有的。

1.4 BTF Hub与外部内核

对于没有BTF支持的老内核,BTF Hub 项目为小版本内核预先提供了从DWARF到BTF的离线转换文件。libbpf支持两种查找BTF的方式:

1. 内置:/sys/kernel/btf/vmlinux (内核自行暴露) 2. 外部:从用户空间路径加载独立BTF文件 3. 嵌入:通过 BPF_CORE_READ 宏内联到eBPF对象文件

// libbpf BTF查找优先级 (简化):
// 1. /sys/kernel/btf/vmlinux                    - 内置BTF
// 2. /boot/vmlinux-$(uname -r)                  - 独立vmlinux ELF中的BTF
// 3. /usr/lib/btf/$(uname -r).btf               - BTF Hub外部BTF
// 4. 用户自定义路径 (通过 struct btf__load_vmlinux_btf_fd())

实际线上环境中,建议将目标内核版本的BTF文件随应用一同分发。可通过环境变量 LIBBPF_SEARCH_BTF_PATH 或eBPF对象编译时嵌入外部BTF的方式实现。

二、libbpf重定位框架:CO-RE的核心机制

2.1 三个重定位类型

CO-RE通过libbpf在加载时完成三类重定位,确保BPF程序中的每个内核类型访问都能正确指向目标内存位置。

类型重定位 (Type-based Relocation) 当BPF代码引用一个目标内核中的类型时,CO-RE记录该类型与本地编译时类型之间的对应关系。加载时libbpf通过名称匹配在目标BTF中找到对应类型。如果类型不存在于当前内核 -- 例如4.15没有struct bpf_timer -- 记录失败。预判这种兼容性需要在用户层处理。libbpf通过 bpf_core_type_id_local() / bpf_core_type_id_kernel() 来验证。

字段重定位 (Field-based Relocation) 最常见的情况:代码中解引用 task->pid。CO-RE编译阶段记录"访问类型为struct task_struct、偏移未知、名为 pid 的字段"这一意图。加载时libbpf读取目标BTF,找到 struct task_struct 在目标内核中 pid 的实际偏移量(可能在3.10中是offset 390、在5.15中是offset 2368),然后将该偏移量通过写入BPF指令的immediate field回填到eBPF字节码中。

自定义重定位 (Custom Relocation) 用于一些特殊语法,例如CO-RE中的 bpf_core_read_user() vs bpf_core_read_kernel() 区分。libbpf根据上下文决定读取目标。

2.2 BPF_CORE_READ系列宏

BPF_CORE_READ 是CO-RE的核心API,它在内核中读取字段的同时利用BTF信息重定位,实现跨版本兼容。常见宏包括:

// 直接读取内核对象字段(重定位后)
BPF_CORE_READ(task, pid)

// 相当于 C: task->pid,但内部通过 CO-RE 重定位

// 通过指针间接读取
BPF_CORE_READ_PTR(ptask, pid)

// 通过字符串形式(不推荐,开销大)
BPF_CORE_READ_STR(task, pid)

// 用户空间重定位读
BPF_CORE_READ_USER(task, pid)

展开后核心工作原理:BPF_CORE_READ宏在编译时调用bpf_core_read() BPF辅助函数,同时在指令的重定位记录中记录"struct task_struct.pid"这一意图。在实际加载时,BTF存在性地验证、字段可用性检查、偏移量计算一次性完成。

优点总结:

  • 零开销:没有函数调用开销,直接被重定位为常数偏移访问
  • 类型安全:BTF级别类型检查,避免word-size错误
  • 向前兼容:新版内核新增字段对老BPF程序无影响 (除非程序显式引用新字段)

三、gcc/__builtin_preserve_access_index与重定位信息

3.1 编译期保留重定位信息

让Clang记录重定位信息的关键属性是 __builtin_preserve_access_index。当Clang识别到CO-RE宏时,它:

  • 在当前访问的内联路径上记录完整访问链 (task->group_leader->pid 被记录为 ①A:struct task_struct ②A:member 0x0 (group_leader) ...)。
  • 将重定位信息编入ELF的.BTF.ext section中
  • 这些重定位记录就是libbpf在加载时需要对照的信息

举个具体例子:编译命令 clang --target=bpf -g -O2 开启debug info(必需),Clang就可以在BTF中生成.BTF.ext section,将所有access chain都记录为location字符串 + CO-RE cookie。

3.2 重定位校验过程

加载时libbpf执行以下步骤:

  1. 解析用户BPF对象中的.BTF.ext重定位记录
  2. 逐条匹配:从目标BTF中查找对应类型
  3. 验证字段存在性 (兼容类型可以自动推导)
  4. 计算目标偏移+bitfield偏移
  5. 修改BPF指令的立即数(opcode)字段,填入实际偏移
  6. 记录是否出现兼容类型不匹配 (用户层可选择忽略或报错)

验证过程中如果出现'bpf_core_field_exists()'检查失败,会导致该重定位被标记为"non-existing field",用户需要处理。

四、bpftool gen skeleton与自动生成用户态绑定代码

4.1 skeleton是什么

bpftool gen skeleton 命令接收编译后的BPF对象文件(.o),自动生成一个C头文件(skel),包含:

  • BPF对象配置的解析和加载封装
  • C结构体定义,将BPF maps、program实例化后反映到用户态结构体字段中
  • 自动生命周期管理:销毁时卸载BPF程序和释放maps
  • 全局变量自动映射:BPF中的const volatile变量可以通过结构体成员动态修改

生成的skeleton框架消除了libbpf API调用的重复代码 -- 不再需要手动 bpf_object__open() + bpf_object__load() + bpf_object__find_map_by_name() 的传统繁琐流程。

4.2 skeleton使用范式

开发者的BPF代码只需:

// 1. 定义BPF map (可以指定 __uint 类型常量)
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24);
} rb SEC(".maps");

// 2. 定义追踪点
SEC("tp/sched/sched_process_exec")
int tracepoint__sched__sched_process_exec(struct trace_event_raw_sched_process_exec *ctx) {
    // BPF程序逻辑
    struct event *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
    if (!e) return 0;
    
    e->pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(e->comm, sizeof(e->comm));
    bpf_ringbuf_submit(e, 0);
    return 0;
}

// 3. 声明全局可变(由用户态动态设置)
__u32操控_PID = 0;

编译后生成skeleton:

$ bpftool gen skeleton boot_monitor.o > boot_monitor.skel.h

用户态代码:

#include "boot_monitor.skel.h"

int main() {
    struct boot_monitor_bpf *skel = boot_monitor_bpf__open_and_load();
    if (!skel) { fprintf(stderr, "open and load failed\n"); return 1; }
    
    // 修改操控PID (会直接写入BPF map变量)
    skel->rodata->操控_PID = 0;  // 追踪所有进程
    
    // 附加BPF程序到Hook点
    boot_monitor_bpf__attach(skel);
    
    // 消费ringbuf
    struct ring_buffer *rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), print_event, NULL, NULL);
    while (ring_buffer__poll(rb, 100) >= 0) {}
    
    boot_monitor_bpf__destroy(skel);
}

4.3 skeleton处理全局变量

skeleton最大的易用性改进在于BPF的全局和常量变量的处理。通过 __uint 或 volatile const 声明:

  • const volatile 变量:可在BPF程序加载后(skeleton attach前)通过skel->rodata结构体修改
  • __uint 配置:对象内map配置参数,同样可在open和load之间动态设置
  • 销毁时自动释放所有资源,无需手动处理

这彻底消除了BPF变量需要通过syscall(bpf())逐个操作的丑态 -- skeleton将其映射为用户态C结构体的成员直接读写。

五、跨内核版本兼容策略

5.1 条件编译 vs 运行时检查

CO-RE为两种不同的兼容性场景提供了不同的机制:

编译期条件:当内核API完全不同(如4.x vs 5.x)时,需要使用#ifdef重写逻辑。但很多场景中这并不需要。

运行时检查:对于只在某些版本引入的新字段,使用libbpf API:

// 字段存在性检查
if (bpf_core_field_exists(struct task_struct, flags)) {
    // 较新内核支持 .flags 字段
    BPF_CORE_READ(task, flags);
}

// 类型/枚举存在性检查 (bool bpf_core_enum_value_exists(enum task_state, RUNNING))

// 类型大小一致性验证
if (bpf_core_type_matches(struct task_struct)) {
    // 类型布局一致
}

5.2 弱符号(Weak Symbol)与前向声明

libbpf的compatible type推导允许不同内核的内联字段名称不同但语义兼容的情况。例如,我们可以在eBPF代码中引用弱声明(allow_libbpf_unknown)

// 最简化的"假设所有字段都存在"模式
struct task_struct {
    int pid;
    int tgid;
    char comm[16];
} *task = ...;

// libbpf会通过BTF重定位,自动将 .pid 对应到
// 目标内核struct task_struct中实际的pid偏移

这种"本地前向声明"的做法极为实用:我们不需要包含完整的内核头文件定义,只需声明程序会用到的字段,libbpf的BTF重定位会自动匹配到目标内核中的完整定义。

5.3 处理字段变更

实际生产中最常见的问题:字段在不同版本间改名或被合并。CO-RE的兼容性处理:

  1. 字段偏移策略:如果字段语义不变只是改名,使用bpf_core_enum_value_exists + 兼容宏
  2. 备用字段策略:尝试多个候选字段名,使用first_exists宏选择
  3. compile-time vs runtime:重定位解析时机设计确保不会崩溃

生产实践中的最佳做法:写一个宏封装最优字段读取逻辑:

#define BPF_CORE_READ_FIELD(obj, fieldA, fieldB, fallback) \
    (bpf_core_field_exists(typeof(*(obj)), fieldA) ? BPF_CORE_READ(obj, fieldA) : \
     bpf_core_field_exists(typeof(*(obj)), fieldB) ? BPF_CORE_READ(obj, fieldB) : \
     fallback)

5.4 典型兼容性错误处理

// 场景:确认struct task_struct是否新到足够版本
// 方法:通过bpf_core_type_id + bpf_core_type_matches
struct task_struct ___task;
if (bpf_core_type_exists(struct task_struct)) {
    // task_struct存在
}

// 场景:确认内核是否支持某个特定程序类型
// 方法:尝试-bpf_prog_load,检查返回值-EINVAL

六、CO-RE生产过程案例

6.1 环境准备

生产构建环境需要:

  1. Clang >= 11 (推荐Clang 15+,完整的BTF支持)
  2. libbpf (从github.com/libbpf/libbpf获取)
  3. bpftool (生成skeleton所必需)
  4. 目标BTF文件 (vmlinux.h生成所必需)
$ clang --version
clang version 15.0.0
$ bpftool --version
bpftool v7.0.0

6.2 构建流程

标准CO-RE项目构建使用以下Makefile模板:

# Makefile
APP = boot_monitor
OBJECTS = $(APP).bpf.o $(APP).skel.h

all: $(APP)

vmlinux.h:
	bpftool btf dump file /sys/kernel/btf/vmlinux format c > $@

$(APP).bpf.o: $(APP).c vmlinux.h
	clang --target=bpf -g -O2 -Wall -c $< -o $@

$(APP).skel.h: $(APP).bpf.o
	bpftool gen skeleton $< > $@

$(APP): main.c $(APP).skel.h
	clang -g -O2 -Wall -c main.c -o main.o
	clang main.o -lbpf -lelf -lz -o $@

clean:; rm -f $(APP) *.o *.skel.h vmlinux.h

注意的几个要点:

  • -g 必需:开启debug info生成BTF.ext section
  • -O2 必需级别:Clang需要优化识别CO-RE内重的展开
  • --target=bpf :交叉编译到BPF虚拟机指令集
  • 用户态仅需 -lbpf -lelf -lz 三个外部链接

6.3 生产打包分发

CO-RE编译后分发方案:

方案一:预编译BPF对象 (推荐): 编译一个通用的BPF ELF对象文件(.o),随应用一起分发。运行时通过libbpf加载并重定位。注意vmlinux.h必须在编译时包含足够多的其他BTF类型,使得BTF重定位能解析所有依赖。(这就vmlinux.h设计为包含全量类型的作用)

方案二:嵌入外部BTF: 将多个内核版本的BTF文件嵌入(BTF relocation cookie方式)到一个BPF对象中,包含vmlinux和各种模块BTF。但可能显著增加体积。

方案三:CI/CD按内核版本分发: 在CI流水线中为每个目标内核版本分别预编译BPF代码,按需分发。

生产实践表明大部分应用选择方案一 + 兼容老的BTF Hub文件。

七、调试与排错技巧

7.1 BTF信息检查

// 查看内核BTF
$ bpftool btf dump file /sys/kernel/btf/vmlinux format raw | head
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c | wc -l

// 验证目标内核是否存在BTF
$ ls -la /sys/kernel/btf/vmlinux

// 查看eBPF对象的BTF重定位段
$ readelf -S boot_monitor.bpf.o | grep BTF
  [2] .BTF               PROGBITS         0000000000000000  000003d8
  [3] .BTF.ext           PROGBITS         0000000000000000  000043e8

7.2 libbpf日志

libbpf提供详尽的日志输出。激活方式:

// 代码中设置
libbpf_set_print(custom_print_fn);

// 或环境变量 (importance >= 2)
$ LIBBPF_LOG_LEVEL=2 ./boot_monitor

对于重定位错误,libbpf会输出类似:

libbpf: CO-RE relocation [42] struct task_struct.unknown_field: 
  failed to find kernel field
libbpf: failed to find BTF info for struct task_struct member index 25

7.3 常见错误解析

  • "invalid RELO type":-g flag缺失或Clang版本过低,无法生成BTF.ext
  • "failed to find kernel field":程序引用的字段在此内核中不存在,需运行时检查
  • "no matching type":本地类型名称与目标BTF不匹配,通常拼写错误导致
  • "BTF is not BIT":目标内核没有BTF数据,需要BTF Hub或手动嵌入

7.4 处理缺少BTF的老内核

线上常有低于5.4的内核无法提供BTF。两个思路:

  1. BTF Hub:从github.com/aquasecurity/btfhub-archive下载对应版本的BTF文件
  2. 将BTF文件嵌入BPF对象(需特定compile flag)
  3. 最终在部署脚本中自动选择:
#!/bin/bash
KERNEL=$(uname -r)
BTF_FILE="btf/${KERNEL}.btf"
if [ ! -f /sys/kernel/btf/vmlinux ] && [ -f "$BTF_FILE" ]; then
    export LIBBPF_SEARCH_BTF_PATH="$(dirname $BTF_FILE)"
fi
exec ./boot_monitor

八、生产级CO-RE项目工程设计

8.1 目录布局

my_ebpf_project/
├── src/
│   ├── bpf/                  # eBPF (C) 源码
│   │   ├── my_prog.bpf.c    # 内核端BPF程序
│   │   ├── my_maps.bpf.h    # 共享MAP定义头
│   │   └── vmlinux.h        # BTF导出的vmlinux头
│   ├── user/                 # 用户态程序
│   │   └── main.c
│   ├── skel/                 # 自动生成
│   │   └── my_prog.skel.h
├── btf/                      # 老内核BTF备份
│   ├── 4.18.0-193.el8.x86_64.btf
│   └── 5.4.0-96-generic.btf
├── Makefile
├── Dockerfile                # 构建容器
├── .github/workflows/        # CI
└── tests/
    └── test_compat.c         # 兼容性测试

8.2 可复用的CO-RE头文件

封装常用宏降低使用门槛:

// co-re-helpers.h
#pragma once

// 安全字段读取:不存在时返回默认值
#define BPF_CORE_READ_INTO(dst, src, a) ({ \
    if (bpf_core_field_exists(typeof(*(src)), a)) \
        *dst = BPF_CORE_READ(src, a); \
})

// 获取满足存在性的首个字段
#define BPF_CORE_READ_FIRST(src, a, b) ({ \
    typeof((src)->A) __r; \
    if (bpf_core_field_exists(typeof(*(src)), a)) \
        __r = BPF_CORE_READ(src, a); \
    else __r = BPF_CORE_READ(src, b); \
    __r; \
})

// 强类型枚举重定位
#define BPF_CORE_READ_ENUM(src, field, type) \
    bpf_core_enum_value_exists(type, BPF_CORE_READ(src, field))

8.3 测试策略

CO-RE项目必须实现以下测试覆盖:

  • 编译兼容性矩阵:在多种Clang版本(11~18)下确保通过
  • BTF信息检查:校验.BTF.ext记录的完整性
  • 多内核集成测试:在4.18/5.4/5.10/5.15/6.1等代表性内核上验证运行
  • skeleton结构体验证:确保skel字段与BPF对象一致

九、CO-RE未来方向:应用生态与内核发展

9.1 BTF Extensions

内核5.18引入的Kernel Modules BTF允许模块独立存储BTF,libbpf相应地支持了module BTF重定位。现在CO-RE不仅能重定位vmlinux中的类型,还可以重定位任意loadable module中的类型 -- 这为基于第三方模块(如MLNX_EN)的BPF追踪提供了可能。

9.2 BPF Type Matches

libbpf 1.0 引入的强类型匹配(__builtin_preserve_type)替代了简单的名称匹配+大小比较,确保BPF对象对目标内核类型的结构精确一致。

9.3 Userspace CO-RE

社区已经在探讨采用BTF思想实现用户态CO-RE:BPF skel模式的核心思想(运行重定位+类型发现)被证明比传统syscall barrier更优雅。如果用户态也能基于BPF验证器的安全沙箱模型(bpf trampoline等),CO-RE思想的应用空间将更加广阔。

9.4 eBPF-Core in Cloud Native Inference

Cilium 1.12+全面切换到CO-RE、Falco 3.0+基于libbpf+skeleton、Tetragon全程CO-RE实现 -- CO-RE已经成为云原生BPF应用的默认选择。理解CO-RE,意味着理解现代eBPF生态的运作基石。

十、总结

BPF CO-RE代表了eBPF基础设施层数年来最重要的架构演进。它的成功证明了一个核心思路:在运行时通过类型信息实现指令重定位,比在编译时为每个目标做cross-platform overengineering优雅数个数量级。

核心技术栈总结:

  • BTF:内核类型信息的二进制编码格式,是CO-RE的信息基础
  • vmlinux.h:BTF导出的全量头文件,消除了依赖内核头文件
  • libbpf:重定位框架,加载时解析BPF字节码并修正字段访问
  • bpftool gen skeleton:自动化生成用户态绑定,封装资源管理

掌握CO-RE,你就掌握了编写生产级可移植eBPF应用的钥匙。随着eBPF生态从网络追踪扩展至安全、观测、调度等核心内核子系统,CO-RE的重要性只会持续上升。

# 最终开发体验:
# 1. 编写BPF程序
$ vim my_prog.bpf.c

# 2. 一键编译
$ make          # clang --target=bpf -g -O2 ... ; bpftool gen skeleton ...

# 3. 跨任意BTF-enabled内核运行,无需重新编译
$ ./my_prog     # 5.4内核 ✅  5.10内核 ✅  5.15内核 ✅  6.1内核 ✅
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部