
我入行做软件质量保障那几年最深的感受就是测试用例写得再密心里还是虚的。该跑的用例跑了该压的接口压了代码评审也过了可到了凌晨上线还是会因为一个边界条件崩掉。问题不在于测试不努力而在于测试本质上是在“抽样”验证——你永远只能验证你已经想到的那部分行为而程序的可能执行路径是无限的。后来我接触到了程序验证才知道有一批人一直在用数学方式直接“证明”程序没有某类Bug而不是靠多跑几个用例去“猜”。这篇文章我打算把这套思路掰开揉碎从核心逻辑讲到工具实操希望能帮正在和Bug死磕的同学开一扇新的门。1. 程序验证到底在证明什么1.1 测试和验证的本质差别先说个很直观的类比。测试就像你检查一个行李箱翻遍了所有隔层、口袋确认没有违禁品——但你只能检查你想到要翻的地方如果你压根不知道某个夹层存在那这个夹层里的东西你就永远查不到。程序验证的思路完全不同它更像是用X光机对整个行李箱做全量扫描甚至更进一步在数学层面证明“这个行李箱的结构决定了它不可能藏下违禁品”。程序验证不是“找几个样例跑一下看结果对不对”而是用逻辑推理的方式证明程序在所有可能的输入下都满足某个性质。举个例子你写了一个二分查找int binary_search(int arr[], int n, int target) { int left 0, right n - 1; while (left right) { int mid left (right - left) / 2; if (arr[mid] target) return mid; else if (arr[mid] target) left mid 1; else right mid - 1; } return -1; }测试能告诉你“对于我试过的这100组输入结果是正确的”但验证能告诉你“对于任意有序数组、任意目标值这段代码要么返回正确的下标要么返回-1表示不存在并且永远不会访问越界下标”。后者的强度完全不同。1.2 什么样的Bug才算“被证明消除”这里要澄清一个关键认知程序验证不是万能的它不是魔法。能被证明的性质通常是形式化规格里写清楚的性质比如内存安全不会越界访问、不会空指针解引用、不会释放后使用。算术溢出所有整数运算结果不会超出类型范围。功能正确性程序的输出和规格要求的一致。终止性程序在所有输入下都会结束不会死循环。并发安全不存在数据竞争、死锁等并发问题。你可以在这些性质上做到“数学级别”的保证——不是“大概率没问题”而是“在任何情况下都不可能违反”。当然能证明的性质和你实际关心的Bug之间永远隔着一个“规格建模”的距离这一点后面我会专门说。1.3 为什么“程序正确性”救不了一切既然这种方法这么强为什么不是所有公司和项目都在用原因很现实证明一个程序正确往往比写这个程序还要贵得多。一段几十行的二分查找代码完整的形式化证明可能需要几百行甚至上千行的推理。工业界的软件动辄百万行级别全部做全量验证是不现实的。所以程序验证在实践中的定位一直是“用在最关键的地方”操作系统的内核模块、密码学库、航天控制软件、医疗设备驱动、区块链的共识协议这类一旦出错代价极高的场景。这就引出了程序验证领域的一个核心原则——性价比权衡。你投入多少验证强度取决于Bug的代价有多大。后面我会给出具体的工具选型和落地策略。2. 核心逻辑体系前置条件、后置条件与不变量2.1 Hoare三元组——验证的逻辑地基程序验证最经典的逻辑框架是Tony Hoare在1969年提出的Hoare逻辑核心是一个三元组{ P } C { Q }P是前置条件Q是后置条件C是一段程序。这个三元组的含义是如果在执行C之前程序状态满足P那么执行完C之后程序状态必须满足Q。用人话说就是——如果你保证进入函数时的状态符合我们的约定那函数结束时我就向你保证状态变成我们约定的样子。比如{ x 0 } y sqrt(x); { y * y x (y 1) * (y 1) x }这就表达了一个整数平方根函数的核心约定。你可能会说这不就是断言assert吗本质上是一个路子但程序验证把断言这种“运行时检查”升级成了“编译期/证明期检查”——不需要真正跑起来直接用逻辑推导就能验证。2.2 循环不变量——验证的命门真正让程序验证变得困难的核心结构是循环。为什么因为循环的迭代次数可能是动态的、不确定的你没法简单地枚举每一条执行路径。比如s 0; for (i 1; i n; i) { s i; }这段代码断言s n * (n 1) / 2是很容易的但要在数学上证明它就需要循环不变量。所谓不变量就是“循环从头到尾始终保持为真的条件”。对于上面这个例子不变量可以是s (i - 1) * i / 2这个性质在进入循环前为真i1时s0右边也是0每执行一次循环体它继续保持为真归纳地看从i迭代到i1时式子仍然成立并且在循环结束时in1代入得到 s n*(n1)/2正好是我们要的结果。这就是程序验证最核心的思维模式数学归纳法。验证循环的过程就是找一个不变量然后证明“初始成立 → 保持成立 → 退出时有用”这三步。2.3 一个完整的推理示例我们看一个稍微完整一点的例子体会一下推理链条是怎么走的。假设有代码// { n 0 } int sum 0; // { n 0 sum 0 } for (int i 1; i n; i) { // { sum (i - 1) * i / 2 } sum sum i; // { sum i * (i 1) / 2 } } // { sum n * (n 1) / 2 }每一步的逻辑推导都很机械人的工作主要是找出那个关键的循环不变量sum (i-1)*i/2。工具可以辅助检查证明的正确性但找不变量这件事目前很大程度上还是要靠人的洞察力。这个思维模式特别像高中数学里的数列证明——先猜出通项公式再用归纳法验证。程序验证就是把这种做题思路应用到了所有带循环的程序上只不过由工具来做机械推导部分人负责更有创造性的部分。3. 程序验证的工具链与现实选择3.1 直接做全量证明Coq与Isabelle/HOL如果说程序验证有一种“重武器”那就是交互式定理证明器代表是Coq和Isabelle/HOL。这类工具的思路非常直接你把程序、规格、不变量全部用形式化语言写进去然后像证明数学定理一样一步一步地交互式推导出“程序满足规格”这个结论。每一步推导都被工具检查不存在“我觉得显然成立”这种糊弄过去的空间。用Coq验证一个简单的求和程序大致长这样Fixpoint sum (n : nat) : nat : match n with | 0 0 | S m n sum m end. Theorem sum_formula: forall n, sum n n * (n 1) / 2. Proof. induction n. - simpl. reflexivity. - simpl. lia. Qed.代码里的induction n就是数学归纳法在证明器里的体现。看起来简单实际做一个完整的项目验证工作量相当可观。我有朋友在团队里用Coq验证过一个加密协议半年时间就验证了核心模块几千行代码人力成本极高。这类工具学起来是有一条陡峭的学习曲线的Coq和Isabelle/HOL都有非常强的函数式和逻辑学背景要求。国内能把Coq用熟练的工程师数量本身就很少——这不是说这东西中国人学不会而是中文资料太少官方文档全都是英文的又厚又抽象入门门槛确实高。3.2 离线建模验证TLA的独特玩法TLA是一个我很推崇的思路——它不走“验证代码”这条路而是走“验证设计”的路。TLA由图灵奖得主Leslie Lamport发明核心思想是在写真正代码之前先用数学语言把系统设计建模出来验证这个设计本身有没有致命缺陷。这个思路特别适合分布式系统。比如你想实现一个Raft共识协议先别急着写Go或者Java代码先用TLA把协议建模然后用TLC模型检查器搜索所有可能的状态转移路径看能否找到死锁、活锁、重复投票、状态不一致等致命Bug。TLA的写法是数学化的。举个例子一个简单的计数器建模---- MODULE Counter ---- EXTENDS Naturals VARIABLE x Init x 0 Next x x 1 Spec Init /\ [][Next]_x 这套语言用TLA自己的语法描述“系统初始状态是什么”“每一步允许做什么变换”。模型检查器会把所有能走到状态都遍历一遍检查是否有违反不变量的路径。我在实际项目里的经验是TLA最大的价值不在“验证”而在“逼你把设计想清楚”。你写TLA模型的这个过程本身就是一次彻底的设计复盘。很多并发竞态条件的坑在建模阶段就会暴露出来这比到生产环境出了事故再去定位便宜太多了。3.3 工业级的有限验证Frama-C与CBMC介于全量证明和佛系测试之间的是一片广阔的“半自动验证”天地。工业界最常用的两类是静态分析器和有界模型检查器。Frama-C是我最常用的C语言验证工具它的思想是你先给C函数写ACSL规格语言描述的前置条件和后置条件然后工具基于中间表示生成一堆验证条件最后丢给后端的SMT求解器去证明或找出反例。比如用Frama-C验证之前那个求和程序规格可以用ACSL这样写/* requires n 0; ensures \result n*(n1)/2; */ int sum_to_n(int n) { int sum 0; /* loop invariant sum (i-1)*i/2; loop invariant 1 i n1; loop assigns sum, i; loop variant n - i; */ for (int i 1; i n; i) { sum i; } return sum; }注意注释里的loop invariant——这个正是我们必须提供给工具的循环不变量。如果你不写Frama-C通常很难自动推断出来。CBMC是另一个思路它做“有界模型检查”把程序的所有执行路径展开到一定的循环深度比如100次迭代然后把这个展开后的程序转换成SAT/SMT公式用一个布尔公式求解器去检查在“这些路径长度内”是否存在违反断言的执行轨迹。CBMC去找Bug的能力很强它不要求你写规格语言只要代码里有assert语句它就能自动检查这些断言在“有限展开”内是否一定成立。这个工具的定位是“深度Bug猎人”——找漏洞查到一定深度代价是定理层面上并不完备因为如果Bug只出现在200次循环之后而展开深度只有100就会漏掉。下面这张表我经常拿来跟朋友介绍不同工具的选择逻辑工具验证强度自动化程度适用场景学习成本Coq/Isabelle数学级全量证明低需要人主导证明核心算法、协议、安全关键逻辑极高TLA设计级完备检查中模型检查自动化分布式系统设计、并发协议高Frama-C源码级函数验证中需人写不变量C语言安全关键模块中CBMC有界深度的深挖高无需规格找深层Bug、fuzz辅助中低4. 验证的核心约束不可判定性与工程对策4.1 停机问题与Rice定理完美的验证不可能存在这个领域的根本性障碍在理论上早就被数学家钉死了。1936年Turing证明了停机问题不可判定——不存在一个算法能判断任意程序对任意输入是否停止。这直接给“全自动验证一切程序”判了死刑。更狠的推论来自Rice定理任何关于程序“非平凡语义属性”的判定问题都是不可判定的。所谓非平凡语义属性指的是“不只是看程序语法还要看程序行为”的属性——比如“这个程序不会死循环”“这个程序不会数组越界”“这个程序的输出总是偶数”全部不可判定。这意味着什么意味着不存在一个全自动工具能在有限时间内对所有程序给出“正确/不正确”的完备答案。任何声称能“自动证明你的程序没有Bug”的工具一定在某些情况下要么拒绝回答要么回答不了。这一下就把程序验证的“能做”和“不能做”分界画得极其清楚验证的本质是在“可判定的子集”里工作。4.2 工业界怎么和这个天花板共处既然完备自动化不可能那工程出路在哪答案是在不同层面做出合理取舍缩小范围不验证所有程序只验证语法受限、结构受限的特定程序。比如Frama-C要求你写出不变量CBMC限制循环展开深度本质就是把问题限制到可判定子集内。接受不完备如果验证器回来说“我没找到反例”工具给出的不是“程序正确”这个强结论而是“在我检查的范围内没发现Bug”这个弱结论。工业界大量使用这个弱结论配合测试一起用。混合验证关键性质用强验证Coq证明整体项目用弱验证静态分析测试。这是最实际、也是我推荐大家参考的策略。我经常打一个比方程序验证的天花板就像你在房间里找一只理论上正好被柱子挡住的猫你绕着房间走了三圈没看到猫不能证明猫不存在——你只能做的是把视线能覆盖到的地方挨个查干净再把能站上去的凳子都站一遍、把能挪开的柜子都挪开把“看不到猫”的置信度无限拉高。工程上我们做的正是这件事。4.3 关于“可判定片段”的实用理解SMT求解器能处理一大类“无量词的一阶逻辑公式”包括线性算术、数组、位向量等理论。这就是现代验证工具的自动化引擎。举个例子Frama-C把验证条件生成出来之后交给Alt-Ergo或者Z3去判定本质上就是在问SMT求解器给定这些约束是否存在一组赋值让错误条件成立如果能找到说明程序可能存在Bug需要进一步看是不是真Bug还是规格误写如果证明约束不可满足说明在这个逻辑片段内性质是成立的。SMT求解器是一个纯数学引擎它不“看程序”、不“了解业务”它只是机械地在逻辑公式的海洋里找赋值或者证明没有赋值。理解这一点很重要——工具链的智能化让验证的自动化程度越来越高但底层引擎的判定能力仍然是“受限但可靠”的。5. 实操用Z3和Horn子句完成一次真实验证5.1 验证问题如何变成数学公式前面讲的都是理论框架这一步我把整个实操链条完整走一遍。我们以最简单的程序为例验证一个函数abs(x)总是返回非负数。int abs(int x) { if (x 0) return -x; else return x; }我们的目标性质是\result 0。程序验证的思路是把这段代码的执行过程编码成一阶逻辑公式。大致的编码逻辑是这样的整个函数有两种执行路径。路径1进入时x 0执行return -x结束时返回值是-x。路径2进入时x 0执行return x结束时返回值是x。所以要证明“对于任意输入返回结果非负”等价于证明下面这个公式永真(对任意的 x) (如果走路径1那么 -x 0如果走路径2那么 x 0)由于路径条件是互斥且完全的每个x要么走路径1要么走路径2合在一起就是(forall x) (x 0 - -x 0) /\ (forall x) (x 0 - x 0)第一个子句在整数算术意义上是永真的因为x 0时-x 0 0第二个子句是平凡恒真的。所以整个性质得证。这个例子的每一步都极其简单但它展示了验证最核心的思想把程序的行为翻译成逻辑公式然后用数学方式证明公式永真。5.2 用Z3实操纸上谈兵部分我们用代码把这件事自动化。微软的Z3是一个工业级SMT求解器通过Python绑定可以直接调用from z3 import * x Int(x) ret If(x 0, -x, x) s Solver() # 断言存在反例返回值小于0 s.add(ret 0) # 检查反例是否存在 if s.check() unsat: print(证明完成abs(x) 对所有整数输入都返回非负数) else: print(找到反例程序有Bug:, s.model())这段代码的验证逻辑是反证法要证明“所有输入下 return 0”只需要证明“存在输入使 return 0”这件事是假的——也就是不可满足的。s.check() unsat意味着约束集合无解也就是不存在让返回值小于0的整数输入程序的性质得证。实际跑一下Z3会输出“证明完成”。这个例子虽然简单但验证的流程骨架已经完整了。5.3 引入循环后的处理方式如果程序里出现了循环简单的一锤子逻辑编码就不够了这时候需要引入Horn子句的形式来描述验证条件。Horn子句是一种结构受限的逻辑公式现代SMT求解器针对它的求解做了大量优化。以我们前面那个求和循环为例。验证目标依然是sum n*(n1)/2工具会把循环转换成如下所示的约束系统// 初始状态i 1, sum 0 Inv(1, 0) true // 保持状态执行一次循环体后不变量仍然成立 Inv(i, sum) - Inv(i 1, sum i) // 退出状态循环结束时由不变量推出目标性质 Inv(n 1, sum) - sum n * (n 1) / 2第一个约束是不变量的初始条件第二个是保持条件第三个是退出条件。如果这三个约束组成的Horn子句系统有解就等价于循环不变量成立且能推出目标结论。用Z3求解Horn子句系统的做法如下from z3 import * i, s Ints(i s) # 定义不变量作为未解释函数Inv(i, sum) Inv Function(Inv, IntSort(), IntSort(), BoolSort()) # 约束1初始状态成立 constraint_init Inv(1, 0) # 约束2保持性 constraint_step ForAll([i, s], Implies(Inv(i, s), Inv(i 1, s i))) # 约束3退出时推导出目标 constraint_exit ForAll([i, s], Implies(Inv(n 1, s), s n * (n 1) / 2))这里为了演示写了伪代码逻辑实际编码要考虑n是常量还是符号一整块处理。真实工具中用到的Horn子句求解器比如Z3的Spacer引擎能力更强可以自动推断不变量不需要人手动给出来但知道底层逻辑会帮助理解——所谓“自动推断不变量”本质上就是在Horn子句空间中搜索一个满足上述约束的不变量谓词。这也是为什么验证工具能自动化推进的原因——它在解一个约束求解问题虽然“给定程序找不变量”的理论上界是不可判定的但大多数实际程序里的不变量形态相对比较有规律工程化搜索往往是有效的。5.4 实操流程总结用一套工具完成验证的流程大致可以归纳为四个步骤建模把程序预处理成中间表示去掉语法糖和复杂宏。规格标注给函数写清楚前置条件、后置条件、断言。验证条件生成工具自动把程序翻译成逻辑公式或Horn子句。求解与报告后端SMT求解器负责证明或找反例找到反例就对应到具体的执行路径帮助定位Bug。我在实际用Frama-C做项目时绝大部分时间花在第二步和第三步——不是工具跑得慢而是我规格写不精确、不变量找得不对导致生成的公式要么证明不了要么本身就错了。验证工具逼着你把程序的“契约”写得极其严谨这正是它最大的价值。6. 常见问题与排查技巧实录6.1 规格写错了比代码写错了更可怕刚开始用程序验证的朋友最容易踩的坑是验证器报错“证明失败”第一反应是代码有Bug但其实很多时候是你规格写错了。比如你要验证abs函数规格写成ensures \result 0; ensures \result x;第二个规格条件写错成“返回值等于原始传入参数”这显然不对——当x是负数时返回的是-x而不是x。但你的验证器会诚实地告诉你“无法证明”。这时候如果不去检查规格只会觉得工具不行或者费半天劲去改那段本来就没问题的代码。排查这类问题有一个很管用的思路先假设代码是对的审视规格的每个条件是否合理。把规格一条条单独挑出来验证哪个性质证不出来就说明哪个条件要么规格错要么代码错再逐一缩小范围。我在实际项目里靠这个方法快速定位过不少问题结论经常是“代码没问题规格期望和业务需求不一致”——换句话说程序本身和规格描述的不一致反而是规格暴露了业务理解的偏差。6.2 循环不变量该去哪里找找循环不变量是程序验证里最依赖经验的部分。我总结出几条实用的套路从循环退出条件反推循环结束后需要什么式子成立它往往就是不变量的一个合取项。观察循环体对变量做了什么变换如果循环体是s : s i不变量里大概率有“s等于某个求和/累加公式”的结构。不变量不一定只有一个式子可以是多个条件的合取。比如数组遍历时可能既要管0 i n又要有sum的累加关系。当同时修改多个变量时每个变量之间的关系都要反映在不变量里漏掉任何一个都可能让验证失败。举一个常见场景交换数组两个元素int tmp a[i]; a[i] a[j]; a[j] tmp;这个操作没有循环不需要不变量但它的“后置条件”需要写清楚除了下标i和j的位置发生了交换其他所有位置的值保持不变。这种“frame condition”的表达在验证中经常是难点——你需要明确告诉工具“哪些东西没变”否则工具不知道也就没法利用这一点。6.3 验证器卡死或者超时怎么办SMT求解器在理论上可能跑很久这一点和SAT问题很像——NP问题遇到复杂实例计算量会爆炸。实际中我也遇到过Frama-C丢给Alt-Ergo证明一个比较复杂的不变量时跑了十几分钟没有回应。几个可以实操的对策检查是否有位向量运算混在整数运算里这类混合理论很多SMT求解器处理很慢。尝试把复杂的断言拆成多个小断言分步验证不要求一次证明一个大结论。用Frama-C的-wp-timeout参数给每个证明任务设定上限如果超时就返回unknown你再考虑补强不变量或者换求解器。换更快的后端求解器Z3通常比Alt-Ergo在混合算术推理上更快在Frama-C的配置里切换即可。还有一个小经验工具报“unknown”不一定代表你的程序或规格有问题只是它的技能条不够用了这时候补充适当的不变量或中间断言往往能帮它降低难度顺利证明出来。6.4 什么时候劝你放弃验证、改用其他手段最后说一个比较现实的建议。程序验证不是所有场景的最优解我个人劝退过一些朋友业务逻辑频繁变化的项目规格和证明都要跟着改维护成本会把团队拖垮。这种情况更适合用自动化测试做好回归保护。代码本身还处于原型探索期设计没有稳定下来先做题再验证纯粹是浪费。团队没有愿意啃逻辑学和验证工具的人强行上一套Coq流工具大概率变成PPT项目。程序验证最绽放的场景是代码逻辑核心、稳定、Bug代价极高、并且团队有能力为验证投入时间。像金融系统的撮合引擎、工业控制协议的实现、密码学库的核心运算这些场景值得深度投入。其他地方把它当做一个补充手段使用——关键模块用验证普通模块用测试——是非常合理的策略。我在前面提到的那种“测试只是抽样验证才是全部”的理念其实是半对半错。工程现实从来不是在“测试”和“验证”里二选一而是用测试找常见的Bug用验证消灭特定类型的关键风险用代码评审填补两者之间的盲区。真正把Bug降下来的不是某一招而是整个质量体系的配合。程序验证这么多年在工业界始终处于“听着很强用起来门槛高”的状态但我个人觉得随着SMT求解器能力的持续提升和工具链的不断成熟它正在一步步从“学术圈自嗨”走向“工业界刚需”。尤其是它的核心思想——用数学的眼光审视程序的本质——不管你是否真正落地这套技术这套思维方式本身对任何一个开发者的成长都很有价值。