关注了就能看到更多这么棒的文章哦~
作者:*Daroc Alden*
2025年12月23日
Gemini 辅助翻译
https://lwn.net/Articles/1050779/
从理论层面来看,BPF 验证器 (BPF verifier) 通过考虑 BPF 程序可能采取的每条路径来工作。然而,在实践中,它需要在合理的时间内完成这项任务。在 2025 年 Linux Plumbers 大会 (LPC) 上,Mahé Tardy 和 Paul Chaignon 详细解释了实现这一目标的主要机制:状态剪枝 (state pruning)。他们重点介绍了两种有助于减少验证器需要检查的路径数量的优化,并讨论了这些优化给验证器代码带来的一些复杂性。
Tardy 首先举了一个最简单的分支控制流 (branching control flow) 的例子:一个程序中只包含一个 if 语句。这个程序有两条潜在的执行路径 (execution paths)。如果再增加一个(非嵌套的)if 语句,路径数就会变成四条,然后是八条。当程序达到实际规模时,可能的路径数量将变得完全难以处理 (intractable)。然而,有时条件分支 (conditional branch) 并不会导致验证器关心的任何变化:
intindex=3;
if(condition){
// Some code that doesn't change the value of index
...
}
// The validity of index doesn't depend on whether the branch was taken
intfoo= array[index];Tardy 说,状态剪枝的核心问题是:“我们能否跳过一些其他的路径?” 为了确定这一点,验证器会在程序执行过程中使用特殊的“剪枝点” (pruning points),在这些点上,它知道可能可以裁剪掉冗余路径。剪枝点被插入到条件跳转 (conditional jumps) 的源头和目标、无条件跳转 (unconditional jumps) 重新汇合到不同指令序列的位置,以及函数调用 (function calls) 处。在上面的例子中,剪枝点添加在与 if 语句对应的条件跳转处,以及 if 语句的末尾。

当验证器在验证过程中到达一个剪枝点时,它会保存当前状态 (current state) 的副本以供后续参考。如果剪枝点是一个条件分支,它还会将该状态副本压入堆栈,以便稍后返回并探索。该状态包含影响 BPF 程序执行的所有信息,包括当前指令指针 (instruction pointer),因此在回溯 (backtracking) 时不会丢失任何信息。当验证器到达后续的剪枝点时,它会将当前状态与已保存的状态进行比较;如果当前状态与之前观察到的状态等效 (equivalent),验证器就知道它可以停止探索当前状态,因为它不会发现任何新的信息。
然而,上述解释对“等效”一词赋予了很大的权重。Tardy 说,理论上,如果当前状态是保存状态可能值的一个子集(特别是如果它们都在程序的同一位置出现),那么两个状态就是等效的。因此,两个除了保存状态中寄存器 r1 的值未知而当前状态中 r1 的值为 4 之外完全相同的状态,也被认为是等效的。
当状态剪枝在内核版本 3.18 中引入时,检查就是如此简单。但是,从那时起,状态剪枝的实现增加了越来越多的复杂性,以允许验证器高效地剪枝更多的状态。例如,最近的内核使用最近最少使用缓存 (least-recently-used cache, LRU 缓存) 来存储已见状态,以减少状态剪枝的内存占用 (memory footprint)。
Chaignon 说,实际上,真实程序很少会出现状态完全相互包含的情况。但是,如果验证器只比较那些对验证实际重要的状态部分 (parts of the state),它就能发现更多重叠状态 (overlapping states)。“此外,我们比较的越少,状态剪枝就越高效。”

为了说明这一原则,Chaignon 阐述了当前验证器中最重要也最复杂的两种状态剪枝优化。第一种是在比较状态时只考虑“活跃”寄存器 (live registers)。如果一个寄存器的值在程序未来会被使用,它就是活跃的;如果它在被覆盖之前不再被使用,它就是非活跃的 (dead)。如果两个状态除了非活跃寄存器的内容外完全相同,验证器可以推断,进一步探索不会导致任何不同的程序行为 (program behaviors),因为非活跃寄存器按定义不会被使用。因此,该状态可以安全地被剪枝。
这确实要求验证器知道寄存器何时活跃或非活跃;它在主验证逻辑开始之前对程序进行一次预处理阶段 (pre-pass) 来计算这些信息。由于程序使用了哪些寄存器可以通过检查单个指令纯粹地看到,所以这个阶段相对简单。然而,栈槽 (stack slots) 就不那么简单了。同样的活跃度概念可以应用于栈槽,但由于指针运算 (pointer arithmetic) 的可能性,验证器实际上无法在不模拟程序的情况下知道程序在哪些点使用了哪些栈槽。因此,验证器针对基于栈槽的剪枝的等效逻辑与主验证阶段交织在一起,这使得“整个实现变得截然不同且更加复杂。”
Chaignon 介绍的第二种优化则更为特殊。它源于一个观察:验证器通常不关心寄存器的精确值 (exact value)。如果一个寄存器被用作数组索引 (index into an array),它需要关心该值是否在边界内 (falls within bounds)。但如果一个值只是存储到 BPF map 中供以后使用,验证器就不关心其精确值是什么。
因此,如果两个状态除了活跃寄存器或栈槽中的值外是等效的,但该值从未以需要验证器关注的方式使用,那么该状态就可以安全地剪枝。为了跟踪这一点,每当一个值被用于验证(例如,用作数组索引)时,它就会被标记为“精确的” (precise)。该标记会向后传播 (propagated backward) 到之前的状态以及所有促成该值 (contributed to that value) 的其他寄存器和栈槽。当比较两个状态的等效性时,验证器只检查那些被标记为精确的值。
Chaignon 说,验证器中状态剪枝的整体实现 (overall implementation) 随着时间发生了很大变化。细节远超演示文稿的范围,因此他和 Tardy 打算发表一系列博客文章,介绍他们在准备本次演讲过程中学到的其他内容。截至撰写本文时,这些文章尚未发布,但预计它们将出现在 Tardy 的博客 或 Chaignon 的博客 上。
一名观众成员问,验证器是否会把两个状态合并 (unions two states together)。他们说,那样会失去精度 (lose precision),但总比验证器无法在合理时间内证明程序安全 (failing to prove the program safe) 要好。Chaignon 回答说,Linux 内核验证器 (Linux kernel verifier) 不会那样做,但 Windows eBPF 实现 (Windows implementation of eBPF) 会。这种操作被称为泛化 (widening),它并不总是有效。泛化用一个更通用的状态 (general state) 替换特定状态 (specific state),希望能够引起更多的状态剪枝。很难确切知道泛化何时真正有效,何时会导致验证器拒绝实际上安全的程序 (rejecting programs that are actually safe)。另一位观众成员插话澄清说,Linux 实现确实在一个特定地方进行了泛化:在循环的第二次迭代 (second iteration of a loop) 中,如果一个值没有被标记为精确的,它就会被泛化(即,验证器假定该寄存器或栈槽可以取任何值)。这有助于循环达到一个不动点 (fixed point),这样循环的后续迭代 (future iterations) 就可以被剪枝,因为它们不会增加任何新信息 (add any new information),这对于实践中快速验证 (fast verification in practice) 至关重要。
[ 感谢 Linux 基金会,LWN 的差旅赞助商,支持我参加 Linux Plumbers 大会。]
全文完LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~
3

被折叠的 条评论
为什么被折叠?



