2.1 整体架构:虚拟机验证器与JIT


2.1 整体架构:虚拟机、验证器与 JIT

本节摘要:eBPF 运行时由字节码虚拟机、加载期验证器、JIT 编译器和 Map 组成。验证器在 JIT 之前用抽象状态走完所有路径,证明无越界、无未终止循环、无非法 Helper。少了这一层,eBPF 就退回内核模块;有了这一层,表达力必须让步给可证明性。

核心问题

阅读完本节,你应当能够:

  1. 说明寄存器模型与上下文指针为什么限制了你能写的代码
  2. 解释验证器跟踪的是类型和范围,不是“业务对不对”
  3. 说出 JIT 优化的是执行,不替代安全检查
  4. 在架构图上标出用户态、内核、钩子三层各自的职责

第 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:把已证明的指令变成这一台机器的话

解释执行字节码在早期可用,生产热路径受不了。验证通过后,JIT 把指令翻成当前架构的机器码。XDP 这种每包都跑的程序,没有 JIT 几乎没有意义。

JIT 不放宽安全。它翻译的是已经证明过的程序。你不能指望“开了 JIT 就能写更复杂的循环”——复杂循环在验证阶段就被挡下了。性能优化首先是减少挂载次数、缩小 Map 查找、避免在 kprobe 里做重活,而不是幻想 JIT 替你把错误架构变快。第 8 章会回到开销。

还有一条容易忽略:不同架构、不同内核版本的 JIT 覆盖和优化不同。验证通过保证“能安全跑”,不保证“在 ARM 和 x86 上延迟一样”。做多架构节点池时,要用真实内核测,而不是只看开发笔记本。

图:用户态、验证闸门、钩子执行三层

图:用户态、验证闸门、钩子执行三层

问题:能不能关掉验证器换性能?

生产上这等于拆掉闸门。解释器和调试环境里或许有宽松模式,但把未验证程序送进内核,eBPF 相对模块的那点优势就没了。真要性能,去减工作量和选更靠前的挂载点。

问题:为什么不直接让内核跑一段受控 C?

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 里先拒绝,而不是在生产里第一次拒绝。第一次发生在生产,就是矩阵欠债。欠债利息是故障窗口。

对照清单

  1. 把验证通过理解成业务正确,会把错误计数送进生产,因为闸门不审产品意图。
  2. 把 JIT 理解成可以写更复杂循环,会在验证阶段就被挡下,复杂循环不属于编译器优惠。
  3. 开发机新内核用了旧生产没有的 Helper,失败会第一次出现在目标节点而不是 CI。
  4. ARM 与 x86 的尾延迟不同,只测笔记本就不能给混合架构池做容量承诺。
  5. 寄存器职责混用会在分支合流处变成未知类型,看起来像无理拒绝,其实是你没把证明写进代码。
  6. 上下文指针不是完整内核,长指针链要走 Helper,直接解引用是在用模块习惯写沙箱程序。
  7. 安全团队需要看到顺序:先证明再变机器码再挂上,任意内核执行在模型里进不去。
  8. 产品审查仍要审策略对错,两套审查叠在一起才完整,用其中一套替代另一套会漏。
  9. 解释执行只适合理解,不适合热路径承诺,承诺必须基于 JIT 后的真钩子测量。
  10. 三件套版本写入兼容说明,比支持 Linux 这种空话更能减少两台机器两种命运。
  11. 最小程序生长:空返回、计数、lookup、字段,每一步都能指出是哪一层开始失败。
  12. 验证器实现缺陷按内核漏洞修,不要在业务代码里赌它这次没看见越界。

要点串联

  • 窄虚拟机是为了可分析:寄存器、栈、循环上界都是验证器能结束的前提。
  • 上下文随程序类型而变:R1 不是完整内核,多数内核对象要经 Helper。
  • 验证器查安全不查业务:通过不等于计数正确。
  • 顺序不可颠倒:验证在 JIT 前,失败则机器原状。
  • JIT 只加速已证明程序:不放宽循环,不统一跨架构延迟。
  • 三层分工:用户态交付,闸门证明,钩子执行。

下一节把这张图按时间走一遍:文件描述符、挂载、第一次触发、以及失败时你实际会看见什么。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U