eBPF 基础

本页目录

loader 退出了,程序却还挂着;验证器通过了,计数仍可能丢失。eBPF 的对象寿命、内存安全证明和业务正确性是三件事。

本篇讨论扩展 BPF。seccomp 使用经典 BPF 过滤格式及专门系统调用语义,不能混作普通 eBPF 程序类型。

查阅目标阅读位置
加载、挂接与 verifier第 1~2 节
运行成本、map 与事件缓冲第 3~5 节
兼容性与权限第 6~7 节

1. 生命周期里有多个独立对象

BPF 对象:编译、加载、挂接与持续引用 编译为 BPF ELF 代码、map 描述、BTF 与重定位信息 1 用户态 loader 准备对象 按目标 BTF 做 CO-RE 重定位,建立 maps 2 内核加载并验证程序 检查类型、边界、资源及目标程序类型约束 3 挂接到支持的执行点 事件触发后使用 maps 或输出事件 4 移除挂接并释放引用 FD、link、pin 等共同决定对象寿命 5 load 成功不代表已经 attach;关闭一个 FD 也未必立即销毁对象。
对象或动作在生命周期中的作用
程序经解析、重定位和内核加载,持有所需 map 等引用
map独立管理状态,寿命不简单等于 loader 进程
attach / BPF link将程序接到事件源,按接口管理关联
pin将对象引用保存在 bpffs,供其他进程取得

libbpf 协调 ELF 解析、重定位和内核接口调用;不能把加载成功当作已经挂接。

关闭 loader 进程不一定撤销所有程序:被 pin 或由其他对象引用的资源仍可能存在;不同 attach API 的寿命规则也不同。设计工具时应明确谁持有 link、是否 pin、退出时如何撤销,不能只依赖“进程结束应该会自动清理”。见 libbpf 总览。

2. Verifier 检查的是可证明的安全条件

验证器沿控制流传播抽象状态,跟踪寄存器与栈槽中的标量范围、指针类型、已初始化区域和需要释放的资源。它通过状态合并、剪枝等机制处理路径,不是把每种具体输入逐一执行一遍。

检查常见拒绝原因
类型和来源把普通整数当内核指针,读取未初始化寄存器
内存边界没有证明报文范围,越过 map value 或栈边界
引用与资源取得受跟踪对象后没有在所有路径释放
helper/kfunc 契约程序类型不允许调用,参数类型或上下文不满足
控制流与复杂度无法证明终止,状态探索或调用/栈限制超出预算

有界循环、bpf_loop 或迭代器等机制扩大了可表达范围,但并非任意循环都接受。程序指令数量与 verifier 探索复杂度是不同限制;不应把“一百万”同时当作源代码长度、运行次数和所有程序统一上限。

BPF 栈有严格限制,常见每个子程序栈帧上限为 512 字节;调用链、tail call 组合还会增加约束。tail call 的链式限制又不是普通 BPF-to-BPF 调用深度。应以目标内核的验证日志为准,而不是记住一张跨版本通用常数表。

非空证明与 key 范围不是一件事

下面是 map 查找后的局部模式,假设 counts 的 value 类型是 __u64,key 是已初始化的 __u32:

__u32 key = 7;
__u64 *value = bpf_map_lookup_elem(&counts, &key);
if (!value)
    return 0;
__sync_fetch_and_add(value, 1);

查找 helper 根据 map 类型和 key 内容决定结果;验证器要确认 key 内存可以读取,并在解引用 value 前确认它不是 NULL。不能把 if (x < 10) 当成任意 map 查找必定成功的证明,尤其 x 可能有符号而下界尚未检查。

普通 (*value)++ 对共享 value 的并发更新可能丢计数,例子使用原子加;原子操作仍要满足目标指令和访问约束。验证器接受不证明业务统计正确,也不保证内核或 verifier 的实现永无漏洞。见 verifier 文档。

把一次运行的 key 是否存在,与加载时的所有路径证明分开控制。即使这个样本恰好安全,未排除的 NULL 或越界路径仍会阻止加载。

正在呈现知识画面
这次查找成功,不等于所有路径都安全

验证器要证明所有可达路径的类型与边界。一个恰好非空且未越界的运行样本,不能排除另一条 NULL 解引用路径。

3. JIT 降低解释开销,不消除所有成本

支持并启用 JIT 时,BPF 指令编译成目标机器码;其他可用执行路径取决于架构和构建配置。不能为所有 BPF 指令指定固定“对应 1 到 3 条 x86 指令”:调用、原子操作、检查和 JIT 加固都会影响生成代码。

运行成本还包括 hook、map 查找、锁、事件复制及用户态消费。验证发生在加载阶段,但源程序用于证明边界的判断可能仍保留在运行代码中,因此不能说“所有边界检查运行时零开销”。

4. Maps 是具有不同语义的状态对象

Map适合存什么使用边界
ARRAY固定索引配置或计数索引范围固定,元素不能像 hash 一样删除
HASH稀疏键值状态容量和更新失败需要处理
PERCPU_ARRAY / HASH分 CPU 计数用户态需汇总各 CPU 槽,汇总不是原子快照
LRU_HASH容量受限的近似缓存淘汰会丢失状态,不适合无损保存所有在途请求
RINGBUF向消费者输出事件空间不足时预留/输出可失败
PROG_ARRAYtail call 目标不是普通函数指针数组

per-CPU 减少 CPU 间共享写入竞争,但不代表整个 map 实现“绝对无锁”,也不自动解决用户态读取时的一致性。hash 的结构更新与 value 内多个字段的原子一致性同样需要区分。

BPF 全局变量也能保留状态,libbpf 常把相关数据段组织为 map,因此“程序完全无状态”只是过度简化。更多语义见 BPF maps。

5. Ring buffer 与 perf buffer 如何选择

perf event array 常配合每 CPU perf 缓冲区输出 BPF 事件;BPF ringbuf 提供多生产者、单消费者的共享环形缓冲区,也可以用多个 ringbuf 分片。二者都支持变长记录,不能把 perf buffer 说成仅支持固定记录长度。

较早预留未完成,后面的事件也可能等待

ringbuf 的 reserve/submit 路径允许直接填充预留记录;较早预留的记录若尚未完成,会影响消费者继续读取后续记录。顺序对应同一个 ringbuf 内的预留序列,不等于跨 CPU 事件的绝对发生时间顺序,更不等于多个 ringbuf 之间有全局顺序。

两种方案都可能丢事件。ringbuf 预留失败要计数,perf buffer 的 lost 信息要处理;timestamp、CPU、事件标识和丢失计数应成为数据协议的一部分。是否共享内存、是否有复制也依具体输出 API 和消费路径不同,不能简单说 perf 固定两次复制、ringbuf 永远无复制。见 BPF ring buffer。

6. BTF 与 CO-RE 的兼容边界

BTF 描述类型等信息;CO-RE 在加载阶段利用目标 BTF 处理字段偏移、大小、存在性等重定位。BPF_CORE_READ(task, pid) 可以使字段移动后的访问仍正确,但不把整个内核内部 ABI 变成稳定接口。

兼容条件CO-RE 是否能单独解决
字段偏移变化可通过目标 BTF 重定位处理
字段缺失需要适当存在性分支或兼容路径
helper、kfunc、map 或 attach 不支持不能靠字段重定位补齐
字段语义改变、函数消失、权限不足仍需程序设计和部署处理

程序最终还必须通过目标版本 verifier。

/sys/kernel/btf/vmlinux 常提供运行内核 BTF,模块 BTF 又有自己的可用条件。CO-RE 不必在每台目标机安装完整编译器,但构建与发布仍需规划最低能力、兼容路径及验证矩阵。

7. 权限和观察工具

加载 tracing 相关程序通常还涉及 CAP_PERFMON,网络管理类操作可涉及 CAP_NET_ADMIN;CAP_BPF 并不是所有 attach 的通行证。非特权 BPF 受 sysctl、程序类型及其他限制。LSM、lockdown 等还可能继续约束操作。

安装 bpftool 后,以下命令用于只读检查对象;能看到什么取决于权限:

bpftool prog show
bpftool map show
bpftool link show

bpftool feature probe 会通过尝试相关操作探测能力,不应把它当作纯粹读取一份静态版本表。JIT 指令展示也依权限和内核配置,不保证每个程序都有可导出的机器码。

继续阅读:程序类型、追踪与调试。