1969年,Tony Hoare发表了一篇只有三页的论文,提出了后来被称为"Hoare逻辑"的形式系统。他在论文开篇写道:“计算机程序员总是要和程序正确性打交道。“半个多世纪过去了,程序员们依然在和程序正确性打交道——只是我们选择的方式,几乎从未真正拥抱过Hoare的方案。

2010年,seL4微内核团队宣布完成了从高级规范到C代码的完整形式化验证。这是操作系统历史上的里程碑:8,700行C代码、600行汇编,每一行都有数学证明支撑。这项工作耗时数年,投入巨大,最终证明了什么?证明了这个内核永远不会崩溃,永远不会执行非法操作,所有系统调用都会正确返回。

听起来像是一场胜利。但让我们换个角度看:在同样的时间里,一个普通的程序员团队可以写出数万行代码,而seL4团队平均每天只能产出四行经过验证的代码。

形式化验证——用数学证明程序正确——为什么如此困难?这不仅仅是一个技术问题,更是一个横跨数学、逻辑学和软件工程的深刻困境。

理论铁律:你无法证明一切

在讨论"有多难"之前,我们需要先回答一个更根本的问题:是否可能?

1936年,Alan Turing证明了停机问题的不可判定性:不存在一个通用算法,能够判断任意程序是否会终止。这个结论对软件验证意味着什么?

Rice定理给出了更尖锐的回答:任何非平凡的程序语义属性都是不可判定的。

“非平凡"是什么意思?如果一个属性对某些程序成立,对另一些程序不成立,它就是非平凡的。“程序是否会访问空指针"是非平凡的——有的程序会,有的不会。“程序是否终止"是非平凡的。“程序是否总是返回正确结果"也是非平凡的。

这意味着什么?这意味着不存在一个算法,能够自动判断任意程序是否满足你关心的任何有意义的性质。你想证明你的程序没有缓冲区溢出?不可能自动化。你想证明你的程序满足某个规范?不可能自动化。

当然,这并不意味着证明是不可能的——它只是意味着证明过程无法完全自动化。你必须付出人类的智慧,在每个具体案例中进行创造性的推理。这正是形式化验证如此困难的第一个原因:理论本身设定了硬边界

graph TD
    A[程序验证问题] --> B{是否可判定?}
    B -->|Rice定理| C[所有非平凡语义属性<br/>不可判定]
    C --> D[停机问题]
    C --> E[内存安全]
    C --> F[功能正确性]
    
    D --> G[无法自动判定程序是否终止]
    E --> H[无法自动判定是否有空指针/越界]
    F --> I[无法自动判定是否满足规范]
    
    G --> J[必须依赖人类引导的证明]
    H --> J
    I --> J

证明的代价:从人年到人天

让我们看看那些成功案例,它们付出了什么代价。

seL4:操作系统验证的标杆

seL4是一个第三代的L4微内核。研究团队选择了微内核架构,正是因为它的代码量小——约10,000行C代码——使得验证成为可能。

验证工作使用Isabelle/HOL定理证明器完成。团队证明了:

  • 无代码注入攻击:内核永远不会执行外来代码
  • 无缓冲区溢出:所有数组访问都在边界内
  • 无空指针访问:每个指针访问前都有非空证明
  • 无内存泄漏:内核永远不会耗尽内存
  • 所有系统调用终止:内核永远不会冻结

但为了达到这些,团队不得不做出巨大的设计妥协:

设计限制清单:
├── 禁止对局部变量取地址
├── 禁止函数指针调用(汇编代码除外)
├── 禁止goto语句
├── 禁止switch的fall-through
├── 禁止并发(单处理器版本)
└── 中断处理极其受限

他们甚至为验证专门设计了Haskell原型,作为抽象规范和C实现之间的桥梁。这不是在验证一个已有的系统,而是为验证而设计一个系统。

CompCert:编译器也能证明

CompCert是一个经过验证的C编译器。它证明了编译过程的语义保持性:编译后的程序行为与源程序行为完全一致。

代价是多少?100,000行Coq证明代码,6人年的工作量。

Xavier Leroy在论文中写道:“CompCert的证明是迄今为止使用证明助手完成的最大的证明之一。”

IronFleet:分布式系统的证明

微软研究院的IronFleet项目证明了Paxos算法的正确实现。他们使用了Dafny验证语言和Z3 SMT求解器。

数据:约5,000行Dafny代码,3.7人年。折算下来,每天约4行经过验证的代码。

Hillel Wayne在分析这个数据时写道:“证明是困难的。令人恼火的困难。‘放弃编程加入马戏团’式的困难。”

为什么证明比编码难这么多?

困难一:证明需要完美,编码只需足够好

普通软件开发有一个隐含的假设:我们不需要完美。测试覆盖90%的代码路径,剩下10%靠运气和后期修复。类型系统捕获一些错误,运行时异常处理另一些,用户反馈再发现剩下的。

但证明不接受"足够好”。你要证明一个程序没有空指针访问,就必须证明每一个可能的指针访问都安全。不是99%,是100%。

这带来了一个有趣的现象:程序越长,证明的增长远超线性。 一个10行程序的证明可能需要100行,一个100行程序的证明可能需要10,000行。每增加一行代码,可能需要证明它与所有已有代码的交互都正确。

困难二:你必须证明一切,包括"显然"的事

数学证明有一种特殊的痛苦:你必须证明那些看起来显然的事情。

在程序证明中更是如此。你想证明一个简单的插入排序正确?你需要先定义什么是"有序列表”,然后证明:

  • 基础情况:空列表加一个元素,一定有序
  • 归纳步骤:有序列表插入元素后仍有序

这听起来简单,但每一行都需要形式化。什么是"有序”?你需要一个数学定义:

∀ i j, i < j → list[i] ≤ list[j]

然后你需要在定理证明器中构造这个证明。Coq不会接受"显然正确"的论证——你必须明确地写出每一步推理。

更糟糕的是,很多数学上"显然"的事情在计算机上并不成立。比如,加法满足结合律吗?

(a + b) + c = a + (b + c)

在数学上成立。但在C语言中?如果 a + b 溢出,行为未定义。你假设了结合律,你的证明就是错的。

困难三:语言设计从未考虑过证明

现代编程语言的设计目标是什么?性能、表达能力、开发效率。可证明性?从来没有进入过优先级列表。

这带来了无数的问题:

别名问题:两个指针指向同一块内存时,修改一个会影响另一个。证明程序正确,必须考虑所有可能的别名情况。这在C语言中尤其困难。

副作用:表达式 a + f() 中,如果 f() 有副作用(比如修改了全局变量),那么表达式的求值顺序就变得至关重要。但C语言没有规定求值顺序。你怎么证明程序正确?

并发:多线程程序的可能执行路径数量是天文数字。三个线程,每个四步,它们可以以34,650种方式交错执行。如果每个线程有五种初始状态,总行为数超过400万。

seL4团队选择了一个极端的解决方案:放弃并发。他们只验证单处理器版本,禁用内核抢占,把并发问题推给用户空间。

这不是逃避——这是现实。在现有语言中证明并发程序正确,几乎是不可能的任务。

flowchart TB
    subgraph 语言特性
        A[指针/引用] --> A1[别名分析困难]
        B[动态内存] --> B1[内存安全证明复杂]
        C[并发/多线程] --> C1[状态空间爆炸]
        D[异常处理] --> D1[控制流复杂化]
        E[函数指针] --> E1[间接调用难以分析]
    end
    
    subgraph 证明挑战
        A1 --> F[证明成本指数增长]
        B1 --> F
        C1 --> F
        D1 --> F
        E1 --> F
    end
    
    F --> G[最终结果:<br/>放弃部分语言特性<br/>或接受不完备证明]

模型检测:自动化的希望与绝望

如果证明太困难,有没有更自动化的方法?

模型检测(Model Checking)提供了一个诱人的承诺:你只需要写出系统的规范,计算机自动检查所有可能的状态。不需要写证明,不需要复杂的推理。

状态爆炸:无法回避的梦魇

模型检测的核心思想是穷举。检查系统是否满足某个性质?枚举所有可能状态,逐一验证。

但状态数量会爆炸性增长。考虑一个简单的消息队列:

  • 队列长度10
  • 每个位置可以是"空"或"有消息”
  • 消息类型有5种

仅这一个组件,状态数就是 $6^{10} = 60,466,176$。

加上多个进程的交错执行?数字变得毫无意义。

研究人员花了三十多年对抗状态爆炸:

对抗状态爆炸的技术演进
├── 符号模型检测(BDD,1990年代)
│   └── 用布尔公式隐式表示状态集
├── 有界模型检测(1999年)
│   └── 只检查有限深度的执行路径
├── 偏序规约(1990年代)
│   └── 只检查代表性执行路径
└── 对称性规约
    └── 利用系统对称性减少状态

这些技术扩展了模型检测的能力边界,但问题依然存在:复杂系统天生有指数级的状态空间

AWS的TLA+实践:值得吗?

Amazon是TLA+的重度用户。他们在DynamoDB、EBS、S3等核心服务中使用TLA+进行规范验证。

结果?在DynamoDB的复制系统中,TLA+发现了需要35步追踪才能复现的bug。这种bug用任何常规测试方法都几乎不可能发现。

但AWS工程师也在论文中承认,引入TLA+有陡峭的学习曲线。这不是一个你能在周末学会的工具。

表达能力与可验证性的永恒权衡

这里揭示了一个深层的矛盾:语言越强大,越难证明。

你想证明一个使用高级特性的程序?你需要理解这些特性的语义,并在证明中处理它们。

你想证明一个使用指针的程序?你需要处理内存模型、别名、释放后使用。

你想证明一个使用并发的程序?你需要处理内存模型、原子性、可见性。

反之,语言越受限,证明越容易。SPARK是Ada的一个子集,禁用了Ada中所有难以验证的特性:

  • 没有指针(使用引用参数代替)
  • 没有动态内存分配
  • 没有异常
  • 严格的类型检查

代价是什么?你只能写相对简单的程序。

graph LR
    A[编程语言] --> B[表达能力]
    A --> C[可验证性]
    B --> D[高:指针、并发、异常...]
    C --> E[低:需要处理复杂语义]
    D --> F[证明困难]
    E --> F
    
    G[受限语言] --> H[低表达能力]
    G --> I[高可验证性]
    H --> J[证明可行]
    I --> J

规范问题:你怎么知道你证明了正确的事?

证明程序正确,前提是你知道什么是"正确”。

这听起来理所当然,但在实践中是最困难的问题之一。Hillel Wayne区分了两个层面的问题:

验证问题:给定规范,证明程序满足规范。

规范问题:你怎么知道这个规范是正确的?

一个经典例子:你验证了一个排序函数,证明它总是返回有序列表。但用户真正想要的是什么?也许是"稳定排序”?也许是"原地排序"?也许是"O(n log n)复杂度"?

规范本身就是一种编程,只是编程的对象是性质而不是行为。规范也可以有bug。

形式化方法社区有一个经典笑话:

你证明程序满足了规范,规范满足了需求,需求满足了用户期望。但你问用户:“这是你想要的吗?“用户说:“不是。”

这被称为验证与确认的鸿沟。验证解决的是"我们是否正确地构建了产品”,确认解决的是"我们是否构建了正确的产品”。形式化验证只解决了前者。

SMT革命:四行代码的奇迹

2006年,Microsoft Research发布了Z3 SMT求解器。这是一个转折点。

flowchart LR
    A[源代码 + 规范] --> B[验证条件生成器]
    B --> C[一阶逻辑公式]
    C --> D[Z3 SMT求解器]
    
    D --> E{可满足?}
    E -->|SAT| F[证明成功 ✓]
    E -->|UNSAT| G[存在反例]
    E -->|UNKNOWN| H[超时/无法判定]
    
    G --> I[报告错误位置]
    H --> J[需要手动提供引理]

SMT(Satisfiability Modulo Theories)求解器能够自动处理一大类证明问题。你给出约束,它告诉你是否有解。

Dafny验证语言正是建立在Z3之上。你写出带有前置条件、后置条件、循环不变式的代码,Dafny自动生成验证条件,发送给Z3。

这就是IronFleet项目能够达到"每天四行"效率的原因。没有SMT求解器,效率会更低。

但SMT求解器不是万能药:

不完备性:某些问题Z3无法处理。你必须手动提供额外的引理。

不可预测性:Z3的性能难以预测。有时一个简单的证明会卡住几小时。

不透明性:当Z3失败时,你很难理解为什么。是证明不存在,还是Z3不够聪明?

分离逻辑:堆的救赎

2002年,John Reynolds、Peter O’Hearn等人提出了分离逻辑(Separation Logic)。这是程序验证领域最重要的进展之一。

graph TD
    subgraph 传统Hoare逻辑
        A["整个堆状态 H"] --> B["必须推理所有内存"]
        B --> C["证明复杂度 O(n²)"]
    end
    
    subgraph 分离逻辑
        D["堆状态 H = H₁ ∪ H₂"] --> E["分离合取 P * Q"]
        E --> F["只推理相关部分"]
        F --> G["Frame Rule"]
        G --> H["局部推理<br/>证明复杂度降低"]
    end
    
    C --> I["难以扩展到大型程序"]
    H --> J["支持模块化验证"]

分离逻辑的核心思想是分离合取(separating conjunction):

$$P * Q$$

它表示:堆可以被分成两个不相交的部分,一部分满足 $P$,另一部分满足 $Q$。

这带来了一个革命性的规则:Frame Rule

$$\frac{\{P\} C \{Q\}}{\{P * R\} C \{Q * R\}}$$

含义是:如果你在状态 $P$ 下执行命令 $C$ 得到状态 $Q$,那么在 $P$ 旁边附加任何不相交的状态 $R$,执行 $C$ 仍然得到 $Q$ 旁边附加 $R$。

这为什么重要?因为它允许局部推理。你只需要关心程序访问的那部分内存,不需要考虑整个堆。

没有分离逻辑,证明一个操作链表的程序正确,你需要证明这个操作不会破坏系统中任何其他数据结构。有了分离逻辑,你只需要证明它正确地操作了它访问的那部分内存。

走出象牙塔:工业界的尝试

SPARK与Ada:航空航天的选择

SPARK是唯一真正进入工业应用的形式化验证语言。它被用于:

  • 空客飞机的安全关键软件
  • 巴黎地铁的信号系统
  • 军事装备的控制软件

为什么SPARK成功了?因为它做了务实的妥协:

  1. 语法是Ada的子集:不需要学习全新的语言
  2. 支持增量验证:可以从零证明开始,逐步增加
  3. 工具链成熟:AdaCore提供商业支持

智能合约:验证的新战场

区块链的智能合约提供了一个有趣的案例。合约一旦部署就无法修改,bug可能导致数百万美元损失。形式化验证在这里找到了完美的应用场景。

Certora等工具可以验证Solidity合约满足特定性质,比如"不会意外锁定资金"。

但智能合约验证也暴露了一个根本问题:验证成本与经济损失的权衡。只有当潜在损失足够大时,验证才是经济的选择。

未来在哪里?

形式化验证不会取代测试。但它会在特定领域变得越来越重要。

更好的工具

Rust生态正在发生变化。Prusti、Kani、Verus等工具正在让Rust程序验证变得可行。Rust的所有权系统天然适合分离逻辑的推理模式。

更好的抽象

现代验证语言(如Dafny、F*)正在降低入门门槛。你不需要是证明论专家,也能写出验证通过的代码。

更强的SMT求解器

Z3仍在进化。每一次性能提升,都扩展了可验证程序的边界。

timeline
    title 形式化验证发展历程
    section 1960s 
        1967 : Floyd提出程序验证思想
        1969 : Hoare发表Hoare逻辑
    section 1970s
        1972 : VDM规范语言诞生
        1977 : 抽象解释理论提出
    section 1980s
        1981 : 模型检测概念诞生
        1988 : BDD符号模型检测
    section 1990s
        1996 : SPARK语言成熟
        1999 : 有界模型检测
    section 2000s
        2002 : 分离逻辑提出
        2006 : Z3 SMT求解器发布
    section 2010s
        2010 : seL4验证完成
        2015 : IronFleet项目
quadrantChart
    title 验证方法选择矩阵
    x-axis 低成本 --> 高成本
    y-axis 低保证 --> 高保证
    quadrant-1 高保证高成本
    quadrant-2 高保证低成本
    quadrant-3 低保证低成本
    quadrant-4 低保证高成本
    单元测试: [0.2, 0.3]
    模糊测试: [0.3, 0.4]
    类型系统: [0.25, 0.45]
    静态分析: [0.35, 0.5]
    模型检测TLA+: [0.6, 0.75]
    代码评审: [0.3, 0.35]
    定理证明Coq: [0.9, 0.95]
    seL4验证: [0.95, 0.98]

结语:四行代码的意义

让我们回到那个数字:每天四行经过验证的代码。

这个数字令人沮丧,但也蕴含着深意。它说明,我们正在用最严格的标准要求软件——数学证明级别的严格。

这不是效率低下,这是质量维度的根本不同。当我们说"这个程序被形式化验证过",我们说的是一件有意义的事:它不会因为意外而崩溃,不会因为边界情况而失败,不会因为未预见的输入而行为异常。

这值得吗?对于运行核反应堆的软件,值得。对于控制心脏起搏器的软件,值得。对于存储数十亿人数据的云服务,可能也值得。

但对于你下周要交付的Web应用?可能不值得。

形式化验证的困境不是技术失败,而是成本的真相。我们终于有了理论上可以证明程序正确的方法,但这个成本如此之高,以至于只能用于最关键的系统。

也许未来的某一天,工具会进化到让验证成本接近编码成本。但在那之前,我们需要诚实面对这个问题:

证明程序正确,比写出程序正确,难了太多太多。

这不是悲观。这是对现实的认识。只有认识到困难,我们才能在测试、类型系统、代码评审和形式化验证之间,做出明智的选择。


参考来源

  1. Klein, G., et al. “seL4: Formal Verification of an Operating-System Kernel.” Communications of the ACM, 2010.
  2. Leroy, X. “Formal Verification of a Realistic Compiler.” Communications of the ACM, 2009.
  3. Hawblitzel, C., et al. “IronFleet: Proving Practical Distributed Systems Correct.” SOSP, 2015.
  4. Lamport, L. “Specifying Systems: The TLA+ Language and Tools for Hardware and Software Engineers.”
  5. Newcombe, C., et al. “Use of Formal Methods at Amazon Web Services.” 2014.
  6. Wayne, H. “Why Don’t People Use Formal Methods?” 2019.
  7. Reynolds, J. “Separation Logic: A Logic for Shared Mutable Data Structures.” LICS, 2002.
  8. Hoare, C.A.R. “An Axiomatic Basis for Computer Programming.” Communications of the ACM, 1969.
  9. Cousot, P. & Cousot, R. “Abstract Interpretation: A Unified Lattice Model for Static Analysis.” POPL, 1977.
  10. Leino, K.R.M. “Dafny: An Automatic Program Verifier for Functional Correctness.” LPAR, 2010.
  11. Beringer, L. et al. “Verified Compilation for Shared-Memory C.” ESOP, 2015.
  12. Zave, P. “Using Lightweight Modeling to Understand Chord.” ACM SIGCOMM CCR, 2012.
  13. Fox, A. & Myreen, M. “A Verified Compiler for CakeML.” JFP, 2019.
  14. Swamy, N. et al. “Dependent Types and Multi-Monadic Effects in F*.” POPL, 2016.
  15. Amme, W. et al. “Symbolic Model Checking: 10^20 States and Beyond.” LICS, 1990.