2. eBPF程序生命周期与开发工具链

eBPF 程序不是一段“写完就能跑”的普通 C 代码。它从源码到内核中真正执行,要经过一条由用户态工具链和内核验证器共同把关的流水线:编写、编译、加载、验证、运行、卸载。理解这条流水线,比记住某个命令更重要,因为绝大多数“程序加载失败”“map 读不到数据”“挂载点没触发”的问题,都能在流水线的某个环节定位到原因。

本节先建立整体认识,再动手完成第一个 eBPF 程序。整体上,eBPF 程序的生命周期可以概括为六个阶段,它们之间的对象流转关系如下图所示。

eBPF 程序生命周期与工具链分工 用户态工具链 内核态 ① 编写 hello.bpf.c:SEC("xdp") 入口 + map 定义 ② 编译 clang -target bpf → hello.bpf.o(ELF) ③ 加载 libbpf / bpftool / BCC → bpf() 系统调用 ⑥ 卸载 关闭 fd / detach → 引用计数归零释放 工具链:clang/LLVM · libbpf · bpftool · BCC 产物:.bpf.o · skeleton · 用户态 loader ④ 验证(Verifier) 检查指令合法性、有界循环、指针安全 拒绝越界访问 / 未初始化读取 / 无限循环 ⑤ 运行(JIT + 挂载点) XDP hook / kprobe / tracepoint 触发执行 JIT 编译为原生指令,map 读写共享数据 内核对象 program fd · map fd · link fd 引用计数决定生命周期 bpf(BPF_PROG_LOAD) 通过 close(fd) 失败:返回错误码

图中左侧是用户态工具链,右侧是内核态。真正把两侧连接起来的是 bpf() 系统调用:加载时把编译好的 BPF 指令交给内核,验证器通过后程序才被允许运行。下面按阶段展开。

编写:一个最小可用的 XDP 程序

第一个程序选择 XDP 挂载点,因为它触发直观、依赖少。程序逻辑很简单:统计经过网卡的数据包数量,并把每个包放行。计数需要在内核态和用户态之间共享,因此要用到 BPF map。示例文件命名为 hello.bpf.c:

// hello.bpf.c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 1);
    __type(key, __u32);
    __type(value, __u64);
} pkt_count SEC(".maps");

SEC("xdp")
int hello_xdp(struct xdp_md *ctx)
{
    __u32 key = 0;
    __u64 *val = bpf_map_lookup_elem(&pkt_count, &key);
    if (val)
        __sync_fetch_and_add(val, 1);
    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

这里有几个必须注意的点。SEC("xdp") 是段名约定,libbpf 和 bpftool 靠它识别程序类型和挂载点;SEC(".maps") 标记 map 定义;LICENSE 段不能省,内核会检查许可证,GPL 兼容的许可证才能使用部分辅助函数。返回值 XDP_PASS 表示把包交给协议栈继续处理,其他常见返回值还有 XDP_DROP(丢弃)和 XDP_TX(从原网卡发回)。

编译:clang/LLVM 生成 BPF 目标文件

eBPF 程序用 C 编写,但编译目标不是 x86 或 ARM,而是 BPF 指令集。这由 clang 的 -target bpf 完成,后端由 LLVM 提供。推荐加上 -g 生成 BTF 调试信息,libbpf 和 bpftool 依赖它来理解 map 和结构体布局:

clang -O2 -g -target bpf \
  -D__TARGET_ARCH_x86 \
  -I/usr/include/x86_64-linux-gnu \
  -c hello.bpf.c -o hello.bpf.o

llvm-objdump -S hello.bpf.o   # 查看反汇编,确认指令与段

编译成功后得到的是 ELF 格式的 hello.bpf.o,里面包含 xdp 段(程序指令)、.maps 段(map 元数据)、license 段和 BTF 信息。它还不能直接运行,需要加载器把它送进内核。

加载与验证:内核是最后一道关卡

加载方式有三种常见选择。用 bpftool 可以快速验证程序能否通过验证器:

bpftool prog load hello.bpf.o /sys/fs/bpf/hello \
  type xdp

如果验证失败,命令会返回错误并打印 verifier 日志,日志里会指出具体指令行和拒绝原因,例如“invalid mem access”或“back-edge from insn 12 to 5”。这是排查问题最直接的入口。加载成功后,程序以 fd 形式存在,/sys/fs/bpf/hello 是 pin 到 bpffs 的路径,便于后续引用。

生产环境更常用 libbpf 的 skeleton 机制:bpftool gen skeleton hello.bpf.o > hello.skel.h 生成头文件,用户态程序通过 hello_bpf__open()、hello_bpf__load()、hello_bpf__attach() 三步完成加载和挂载,并可直接访问 skel->maps.pkt_count 读取 map。BCC 则走另一条路:它把 C 代码在运行时用 LLVM 编译并加载,适合快速脚本和观测,但部署时依赖较重。

验证器是 eBPF 安全性的核心。它要求所有内存访问可证明有界、循环必须有明确上界、指针在使用前必须经过空值检查。写程序时“先判空再解引用”不是风格问题,而是能否通过验证的硬性要求。

运行与观测:确认程序真的在工作

把程序挂到网卡上并读取计数。假设网卡为 eth0:

ip link set dev eth0 xdp obj hello.bpf.o sec xdp
bpftool map dump name pkt_count

挂载后产生流量(例如 ping 或 curl),再执行 bpftool map dump,应看到 value 从 0 增长。若计数始终为 0,先确认挂载点是否正确、流量是否真的经过该网卡,再用 bpftool prog show 查看程序是否处于 attached 状态。用 ip link set dev eth0 xdp off 可以卸载。

卸载:引用计数决定释放时机

eBPF 对象由内核引用计数管理。程序 fd、map fd、link fd 每被引用一次,计数加一;关闭所有 fd 且没有其他引用(如 pin、attach)后,内核自动回收。因此卸载通常就是关闭 fd 或 detach 挂载点,不需要手动“删除程序”。用 bpftool 加载并 pin 的程序,需要 rm /sys/fs/bpf/hello 解除 pin 引用。

阶段关键工具产物/动作常见失败原因
编写编辑器hello.bpf.c缺少 LICENSE、段名错误
编译clang/LLVMhello.bpf.o缺 -target bpf、头文件路径错
加载libbpf/bpftool/BCCbpf() 系统调用权限不足、内核不支持该程序类型
验证内核 verifier通过或错误日志越界访问、无界循环、未判空
运行JIT + hookmap 数据变化挂载点未触发、返回值不当
卸载close/detach引用计数归零fd 泄漏、pin 未清理

把这条链路走通一次,后面学习 map 类型、辅助函数、CO-RE 和复杂挂载点时,遇到问题就能快速判断是编译、加载、验证还是运行阶段的问题。第一个程序的价值不在于功能,而在于建立对整条工具链的掌控感。