本节摘要:eBPF 运行时由字节码虚拟机、加载期验证器、JIT 编译器和 Map 组成。验证器在 JIT 之前用抽象状态走完所有路径,证明无越界、无未终止循环、无非法 Helper。少了这一层,eBPF 就退回内核模块;有了这一层,表达力必须让步给可证明性。
阅读完本节,你应当能够:
第 1 章把需求写成“在瞬间 T 读对象 O”。内核凭什么让一段来自用户态的逻辑碰 O?答案不是沙箱里的解释器慢慢跑——生产上那样太慢——而是:先在纸面上把所有执行路径走完,再把证明过的指令变成机器码。
经典 BPF 几乎只做包过滤,指令极少,输出基本是“留或丢”。eBPF 把它扩成通用寄存器机:十个 64 位通用寄存器,外加只读栈指针;调用约定里返回值走 R0,参数走 R1 到 R5。你从 C 或 Rust 编译过来,看起来像普通函数,落到字节码上仍是这套窄机器。
窄是故意的。寄存器数量有限,栈小,禁止任意函数指针,循环必须让验证器看出上界。这些限制让“枚举所有路径”在工程上勉强可行。如果允许和用户态一样的通用语言,验证器会在状态爆炸里淹死,内核就只能退回“信任作者”——也就是模块模型。
程序启动时,R1 指向上下文。上下文类型由程序类型决定:追踪类可能是寄存器现场,网络类可能是包描述符,cgroup 类可能是套接字。你不能把上下文当成完整内核。很多字段要经 Helper 读,验证器据此知道你碰的是“包里的某段”还是“内核任意地址”。
R0 返回值 R1 上下文或第一个参数 R2-R5 其余参数 R6-R9 跨调用保存 R10 只读栈指针
把这台机器想成电网调度室里的操作台:按钮是真的,能切负荷,但台子上没有通往汽轮机内部的焊枪。Helper 就是那些标了牌的按钮。
验证器不跑你的业务测试。它问的是:在所有分支、所有可能的输入形状下,每条内存访问是否落在已知对象内,寄存器是否已初始化,循环是否有界,Helper 参数类型是否匹配。
它为每条路径维护抽象状态:这个寄存器现在是标量还是指向 Map 值的指针,指针偏移是否仍在值大小之内。遇到分支就分叉状态。路径太多会直接拒绝——这就是“逻辑正确但加载失败”的常见来源。你在用户态觉得 if (p) use(p->field) 很普通,验证器可能因为 p 来自一次它无法跟踪的运算而说不。
验证必须在 JIT 之前。一旦变成机器码,再证明“不会写出界”就晚了。顺序是安全属性,不是风格问题。第 5 章会把拒绝类型拆开;这里先记住分工:虚拟机提供可分析的指令集,验证器消费它。
| 组件 | 发生时机 | 它保证什么 | 它不保证什么 |
|---|---|---|---|
| 字节码虚拟机 | 编译后、加载时 | 指令集可被静态分析 | 业务语义正确 |
| 验证器 | 加载时,JIT 前 | 内存安全、可终止、Helper 契约 | 计数器没写错、策略符合产品意图 |
| JIT | 验证通过后 | 接近原生的执行速度 | 额外的安全边界 |
| Map | 运行时 | 跨事件、跨用户态的受控存储 | 无限容量、无锁无开销 |
| 挂载点 | 验证通过后 | 在约定瞬间得到约定上下文 | 该瞬间一定是你要的业务时刻 |
⚠️ 常见坑:把验证通过理解成“程序对了”。验证通过只说明它不会把内核写穿,计数加错、把错误的 PID 当 key,照样能在生产里制造噪音。
💡 关键直觉:验证器是海关 X 光机,不是品控实验室。它查违禁品,不查你申报的货物好不好卖。
解释执行字节码在早期可用,生产热路径受不了。验证通过后,JIT 把指令翻成当前架构的机器码。XDP 这种每包都跑的程序,没有 JIT 几乎没有意义。
JIT 不放宽安全。它翻译的是已经证明过的程序。你不能指望“开了 JIT 就能写更复杂的循环”——复杂循环在验证阶段就被挡下了。性能优化首先是减少挂载次数、缩小 Map 查找、避免在 kprobe 里做重活,而不是幻想 JIT 替你把错误架构变快。第 8 章会回到开销。
还有一条容易忽略:不同架构、不同内核版本的 JIT 覆盖和优化不同。验证通过保证“能安全跑”,不保证“在 ARM 和 x86 上延迟一样”。做多架构节点池时,要用真实内核测,而不是只看开发笔记本。

生产上这等于拆掉闸门。解释器和调试环境里或许有宽松模式,但把未验证程序送进内核,eBPF 相对模块的那点优势就没了。真要性能,去减工作量和选更靠前的挂载点。
C 的任意指针和未定义行为让“所有路径证明”做不到。字节码把语言砍到可分析子集,这才有工程上可结束的验证。表达力损失是买安全的价格,不是历史包袱。
杀毒软件靠特征匹配,漏报是常态。验证器靠抽象解释,它的目标是:凡是过关的程序,在安全模型内不应写出界。漏掉越界是实现缺陷,会被当成内核漏洞修,而不是“下次再扫一遍”。这个差异决定了你和它相处的姿势:不要想绕过,要想如何把意图表达成它能跟踪的状态。
寄存器类型跟踪会让一些“聪明写法”变得很笨。比如把指针存进 Map 当整数、过一会儿再取出来当指针用,类型信息断了。正确做法是把键存进去,下次再 lookup。又比如用同一寄存器先当标量计数器再当指针,不分开就会在合流处被拒绝。写 eBPF C 时,变量职责要单一,几乎像在写强类型汇编。
JIT 带来的另一个误解是“验证过后和原生内核函数一样快”。原生函数可以被编译器跨函数内联、可以被硬件预取帮到忙;eBPF 程序每次从钩子进来,现场是新的,Helper 是间接调用。快,是相对解释执行和相对把包拷到用户态而言,不是相对把逻辑写进内核源码。把复杂算法塞进钩子,JIT 救不了你的缓存。
版本差异也落在这三件套上。验证器一年比一年能证明更多循环,JIT 覆盖更多架构,虚拟机加更多指令。开发机新、生产旧时,你用了新指令或新 Helper,加载失败会表现成验证器说话难懂。矩阵里必须有旧的那一档,而不是只跑开发机内核。把“三件套版本”写进兼容性说明,比写“支持 Linux”这种空话有用。
和安全团队沟通时,强调顺序:先证明,再变成机器码,再挂上。他们担心的是任意内核执行;你要展示的是任意内核执行在这套模型里根本进不去——进得去的只有被证明过的子集。子集仍可能实现错误策略,那是产品审查的事,不要用验证器去替代产品审查,也不要反过来让产品审查替代验证器。
开发笔记本内核新,验证通过。生产长期支持内核旧,拒绝在一条新指令上。看起来像同一份程序,其实字节码对旧验证器不可证明。矩阵缺了旧档,CI 全绿,生产全红。修法不是让生产升级来迁就笔记本,而是让构建在旧档上证明,或把新指令换成旧验证器能懂的形状。
JIT 在两台上的表现也不同。同一段 XDP 在 x86 上延迟可接受,在某 ARM 节点上尾延迟更高,因为 JIT 覆盖和缓存行为不同。架构矩阵和内核矩阵一样,要有实机,不要只信开发机的微基准。微基准没有业务缓存争用,数字会骗人。
把“三件套版本”打进加载器日志:验证器表现出来的拒绝类型、是否 JIT、架构。下次对比两台命运时,不靠记忆。记忆会把问题归因到业务流量,日志会把它归因到指令集。归因错了,你会去调应用,而不是调构建。
对外说支持 Linux 等于没说。要写验证器能否证明所用循环,JIT 覆盖哪些架构,需要哪些 Helper。旧档证明不了的指令,不要用新档开发机偷偷带进制品。制品是给最老那档看的,不是给笔记本看的。架构差异同样写入:ARM 节点单独测 XDP 尾延迟。微基准没有业务缓存争用,不能单独当容量依据。加载器日志打出拒绝类型、是否 JIT、架构,方便对比两台机器的不同命运。命运不同时先看三件套,再看流量。看反了会去调应用。调应用解决不了旧验证器不认识的指令。不认识就拒绝,拒绝是好事,只要你在 CI 里先拒绝,而不是在生产里第一次拒绝。第一次发生在生产,就是矩阵欠债。欠债利息是故障窗口。
下一节把这张图按时间走一遍:文件描述符、挂载、第一次触发、以及失败时你实际会看见什么。