想象你要在悬崖之间修一座大桥。测试是什么?测试是找几辆不同重量的车开过去试试,看看桥晃不晃、裂不裂。可问题来了:你没法把全世界所有车的重量、所有风向、所有地震等级都试一遍。形式化验证换了个思路——它不试车,而是用数学把桥的每一根钢索、每一个焊点的受力方程写下来,直接证明“无论什么车上来,桥都不会垮”。这一下就把“走过的路”变成了“所有的路”。 在软件世界里,代码就是桥,bug 就是坍塌。我们的代码跑在飞机发动机里、跑在金融交易系统里、跑在智能合约里——任何一个隐蔽的错误都可能造成灾难。形式化验证就是那个“数学验桥师”:它不靠运气,不靠经验,而是靠严格的逻辑推导,证明代码的行为完全符合我们写下的规约。本文会带你认识三大流派——Hoare 逻辑、模型检测、定理证明,以及它们背后的工具 Dafny、Coq、TLA+,让你理解“用数学证明代码正确”这件事,到底是怎么发生的。

岗位关联

对 AI 从业者而言,形式化验证让你从“测出 bug”进化到“证明无 bug”,是驾驭高可靠 AI 系统与生成代码质量保障的硬核技能。

目录
  1. 可视化演示
  2. 规约与断言
  3. Hoare逻辑
  4. 模型检测
  5. 定理证明
  6. 关键工具
  7. 完备性局限
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示代码形式化验证的三大方法:霍尔逻辑用{P}S{Q}三元组验证程序、模型检测遍历状态空间验证性质、定理证明从公理出发构建证明树。切换模式查看不同验证流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

霍尔逻辑 前置条件P约束程序执行前的状态,后置条件Q描述执行后的状态 若{P}S{Q}成立且初始满足P,则执行S后必满足Q {P} S {Q}

规约与断言

造桥工程开工前,甲方乙方要先签一份合同。合同上写着:甲方保证地基承载力不低于 300 吨;乙方保证桥在满载时最大形变不超过 5 厘米。有了这份合同,后面的一切验收、追责才有依据。在代码世界里,这份合同叫“规约”(Specification),它把模糊的“这函数应该没问题”变成白纸黑字的数学描述。

规约由两部分组成。前条件(Precondition)是调用方在调用函数前必须保证成立的状态,相当于“甲方必须把地基打好”;后条件(Postcondition)是函数执行完后必须保证的性质,相当于“乙方必须保证桥不塌”。代码里还有一种“合同抽检员”叫断言(assert),它像施工过程中的临时承重测试——程序运行到这里,如果断言不成立,立刻报警。

整套合同可以浓缩成一个漂亮的三元组标记法:

这份三元组字符量虽小,却是整个形式化验证世界的“合同模板”。第一份合同签好,接下来我们就要讨论:怎么证明签下的合同真能兑现。

{P} C {Q}\displaystyle \{ P\}\ C\ \{ Q\}
  1. 前条件 P程序执行前必须已经成立的状态条件。它描述的是“调用方带进来的承诺”。
    P\displaystyle P
  2. 程序段 C被验证的那段代码,是“施工过程本身”。
    C\displaystyle C
  3. 后条件 Q如果 P 成立且 C 执行完毕,那么 Q 必然成立。这是“乙方交工时必须兑现的承诺”。
    {P} C {Q}\displaystyle \{ P\}\ C\ \{ Q\}

Hoare逻辑

有了合同,还需要一种“验算”的方法。回想中学证明题:已知“若 A 则 B”和“若 B 则 C”,就能推出“若 A 则 C”。Hoare 逻辑干的事类似,只不过把 A、B、C 换成了程序状态,把“则”换成了程序的执行步骤。它把一大段代码拆成一条条小的推理规则,像搭积木一样拼出整个程序的正确性证明。

其中最精妙的是 while 循环的推理规则。循环就像桥上来回跑的巡检车,跑多少圈不确定,但我们关心的是:每一圈跑完,车的状态有没有超出安全边界?只要找到一条“无论跑多少圈都成立”的性质,就能对付循环。这条性质叫循环不变式(Loop Invariant),它必须满足:循环开始前成立,每执行一次循环体后依然成立。

Hoare 逻辑把“验证一条循环”变成“找到并验证一个不变式”。这就好比造桥时不需要模拟每一辆车,只需证明“无论车怎么开,中心线都出不了护栏”。找到好的不变式,是证明中最考验功力的地方。

{I∧B} S {I}{I} while B do S {I∧¬B}\displaystyle \frac{\{ I \land B \}\ S\ \{ I\}}{\{ I \}\ \mathbf{while}\ B\ \mathbf{do}\ S\ \{ I \land \lnot B\}}
  1. 循环不变式 I一条在循环任何时刻都保持成立的性质,相当于“桥面中心线永远在护栏内”。
    I\displaystyle I
  2. 循环条件 B决定是否继续循环的布尔表达式,对应“巡检车还没跑完全程”。
    B\displaystyle B
  3. 循环体 S每次循环执行的代码块,是“巡检车跑一圈”。
    S\displaystyle S
  4. 考察前提假设进入循环时 I 和 B 都成立,执行完 S 后 I 仍然成立——这一圈没有破坏安全边界。
    {I∧B} S {I}\displaystyle \{ I \land B \}\ S\ \{ I\}
  5. 结论循环结束后,I 依然成立,并且 B 变成假——巡检结束,边界安全,任务完成。
    {I} while B do S {I∧¬B}\displaystyle \{ I \}\ \mathbf{while}\ B\ \mathbf{do}\ S\ \{ I \land \lnot B\}

模型检测

Hoare 逻辑像一位数学家,在纸上推公式;而模型检测像一台“全路况模拟器”。假设桥的每一项关键参数(载重、风速、温度)都有有限的取值,把所有取值组合成一张巨大的状态图,然后让计算机系统地把图里所有路径都走一遍,看看有没有一条路径会走出护栏。这是典型的“大力出奇迹”——用算力换安心。

模型检测的关键在于两点。第一,性质用“时态逻辑”表达:不光说“现在安全”,还要说“任何时刻有请求,最终都会得到应答”。第二,反例路径:一旦发现一条违规的路径,系统会原样输出这条路径,工程师就拿着这条“行车记录仪录像”去修复代码,再也不用靠猜。

一条典型的时态逻辑性质长这样:

合在一起,这条公式说的是:任何时候来了请求,最终一定会有应答。模型检测把所有“请求—应答”的时间线全部枚举一遍,任何一条断链都会被抓出来。它的弱点是状态图太大时计算机累趴下——这就是后文要讲的状态爆炸。

□(req→◊ack)\displaystyle \Box (req \rightarrow \Diamond ack)
  1. 框号 □(总是)后面的性质在系统运行的每一个时刻都成立。
    □\displaystyle \Box
  2. 请求 req某个输入事件,比如“有车开上桥”。
    req\displaystyle req
  3. 蕴含→如果箭头左边成立,那么右边也必须成立。
    →\displaystyle \rightarrow
  4. 菱形 ◇(最终)在未来的某个时刻,这个事件一定会发生。
    ◊ack\displaystyle \Diamond ack
  5. 应答 ack期望的输出事件,比如“桥面控制系统给出放行信号”。
    ack\displaystyle ack

定理证明

模型检测像“全路况扫描”,定理证明则是“结构力学推导”——它不枚举车辆,而是从公理出发,用推理规则一步步推导出“这座桥在数学上必然安全”。这正是数学家的老本行:归纳法。

数学归纳法完美对应程序的循环验证。就像验证一排无限长的桥墩:先证明第一个桥墩是稳的(基础步),再证明“如果第 n 个桥墩稳,那么第 n+1 个也稳”(归纳步),于是整排桥墩都稳了。循环不变式验证用的正是同一套思想。

定理证明在计算机上由证明助手(如 Coq、Isabelle)执行。你负责提出证明策略,证明助手负责检查每一步是否符合逻辑规则,绝不放水。更妙的是“类型论”发现:一个类型正确的程序,本身就等价于一个构造性证明——写代码和写证明,其实是一回事。这就像设计桥的图纸上,每一道结构力学的推导都是桥本身的一部分。

P(0)∀n. P(n)→P(n+1)∀n. P(n)\displaystyle \frac{P(0) \quad \forall n.\ P(n) \rightarrow P(n+1)}{\forall n.\ P(n)}
  1. 基础步 P(0)证明性质在 0 这个起点成立,对应“第一个桥墩稳”。
    P(0)\displaystyle P(0)
  2. 归纳步假设 P(n) 成立,证明 P(n+1) 也成立,对应“相邻桥墩一个稳则下一个也稳”。
    ∀n. P(n)→P(n+1)\displaystyle \forall n.\ P(n) \rightarrow P(n+1)
  3. 结论对所有自然数 n,P(n) 都成立,对应“整排桥墩都稳”。
    ∀n. P(n)\displaystyle \forall n.\ P(n)

关键工具

现实生活中,验桥团队也分工种:有人操作自动化检测机器人,有人是手算结构力学的老教授,还有人负责全城交通仿真。形式化验证的世界里,对应的就是三件代表性工具。

Dafny 像自动化验桥机器人:你在普通代码里标注前条件、后条件和循环不变式,然后点击运行,Dafny 背后的自动定理证明器会自己把大部分证明推完。它强调“可编程”,适合养在 CI 流水线里,每次提交代码自动验一遍。\\Coq\\ 则更像老教授:它的表达能力更强,能证明非常复杂的性质,但你得手动引导证明过程,效率低却上限高,适合操作系统内核、编译器这类“皇冠上的明珠”。\\TLA+\\ 的视野不在单段代码,而在整个分布式系统——它被用来建模并验证 Raft、Paxos 这类共识协议,相当于“全城交通规划仿真”。

\\工具选型的依据很简单:目标在哪个层次。\\ 验证单个函数,用 Dafny;验证算法库的核心不变量,上 Coq;验证分布式系统协议,请出 TLA+。

TLA+ 的核心是“系统状态如何随时间转移”。它用一个带撇的变量表示“下一时刻的状态”:

在 TLA+ 里,整段系统行为就是一条条这样的状态转移公式的组合。验证器在状态空间里搜索,确认不管怎么转移,关键性质都不会被破坏。选对工具,就成功了一半。

x′=x+1\displaystyle x' = x + 1
  1. 当前状态变量 x系统此刻的状态,如“队列长度是 x”。
    x\displaystyle x
  2. 下一状态变量 x'时间步推进后的状态,读作“x 撇”。
    x′\displaystyle x'
  3. 等式描述的是“当前时刻到下一时刻之间,系统发生了什么动作”。
    =x+1\displaystyle = x + 1

完备性局限

听到这里,你可能想问:既然形式化验证这么强,是不是所有代码都能证明正确?答案是:很遗憾,不能。数学家哥德尔和图灵早就画下了一条边界——有些命题本身就无法被计算或证明。最著名的就是停机问题:不存在一个通用程序,能判断任意程序在任意输入下是否会终止。这就好比不存在一种“万能测桥仪”,能预测所有可能建出来的桥的所有行为。

除了理论边界,还有工程边界:模型检测会遇到状态爆炸——变量稍微多一点,状态图就比宇宙原子还多;定理证明则极度消耗人力,一个复杂证明可能要数学家忙上几年。而且,即便证明通过,它证明的也只是“代码满足规约”,如果规约本身写错了——比如桥的设计蓝图承重就少算了一个零——那证明得再漂亮,桥也会塌。

所以现实中的正确策略是:把形式化验证用在真正要命的代码上(航天、金融、协议、安全关键模块),普通代码用测试和代码评审做常规防守。数学不是银弹,但它是最硬的那颗子弹。

∄ f:Prog×Input→{0,1}, ∀程序 p, ∀输入 x, f(p,x)=1  ⟺  p(x) 终止\displaystyle \nexists\, f : \mathrm{Prog} \times \mathrm{Input} \to \{0,1\},\ \forall \text{程序 }p,\ \forall \text{输入 }x,\ f(p,x) = 1 \iff p(x)\text{ 终止}
  1. 不存在 f找不到这样一个判定函数,即使我们拥有无限算力。
    ∄ f\displaystyle \nexists\, f
  2. 函数签名f 接受一个程序和一份输入,返回 0 或 1。
    f:Prog×Input→{0,1}\displaystyle f : \mathrm{Prog} \times \mathrm{Input} \to \{0,1\}
  3. 终止条件f 返回 1 当且仅当程序 p 在输入 x 上会终止。
    f(p,x)=1  ⟺  p(x) 终止\displaystyle f(p,x) = 1 \iff p(x)\ \text{终止}

AI 应用场景

智能合约安全审计

阶段:代码生成后验证

解决的问题:区块链智能合约一旦部署就不可篡改,重入攻击等漏洞曾造成数亿美元损失。传统人工审计慢且易漏,形式化验证把合约的余额守恒、权限控制写成规约,用数学证明合约在所有调用序列下都不会被攻破。

具体案例:某 DeFi 协议在部署前用 Dafny 为关键资金转移函数编写前条件(调用者余额充足)与后条件(余额守恒),自动证明函数不存在“重复取款”路径,把审计时间从数周压缩到数天。

自动驾驶安全功能验证

阶段:安全关键模块验证

解决的问题:自动驾驶的紧急刹车决策一旦出错就是人命关天。AI 模型本身难以证明,但外围决策逻辑可以用模型检测验证:状态空间建模为“传感器输入—决策输出”的时序系统,穷举所有可能路况组合,确认紧急刹车逻辑绝不会在高速行驶时误触发。

具体案例:某自动驾驶团队对“碰撞避免”模块用 TLA+ 建模,验证“任意时刻检测到障碍物,最终必然触发减速”这条时态逻辑性质,并修复了一条极端光照下的漏检路径。

AI 生成代码的质量保障

阶段:AI代码生成后验证

解决的问题:大模型生成代码速度快,但“幻觉 bug”防不胜防。把形式化验证作为生成代码的质检关卡:为函数生成前条件、后条件注解,用 Dafny 自动验证生成代码是否满足契约,不满足就返回大模型迭代修改。

具体案例:某低代码平台要求所有 AI 生成的排序、加解密函数必须通过 Dafny 验证后才能合入仓库,将线上 bug 率降低了 70%。

Python 演示

python92 行
# -*- coding: utf-8 -*-"""极小命令式语言的穷举验证器通过穷举所有可能的输入组合, 检查程序是否满足规约(前条件/后条件)。这是模型检测思想的最小演示。"""from itertools import product  def eval_expr(e, env):    """求值整数表达式。表达式是元组, 例如 ('plus',('var','i'),('num',1))"""    op = e[0]    if op == 'num':        return e[1]    if op == 'var':        return env[e[1]]    if op == 'plus':        return eval_expr(e[1], env) + eval_expr(e[2], env)    if op == 'mul':        return eval_expr(e[1], env) * eval_expr(e[2], env)    raise ValueError('unknown expr: ' + op)  def eval_bool(b, env):    """求值布尔条件"""    op = b[0]    if op == 'lt':        return eval_expr(b[1], env) < eval_expr(b[2], env)    if op == 'le':        return eval_expr(b[1], env) <= eval_expr(b[2], env)    if op == 'eq':        return eval_expr(b[1], env) == eval_expr(b[2], env)    if op == 'and':        return eval_bool(b[1], env) and eval_bool(b[2], env)    raise ValueError('unknown bool: ' + op)  def exec_cmd(c, env):    """解释执行一条命令"""    op = c[0]    if op == 'skip':        return    if op == 'assign':        env[c[1]] = eval_expr(c[2], env)    elif op == 'if':        if eval_bool(c[1], env):            exec_cmd(c[2], env)        else:            exec_cmd(c[3], env)    elif op == 'while':        while eval_bool(c[1], env):            exec_cmd(c[2], env)    elif op == 'seq':        exec_cmd(c[1], env)        exec_cmd(c[2], env)    else:        raise ValueError('unknown cmd: ' + op)  def verify(program, univ, pre, post):    """穷举所有输入组合, 满足前条件者必须满足后条件"""    names = list(univ.keys())    domains = list(univ.values())    total = 0    passed = 0    for values in product(*domains):        env = dict(zip(names, values))        if not pre(env):            continue        total += 1        exec_cmd(program, env)        if post(env):            passed += 1        else:            print('counterexample:', values)    return total, passed  if __name__ == '__main__':    # 程序: while i < 5 { sum = sum + i; i = i + 1 }    body = ('seq',            ('assign', 'sum', ('plus', ('var', 'sum'), ('var', 'i'))),            ('assign', 'i', ('plus', ('var', 'i'), ('num', 1))))    prog = ('while', ('lt', ('var', 'i'), ('num', 5)), body)     # 前条件: 无限制; 后条件: sum == i * (i-1) / 2    total, passed = verify(        prog,        {'sum': [0], 'i': list(range(6))},        lambda env: True,        lambda env: env['sum'] == env['i'] * (env['i'] - 1) // 2    )    print('verified:', passed, '/', total, 'cases')
目的:用 Python 实现一个极简解释器,对一个小程序穷举所有输入组合,自动检查它是否满足“前条件—后条件”规约,并输出反例。
  1. for values in product(*domains):笛卡尔积展开所有输入的组合,对应模型检测的“状态空间穷举”。
  2. if not pre(env): continue只有满足前条件的输入才进入验证范围。
  3. exec_cmd(program, env)在给定输入状态下“真实执行”一遍程序。
  4. if post(env): passed += 1执行后检查后条件是否成立。
  5. print('counterexample:', values)一旦发现反例立即输出,这就是模型检测里的“反例路径”。

常见误区

误区“我写了 1000 个测试用例,全都通过了,这代码肯定是安全的。形式化验证不过是把测试的数量再加大一点。”

正解测试像开车过桥,你开了 1000 趟,也只能证明这 1000 趟没事;形式化验证是证明桥在数学上不可能垮。测试只能证伪,不能证实。要证明“所有可能的情况都安全”,只有考数学,不能靠堆测试数量。

为何常见:日常开发中测试是主流手段,而且数量越多往往确实越有信心,容易把“多”误解为“全”。

误区“Dafny 给出了 verified 的绿色勾号,这代码绝对没有 bug,可以放心上线了。”

正解验证证明的是“代码满足规约”,而不是“代码满足你的真实意图”。如果规约里前条件写错了,或者把“余额增加”写成了“余额减少”,那么验证结果依然绿色,上线照样爆雷。规则再严,合同文本错了,按合同执行就会出事。

为何常见:验证工具的绿色对勾太有迷惑性,容易让人把“证明”与“绝对安全”画等号。

误区“模型检测把每一个状态都查一遍,那以后机器强大了,是不是所有程序都能交给它?”

正解这是状态爆炸的误区。状态空间随变量数量和并发组件数量指数爆炸,哪怕变量只多 10 个,状态数就可能从一万跳到十亿。增加算力只能把墙推远一点,推不倒墙。所以模型检测要配以抽象和化简,只能用于有限状态的问题。

为何常见:人们天然相信“穷举=彻底”,没意识到穷举本身的规模会失控。

误区“验证一个函数要写一堆注解、跑半天,产品迭代这么快,根本没法在工业界用。”

正解这是性价比误区。验证慢,但 bug 的代价更大——金融交易的账目错一笔、航天器的控制逻辑错一行,损失可能远超验证成本。工业界已经在用 Dafny、TLA+ 验证真实系统,关键在于挑对对象:高频变更的 UI 代码不值得验证,算法核心和资金逻辑必须验证。

为何常见:很多开发者只接触过玩具级示例,没见过验证工具在现代 CI 流水线中自动化运行的真实效率。

面试题

Q1. 测试与形式化验证最本质的区别是?

单选题 · easy

  • A.测试验证具体行为而验证证明性质← 正确答案
  • B.测试的成本总是比验证成本更低
  • C.形式化验证可以完全替代测试工作
  • D.验证只能用来处理最简单的代码段

答案:A。测试只能针对有限的输入样本验证具体行为,形式化验证用数学证明性质对所有情况成立,这是两者最本质的区别。成本因场景而异,验证不能完全替代测试(还要处理代码与模型的偏差),验证也绝不是只能处理简单代码。

Q2. 在规约中“前条件”指的是?

单选题 · easy

  • A.调用前必须成立的状态条件← 正确答案
  • B.执行后必须保证的性质
  • C.测试开始前准备的输入数据
  • D.编译器优化时附加的约束

答案:A。前条件描述调用方在调用函数之前必须保证成立的状态,它是“进场门槛”。后条件才是执行后必须保证的性质,测试输入数据与编译器约束都与规约的前条件无关。

Q3. 对 Hoare 三元组 {P} C {Q} 的正确理解是?

单选题 · mid

  • A.C 执行前若 P 成立且能终止则 Q 成立← 正确答案
  • B.C 执行后若 Q 成立则 P 也一定成立
  • C.P 成立则 C 必定执行且不会终止
  • D.Q 是 C 在执行过程中打印的结果

答案:A。Hoare 三元组的含义是:只要程序 C 执行前 P 成立,并且 C 能正常终止,那么执行后 Q 必然成立。它不要求反向推导,也不承诺循环不终止的情况,Q 更不是打印输出。

Q4. 循环不变式(I)的主要作用是?

单选题 · mid

  • A.描述循环每次迭代前后都保持的性质← 正确答案
  • B.保证循环体内部不会出现任何赋值
  • C.指出循环条件在迭代中必然变为假
  • D.用来消除循环并且提升执行速度

答案:A。循环不变式是一条在循环开始前成立、且每执行完一次循环体后依然成立的性质。它是证明 while 循环正确性的核心工具。它不限制赋值,不保证循环条件一定变假(这由终止性证明负责),更不能消除循环。

Q5. 下列哪些属于形式化验证的常用方法?

多选题 · mid

  • A.模型检测系统化搜索状态空间← 正确答案
  • B.Hoare 逻辑推导程序满足规约← 正确答案
  • C.定理证明用公理规则推出结论← 正确答案
  • D.模糊测试随机生成大量输入

答案:ABC。模型检测、Hoare 逻辑、定理证明是形式化验证的三大流派,分别对应穷举、推导和证明。模糊测试是随机生成输入寻找崩溃的测试方法,覆盖范围依赖随机性,不属于形式化验证。

Q6. 前条件与后条件的关系本质上是?

单选题 · mid

  • A.约定了调用方与实现方的责任← 正确答案
  • B.两者必须写在同一行代码注释
  • C.后条件比前条件更难以被满足
  • D.前条件通常由函数内部生成

答案:A。前条件是调用方的义务,后条件是实现方的承诺,两者共同构成一份契约。它们不要求写在同一行,难易程度因程序而异,前条件由调用方负责,不由函数内部生成。

Q7. 模型检测输出“反例路径”意味着?

单选题 · hard

  • A.程序存在一条违规的执行轨迹← 正确答案
  • B.程序没有任何可执行的状态
  • C.状态空间搜索尚未全部完成
  • D.程序在所有输入下都正确

答案:A。反例路径是模型检测搜索到的一条真实存在的、从初始状态走到违规状态的执行轨迹。它是最有价值的产出——工程师可以直接照着这条轨迹找到 bug。它不代表搜索没完成,恰恰说明搜索已经完成且发现了问题。

Q8. 状态爆炸问题主要受哪些因素的影响?

多选题 · hard

  • A.程序变量的取值范围大小← 正确答案
  • B.并发组件间交错组合数量← 正确答案
  • C.被验证性质的时间复杂度
  • D.程序源文件中注释的多少

答案:AB。变量的取值范围扩大会让状态数指数增长,并发组件越多交错顺序越恐怖,这是状态爆炸的两大主因。被验证性质的时间复杂度不影响状态空间规模,注释更不参与状态建模。

Q9. Dafny 这类工具的核心工作方式是?

单选题 · mid

  • A.读取带注解的代码并自动验证← 正确答案
  • B.需要人工在纸上推导每一条规则
  • C.只能验证属性而不接受代码输入
  • D.通过运行测试用例来检查回归

答案:A。Dafny 读取带前条件、后条件和循环不变式注解的代码,由内置的自动定理证明器完成验证,不需要人工逐条推导。它接受代码作为输入,且不是靠运行测试用例来判断正确性。

Q10. 关于形式化验证的局限性,正确的说法有?

多选题 · extreme

  • A.停机问题决定无法全自动判定任意程序← 正确答案
  • B.验证结果正确性依赖于规约本身的正确← 正确答案
  • C.模型检测可能遇到状态空间爆炸问题← 正确答案
  • D.形式化验证可以保证绝对的所有正确

答案:ABC。停机问题决定了不存在能判定任意程序是否终止的通用算法;验证只对规约负责,规约错了证明再好也白费;模型检测在状态空间过大时会耗尽资源。这些共同证明形式化验证不是“绝对正确的银弹”。