「抽象解释与形式化验证」

测试只能证明程序在有限输入上正确,抽象解释用格上的不动点计算覆盖所有可能输入。本文讲解抽象域与伽罗瓦连接、区间域与多面体域、加宽与收窄、SMT 求解与符号执行,以及静态分析器如何给出可证明的结论。

1. 从测试到证明

一句话总结: 测试只能在有限个输入上验证程序,抽象解释的目标是给出一份覆盖全部输入的证明,代价是结论必须建立在抽象域的近似之上。

写测试时我们做的是一件事:挑一些输入,跑一遍,看输出对不对。这个方法极其有效,但它有一个无法回避的边界——它证明的是「程序在这几个输入上正确」,而不是「程序在所有输入上正确」。一个数组越界 bug 可能只在第 10000 个元素、内存恰好布局成某个样子时才触发;一个整数溢出可能只在输入恰好等于 INT_MAX 时出现。随机测试能提高发现概率,但无法给出保证。

// 一个随机测试很难发现的越界
int lookup(const int *table, int n, int idx) {
    if (idx < 0 || idx >= n) return -1;
    return table[idx];        // 若 n 为负数,两个条件同时为假
}

这段代码看起来没问题。但如果 n 是负数呢?idx < 0 || idx >= n 在 n = -1、idx = 0 时两边都为假,于是走进 table[0],而调用者可能传的是空指针。随机测试生成 n = -1 的概率极低,但静态分析器只要追踪 n 的取值集合,立刻就能发现问题。

抽象解释(abstract interpretation)要回答的就是这类问题:给定程序与一份「抽象」,能不能机械地算出每个程序点上成立的性质,并且保证不漏报?

1.1 健全性与完备性的取舍

一句话总结: 静态分析必须在健全(不漏报真错误)与完备(不报假错误)之间取舍,抽象解释选择了健全,代价是必然存在误报。

目标含义代价
健全(sound)所有真错误都被报出会有误报
完备(complete)所有报出的都是真错误会漏报

抽象解释选择健全:它的结论是「程序在该点上一定满足某性质」或「无法确定」。前者是保证,后者是误报的来源。这个取舍带来一个有趣的性质:抽象解释的结论是单向的。它说「安全」就一定安全;它说「可能不安全」则可能是安全的。因此静态分析器最适合的用法是当验证器(证明无错),而不是当 bug 查找器(证明有错)。

2. 抽象解释基础

一句话总结: 抽象解释把程序的具体语义提升到一个抽象域上,用单调函数与不动点迭代求出每个程序点的抽象状态。

抽象解释的数学框架由 Cousot 与 Cousot 在 1977 年建立。它的核心构造有三件东西:一个抽象域(abstract domain)、一对抽象与具体之间的映射(伽罗瓦连接)、以及一个在抽象域上单调递增的语义函数。

2.1 格与不动点

一句话总结: 抽象域是一个格,格的偏序表示精度,最小上界对应分支汇合,抽象语义函数单调保证不动点迭代必然收敛到最小不动点。

格(lattice)是抽象解释的骨架。一个格由集合与偏序关系组成,偏序 a ⊑ b 表示「a 比 b 更精确」。格上要求任意两个元素都有最小上界(join,记 ⊔)与最大下界(meet,记 ⊓)。

在程序分析里,这两个操作有非常直观的含义:join 用于控制流汇合时合并两条路径的状态,取更保守的那个;meet 用于约束叠加,取更精确的描述。

# 一个具体的小格:三值抽象 {负, 零, 正, 未知}
# 偏序:负 ⊑ 未知,零 ⊑ 未知,正 ⊑ 未知
JOIN = {
    ("neg", "neg"): "neg",  ("neg", "zero"): "top",
    ("neg", "pos"): "top",  ("zero", "zero"): "zero",
    ("zero", "pos"): "top", ("pos", "pos"): "pos",
}

def join(a, b):
    """控制流汇合:任一为 top 则结果 top"""
    if a == "top" or b == "top":
        return "top"
    return JOIN[(a, b)]

def transfer_sign(op, a, b):
    """抽象语义:在符号域上计算 x op y 的结果符号"""
    if a == "top" or b == "top":
        return "top"
    if op == "add":
        if a == "pos" and b == "pos": return "pos"
        if a == "neg" and b == "neg": return "neg"
        if a == "zero": return b
        return "top"
    if op == "mul":
        if a == "zero" or b == "zero": return "zero"
        return "pos" if a == b else "neg"
    return "top"

不动点迭代是求解的方法。程序可以看成一个方程组:每个程序点的抽象状态等于它的所有前驱经过转移函数后的 join。这个方程组一般没有解析解,于是用迭代逼近:

def solve_fixpoint(nodes, entry, succs, transfer, join, init_bottom):
    """Kildall 风格的工作表算法:迭代到所有节点状态稳定"""
    state = {n: init_bottom() for n in nodes}   # 初始全为 bottom
    state[entry] = transfer(entry, None)        # 入口状态
    worklist = list(nodes)
    while worklist:
        n = worklist.pop()
        in_state = init_bottom()
        for p in preds(n, succs):               # 汇合所有前驱
            in_state = join(in_state, state[p])
        out_state = transfer(n, in_state)
        if out_state != state[n]:               # 状态上升,需重新传播
            state[n] = out_state
            worklist.extend(succs[n])
    return state

算法终止的关键在于抽象域的高度有限:状态只会在格上单调上升,而格里只有有限个元素(或虽然无限但满足升链条件),所以最多上升有限次。如果格是无限的(比如区间域里上界可以是任意大的整数),就需要加宽(widening)来强制终止,这一点在第 4 节展开。

2.2 抽象域

一句话总结: 抽象域决定了分析能表达什么性质,域越强越精确但代价越高,工程上通常把多个域组合成乘积域。

抽象域表达的性质精度代价
常量域变量是否等于某个常量低极低
符号域变量的正负零低极低
区间域变量的上下界中低
同余域变量模某个数的余数中低
八边形域变量之间的差有界高中
多面体域变量之间的线性不等式很高很高
等价类域变量之间的相等关系中低
// 不同抽象域能推出什么
void f(int x, int y) {
    if (x >= 0 && x <= 10 && y >= 0 && y <= 10) {
        int d = x - y;         // 区间域:d 在 [-10,10],八边形域更紧
        if (x + y == 20) {
            // 多面体域能推出 x == 10 且 y == 10
            // 区间域只知道 x+y 在 [0,20],无法利用等式约束
        }
    }
}

工程上很少只用单个域。主流做法是乘积域(product domain):把几个域的元组放在一起,每个操作分别作用在各自的域上,join 也是逐分量做。例如把区间域与同余域组合,既能推出 x 在 [0, 100],又能推出 x 是 4 的倍数。

3. 经典数值域

一句话总结: 区间域用上下界描述变量,简单高效但丢失变量间相关性;多面体域用线性不等式组描述,精度高但运算代价随变量数急剧增长。

3.1 区间域

一句话总结: 区间域把每个变量抽象成 [lo, hi],所有运算都按区间算术进行,实现简单、代价线性,是最常用的数值域。

区间域(interval domain)把每个变量的取值抽象成一个闭区间 [lo, hi],[−∞, +∞] 表示完全未知。

class Interval:
    def __init__(self, lo, hi):
        assert lo <= hi
        self.lo, self.hi = lo, hi

    def __add__(self, o):
        return Interval(self.lo + o.lo, self.hi + o.hi)

    def __mul__(self, o):
        cands = [self.lo * o.lo, self.lo * o.hi,   # 乘法取四端点极值
                 self.hi * o.lo, self.hi * o.hi]
        return Interval(min(cands), max(cands))

    def join(self, o):
        return Interval(min(self.lo, o.lo), max(self.hi, o.hi))
# 区间算术的精度损失:x - x 不一定是 [0,0]
x = Interval(0, 10)
print(x + Interval(1, 2))    # [1, 12]
print(x - x)                 # [-10, 10]  <- 丢失了相关性,实际恒为 0

区间域的核心缺陷在最后一行:它无法表达变量之间的关系。x - x 在数学上恒为 0,区间域却给出 [-10, 10]。这个缺陷在数组边界检查里非常致命:

void h(int n, int m) {
    if (n >= 0 && m >= 0 && n + m <= 100) {
        int a[100];
        a[n] = 1;      // 需要 n <= 100,区间域给出 n 在 [0,100],勉强够用
        a[n + m] = 2;  // 需要 n+m <= 100,区间域给出 [0,200],无法证明
    }
}

为了缓解这个问题,工程实现里会加入符号化区间(symbolic interval):区间端点不只是常数,还可以是变量本身。这样 n + m <= 100 这种约束就能被保留下来。

3.2 多面体域

一句话总结: 多面体域用一组线性不等式描述变量的凸包,能精确捕捉变量间的线性关系,但每次 join 与投影都需要求解线性规划,代价高昂。

多面体域(polyhedra domain)用线性不等式组 A·x <= b 描述一个凸多面体,能表达任意线性关系。

# 用多面体域描述程序状态(概念示意,实际用 PPL 或 Apron 库)
# 状态: x >= 0, y >= 0, x + y <= 100
# 细化: if (x + y == 50) -> 加等式约束;if (x > y) -> 加 x - y >= 1
# join 两条路径: A(0<=x<=10, y==0) 与 B(0<=x<=10, y==x)
#   join 后是两者的凸包:0 <= x <= 10, 0 <= y <= x

多面体域的精度优势非常明显,但代价也明显:join 与投影都是线性规划问题,变量数增长时代价急剧上升。因此生产级分析器很少全程使用多面体域,而是按需提升:先用便宜的区间域分析,只对少量关键变量(比如数组下标)提升到多面体域。

# 用 Frama-C 的 Eva 插件做抽象解释分析
frama-c -eva -eva-precision 5 -eva-domains octagon,equality array_bounds.c
# 输出形如:
#   [eva] test.c:12: assertion 'i < 100' got status valid
#   [eva] test.c:15: accessing 'a[i]' with i in [0, 99]  <- 证明安全

4. 抽象解释的工程实现

一句话总结: 真实分析器要处理循环带来的不收敛问题,加宽强制终止、收窄恢复精度,同时控制分析规模与误报率。

4.1 加宽与收窄

一句话总结: 加宽在迭代上升过快时直接跳到上界保证终止,收窄在不动点之后再做一轮下降迭代恢复被加宽丢掉的精度。

循环是抽象解释的最大麻烦。考虑 i = 0; while (i < n) i++;——每轮迭代 i 的上界都变大一点,朴素迭代会得到 [0,0]、[0,1]、[0,2]……永远到不了不动点。加宽(widening)的作用就是在这里踩刹车:当发现上界在增长时,直接把它推到 +∞。

def widen(a, b):
    """加宽算子:b 是本轮迭代结果,a 是上一轮
    上界增长则跳到 +inf,下界减小则跳到 -inf"""
    lo = float("-inf") if b.lo < a.lo else a.lo
    hi = float("inf") if b.hi > a.hi else a.hi
    return Interval(lo, hi)

def narrow(a, b):
    """收窄算子:不动点之后用更精确的 b 收缩 a,仍保持健全"""
    lo = b.lo if b.lo > a.lo else a.lo
    hi = b.hi if b.hi < a.hi else a.hi
    return Interval(lo, hi)

# 迭代过程(n 已知为 [0, 100]):i = [0,0] -> [0,1]
#   上界增长 -> 加宽到 [0, +inf) -> 与 n 的上界取交 -> [0, 100],收敛
# 收窄:用循环体语义重新计算 -> [0, 99](因为 i < n 且 n <= 100)

加宽保证了终止(每轮至少有一个端点跳到无穷,而端点只有两个,所以最多两轮),但代价是精度损失。收窄用来挽回一部分。加宽与收窄的配合是抽象解释工程化的核心技巧:加宽负责快,收窄负责准。典型做法是加宽迭代到不动点,然后跑固定轮数(比如 2 到 3 轮)的收窄,不追求收窄也收敛。

4.2 误报治理

一句话总结: 抽象解释的误报来自抽象域的表达能力不足,工程上通过域提升、路径敏感分析、断言注入与用户提示来降低误报。

手段原理效果
域提升关键变量用更强的域显著,但代价高
路径敏感按条件分支分别分析有效,但状态爆炸
断言注入让用户标注前置条件极有效,但需人工
上下文敏感按调用点分别分析函数有效,但代价高
模型化外部函数为库函数写抽象语义必要,否则大量未知
// 断言注入:把分析器的猜测变成用户的承诺
void process(int *buf, int len) {
    __ESBMC_assume(len > 0 && len <= 4096);   // 告诉分析器
    __ESBMC_assert(buf != NULL, "buffer must be valid");
    for (int i = 0; i < len; i++) buf[i] = 0; // 有了假设,越界可证安全
}

5. SMT 求解与符号执行

一句话总结: SMT 求解器把程序路径编码成逻辑公式并判定可满足性,符号执行用它逐路径探索,是有界模型检验与约束求解的核心引擎。

抽象解释是在语义层做近似,SMT 求解则是在逻辑层做精确判定。两者互补:抽象解释覆盖全部输入但结论保守,SMT 精确但只能处理给定约束。SMT(Satisfiability Modulo Theories)求解器解决的问题是:给定一个一阶逻辑公式(含整数、位向量、数组、浮点等理论),判断是否存在满足它的赋值。Z3、CVC5、Yices 是主流实现。

5.1 符号执行与有界模型检验

一句话总结: 符号执行把输入符号化,沿路径累积约束,用 SMT 求解器求解路径条件,遇到不可满足就剪枝,遇到可满足就生成具体输入。

# 符号执行的简化骨架
from z3 import BitVec, Solver, sat, Not

def sym_exec(branches, target_line):
    """沿路径累积约束,求解出触发目标的具体输入"""
    x = BitVec("x", 32)
    pc, results = [], []                 # pc 为路径条件
    for stmt in branches:
        s = Solver(); s.add(pc); s.add(stmt.cond)
        if s.check() == sat:
            if stmt.line == target_line:
                results.append(s.model())    # 找到触发输入
            pc = pc + [stmt.cond]            # 真分支可行,继续
        else:
            s2 = Solver(); s2.add(pc); s2.add(Not(stmt.cond))
            if s2.check() != sat: break      # 两条分支都不可行
            pc = pc + [Not(stmt.cond)]       # 只能走假分支
    return results

符号执行有两大敌人:路径爆炸(分支数指数增长)与约束求解不可判定(非线性算术、指针别名、浮点)。工程上的应对包括:路径合并(多条路径的约束合并成一个公式一次求解)、状态合并(牺牲精度换规模)、具体化执行(concolic,混合具体执行与符号执行,用具体值简化约束)、有界模型检验(BMC,把程序展开固定轮数转成 SAT/SMT 公式一次求解)。

# CBMC:有界模型检验的典型工具
cbmc --unwind 10 --bounds-check --pointer-check target.c
# 输出形如:
#   [main.assertion.1] line 12 assertion i < 100: SUCCESS
#   [main.pointer_dereference.3] line 15: SUCCESS
#   VERIFICATION SUCCESSFUL
# 用 Z3 求解数组越界的反例
from z3 import Int, Solver, sat
idx, n = Int("idx"), Int("n")
s = Solver()
s.add(n > 0, n < 100, idx >= 0, idx < n)   # 循环不变式
s.add(idx >= 100)                          # 断言越界成立的条件
print(s.check())                           # unsat -> 越界不可能发生

6. 形式化验证与推断

一句话总结: 验证是给定规格检查程序是否满足,推断是从程序反推规格,两者在工具链里对应不同的工作流与成本。

维度验证推断
输入程序 + 规格只有程序
输出满足或不满足(含反例)推导出的性质
成本高(需写规格)低(自动)
强度强(可证明)弱(可证伪)
代表Frama-C、CBMC、KeYInfer、Astree 的部分模式
适用安全关键、算法核心大规模代码库扫描
// ACSL 规格(Frama-C 使用的契约语言)
/*@ requires n >= 0 && \valid(a + (0 .. n-1));
    ensures \result == \sum(a, n);
    assigns \nothing;
*/
int sum(const int *a, int n) {
    int s = 0;
    /*@ loop invariant 0 <= i <= n && s == \sum(a, i);
        loop variant n - i;
    */
    for (int i = 0; i < n; i++) s += a[i];
    return s;
}
# 用 Frama-C 的 WP 插件做演绎验证
frama-c -wp -wp-rte -wp-prover alt-ergo,z3 sum.c
# 输出:每个 goal 的证明状态(Valid / Unknown / Timeout)
infer -- clang -c target.c      # Infer 自动推断,无需写规格

演绎验证(deductive verification)是最强也最贵的一档:把程序与规格转成验证条件(VC),交给定理证明器或 SMT 求解器逐个证明。它的关键难点在于循环不变式——程序里所有循环都必须由人给出不变式,否则无法证明。这是验证成本的主要来源,也是自动推断工具试图自动化但始终只能做到近似的地方。

7. 工具链与陷阱

一句话总结: 抽象解释与验证工具在实践中受限于建模精度、求解器能力与规格成本,理解它们的边界比掌握工具用法更重要。

陷阱表现应对
外部函数未建模大量未知,分析退化写抽象语义或断言前置条件
循环不变式难写验证卡在循环上工具生成候选不变式再人工修正
求解器超时复杂约束无结论分解约束、简化表达式、换求解器
非线性算术求解器直接放弃线性化近似或人工引理
别名分析不足指针分析精度低用强别名分析或类型系统辅助
浮点语义位精确建模代价高用区间浮点抽象或限定范围
规格与实现漂移规格过期导致假证明把规格纳入 CI 回归
// 别名带来的精度崩塌
void copy(int *dst, const int *src, int n) {
    for (int i = 0; i < n; i++) dst[i] = src[i];
    // 若 dst 与 src 可能重叠,分析器必须考虑所有别名组合
}
void copy_fast(int *restrict dst, const int *restrict src, int n) {
    for (int i = 0; i < n; i++) dst[i] = src[i];
    // 加上 restrict 限定后,分析精度立刻提升
}

一个值得强调的实践观点:抽象解释与形式化验证不是「替代测试」,而是「补足测试」。测试擅长发现算法逻辑错误与集成问题,验证擅长证明边界与内存安全。真实的安全关键项目(航空、医疗、内核)用的是组合策略:抽象解释做全量扫描消除整类错误,BMC 做核心模块的有界验证,测试做端到端行为确认,三者覆盖各自擅长的区域。

8. 总结

环节要点
动机测试只能覆盖有限输入,抽象解释覆盖全部输入
健全性取舍选择健全,代价是误报,结论是单向的
格与不动点抽象域构成格,单调语义函数保证最小不动点存在
求解算法工作表迭代,状态在格上单调上升直到稳定
区间域上下界描述,简单高效但丢失变量相关性
多面体域线性不等式组,精度高但 join 与投影代价大
加宽与收窄加宽保终止,收窄恢复精度,两者配合使用
SMT 与符号执行路径条件编码为公式,求解器给出可满足性与反例
验证与推断验证需规格、结论强;推断自动、结论弱
工程边界外部函数建模、循环不变式、求解器能力是主要瓶颈

抽象解释与形式化验证代表了程序分析的理想主义一端:不满足于「跑几次都对」,而要求「对所有输入都对」。这条路线的代价是必须接受近似——抽象域永远无法精确表达所有程序状态,因此分析器永远会在某个地方保守。理解这一点,就能理解为什么一个健全的分析器仍然会报出大量误报,也能理解为什么工业界的做法总是多个域、多种工具、多层成本的组合。下一篇我们从语义的严谨转向语法的工程——解析器生成器与 DSL 前端,看看如何用声明式的语法描述自动生成可用的分析器。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类