news 2026/10/12 2:48:31

eBPF CO-RE实战:从BTF到libbpf解决内核版本兼容问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
eBPF CO-RE实战:从BTF到libbpf解决内核版本兼容问题

写 eBPF 观测程序,最让人头疼的从来不是 BPF 指令怎么写,而是写完之后怎么让它在不同内核版本上都能跑。eBPF CO-RE 模式(Compile Once, Run Everywhere)就是为解决这个可移植性问题而生的。第一次接触 CO-RE 的时候,我最大的感受是:终于不用再为内核版本差异反复改代码、交叉编译甚至维护多套 BPF 源码了。这篇东西,我想把 CO-RE 的底层原理、实操路径和我踩过的坑完整梳理一遍,希望能帮那些正在被内核版本适配折磨的开发者打开一扇门。

1. 为什么需要 CO-RE:先把“可移植性”这笔账算清楚

1.1 传统 BCC 模式的内核版本依赖

在 CO-RE 成为主流之前,大家写 eBPF 观测程序,多半走的是 BCC(BPF Compiler Collection)那一套。BCC 的思路很直接:程序不是先编译好再分发,而是把 BPF 的 C 源码直接分发到目标机器上,在目标机器上调用本机的 clang 现场编译。这样做有什么问题?最明显的,目标机器上必须有完整的 clang/llvm 工具链、内核头文件、内核源码路径,以及 BCC 自身那一堆 Python 依赖。我真实遇到过一台生产环境机器,为了跑一个 BCC 脚本,不得不先装一堆编译工具,最后还因为某发行版的 libbpf 版本和 BCC 内置版本冲突,折腾了大半天。这种“运行时编译”的模式,本质上把所有编译期问题都推迟到了生产环境,风险极高。

BCC 另外一个大坑,是它会读取目标机器上的内核头文件来解析内核数据结构。问题是,很多 Linux 发行版的内核头文件并不完整,或者头文件路径跟 BCC 的默认搜索路径不一致,结果就是明明很简单的task_struct字段访问,编译时报出一堆找不到头文件的错。就算头文件都齐全,不同内核版本之间的结构体定义差异也会让同一个脚本编译出不同的 BPF 指令,稍不留神就会在某个内核版本上编译失败或者运行结果错误。

1.2 手算偏移量的“刀尖舔血”式兼容

再往前倒,在 BCC 出现之前,或者在一些对性能要求极端的场景里,有人会直接绕过编译器,用原生的 eBPF 指令写程序,通过BPF_PROG_LOAD装载。这时候如果程序要访问内核结构体的某个字段,就必须自己算出该字段在结构体里的偏移量。什么意思?task_struct里的pid字段,在某个内核小版本里偏移是0x2c0,换一个内核版本可能就变成了0x2c8。你得针对每个目标内核版本去查pahole的输出、去读源码,然后把硬编码的偏移量写进程序里。我见过有人为了兼容 CentOS 7 和 Ubuntu 20.04 两套内核,维护了两份 BPF 汇编,每次内核更新都要重新手动排查偏移。这种方式的脆弱性不言而喻,稍有一个字段对齐规则的变化,整个程序就会静默读错数据,甚至读到非法地址导致崩溃。

所以,行业里一直在等一个能够“编译一次、到处运行”的机制。CO-RE 就是这个问题的答案。它在编译期做“标记”,在加载期做“修正”,把原本依赖目标机环境和头文件的耦合关系彻底解开了。这也是为什么这几年新出的观测类项目,比如 raw tracepoint 工具、网络策略引擎、一些关键的故障排查平台,底层基本都是 CO-RE 模式。理解 CO-RE,等于拿到了理解现代内核观测工程的一把钥匙。

2. CO-RE 的三个核心支柱:BTF、重定位与 libbpf

2.1 BTF:内核类型信息的“自描述格式”

CO-RE 之所以能“到处运行”,第一步是让目标内核“自报家门”——告诉外部加载器,这个内核里的结构体长什么样、字段偏移是多少、枚举值有哪些。承担这个任务的就是 BTF(BPF Type Format)。

BTF 本质上是一种描述内核数据结构的元数据格式,你可以把它理解成一份“内核类型说明书”。它记录了一整套从struct task_struct到各个枚举类型的定义、字段名、字段偏移、字段大小、类型嵌套关系,甚至是函数签名。如果内核启用了 BTF,外部程序不需要安装内核源码、不需要头文件,也能精确知道当前内核的所有类型布局。

那怎么知道目标机器有没有 BTF 呢?最简单的办法,看/sys/kernel/btf/vmlinux文件是否存在。或者跑一下:

ls -l /sys/kernel/btf/vmlinux

存在这个文件,就说明内核开启了CONFIG_DEBUG_INFO_BTF。通常内核版本 5.2 以上主流发行版都会默认开启。如果是自己编译内核,注意打开这个配置项。BTF 的生成依赖pahole工具链,我是强烈建议做内核观测基础开发的人手上备一个pahole的。

有了 BTF,开发者写 BPF 程序时,就不需要再去 include 各种内核头文件了。你只需要在编译之前,把当前内核的 BTF 导成一个叫vmlinux.h的巨型头文件:

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

这个头文件里写满了所有内核类型定义。你写 CO-RE 程序时,直接#include "vmlinux.h"就够了,不需要再操心不同发行版之间内核头文件的路径和差异。它就像一份“随内核生成的类型快照”,核心价值在于:无论目标内核版本如何变化,总有一份准确描述当前内核类型的 BTF 可用。

2.2 重定位机制:编译期钉桩、加载期填值

有了类型描述只是基础,CO-RE 真正的灵魂在“重定位”(relocation)机制。这个机制可以类比成代码里的“动态链接”:编译 BPF 程序时,遇到需要访问的内核结构体字段,编译器并不知道它在目标内核里的实际偏移,只是生成一个引用“桩子”,并把需要的类型信息(比如字段名pid、所在结构体task_struct)记录下来。当 libbpf 加载这个 BPF 对象时,它读取目标内核的 BTF,找到task_struct里pid字段的真正偏移量,然后把 BPF 指令里那个“桩子”一部分,直接改写成一个具体的立即数偏移,最后才把修正过的 BPF 字节码提交给内核。

CO-RE 重定位支持多种类型,我挑四个最典型的:

重定位类型作用使用场景
字段偏移(Field Offset)修正结构体字段的真实偏移量访问task_struct->pid、sk_buff->len等
字段大小(Field Size)修正字段的实际字节大小跨内核版本字段由u32变u64的场景
枚举值(Enum Value)修正枚举常量的实际数值处理不同内核加进新枚举值的情况
类型存在性(Type Existence / Matches)判断某个类型/字段是否存在做兼容性分支:存在才访问,不存在就走其他路径

实际操作中,访问字段偏移是最常打交道的重定位。为了让重定位信息能够生成,你在 BPF 代码里就得用专门的辅组宏BPF_CORE_READ系列来读写内核结构体,而不是直接对指针解引用。原因很简单:BPF 程序运行在内核态,不能直接解引用任意内核指针(一来可能读到不合法地址,二来也难以做重定位记录),BPF_CORE_READ会展开成一系列bpf_probe_read_kernel调用,同时让编译器能记录下目标字段的类型和路径,为后续 libbpf 重定位提供依据。

这里我想多说一句,很多人第一次看到bpf_core_read觉得麻烦,不愿用。但实际上这套宏有一个隐藏好处:它会自动帮你处理可能存在的为空指针情况,不会让 BPF 程序因为某个字段访问异常直接挂在 tracepoint 回调里。换句话说,CO-RE 不仅是“可移植”,同时也在安全性上比普通解引用高一个级别。

2.3 BPF skeleton:让加载代码短到几乎不用手写

除了内核侧的 BTF 和重定位,CO-RE 模式的编排核心在用户态,这部分作用最大的是 libbpf 和它生成的 BPF skeleton。

大多数人上手 libbpf,是从bpf_object__open_file、再bpf_object__load、再手动找 map、找 program、find prog by name,系列繁琐的 API 开始的。CO-RE 生态里通常会用bpftool gen skeleton从编译好的 BPF ELF 文件生成一个骨架(skeleton)头文件,比如根据trace_openat.bpf.o生成trace_openat.skel.h。这个骨架头文件极大简化了用户态代码,自动帮你实现了 open、load、attach 的封装,并暴露对应的结构体字段。你只需要:

struct trace_openat_bpf *skel = trace_openat_bpf__open_and_load(); if (!skel) { /* 错误处理 */ } trace_openat_bpf__attach(skel);

这直接就完成了全部加载和挂载过程,用户态代码的体量至少减少一半。需要销毁时调用trace_openat_bpf__destroy(skel)即可。骨架代码里的各种细节——map 的解析、program 的 section 到 fd 的映射——都被自动处理好了。

理解 skeleton 出现的背景,就是理解 CO-RE 理念:把机械化的加载工作全部交给统一编译生成的代码,把人的精力集中在 BPF 逻辑本身。它对工程化的帮助在大型观测链路中尤其明显。

3. 从零写一个 CO-RE 观测程序(可直接抄作业)

理论讲太多容易变成纸上谈兵,我还是直接带大家写一个最简单的 CO-RE 程序,功能是统计每个进程调用了多少次openat系统调用。这个例子麻雀虽小,但涵盖了 CO-RE 开发的完整流程:生成 vmlinux.h、写 BPF 内核态代码、用 libbpf 写用户态加载代码、编译链接、运行验证。

3.1 环境准备:内核、编译器、libbpf 一个都不能少

开发 CO-RE 程序,需要一套基础工具链。我用表格列一下版本建议,照着装基本没问题:

依赖版本/配置要求说明
内核5.2+ 且开启CONFIG_DEBUG_INFO_BTF没有 BTF,CO-RE 无从谈起
clang10+(推荐 14+)需要生成 BPF 目标代码,低版本对 CO-RE 原语支持不全
libbpf0.8+(根据发行版可更晚)负责用户态加载,重定位的实现就在这
bpftool与内核版本匹配较稳用来生成 vmlinux.h 和 skeleton

这里要重点强调一下 libbpf 的版本。CO-RE 很多特性写死在 libbpf 里,比如对bpf_core_field_exists等宏的支持、对各种 relocation 的处理逻辑。老版本库可能遇到“用了半天宏结果告诉你 relocation 不支持”的尴尬情况。

3.2 生成 vmlinux.h:程序的“类型字典”

在项目根目录建一个include目录,执行:

mkdir -p include bpftool btf dump file /sys/kernel/btf/vmlinux format c > include/vmlinux.h

生成完最好瞟一眼文件大小,通常有几十 KB。如果只有十几 KB 甚至几 KB,就要怀疑内核的 BTF 信息是不是不完整,或者 dump 命令有问题。正常的内核,文件不会太小。

3.3 内核态代码:用 CO-RE 方式读结构体

新建一个trace_openat.bpf.c,代码如下:

#include <linux/bpf.h> #include <bpf/bpf_helpers.h> #include "vmlinux.h" char LICENSE[] SEC("license") = "GPL"; struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 16384); __type(key, u32); __type(value, u64); } openat_count SEC(".maps"); SEC("tracepoint/syscalls/sys_enter_openat") int trace_sys_enter_openat(struct trace_event_raw_sys_enter *ctx) { u32 pid = bpf_get_current_pid_tgid() >> 32; u64 *value, init_val = 0; value = bpf_map_lookup_elem(&openat_count, &pid); if (value) { __sync_fetch_and_add(value, 1); } else { bpf_map_update_elem(&openat_count, &pid, &init_val, BPF_ANY); } return 0; }

你不用手工去取ctx里的任何字段——tracepoint 的所有参数都在struct trace_event_raw_sys_enter里面,而我们其实只需要 PID,直接用 helper 函数比去解析上下文省事得多。注意这里我们#include "vmlinux.h",没有引用任何系统自带的内核头文件,所以这个.bpf.c在哪个发行版上都能编译,这就是 CO-RE 的可移植性根基。

如果你想演示 CO-RE 的字段偏移重定位,可以改成读task_struct的comm字段,稍微复杂一点,但更能体现 CO-RE 的威力:

SEC("tp_btf/sys_enter") int handle_sys_enter(struct trace_event_raw_sys_enter *ctx) { char comm[16]; struct task_struct *task = (struct task_struct *)bpf_get_current_task(); bpf_core_read_str(&comm, sizeof(comm), &task->comm); bpf_printk("comm=%s\n", comm); return 0; }

这里的bpf_core_read_str会安全地从task->comm读数,而它所读的偏移量由 libbpf 在加载时根据 BTF 重新计算。你写的源码可以一直保持不变,不管task_struct在哪个内核版本里comm字段的偏移是多大。

3.4 用户态加载程序:让 BPF 跑起来

再新建一个main.c,负责加载和轮询 map。最简方式我们直接上 skeleton,先得用 bpftool 生成骨架:

clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I./include -c trace_openat.bpf.c -o trace_openat.bpf.o bpftool gen skeleton trace_openat.bpf.o name trace_openat > trace_openat.skel.h

然后在 main.c 里直接使用这个骨架:

#include <errno.h> #include <signal.h> #include <stdio.h> #include <unistd.h> #include "trace_openat.skel.h" static volatile sig_atomic_t exiting = 0; static void sig_handler(int sig) { exiting = 1; } int main(void) { struct trace_openat_bpf *skel; int err; signal(SIGINT, sig_handler); signal(SIGTERM, sig_handler); skel = trace_openat_bpf__open_and_load(); if (!skel) { fprintf(stderr, "open_and_load failed\n"); return -1; } err = trace_openat_bpf__attach(skel); if (err) { fprintf(stderr, "attach failed: %s\n", strerror(-err)); goto cleanup; } printf("start tracing. press Ctrl+C to stop.\n"); while (!exiting) { sleep(1); } /* dump map entries */ u32 pid; u64 val; struct { u32 pid; u64 count; } entries[1024]; int n = 0; for (pid = 0; pid < 1024; pid++) { if (!bpf_map__lookup_elem(skel->maps.openat_count, &pid, sizeof(pid), &val, sizeof(val), 0)) { if (val > 0) { entries[n].pid = pid; entries[n].count = val; n++; } } } printf("pid\tcount\n"); for (int i = 0; i < n; i++) { printf("%d\t%llu\n", entries[i].pid, entries[i].count); } cleanup: trace_openat_bpf__destroy(skel); return 0; }

编译用户态程序时注意把 libbpf 链接上:

gcc -I./include -o trace_openat main.c -lbpf

然后sudo ./trace_openat,正常会打印 “start tracing”,等几秒 Ctrl+C 之后会打印出 PID 和 openat 次数的对应关系。整个编译流程里,只有 BPF 内核态文件需要 clang 交叉编译,用户态代码就是普通 gcc,不用任何特殊 flags。

3.5 一个不太容易注意到的编译细节

千万别去掉-g选项。CO-RE 重定位信息依赖 DWARF 调试信息生成,如果你编译 BPF 对象文件时不带-g,重定位表会缺失,libbpf 加载时会直接报出类似no BTF found for vmlinux或 relocation 失败的错。我见过太多人因为没加-g折腾一下午。另外,如果 BPF 代码里用到bpf_printk,记得在加载时打开 trace_pipe 查看输出:

cat /sys/kernel/debug/tracing/trace_pipe

注意 bpf_printk 在比较新的内核上可能默认打印到/sys/kernel/tracing/trace_pipe,两边路径都留意一下。

4. CO-RE 实战中的常见问题与排查技巧

CO-RE 不是开箱即用的银弹,实际部署中还是会碰到各种各样的坑。我根据自己的经历,把最典型的几个问题整理成了速查表,方便大家排查。

4.1 问题速查表

现象大概率原因处理方式
加载时报BTF is required, but is missing or malformed内核没开CONFIG_DEBUG_INFO_BTF,或 BTF 信息损坏换内核或重编内核开启 BTF;或考虑在旧内核上回退 BCC
报libbpf: failed to find BTF for extern 'xxx'vmlinux.h 是从另一个内核导出的,与当前内核不匹配在目标机器上重新运行bpftool btf dump ... > vmlinux.h再重编
报failed to resolve CO-RE relocation访问的结构体字段在当前内核 BTF 中不存在用bpf_core_field_exists做存在性判断后再访问
程序加载成功但没有任何输出事件源不正确,或 attach 失败被忽略用bpftool prog list和bpftool perf确认 prog 有没有挂上
Permission denied(加载阶段)无CAP_SYS_ADMIN或CAP_BPF权限用 root 运行,或给程序加 capabilities
BPF 程序编译通过,但 map lookup 结果全 0没等待足够时间,或 map 更新逻辑有误打印 key 看看 PID 值;观察 /sys/kernel/debug/tracing/trace 里的日志

这里我想专门展开说一下 relocation 解析失败的场景。

比如你在代码里访问task->real_parent->tgid,但某个老内核的task_struct里没有real_parent这个字段(虽然这种情况很少,但 infrequent enough)。libbpf 加载时一查 BTF,发现当前内核里不存在这个字段,就会直接判定加载失败。你要做的是用 CO-RE 的bpf_core_field_exists先判断一下:

if (bpf_core_field_exists(task->real_parent)) { /* 访问 */ } else { /* 兼容旧内核的处理 */ }

这个宏在编译期会被解析成一个“存在与否”的常量,libbpf 会把它修正成 1 或 0,你的代码分支就可以按需执行。这种分支处理方式才是 CO-RE 处理内核差异的正确姿势——不是想着“我要兼容所有版本”,而是“访问之前先问一句在不在”。

4.2 内核态崩溃与返回码检查

CO-RE 程序加载成功但运行中崩溃,这种情况极少,但不是没有。BPF 本身有验证器的保护,其实更常见的不是“崩溃”,而是“被拒绝”。你需要注意程序中的指针访问不能越界、循环次数必须是常量等基本规则。我在开发过程中,几乎每次加载失败都会先开 libbpf 的详细日志:

libbpf_set_strict_mode(LIBBPF_STRICT_ALL); /* 或者 */ libbpf_set_print(my_log_handler);

把LIBBPF_STRICT_ALL开着,让它把所有敏感警告都暴露出来,能省掉很多隐蔽 bug。

4.3 老内核兼容策略:CO-RE 并不是万能的

内核没有 BTF,就真的什么都干不了吗?严格来说,没有 BTF 你的 CO-RE 程序肯定无法加载,因为重定位完全没有依据。但现实中很多生产环境还是内核 4.18 甚至更老。业界做法一般是做一个降级链:先看有没有 BTF,有就走 CO-RE;没有 BTF 而 BCC 工具链可用,就回退到 BCC 方案;两边都不行,就只剩下手写偏移量的最原始方案了。

我的经验是,不要试图在一套代码里同时维护 BTF 和非 BTF 两套逻辑,那会让你陷入双倍的维护成本。最好是把 CO-RE 这条主线打通,然后对老机器单独留一个 LTS 分支或者明确不支持,否则运维成本极高。

5. 我从几个项目里总结的实战经验

最后分享几条我实际操作中沉淀下来的经验,不算什么大道理,但每一条都是真金白银换来的。

第一,能用 tracepoint 或tp_btf就别用 kprobe。kprobe 需要注入到函数的任意位置,风险高,而且 kprobe 的函数签名和 tracepoint 参数解析完全不一样,CO-RE 模式下 tracepoint 的上下文结构直接从 BTF 拿,稳定性好得多。我在一个内部分析项目里观察系统调用,最早全用 kprobe,后来全部换成 tracepoint,误报率低了很多。

第二,仔细看 libbpf 版本更新日志。CO-RE 相关的 API 和宏,在 libbpf 0.6 到 1.0 之间变化非常大。你照着旧教程写的代码,在新库上可能编译不过,这不是你写错,而是 API 被重构了。平时多留意依赖的 libbpf 是哪个版本,对照tools/lib/bpf目录下的README或者在线文档去调整。

第三,结构体字段访问,统一用BPF_CORE_READ系列宏。哪怕你当前内核版本下直接解引用也能跑,也坚决只用宏。这不是风格问题,是让编译器有机会把所有访问都标记成可重定位的。直接解引用虽然有时能达到相同效果,但一旦结构体字段偏移变化,它不会报错,而是悄悄读到错误的数据——这是观测系统里最致命的“静默错误”。

第四,把 vmlinux.h 的生成纳入 CI 的一部分。不要手工在开发机上生成一次就提交进去,因为目标生产内核版本可能和开发机不一致。在发布流程里,在目标机器(或与目标内核一致的环境)上生成 vmlinux.h、编译 BPF 对象、再走 skeleton 生成,这样才能确保二进制和当次内核严格匹配。

CO-RE 这条路,越走越深,也越觉得它是 Linux 底层可观测性未来几年绕不开的基础设施。写完这些,我又把那个 openat 的 demo 跑了一遍,看到 map 里统计出来的数字,心里很踏实——这种“同一个二进制,在内核 5.4、5.10、6.1 上都有同样正确结果”的体验,是过去靠 BCC 或者手算偏移完全没法想象的。希望这篇内容,能让你少走一点我当年走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 2:48:14

SpringBoot+Vue全栈实现宽带业务管理系统:权限、订单与工单实战

宽带业务管理系统这类题目&#xff0c;在Java方向的毕业设计和课程设计里出现频率一直很高。单看标题&#xff0c;SpringBoot、Vue、MySQL、MyBatis这几个词几乎把所有主流技术栈都串起来了&#xff0c;后端、前端、数据库三层全部覆盖&#xff0c;是一套标准的前后端分离全栈项…

作者头像 李华
网站建设 2026/10/12 2:48:13

9款降AI率工具实测:继续教育AI写作检测与改写全攻略

最近继续教育圈子里聊得最多的一个话题&#xff0c;就是“降AI率”。不少同学平时工作忙&#xff0c;写课程论文、研修报告、学习心得都习惯先让AI出一版初稿&#xff0c;结果提交时发现平台标注“AI生成内容比例偏高”&#xff0c;轻则打回重写&#xff0c;重则影响成绩甚至涉…

作者头像 李华
网站建设 2026/10/12 2:47:43

Windows下用Docker构建Linux版Electron安装包的完整方案

做桌面端发版&#xff0c;最难受的往往不是写业务代码&#xff0c;而是跨平台打包这一脚。我的开发机一直是Windows&#xff0c;但交付安装包必须包含Linux的deb和AppImage。Electron应用本身是可移植的&#xff0c;可一旦涉及安装包生成、原生模块编译&#xff0c;Windows下的…

作者头像 李华
网站建设 2026/10/12 2:46:29

QuickReport 4.05 在 BCB6/D7 下的安装、导出与避坑全指南

简介&#xff1a;Quick Report 4.05 是面向 BCB6 与 Delphi 7 开发环境的专业报表生成工具&#xff0c;专注于可视化报表设计、数据分组与打印输出&#xff0c;尤其针对自定义纸张尺寸下的布局与打印精度做了优化&#xff0c;适合需要在 C Builder 或 Delphi 中集成复杂报表功能…

作者头像 李华
网站建设 2026/10/12 2:45:57

SpringBoot + Redis 分布式锁实战:原理、实现与避坑指南

很多团队第一次意识到“代码里的锁不管用了”&#xff0c;往往发生在服务从单机部署切到多实例部署之后。单体时代写synchronized很顺手&#xff0c;ReentrantLock也很好用&#xff0c;但一旦服务同时跑在三台机器上&#xff0c;同一个订单的两个请求可能分别落到不同实例&…

作者头像 李华
网站建设 2026/10/12 2:45:48

DNA序列分类实战:主成分分析降维与Fisher判别完整流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华