本页目录

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 (x < 10) {
    // verifier 在这个分支内推导出 x ∈ [0, 9]
    bpf_map_lookup_elem(&map, &x);   // OK: 索引已被证明在界内
}
// 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 — 常用类型节选
enum bpf_map_type {
    BPF_MAP_TYPE_HASH,          // 通用 hash table (最常用)
    BPF_MAP_TYPE_ARRAY,         // 固定大小数组 (O(1) 查找, 不能删除)
    BPF_MAP_TYPE_PERCPU_HASH,   // per-CPU 各存一份, 无锁
    BPF_MAP_TYPE_LRU_HASH,      // 满了按 LRU 淘汰
    BPF_MAP_TYPE_RINGBUF,       // 环形缓冲, 事件流到用户态
    BPF_MAP_TYPE_STACK_TRACE,   // 内核栈追踪存储
    BPF_MAP_TYPE_BLOOM_FILTER,  // 快速"可能在集合中"检查
};

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)。

解法分两半:

  1. BTF (BPF Type Format)——内核自带的类型描述,嵌在 /sys/kernel/btf/vmlinux 里,相当于压缩到几 MB 的"内核 debug info"。
  2. libbpf 的 CO-RE 重定位⁠——加载 .o 时读取当前内核的 BTF,把 bytecode 里所有字段偏移现场修正成本机的真实偏移。

于是同一个 .o 文件可以不加修改地跑在 5.10 和 6.5 上:

#include <bpf/bpf_core_read.h>
x = BPF_CORE_READ(task, pid);   // 偏移在加载时按本机内核解析

bpftool: 管理 BPF 对象

bpftool prog list                          # 列出已加载的程序
bpftool map dump id <map_id>               # 导出 map 内容
bpftool prog dump xlated id <prog_id>      # 查看 BPF bytecode
bpftool prog dump jited  id <prog_id>      # 查看 JIT 后的 x86 汇编
bpftool prog load myprog.o /sys/fs/bpf/myprog   # 加载并 pin 到 bpffs

安全模型

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