本页目录
eBPF 基础
eBPF 让用户态把沙箱程序注入内核运行——verifier 静态证明安全性,JIT 编译到原生指令,maps 与用户态共享数据,CO-RE 一次编译到处运行。它之于内核,近似 JavaScript 之于浏览器。
概述
想在内核里加一段自己的逻辑,传统上只有两条路:改内核源码重编(周期以月计),或写内核模块(一个空指针就 panic 整机)。eBPF (extended Berkeley Packet Filter) 给了第三条路:把程序作为数据提交给内核,内核先用静态验证器证明它无害,再 JIT 成原生代码挂到钩子上运行。就像浏览器敢跑任意网页的 JavaScript 一样,内核从此敢跑用户提交的代码——这是它从最初的包过滤器 (cBPF) 演变成通用内核可编程平台的原因。
版本覆盖 3.18 ~ 6.x。今天它撑起了四大领域:
- 可观测性: bpftrace, bcc, Cilium Hubble
- 网络: XDP, TC BPF, Cilium 容器网络
- 安全: BPF LSM, seccomp
- 性能: 热路径上的自定义逻辑,无需重编内核
生命周期
flowchart LR
subgraph user["👩💻 用户态"]
C["C 代码"] -->|"clang -target bpf -O2"| O["eBPF ELF (.o)"]
O -->|"libbpf + CO-RE relocation"| L["bpf(BPF_PROG_LOAD)"]
R["读取 maps / ringbuf"]
end
subgraph kernel["🛡️ 内核态"]
L --> V{"<b>Verifier</b><br><small>静态证明所有路径安全</small>"}
V ==>|"通过"| J["<b>JIT 编译</b><br><small>bytecode → 原生指令</small>"]
V -.->|"拒绝 -EACCES<br>+ verifier log"| C
J --> A["attach 到 hook<br><small>kprobe / tracepoint / XDP…</small>"]
A -->|"事件触发执行"| M[("BPF maps")]
end
M --> R
classDef gate fill:#d2992226,stroke:#d29922,stroke-width:2.5px
classDef fast fill:#4493f81f,stroke:#4493f8,stroke-width:2px
classDef store fill:#3fb9501f,stroke:#3fb950,stroke-width:2px
class V gate
class J,A fast
class M store
关键在于顺序:先验证、后编译、再挂载。程序到不了 hook 上,除非 verifier 已经证明它在所有执行路径上都安全。
Verifier: 安全性的基石
kernel/bpf/verifier.c,约 1.6 万行,内核中最复杂的验证器。它对程序做符号执行,穷举所有可能路径,检查三类性质:
| 类别 | 具体约束 |
|---|---|
| 控制流 | 无不可达指令;无死循环(5.3+ 允许可证明终止的 bounded loop);无越界跳转;调用深度 ≤ 32 |
| 数据流 | 追踪每个寄存器的类型(PTR_TO_CTX / PTR_TO_MAP_VALUE / SCALAR_VALUE…);追踪每个标量的取值范围;强制空指针检查;map 访问的 key/value 尺寸匹配 |
| 资源 | 指令数上限 100 万(5.1+);栈深度 512 字节 |
验证失败时 BPF_PROG_LOAD 返回 -EACCES,并附带一份逐指令的 verifier log——读懂这份 log 是 eBPF 开发的日常。
边界追踪 (Bound Tracking)
verifier 最精妙的机制:对每个寄存器维护 [smin, smax, umin, umax] 四个边界,沿着分支把条件"学"进去:
if
// else 路径上则推导出 x ∈ [10, MAX]
数组索引不出界、map key 合法、指针算术不越界——都靠这套区间推导在加载时静态证明,运行时零开销。
JIT 编译
验证通过后,BPF bytecode 被逐指令翻译成原生机器码(x86 侧在 arch/x86/net/bpf_jit_comp.c),每条 BPF 指令对应 1~3 条 x86 指令:
BPF_ADD reg, imm → add $imm, %reg
BPF_LDX reg, src → mov (%src), %reg
BPF_CALL fn → call fn (tail call 优化成 jmp)
BPF_EXIT → ret
所以 eBPF 不是"内核里的解释器"——JIT 后的执行性能接近手写内核代码,这是它敢放在 XDP 这种每包必经的热路径上的底气。
BPF Maps: 程序间数据共享
BPF 程序本身是无状态的、事件驱动的;状态放在 maps 里——内核态的键值存储,BPF 程序之间、BPF 与用户态之间靠它共享数据。
// include/uapi/linux/bpf.h — 常用类型节选
;
BPF 侧通过 helper 操作:bpf_map_lookup_elem / bpf_map_update_elem / bpf_map_delete_elem;用户态通过 bpf() 系统调用或 libbpf 的封装读写同一个 map。
Ring Buffer vs Perf Buffer
把内核事件源源不断送到用户态,是可观测性场景的主干道。5.8 之前用 perf buffer,之后应一律用 ringbuf:
| Perf Buffer(旧) | Ring Buffer (5.8+) | |
|---|---|---|
| 缓冲结构 | 每 CPU 一个 | 全 CPU 共享一个 |
| 顺序 | 跨 CPU 乱序 | 全局有序 |
| 内存效率 | 按最忙 CPU 预留 | 共享,省内存 |
| 拷贝 | 两次 | 预留-提交 (reserve/commit),mmap 零拷贝 |
| 记录长度 | 固定 | 可变 |
CO-RE: Compile Once, Run Everywhere
问题:BPF 程序编译时引用内核结构体,字段偏移被固化进 bytecode;下一个内核版本字段挪了位置,程序就读错内存。早年的 bcc 因此要在目标机上现场编译(拖一整套 LLVM + kernel headers)。
解法分两半:
- BTF (BPF Type Format)——内核自带的类型描述,嵌在
/sys/kernel/btf/vmlinux里,相当于压缩到几 MB 的"内核 debug info"。 - libbpf 的 CO-RE 重定位——加载
.o时读取当前内核的 BTF,把 bytecode 里所有字段偏移现场修正成本机的真实偏移。
于是同一个 .o 文件可以不加修改地跑在 5.10 和 6.5 上:
x = ; // 偏移在加载时按本机内核解析
bpftool: 管理 BPF 对象
安全模型
eBPF 程序被约束到"不可能伤害内核"的子集里,它不能:
- 无限运行(指令数上限 + 终止性验证)
- 做没有边界检查的指针访问
- 直接读任意内核内存——只能经 helper(
bpf_probe_read_kernel) - 直接写任意内核内存——只能写自己的 maps
- 睡眠或调用
schedule(sleepable BPF 是受限的例外) - 调用白名单(helper/kfunc)之外的内核函数
特权层级:
| 能力 | 可以做什么 |
|---|---|
CAP_BPF | 加载大多数 BPF 程序类型 |
CAP_SYS_ADMIN | 所有类型,包括 tracing |
| 无特权 | 仅当 kernel.unprivileged_bpf_disabled=0,且只有 socket filter 等有限类型 |
程序能挂到哪些钩子、各类型能拿到什么上下文,见 eBPF 程序类型;实战追踪工具链见追踪调试与ftrace 与 bpftrace 用户态。
参考
- 源码:
kernel/bpf/(verifier, syscall, helpers),arch/x86/net/bpf_jit_comp.c(JIT),tools/lib/bpf/(libbpf),tools/bpf/bpftool/ - 内核文档:
Documentation/bpf/(极好的文档) - 书籍: "BPF Performance Tools" (Brendan Gregg)
- LWN: "A thorough introduction to eBPF" (lwn.net/Articles/740157/)
关键词: eBPF, verifier, JIT, BPF maps, BTF, CO-RE, libbpf, bpftool, ring buffer, CAP_BPF