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、KeY | Infer、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 前端,看看如何用声明式的语法描述自动生成可用的分析器。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。