如果你在跟操作系统形式化验证这条线跑过一段,应该已经体会过那种感觉:交互式证明器里堆着一长串待证目标,手工写策略一个一个磨,磨到后面整个人都是麻木的。这篇教程要聊的,就是专门解决这个痛点的"自动证明工具"——SMT求解器、自动化策略、外部证明器桥接,它们怎么配合、怎么选、怎么把验证工作量真正压下来。我是从验证一个模拟内核的镜像加载器和调度器模块开始用这套东西的,踩过不少坑,这篇文章算是把整套打法沉淀下来,适合已经跑通基础验证流程、但被海量证明义务折磨的读者,也适合刚接触形式化验证、想搞清楚工具如何分工的人。
1. 为什么操作系统验证绕不开自动证明工具
1.1 你的验证工作会卡在哪一步
形式化验证在操作系统里要干的活,说白了就是拿数学语言把内核行为描述一遍,再证明实现真的满足这些描述。功能上要证内存隔离、中断处理、调度公平性,工程上要证指针不越界、整数不溢出、队列操作不丢数据。这些性质不是写几条注释就完事的,每条都要变成可机械检查的证明。
问题在于规模。一个几千行的内核模块,跑一遍验证流程,产生的证明义务(verification condition,简称VC)动辄上万条。前两篇教程我们搭好了验证框架、定好了编写规范,但真上手你会发现:大多数产出时间不花在设计不变量上,而是花在"把一个个看起来显然成立的小目标亲手证掉"上。我起初手工处理一个四百行的双向链表模块,光辅助引理就写了近两百个,每个引理平均要两三分钟去磨,一周下来整个人都被拖垮了。
这不是个例。业界普遍的经验是:交互式证明器的证明脚本长度往往是被证代码的几倍甚至十几倍,如果全走手工路线,验证一个中等规模内核模块的时间成本会很夸张。自动证明工具在这条链路里的角色,就是把"人肉证明"这部分从天量目标里剥离出去。我做过一次统计,某个三千行的模块,产生的证明义务大约一万两千条,最后我真正动手写的核心引理只有六十来个,剩余九成以上的叶子目标都被自动工具直接关闭了。
1.2 自动化能把人工工作量压到十分之一
为什么自动工具能做到这个程度?因为操作系统代码里大量目标属于"可判定的理论片段"。比如纯线性整数算术(Presburger算术)有确定性算法,32位整数加法和比较可以用SAT求解器完全算清楚,一阶逻辑里的等词推理也有成熟的自动搜索算法。这些对人类来说枯燥、重复、容易漏条件的机械推理,恰恰是机器最擅长的。
我在验证调度器轮转索引时感受最深。目标无非是"cur加1后要么仍在合法范围,要么回绕到0",纯手工写证明要处理六个分支的算术细节,每分支一两行策略,看起来轻松,但一旦出现负数前置条件、未初始化的状态变量、边界值,人眼根本盯不住。把这类目标丢给自动工具,几十个变体一条命令全部close。算下来,自动化率能到百分之八九十,人工只需要盯住最核心的结构性引理,这就是数量级上的差别。
需要说清楚的是:这个比例不是白来的。自动工具能这么高效,前提是你把前置条件和数据结构的性质写得足够完整。它解决的是"证明执行"的苦力活,不是"证明设计"的脑力活。你把前置条件写漏一条,自动工具照样会诚实告诉你"该目标不成立",甚至直接给你抛一个反例。这一点恰恰是形式化验证最值钱的地方——它不会假装正确。
1.3 自动与交互的正确关系
很多初学者有个误区,觉得"自动证明工具"就是全自动,把整个内核的正确性丢给某个神奇程序,点一下按钮就出结果。现实不是这样。自动工具的定位是"交互式证明器里的加速器",它处理的是局部目标,而完整的验证仍然需要人来做全局规划:哪些性质写成顶层规范,哪些操作定义成函数,哪些地方需要手写归纳。
我自己的分工原则很简单:结构性问题靠人,计算性问题靠机器。什么时候做归纳、对哪个变量做归纳、需要引入哪个中间引理,这种全局判断必须人来做;而一旦归纳框架搭好了,剩下的算术化简、等式替换、布尔逻辑穷举,一律交给自动工具。交互式证明器里的auto、lia、ring这些策略,就是为了让你把"思路"写出来,把"体力活"甩给机器。
这个认知调整过来之后,整个开发节奏就不一样了:你不是在"写证明",而是在"引导证明"。目标一多,你考虑的不是逐个击破,而是如何把目标拆到位、把不变量设到位,剩下的交给工具。后面几节我会把具体工具的原理和用法掰开揉碎讲清楚。
2. 自动证明工具的分类与底层原理
2.1 所有自动证明都从一个验证条件开始
要理解自动证明工具,得先知道它面对的输入是什么。验证一个程序片段时,我们通常采用前置条件/后置条件的规格,再用最弱前置条件演算把程序转换成一条逻辑公式:如果前置条件成立,执行这段代码之后,后置条件必然成立。这条公式就是验证条件。
举个例子,一个内存分配函数说"分配后返回的地址加上请求的大小不越过堆顶",验证器会把它翻译成一条关于地址和大小整数变量的公式。翻译完,证明任务就退化成一个纯逻辑问题:这条公式是不是恒真?如果恒真,程序这一行为就保证正确;如果存在反例,工具会给出具体赋值,告诉你哪个输入会让性质被破坏。
这一步翻译通常由验证框架自动完成,不是人手工手写的。但你要理解你面对的目标是怎么来的,因为"目标是不是可判定的""该用哪类求解器"完全取决于这条公式的形状。这就是为什么我建议每个做验证的人都把验证条件的生成链路摸一遍,而不是只盯着求解器输出。
2.2 SMT求解器:算术与位级的快刀
SMT求解器(可满足性模理论求解器)是自动证明工具里最常见的一类。它的核心思想是把一个复杂的逻辑公式分解成布尔结构和底层理论两部分。布尔部分交给SAT求解器做组合搜索,理论部分(比如线性整数算术、位向量运算)由专门的判定程序负责,两者通过DPLL(T)框架协作。
听起来很抽象,生活里可以这么类比:SAT求解器像一个暴力试错器,把每个布尔变量当成开关一组合一组合试过去;而理论部分像代数学老师,面对一组整数方程直接给出代数解。两者配合,才能处理"既有布尔条件又有算术关系"的真实程序推导。
SMT求解器对操作系统验证特别有用的点在于位向量理论。C语言里32位整数溢出是回绕语义,数学整数没有回绕,这种语义错位是验证内核代码最容易翻车的地方。位向量理论把整数当成固定位宽二进制数,每一位都可穷举,溢出行为和你写C时的真实语义完全一致。我用Z3验证分配器地址计算时,一条"分配后堆顶不超过上限"的目标,用数学整数编码要写一堆范围约束,用位向量编码几行断言直接出结果。
2.3 ATP与量词处理:一阶逻辑里的搜索
SMT求解器擅长算术和位向量,但碰到包含量词("对所有进程""存在一个页面")的一阶逻辑目标,处理起来就麻烦得多。这类目标更适合自动定理证明器(ATP),典型的有E、Vampire、SPASS这些基于归结原理(resolution)的证明器。
归结原理本质上是一套机械的推理规则:把要证的公式取否定,转换成子句集合,然后反复应用归结规则,直到推出矛盾。这个搜索空间非常大,所以ATP的工具一般都有大量启发式策略,控制子句筛选、权重、饱和过程。它只能保证"能找到证明时尽量找到",碰到找不到的情况,不会告诉你为什么——因为一阶逻辑本来就是半可判定的,不存在对所有公式都有效的算法。
SMT求解器里也有量词处理机制,常见的是E-matching:根据已经出现的项去匹配量词模板,决定何时实例化量词。这个机制是启发式的,性能波动很大。实操中我一般遵循一条经验:能避开量词就避开,能手工实例化就手工实例化。把量词问题转化成无量化目标,求解速度会快上几个数量级。
2.4 交互式证明器里的自动层
Coq、Isabelle/HOL这类交互式证明器也带有自动化策略。Coq里最常用的有auto、eauto做一阶逻辑和构造逻辑的搜索,lia完解线性整数算术,ring处理多项式环运算,congruence处理等词推理。Isabelle/HOL里的auto、simp、arith也承担类似职责。这些策略本身就覆盖了大部分"显然成立"的目标。
更妙的机制是外部求解器桥接。Isabelle/HOL里的Sledgehammer会把当前目标翻译给外部的ATP和SMT求解器,让它们搜索证明,再把找到的证明线索翻译回Isabelle自身的推理规则,由证明内核重新校验。Coq生态里也有类似能力的SMTCoq,把Z3、CVC4、Alt-Ergo等求解器的证明结果转成可校验的证书。
理解这个机制至关重要:外部求解器只是"提出建议",最终的证明必须在内核里重建一遍才算数。所以哪怕某个求解器存在bug或者给出了错误结果,只要它无法在内核规则下重建证明,框架就不会接受。这从根本上保证了自动化不会破坏可信度——这也是形式化验证区别于"测试"和"静态分析"的底线。
3. 工具选型:按验证场景挑合适的组合
3.1 主流通用工具横评
刚开始选工具的人很容易被名字绕晕。我按实际使用体验整理了一张对照表,先说适用范围,再谈怎么选。
| 工具 | 定位 | 自动化能力 | 最擅长的场景 | 上手成本 |
|---|---|---|---|---|
| Coq | 交互式证明器 | 策略+外部桥接 | 全功能证明、归纳证明、生成可执行代码 | 中等偏高 |
| Isabelle/HOL | 交互式证明器 | Sledgehammer+简化器 | 大规模代码库形式化、强自动化 | 中等 |
| Z3 / CVC5 | SMT求解器 | 完全自动(可判定片段内) | 整数、位向量、数组、量词启发 | 偏低 |
| Why3 | 程序验证平台 | 多证明器驱动 | 把程序VC分发给多个求解器 | 偏低 |
| Alt-Ergo | SMT求解器 | 完全自动 | 与Why3配合,适合程序验证VC | 偏低 |
Coq和Isabelle/HOL的优势在于表达能力。操作系统证明里最核心的不变量、归纳结构、抽象层次匹配,都需要依赖类型或高阶逻辑才能干净地描述。Z3这类SMT求解器表达力相对有限,但胜在快、全自动、位向量支持好。所以普遍的做法不是"选一个",而是"主证明器+多个自动求解器"的组合拳。
3.2 按OS任务怎么组合
操作系统验证任务可以粗略分成三层。最顶层是规范层面的证明,比如虚拟地址到物理地址的映射关系、调度器的公平性定义,这些和具体C代码无关,适合在Coq或Isabelle/HOL里做,因为它们能表达任意数学结构。中间层是设计与实现之间的细化证明,也就是"抽象机器的每一步操作都能在具体数据结构上找到对应实现",这部分需要归纳和不动点推理,交互式证明器是主力,自动策略用来清理中间目标。
最底层的具体实现,也就是C代码和汇编层面的行为——指针运算、整数回绕、内存偏移、并发访问——这部分的验证条件最枯燥、量最大,也最适合交给SMT求解器。Z3的位向量逻辑就是为这类目标设计的。
我的实际工作流是这样的:核心算法的性质用Coq证明,C实现层面的验证条件通过验证工具翻译成SMT-LIB格式,交给Z3处理;如果Z3解不出来,再退回交互式证明器手工引导。用这套组合,一个调度器模块的证明义务分布大概是:顶层规范证了核心20条引理,中间细化层约80条,底层VC上千条全部进Z3。自动化率确实能达到九成。
3.3 我的推荐配置
如果从零开始,我给的条件是"别只装一个求解器"。原因是不同求解器的启发式策略完全不一样,同一目标在Z3上超时、在CVC5上可能两秒就出结果,反过来也常见。Isabelle/HOL的Sledgehammer默认就让多个prover并行跑,道理就在这。多装几个,命中率能明显提高。
装工具链的时候也别求新求快。我踩过版本不兼容的坑:某个证明脚本在Z3旧版本上能秒出,换到新版本后由于默认选项变化直接报unknown。所以现在我的规矩是锁死工具版本,并且把验证脚本里记录版本号作为习惯。具体怎么装,主流证明器和求解器都有官方二进制包或多平台包管理器,按官网文档走就行,这部分没什么玄学。
4. 实操:让自动工具消化九成证明义务
4.1 环境怎么搭
先交代一下实操环境。我这里的主证明器用的是Coq系工具链,SMT求解器用了Z3,另外配了一个CVC5做交叉验证。安装按照各自官方二进制包来,或者用系统包管理器装也行。装完之后第一步不是写证明,而是跑一下自带的冒烟测试,确认求解器能正常响应,因为后面所有脚本都会调用它们,环境坏了排查起来特别费劲。
建议把SMT-LIB这个标准格式的概念提前建立起来。它是求解器之间的通用语言,Z3、CVC5、Alt-Ergo都能直接读。有了它,你就不会被某个特定求解器的专有接口绑死。后面所有底层验证条件我都习惯先写成SMT-LIB文件,再用各个求解器分别跑。这样哪个求解器对这个目标最有效一目了然。
4.2 场景一:用SMT证明分配器不越界
第一个实战场景是模拟内核里最简单的内存分配器。分配函数的前置条件是"当前堆顶加上请求大小不越过地址空间上限",我要证的结论是"分配后的新堆顶仍然在上限之内"。先把这个验证条件写成SMT-LIB:
(set-logic QF_LIA) (declare-const heap_end Int) (declare-const size Int) ; 前置条件:堆顶非负,请求大小为正,剩余空间足够 (assert (>= heap_end 0)) (assert (> size 0)) (assert (<= heap_end (- 4294967295 size))) ; 企图证明的目标取反:分配后堆顶越界 (assert (> (+ heap_end size) 4294967295)) (check-sat)这里用的是"反证法"思路:把想证明的性质取反,塞给求解器。如果结果是unsat,说明不存在任何满足前置条件却导致越界的输入,那么性质就成立。如果结果是sat,求解器会给你一组具体的赋值,那就是反例,说明你的前置条件漏了约束。
我跑这个文件,Z3返回unsat,证明工作完成。这里注意两点。第一,我用的是数学整数(Int),它没有溢出回绕,所以地址范围上限必须显式写出来。第二,如果把最后一行改成检查"分配后堆顶恰好等于上限加1",求解器会返回sat并给出一组具体输入,这是检查工具本身是否理解问题的最快方法。
如果是32位真实内核代码,数学整数并不完全等价。那就要换位向量逻辑,把Int换成(_ BitVec 32),让溢出语义和C语言完全一致。这一步千万别偷懒,我之前因为在数学整数上证明通过、却漏了真实机器的回绕语义,后面联调时差点吃大亏。
4.3 场景二:轮转调度器索引的归纳证明
第二个场景更像典型的交互式证明。轮转调度器维护一个当前进程索引cur和一个总进程数n,每次调度要么cur+1还在范围内,要么回绕到0。我需要证明这个索引永远落在[0, n)区间内。用Coq定义这个跳转函数:
Definition next_idx (n cur : nat) : nat := if cur + 1 =? n then 0 else cur + 1. Lemma next_idx_lt : forall n cur, n > 0 -> cur < n -> next_idx n cur < n. Proof. intros n cur Hn Hlt. unfold next_idx. destruct (Nat.eqb (cur + 1) n) eqn:E. - apply Nat.eqb_eq in E. subst. lia. - apply Nat.eqb_neq in E. lia. Qed.看这个证明脚本,人做的只有两件事:判断这里需要对cur + 1是否等于n做分情况讨论,然后分别在两个分支里把等词信息拿到手。剩下的算术推理,第一个分支(回绕到0)直接由lia解决,第二个分支(正常自增)也是lia一行关闭。如果目标是纯线性算术,lia是完全自动的判定算法,不用人再干预。
这个例子的价值在于展示了"人机分工"的边界:归纳方向、分情况策略是人定的,叶子级的算术处理是机器干的。遇到更复杂的版本,比如进程数本身会动态变化,那就需要对进程数做归纳,但这个归纳框架依然是人的职责。自动工具再强,也替代不了这个层面的判断。
4.4 场景三:让外部求解器替手工搜索
第三类是纯手工证明特别头疼、而外部求解器能帮上大忙的目标。我之前在证"进程状态迁移函数保持状态集合封闭性"时,目标里有大量等式替换和布尔组合,用auto能过,但偶尔会跑得非常慢。这时候用外部求解器最划算。
Isabelle/HOL里直接写:
lemma next_state_preserves_valid: fixes s expected :: state assumes "valid_state s" shows "valid_state (next_state s expected)" using assms try0 done这里的try0会快速尝试simplification、auto、blast等自动策略,多数简单目标能直接拿下。如果try0不行,再上sledgehammer,它会并行调用外部的ATP和SMT求解器,把找到的证明线索拉回来重建。Coq里对应的是smt策略(来自SMTCoq扩展包),把目标交给Z3后拿回证书再校验。
用外部求解器桥接有个体验上的飞跃:以前要自己猜"这个引理应该用什么顺序展开",现在是直接把整个目标丢出去让求解器自己搜。搜到了它会告诉你用了哪些引理,你甚至能反过来学到更好的证明顺序。搜不到也不会卡死,退回手工继续。
4.5 把自动工具串进开发循环
工具都上手之后,最关键的其实是把它们串成一套顺畅的迭代流程。我自己固定下来的循环是这样:先写前置条件和后置条件,然后用Quickcheck或Nitpick这一类反例查找工具快速扫一遍——这一步能发现绝大多数不成立的弱性质,比直接上证明工具快得多。扫描通过后,把目标交给自动化策略,一步步升档:先是auto/simp这类内置策略,再是lia这类算术判定,然后是sledgehammer/smt这类外部求解器桥接,最后才轮到手工写归纳和分情况。
这个顺序是反直觉的:很多人一上来就上最猛的求解器,我反而建议从弱到强、逐级升档。因为弱策略在那个目标上失败的原因,往往能提示你"目标还缺哪个引理"。直接丢给Sledgehammer,它可能七拐八绕找到一个完全不同的证明路径,虽然也能过,但依赖了一堆复杂引理,后续维护起来反而难。
5. 常见问题与排查技巧实录
5.1 现象速查表
自动证明工具用久了,会遇到的问题来来回回就那么几类。我整理了一张速查表,都是我真实踩过的坑:
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| 求解器返回unknown | 超出可判定片段,或非线性算术 | 检查目标是否含非线性项;改用位向量;拆细目标 |
| Sledgehammer找不到证明 | 目标缺少中间引理;逻辑超出一阶 | 补引理;手工做归纳框架后再让sledgehammer处理叶子 |
lia解不出来 | 目标含非线性乘法;或需要归纳 | 改用ring、nlinarith;补归纳步骤 |
| 明明前置条件写了,还是报不成立 | 前置条件没有传到目标里 | 用intros/assert显式引入;检查变量作用域 |
| 求解器版本更新后脚本失效 | 默认选项或语法变化 | 锁版本;用SMT-LIB文件保持可迁移性 |
| auto运行极慢 | 搜索空间爆炸,数据库过大 | 缩小auto using引理集合;增加搜索深度限制 |
这张表打印出来贴在显示器边上,比翻文档实用得多。
5.2 自动证明失败的三类根因
排查久了会发现,大部分失败最终都可以归到三个根因上。第一个是缺不变量。操作系统代码里大量性质不是对"当前状态"成立,而是对"从初始状态一路演化而来"的状态成立。你不把这个演化约束写成不变量并证明它被循环维护,任何自动工具都救不了你——它们只能从你给的约束里推导,不会替你发明缺失的全局性质。
第二个根因是编码语义错位。数学整数、有符号整数、32位无符号整数、位向量,它们的运算规则完全不同。拿数学整数去套C代码的验证,溢出行为对不上;拿有符号整数去处理无符号指针运算,符号扩展会引入假反例。我见过的最多错误都在这个层面,很多人折腾半天求解器,最后发现问题是编码选错了。
第三个是目标过大。一条巨型公式丢给任何求解器都可能在搜索空间里迷路。解决办法不是换更快的求解器,而是拆引理:把一个复杂定理拆成一串只有几步推理的小引理,每个小引理证明它的一件事。这个"证明工程化"思维,是自动工具能否真正发挥作用的分水岭。拆得好的项目,每个求解器调用都秒回;拆不好的项目,再强的机器也救不回来。
5.3 几个让求解器更容易出结果的细节
最后分享几个我自己验证过的细节。给验证条件加显式前置条件是最划算的投资。你少写一条size > 0,求解器就可能对着反例发呆半天;写全了,很多目标直接变成可判定片段的平凡推论。
给量词目标加模式提示也很有用。SMT求解器做量词实例化时靠E-matching猜测,你给它一个模版(pattern),它就能精准实例化,而不是盲目枚举。这个技巧能把带量词的目标从"超时"变成"秒出"。
还有一个容易忽略的点:利用反例工具做"预检"。我现在的习惯是,任何一个新出的引理,先用Quickcheck跑几轮随机测试,再让Nitpick做有限搜索找反例。这两个工具都不会给出完整证明,但它们能在几秒内告诉我"这个引理是不是扯淡"。如果它们都找不到反例,再启动证明工具,成功率会高得多。
我个人实际体会最深的一条,还是复现性。某个晚上我在一台机器上调通了所有脚本,每条目标都自动关闭;第二天换台环境跑,版本一变,其中两条路径直接断掉。排查半天才发现是求解器默认策略变了。现在我的规矩是:每个验证项目里锁死工具版本,脚本开头记录版本号,CI里强制跑一遍回归。自动证明工具给你的是速度,但复现性和可维护性才是整个验证项目能持续滚下去的根本。这个顺序别搞反了。