如果你最近在调研eBPF,大概率会在第一步就卡住:教程说跑一个 minmal 示例,你复制粘贴、敲下 make,结果是一整屏报错。BPF 这套东西的 API 本身并不复杂,复杂的是它脚下那套环境——内核版本、BTF、编译链、挂载点、权限,每一层都能让你浪费一个下午。这篇文章就把“搭建 BPF 内核调试开发环境”这件事从头到尾拆开,从内核检查、工具链选择到第一个程序落地,再到 verifier 报错怎么读、容器里开发怎么挂载,全部过一遍。内容偏实操,适合刚接触 BPF、想在本地机器上跑通并持续调试的开发者,也适合准备把 BPF 工程放进容器或虚拟机里的人。
我默认你用的是 Linux,有一定命令行基础,并且有 root 或 sudo 权限。没有 root 也能跟着看,但第一次加载 BPF 程序时,大概率还是需要切到 root 试一把。准备好了就从第一步开始。
1. 动手前先把内核“体检”一遍:版本、配置、BTF,一个都不能少
1.1 不同内核版本能干什么,不能干什么
BPF 这套能力完全跟着内核走。同一个 BPF 程序,换个内核,编译照过、加载报错、跑一半失效三种情况我都见过。所以第一步不是装工具,是先搞清楚你手头这枚内核到底给了你多少权限。
| 内核版本 | 里程碑 | 对你意味着什么 |
|---|---|---|
| 3.18 | 加入 eBPF 基础架构 | 只能算概念验证,学习价值很低 |
| 4.4 | map 与 bpf 系统调用逐步完善 | 可以写简单程序,但能力受限 |
| 4.6 | tracepoint 和 perf 事件支持 | 观测类程序开始可用 |
| 4.20 | BPF trampoline 引入 | 低开销 attach,简洁架构 |
| 5.2 | 内核侧 BTF 正式落地 | CO-RE 开发的重要前提 |
| 5.5 | 用户/内核空间读取 helper 拆分 | 现代 BPF 编程必备语义 |
| 5.11 | memcg 替代 RLIMIT_MEMLOCK | 权限和内存限制模型变化 |
结论很简单:开发机内核至少要 5.2 以上,想走得顺,建议直接选 5.10、5.15、6.1 这种长期维护版本。不要拿 3.x、4.x 去折腾,那不是学 BPF,是给自己加戏。
1.2 三个命令检查 BTF、配置和挂载点
“环境不行”这件事不能靠感觉,要靠几条命令快速判断。先把下面三个命令跑一遍:
uname -r ls -la /sys/kernel/btf/vmlinux cat /boot/config-$(uname -r) | grep -E 'CONFIG_BPF|CONFIG_DEBUG_INFO_BTF|CONFIG_KPROBE|CONFIG_TRACEPOINT|CONFIG_FTRACE'第一行看版本,第二行看BTF 文件是否存在。如果/sys/kernel/btf/vmlinux存在,说明内核自带 BTF,CO-RE 相关开发基本无忧;不存在也不一定世界末日,但你要提前知道后面会多出很多麻烦。第三行是内核编译配置,重点看CONFIG_DEBUG_INFO_BTF=y和CONFIG_BPF_SYSCALL=y,其余更多决定的是附加能力。
再补两个检查,避免后面反复踩坑:
mount | grep -E 'trace|bpf' cat /proc/sys/kernel/unprivileged_bpf_disabled cat /proc/sys/kernel/perf_event_paranoidtracefs 没挂载,后面bpf_printk的输出你会什么都看不到;perf_event_paranoid偏高则会影响依赖 perf_event_open 的程序类型。这两项不查,等跑起来再查,方向就反了。
我在排查某台开发机的加载问题时见过这样的例子:系统内核是 5.15,版本不低,但CONFIG_DEBUG_INFO_BTF没有开。隔空帮别人看问题,对方反复收到libbpf: failed to find valid kernel BTF,一直以为工具链装错了。后来我看到他贴出的内核配置截图,BTF 配置就是 0。这种问题定位只要两分钟,但方向不对能查一下午。
1.3 要不要为了 BPF 单独编内核
这是很多人纠结的问题。我的判断标准很直接:
- 如果你的发行版内核不低于 5.2,且 BTF 配置已开启,不需要自己编内核,直接进入工具链安装。
- 如果内核版本太老,或者发行版内核镜像没有开 BTF、调试事件,且你短期不想换系统,才需要考虑自己编。
- 如果你想尝鲜最新 BPF helper、新的程序类型,又或者要做的不是 BPF 开发而是内核本身的调试,自编译内核才有明确价值。
普通学习场景,系统内核完全够用。自己编内核不是不能,而是没必要——编译一次半小时起,中间任何一个配置错位都可能让你把时间耗在“验证环境”而不是“学 BPF”上。等你在系统内核上跑通了二三十个例子,再考虑定制内核不迟。
2. 工具链选型:为什么 clang/LLVM 是 BPF 的事实标准,以及 libbpf 三件套怎么配
2.1 工具链的本职与分工
搭建 BPF 开发环境,本质是把四样东西配齐:编译器负责把 C 编译成 BPF 字节码,libbpf 负责在用户空间加载和管理程序,bpftool 是内核侧探针和管理员助手,pahole 则负责在编译内核或处理调试信息时生成 BTF。
这里第一个容易产生误解的点是:为什么一定是 clang/LLVM,而不是 gcc?BPF 字节码本质是内核虚拟机指令集,LLVM 后端可以把它当作一个独立 target 输出,clang -target bpf这个交叉编译流程非常成熟。gcc 从 10 才开始逐步支持,而且生态里围绕 BTF、skeleton、CO-RE 的工具链都默认与 clang 搭档。所以别在 gcc 上纠结,直接 clang。
libbpf 是从内核源码树里长出来的用户空间库,负责解析 BPF 目标文件、创建 map、加载程序、完成 attach。bpftool 是它的“管理员外壳”,可以 dump BTF、生成 skeleton、查看当前已加载的 program 和 map。pahole 做的事情比较底层,一般开发阶段用不到,但它在你自编译内核时会成为关键角色。
2.2 安装与版本匹配建议
Debian/Ubuntu 系可以直接装:
sudo apt update sudo apt install -y clang llvm libelf-dev zlib1g-dev dwarves linux-tools-genericlinux-tools-generic会带来与当前内核匹配的 bpftool、perf 等工具;如果缺bpftool,在 apt 里搜一下linux-tools-$(uname -r),不同发行版的包名略有差异。
RedHat/Fedora 系:
sudo dnf install -y clang llvm libbpf-devel bpftool libelf-devel zlib-devel dwarves版本方面,clang 11 以上能跑,14 以上比较顺滑,16 以上更省心。装完后用clang --version、bpftool version、pahole --version各看一眼。pahole 如果低于 1.16,后面自编译内核时多半会出幺蛾子。
如果你用的发行版没有现成 bpftool 包,也可以从内核源码里编译:拿到与运行内核版本匹配的源码包,进入tools/bpf/bpftool目录,执行make然后sudo make install。这条路不算难,但没必要主动走,优先用官方源。
2.3 没有 BTF 内核的备用方案
系统内核确实没有 BTF 时,三个方向可以选:换一个默认开启 BTF 的发行版或内核;自己编内核并在配置中打开CONFIG_DEBUG_INFO_BTF;用 pahole 从带 DWARF 信息的 vmlinux 里补一份 BTF。
第三条的典型命令是这样的:
pahole -J vmlinux严格说,这条命令处理的是你准备编译、安装的内核二进制,不是给已经加载运行的老内核打补丁。如果你的发行版提供了带 DWARF 的 vmlinux 包,且版本与运行内核一致,也可以在重启前把它替换到对应位置,再配合后续加载。多数人遇到“内核没 BTF”的最终解法仍然是回到前两条——换一个开启 BTF 的内核,省时省力。
3. 首个 BPF 程序落地记:从一段 C 代码到内核侧的实际运行
3.1 用 libbpf-bootstrap 起一个最小骨架
新手最容易死在“第一个程序”。手写加载逻辑不是不行,那意味着你要处理目标文件解析、map 创建、program 加载、attach、错误清理,至少小两百行样板代码。所以我建议直接用 libbpf-bootstrap 这类工程骨架,它把这些重复劳动全部自动化,你把精力放在 BPF 程序本身。
把工程递归克隆下来,进入 C 示例目录,直接 make:
git clone --recursive <libbpf-bootstrap 仓库地址> cd libbpf-bootstrap/examples/c make sudo ./minimalminimal是这套骨架里最小的例子,它会把一个挂载在sys_enter_openat这个 tracepoint 上的 BPF 程序加载到内核,然后保持运行,直到你按 Ctrl+C 退出。
minimal 对应的内核侧程序长这样:
#include <linux/bpf.h> #include <bpf/bpf_helpers.h> char LICENSE[] SEC("license") = "Dual BSD/GPL"; SEC("tp/syscalls/sys_enter_openat") int handle_openat(void *ctx) { bpf_printk("BPF triggered: openat called\n"); return 0; }SEC宏声明这个函数的 section 名和挂载目标,bpf_printk是调试阶段最常用的输出函数。编译和 skeleton 生成的工作都已经被骨架工程的 Makefile 处理掉了。
3.2 挂载点与输出:bpf_printk 讲给谁听
很多初学者以为bpf_printk会打到 stdout 或 dmesg,其实不是。它实际写进内核 trace 环形缓冲区,你要看它需要读取 tracefs 下的trace_pipe文件。常见路径是:
sudo cat /sys/kernel/debug/tracing/trace_pipe如果目录不存在或读不到,先挂载:
sudo mkdir -p /sys/kernel/debug/tracing sudo mount -t tracefs tracefs /sys/kernel/debug/tracing新版内核也可能把 tracefs 独立挂在/sys/kernel/tracing。所以先mount | grep trace看实际位置,再决定 cat 哪个路径。
跑起来之后找到另一个终端,执行一条会触发 openat 系统调用的命令:
cat /etc/hostname > /dev/null回到 trace_pipe 窗口,会看到类似输出:
cat-5786 [003] ....1 34891.022053: bpf_trace_printk: BPF triggered: openat called进程名、PID、CPU、时间戳都在这短短一行里。这个小闭环跑通,意味着你的编译、加载、attach、事件触发、内核侧打印、用户空间读取这条链路全部正常。后面所有调试都建立在这个闭环之上。
3.3 权限、rlimit 和普通用户模式
加载 BPF 程序需要权限,因为bpf()系统调用默认属于特权操作。检查一下:
cat /proc/sys/kernel/unprivileged_bpf_disabled值为 1 时非 root 用户基本没法加载程序。开发阶段我建议直接 sudo,不要在这里浪费时间去折腾最小权限。等程序稳定了,再研究用CAP_BPF之类的细粒度权限去部署。
顺便提一个老发行版特有的坑:在 5.11 之前,BPF map 占用的内存会计入进程的 RLIMIT_MEMLOCK,所以程序一加载就会出现Failed to bump RLIMIT_MEMLOCK之类的报错。解法很简单:
ulimit -l unlimited新内核改用 cgroup memory 之后这个报错少了,但如果你手里的机器是 CentOS 8 这类老系统,这句命令能救你一命。
4. 调试环节的“定位-修复-验证”链条:verifier 日志与 bpftool 实战
4.1 第一步:先分清是哪一层的错
被 BPF 环境折磨过的人,最典型的痛点是不分层。遇到报错就去重编译、重装依赖、重启机器,最后发现是程序里一个指针写法不对。我把报错大致分成五类,可以先对照着分诊:
| 报错现象 | 所在层 | 首要怀疑点 |
|---|---|---|
| clang 编译期语法错误 | 编译器 | 头文件路径、语法、target 没指定 |
| libbpf 报 BTFF 或加载失败 | 用户空间加载器 | 内核配置、权限、section 名 |
| 加载成功但事件不触发 | 挂载/追踪层 | section 名、attach 方式、tracefs 路径 |
| verifier 报寄存器访问问题 | 内核验证器 | 指针用法、helper 参数类型 |
| 程序在跑但看不到输出 | 观测链路 | trace_pipe 路径、格式串、挂载位置 |
这个表看起来简单,但它能帮你节约大量时间。遇到任何 BPF 问题,先冷静确认属于哪一层,再看最后两行报错关键字,而不是一上来就怀疑整个环境。
4.2 verifier 拒绝你的程序时,它在说什么
verifier 是内核里的静态验证器,会穷举式检查 BPF 程序所有可能路径,防止你写出让内核崩溃的代码。它报错很严格,但也恰恰是它的严格让你敢在产线上加载 BPF。
我早期写 BPF 程序时试过直接读取用户指针。代码大概是这样的错误示范:
SEC("kprobe/do_sys_openat2") int BPF_KPROBE(handle_open, struct pt_regs *ctx) { void *user_how = (void *)PT_REGS_PARM3(ctx); int flags = ((struct open_how *)user_how)->flags; return 0; }在用户空间这么写没问题,但在内核侧,verifier 看到你试图从任意指针读取数据,会直接拒绝,报错形如:
libbpf: prog 'handle_open': failed to load: -13 ... 12: (61) r2 = *(u32 *)(r1 + 0) R1 invalid mem access 'inv'最后一行R1 invalid mem access 'inv'是核心线索:它告诉你寄存器 R1 指向的内存无法被确认安全。这里的正确做法是用安全读取 helper:
struct open_how how; bpf_probe_read_user(&how, sizeof(how), user_how); int flags = how.flags;bpf_probe_read_user是内核信任的安全读取函数,内部做了异常捕获,verifier 会放行。看到 verifier 报错时,先看它提到哪个寄存器:R1/R2 通常是 helper 参数,R10 是栈指针,R0 是返回值。这个信息差只要五分钟就能看懂,但没人提示的话,新手会在日志里迷茫很久。
4.3 bpftool 和 trace_pipe 联合定位实战
调试 BPF 程序,bpftool 是最顺手的工具。程序加载后先看有没有真的进内核:
sudo bpftool prog list sudo bpftool map list如果 list 里看不到你的程序,说明加载阶段就失败了,回看 libbpf 报错。如果能看到,接着用:
sudo bpftool prog dump xlated id <ID>这条命令能看到内核最终接受的指令,有助于确认 helper 调用、常量展开是否符合预期。要看 map 内容则用:
sudo bpftool map dump name <map_name>我遇到过一种“加载成功但事件不触发”的情况,排查到一半发现是 section 名字写错了。程序加载阶段 libbpf 根据SEC("tp/syscalls/sys_enter_openat")解析 attach 点,一旦名字写错,程序虽然可以加载,attach 时却会失败或者根本挂不上。此时 bpftool prog list 里的 attach type 和预期不一致,一瞬间就能发现问题。
另一种“静默失败”是 tracefs 路径不对。程序其实在跑,事件也触发了,但你在一个不存在的路径下 cat,自然什么都看不到。所以组合拳是:先mount | grep trace确认位置,再确保trace_pipe在事件发生前已经打开,然后用 bpftool 确认 attach,最后再触发事件看输出。这几步串起来,绝大多数“黑洞”问题都能定位。
调试阶段还有一个低成本的技巧:把 BPF 程序里关键分支都放一个bpf_printk,把它当成“日志管道”,事件到哪一步、变量取成什么值一目了然。虽然生产环境要收敛打印,但开发期它是最高效的探针。
5. 进阶玩法:自定义内核、容器与虚拟机下的 BPF 环境调试要点
5.1 自定义内核:开启哪些选项,编译时那点事
需要自编译内核的情况,无非三种:系统内核太旧、发行版没开 BTF、想尝鲜新特性。如果真走到这一步,最核心的配置项先看清楚:
| 配置项 | 作用 |
|---|---|
| CONFIG_DEBUG_INFO_BTF | 生成 BTF,CO-RE 开发基础 |
| CONFIG_DEBUG_INFO_DWARF4 | 提供完整 DWARF,供 pahole 转换 |
| CONFIG_BPF_SYSCALL | 允许 bpf 系统调用 |
| CONFIG_BPF_JIT | 启用 JIT,提升运行性能 |
| CONFIG_KPROBE_EVENTS / TRACEPOINTS / FTRACE | 事件追踪能力 |
| CONFIG_IKHEADERS | 提供与内核匹配的头文件归档,调试时很好用 |
编译的大致步骤是:
make olddefconfig scripts/config --enable DEBUG_INFO_BTF --enable DEBUG_INFO_DWARF4 --enable IKHEADERS make -j$(nproc) sudo make modules_install install这里最容易踩的坑是 pahole 版本太低。CONFIG_DEBUG_INFO_BTF 开启后,内核构建系统会调用 pahole 从 vmlinux 生成 BTF 段;pahole 过老会导致生成的 BTF 损坏或缺失,最明显的表现是编译完重启后/sys/kernel/btf/vmlinux依然不存在。先在编译前pahole --version确认在 1.16 以上,能省一次全量编译的重来。
5.2 容器内开发的挂载与权限细节
容器里做 BPF 开发,有一个根上的认知要先立起来:BPF 程序运行在宿主机内核上,不是容器内核上。容器里编译和加载只是把用户空间动作搬进去,最终打交道的还是宿主内核。因此容器必须和宿主机共享内核能力。
一个常见启动参数如下:
docker run -it --rm --privileged \ -v /sys/kernel/btf:/sys/kernel/btf:ro \ -v /sys/kernel/debug:/sys/kernel/debug:rw \ -v /sys/fs/bpf:/sys/fs/bpf \ -v /lib/modules:/lib/modules:ro \ ubuntu:latest bash--privileged是图省事的做法,能跑通但不是最小权限,生产环境别这么干。一般至少需要CAP_BPF、CAP_SYS_ADMIN这类能力。挂载/sys/kernel/btf很容易被忽略,漏了它,容器里生成vmlinux.h就会失败。进入容器后,如果trace_pipe不可用,先手动挂载:
mount -t tracefs tracefs /sys/kernel/tracing 2>/dev/null || true mount -t bpf bpf /sys/fs/bpf虚拟机场景没有容器这么绕,guest 内核自己必须带 BTF 和所需追踪配置,不需要专门开嵌套虚拟化就能跑 BPF。唯一要留意的是云主机环境,宿主侧若启用了安全限制或沙箱隔离,guest 里的 kprobe 可能受限,表面上看是加载失败,实际是底层的 capability 没放开。
5.3 建立最小复现的习惯,等于给自己装一个外挂
最后聊一个我自己调试 BPF 时的习惯:每次调试都建立最小复现链路。一个程序加载成功、事件触发、输出可读,这个最小闭环一旦建立,后面无论怎么改都在这个闭环里验证。
我的做法是提前把日志管道挂成后台任务:
sudo sh -c 'cat /sys/kernel/debug/tracing/trace_pipe > /tmp/trace.log & echo $! > /tmp/trace.pid'之后任何 BPF 程序在跑,我只需要tail -f /tmp/trace.log就能实时看输出,事件发生再也不用抢着开终端。退出时kill $(cat /tmp/trace.pid)即可。
这套流程跑顺之后,BPF 调试就不再是“玄学”。每次改一处变量、保存报错原文、对比 bpftool 输出,问题基本都能在半小时内缩小到具体某一行代码。环境搭建只是入场券,真正决定开发效率的,是你面对报错时能不能快速判断它是哪一层的错、该找哪个工具说话。