1. 从“跑一遍看看”说起:具体执行为什么值得单独拎出来讲
刚接触形式化方法那会儿,我对“具体执行”这个词是有点不屑的。程序跑一遍看看结果,这不是最基础的操作吗?写个测试用例,输入几个值,看输出对不对,这有什么好讲的?后来踩的坑多了才明白,具体执行(Concrete Execution)在形式化方法体系里,远不止“跑一遍”这么简单。它是整个形式化验证链条的起点和锚点,是抽象解释、符号执行、模型检测这些高级技术的参照系。你后面做的所有抽象、推理、证明,最终都要回到具体执行上来对账——如果具体执行这一层就搞错了,上面盖的楼全是空中楼阁。
这篇文章想聊的,就是形式化方法里 Part A 这个位置上的具体执行。它是什么、为什么它被放在第一部分、它和后面那些听起来更高级的技术是什么关系、实际做项目时怎么把它用扎实。适合两类人看:一类是刚开始学形式化方法、被各种抽象概念绕晕的朋友;另一类是在工程里做程序分析、测试、验证,想搞清楚“形式化到底怎么落地”的从业者。我会尽量少堆术语,多用实际写代码、跑程序时会遇到的场景来解释。
先给一个最直白的定义:具体执行就是给定一个程序和一个具体的输入(一组确定的初始状态),按照程序的语义一步一步走,得到确定的执行轨迹和最终结果。这里的“具体”是相对于“符号”和“抽象”而言的——输入是具体的值,不是符号表达式;状态是具体的,不是抽象域里的元素。听起来像废话,但正是这种“确定性”和“可复现性”,让它成为整个形式化方法大厦的地基。
为什么 Part A 要讲这个?因为形式化方法的核心目标是“证明程序在所有可能输入下都满足某个性质”。但“所有可能输入”是个无穷集合,你不可能真的跑完。所以你需要一套办法,从有限的具体执行出发,去推断无穷的行为。具体执行就是那个“有限”的起点。你得先能精确地描述一次执行发生了什么,才能去谈“所有执行”会怎样。这就像学数学先学算术再学代数,跳过算术直接搞代数,符号你都认识,但意义你把握不住。
2. 具体执行到底“具体”在哪:拆开来看它的四个要素
2.1 程序状态:从变量到内存的完整快照
具体执行的第一步,是搞清楚“状态”是什么。一个程序在任意时刻的状态,可以理解成一张完整的快照:所有变量的当前值、程序计数器(下一条要执行的指令位置)、调用栈的内容、堆内存里对象的布局、甚至文件描述符和网络连接的状态。在形式化方法里,我们通常会把状态建模成一个从变量名到值的映射,记作 σ。比如 σ = {x ↦ 3, y ↦ 5, pc ↦ 7},表示变量 x 是 3,y 是 5,下一条执行第 7 行。
这里有个容易忽略的点:状态的粒度取决于你的分析目标。如果你只关心数值计算,那把整数变量建模好就够了;如果你要分析并发程序,那线程调度状态、锁的持有情况都必须进状态;如果你做内存安全验证,那堆上的对象图和指针关系就是状态的核心。我见过不少初学者,一上来就想建一个“万能状态模型”,结果复杂度爆炸,什么都分析不了。正确的做法是:先明确你要验证什么性质,再决定状态里放什么。具体执行阶段的状态建模,宁可窄一点、精确一点,也不要宽而模糊。
2.2 执行步:一条指令如何改变状态
有了状态,下一步就是定义“执行步”——也就是一条指令如何把当前状态变成下一个状态。在形式化方法里,这通常用一个转移关系来描述,记作 σ → σ',读作“从状态 σ 执行一步到达状态 σ'”。对于一条赋值语句x = x + 1,转移关系就是:如果当前 σ 里 x 的值是 v,那么 σ' 就是把 x 映射到 v+1、其他变量保持不变的新状态。
这个转移关系的定义必须精确到不留歧义。举个例子,x = x + 1在 C 语言里和在某些函数式语言里语义就不一样:C 里是原地修改,函数式语言里可能是绑定新值。再比如整数溢出,C 的有符号整数溢出是未定义行为,那你的转移关系在溢出时应该怎么定义?是报错、是回绕、还是进入一个特殊状态?这些细节在具体执行阶段就必须定死,因为后面所有推理都依赖它。我个人的经验是:转移关系的定义要跟着语言的官方语义走,不要自己发明。如果语言标准说溢出是未定义行为,那你的形式化模型里就应该有一个明确的“未定义”状态,而不是偷偷让它回绕。
2.3 执行轨迹:一串状态连起来的故事
单个执行步是原子操作,把很多步连起来,就得到一条执行轨迹(Execution Trace):σ₀ → σ₁ → σ₂ → ... → σₙ。这条轨迹就是“具体执行”的完整记录。σ₀ 是初始状态(由具体输入决定),σₙ 是终止状态(如果程序能终止的话)。
轨迹这个概念在调试和验证里都极其有用。调试时你看的就是轨迹:哪一步之后变量变成了意外的值,哪一步走进了不该走的分支。验证时,轨迹是你要推理的对象:你想证明“对所有可能的初始状态,所有轨迹的终止状态都满足性质 P”。注意这里的量词结构——“对所有初始状态”和“所有轨迹”,这就是为什么具体执行只是起点:它只给你一条轨迹,而验证要管所有轨迹。
2.4 终止与发散:不是所有执行都会停下来
具体执行还有一个绕不开的问题:程序可能不终止。一个while(true){}的具体执行就是无限长的轨迹,永远不会到达终止状态。在形式化方法里,这被称为发散(Divergence)。处理发散是具体执行语义定义里的一个关键决策:你是把发散建模成一个特殊的“底”状态(记作 ⊥),还是允许轨迹是无穷的?
这两种做法各有道理。把发散建模成 ⊥ 的好处是状态空间是完整的,每个执行要么到终止状态,要么到 ⊥,便于做归纳推理。允许无穷轨迹的好处是更贴近实际语义,尤其是做活性(Liveness)性质验证时,无穷轨迹是必须的。我在实际项目里的选择是:如果只关心安全性(Safety)性质,用 ⊥ 建模发散更省事;如果要验证“程序最终会做某事”这类活性性质,就必须保留无穷轨迹。这个选择会影响你后面所有工具和证明策略的选型,所以在 Part A 阶段就要想清楚。
3. 为什么具体执行是形式化方法的第一块基石
3.1 它是抽象解释和符号执行的“地面真值”
抽象解释(Abstract Interpretation)和符号执行(Symbolic Execution)是形式化方法里两个重量级技术。抽象解释把具体状态抽象成抽象域里的元素,比如用区间 [1, 10] 来概括所有可能的值;符号执行把具体输入换成符号变量,用路径约束来描述执行。这两个技术听起来比具体执行高级得多,但它们正确性的最终判据,都是具体执行。
抽象解释的可靠性要求是:抽象域里的每个元素,都必须“覆盖”它对应的所有具体状态。这个“覆盖”关系怎么定义?就是靠具体执行的语义。符号执行的路径约束求解出来的解,必须对应一条真实的具体执行轨迹,否则就是假阳性。所以你看,具体执行是整个技术栈的语义基准。基准错了,上面全错。这也是为什么形式化方法的教材和课程,几乎无一例外把具体执行放在最前面讲——它不是最简单的部分,而是最根本的部分。
3.2 它让“程序行为”这个模糊概念变得可操作
“程序行为”这个词,平时说起来很顺口,但真要严格定义,很多人会卡壳。具体执行给了我们一个可操作的定义:程序的行为就是它所有可能的具体执行轨迹的集合。这个定义的好处是,它把“行为”这个抽象概念,化归成了“轨迹集合”这个数学对象。一旦变成集合,你就可以做集合运算:求交、求并、包含判断。验证“程序满足性质 P”,就变成了“所有轨迹的终止状态都在 P 定义的集合里”。
这个化归是形式化方法能成为“方法”的关键一步。没有它,验证就只是“我觉得没问题”;有了它,验证就是“我证明了这个集合包含关系成立”。而具体执行,就是构造这个集合的原材料。
3.3 它暴露了“未定义行为”和“环境交互”这些硬骨头
写代码的人都知道,程序的行为不只取决于代码本身,还取决于运行环境:输入数据、内存布局、调度顺序、外部设备的响应。具体执行把这些因素都摆到台面上,逼你在建模时做决定。比如,一个读取未初始化变量的程序,它的具体执行是什么?是读到某个任意值,还是直接崩溃?C 标准说这是未定义行为,那你的形式化模型就必须显式地处理这个“未定义”。
我见过很多项目,在具体执行阶段偷懒,把未定义行为当成“不会发生”或者“随便给个值”,结果后面做验证时,证明出来的结论在实际运行中根本不成立。未定义行为不是边缘情况,它是形式化验证必须正面处理的中心问题。Part A 讲具体执行,很大程度上就是让你在最早的时候,把这些硬骨头啃下来,而不是留到后面变成定时炸弹。
4. 动手实现一个最小具体执行器:从零到能跑
4.1 语言选型和整体架构
要真正理解具体执行,最好的办法是自己写一个最小实现。我选一个极简的命令式语言,只有赋值、条件、循环、顺序执行这几种结构,变量都是整数。用 Python 来写解释器,因为 Python 的字典天然适合表示状态映射,代码量能控制在两百行以内,逻辑清晰。
整体架构分三块:语法树定义、状态表示、执行引擎。语法树用类来表示,每个节点有一个eval方法,接收当前状态,返回新状态。状态就是一个 Python 字典,键是变量名,值是整数。执行引擎负责从初始状态出发,反复调用根节点的eval,直到状态不再变化(终止)或者检测到发散。
这个架构的好处是语义和实现一一对应:语法树的每个节点对应一条语言的语法结构,eval方法对应转移关系,执行引擎对应轨迹生成。你写完之后,对“具体执行”的每个概念都会有肌肉记忆。
4.2 状态和转移关系的代码落地
先定义状态。我用一个简单的类包一下字典,方便加一些辅助方法:
class State: def __init__(self, mapping=None): self.vars = dict(mapping) if mapping else {} self.pc = 0 # 程序计数器,这里简化处理 def get(self, name): return self.vars.get(name, 0) # 未初始化默认为 0,实际项目要处理未定义 def set(self, name, value): new_state = State(self.vars) new_state.vars[name] = value new_state.pc = self.pc + 1 return new_state def __repr__(self): return f"State({self.vars}, pc={self.pc})"注意set方法返回的是新状态,不是原地修改。这是有意为之:具体执行的轨迹是一串不可变的状态,每一步都产生新状态,旧状态保留。这样你才能完整回放轨迹,做调试和验证。如果原地修改,轨迹就丢了。
然后是语法树节点。赋值节点:
class Assign: def __init__(self, name, expr): self.name = name self.expr = expr def eval(self, state): value = self.expr.eval(state) return state.set(self.name, value)表达式节点(这里只展示加法和变量引用):
class Var: def __init__(self, name): self.name = name def eval(self, state): return state.get(self.name) class Add: def __init__(self, left, right): self.left = left self.right = right def eval(self, state): return self.left.eval(state) + self.right.eval(state)条件节点和顺序节点:
class If: def __init__(self, cond, then_branch, else_branch): self.cond = cond self.then_branch = then_branch self.else_branch = else_branch def eval(self, state): if self.cond.eval(state) != 0: return self.then_branch.eval(state) else: return self.else_branch.eval(state) class Seq: def __init__(self, first, second): self.first = first self.second = second def eval(self, state): intermediate = self.first.eval(state) return self.second.eval(intermediate)循环节点是最需要小心的。我用一个While节点,它的eval会反复执行循环体,直到条件为假。为了防止无限循环,我加一个步数上限:
class While: def __init__(self, cond, body, max_steps=10000): self.cond = cond self.body = body self.max_steps = max_steps def eval(self, state): steps = 0 current = state while self.cond.eval(current) != 0: current = self.body.eval(current) steps += 1 if steps > self.max_steps: raise RuntimeError("Possible divergence: step limit exceeded") return current这个max_steps就是处理发散的一种实用手段:用步数上限来近似“发散”。超过上限就报错,提示可能不终止。实际的形式化工具里,这个上限要么是用户指定的,要么是通过更精细的终止性分析来判定的。
4.3 跑一条完整轨迹并打印出来
有了这些节点,就可以构造一个程序并执行了。比如计算x = 1; y = 2; while (x < 5) { y = y * x; x = x + 1; }:
program = Seq( Assign('x', Const(1)), Seq( Assign('y', Const(2)), While( LessThan(Var('x'), Const(5)), Seq( Assign('y', Mul(Var('y'), Var('x'))), Assign('x', Add(Var('x'), Const(1))) ) ) ) ) initial = State() final = program.eval(initial) print(final)跑出来最终状态是{x: 5, y: 48}。你可以手动验算一下:x 从 1 到 4,y 依次乘 1、2、3、4,初始 y=2,所以 2×1×2×3×4 = 48。对上了。
但光看最终状态不够,我想看轨迹。改一下执行引擎,让它记录每一步的状态:
def execute_with_trace(program, initial): trace = [initial] current = initial # 这里需要把 eval 改成逐步执行,简化起见用递归展开 # 实际实现里可以用生成器或者显式栈 ...完整实现会稍长,但思路是:把eval从“直接返回最终状态”改成“每执行一步就 yield 一个状态”,然后用一个列表收集。这样你就能打印出完整的执行轨迹,看到 x 和 y 每一步怎么变。这个轨迹就是具体执行的产物,也是后面做符号执行时路径约束的来源。
5. 具体执行和符号执行、抽象解释的边界在哪
5.1 一张表看清三者的分工
| 维度 | 具体执行 | 符号执行 | 抽象解释 |
|---|---|---|---|
| 输入 | 具体值 | 符号变量 | 具体状态集合 |
| 状态 | 具体状态 | 符号状态 + 路径约束 | 抽象域元素 |
| 输出 | 一条轨迹 | 路径条件集合 | 不变式 |
| 覆盖 | 单条路径 | 多条路径(受约束求解限制) | 所有路径(受抽象域限制) |
| 精度 | 精确 | 路径内精确 | 保守近似 |
| 代价 | 低 | 中到高 | 中 |
| 典型用途 | 测试、调试、语义基准 | 漏洞挖掘、测试生成 | 编译优化、静态验证 |
这张表的核心信息是:具体执行是精度最高、代价最低、但覆盖最窄的技术。它精确到每一步,但只能覆盖一条路径。符号执行和抽象解释都是在“覆盖”和“精度”之间做权衡。理解这个权衡,你就理解了形式化方法的技术版图。
5.2 什么时候该用具体执行,什么时候不该
具体执行最适合的场景是:你需要一个确定的、可复现的参照。比如你写了一个抽象解释器,想验证它是否可靠,那就拿具体执行的结果来对账:抽象域里的结果必须包含具体执行的结果。再比如你做符号执行,求解出来的测试输入,必须能真的跑出一条具体轨迹来,否则就是假阳性。
不该用具体执行的时候也很明确:当你需要覆盖大量输入空间时。具体执行一次只走一条路,输入空间大了根本跑不过来。这时候就得上符号执行或者抽象解释。但注意,这不是说具体执行就没用了——即使在上层技术里,具体执行仍然作为“具体化”步骤存在:符号执行求解出具体输入后,还是要跑一遍具体执行来确认。
5.3 一个常见的误解:具体执行等于测试
很多人把具体执行和测试划等号,这是个误解。测试是“选一组输入,跑程序,看结果”,具体执行是“给定输入,按语义走一遍,得到轨迹”。测试是具体执行的一种应用,但具体执行本身是语义层面的概念,不依赖于“选输入”这个动作。在形式化方法里,具体执行更多是作为语义定义的一部分,而不是测试活动的一部分。你可以不跑任何测试,但仍然在形式化模型里定义具体执行语义。这个区分很重要,因为它决定了你把具体执行放在“验证方法”里还是“语义基础”里。Part A 的定位是后者。
6. 实操中容易踩的坑和排查清单
6.1 状态建模的粒度陷阱
最常见的坑是状态建模太粗或太细。太粗,比如只建模几个关键变量,其他变量忽略,那你的具体执行轨迹可能和真实程序对不上,后面所有推理都失去意义。太细,比如把每个临时变量、每个寄存器都建模,状态空间爆炸,执行一步要处理几百个字段,效率极低。
我的经验法则是:状态里只放“影响你要验证的性质”的变量,以及“影响控制流”的变量。其他变量如果只是中间计算,不影响最终性质,可以合并或者抽象掉。但合并的时候要小心,确保合并后的状态仍然能精确地驱动控制流。这个平衡点需要根据具体项目调,没有万能公式。
6.2 循环和递归的终止性处理
循环和递归是具体执行里最容易出问题的地方。一个没写好的循环,可能让你的执行器卡死。除了前面说的步数上限,还有几种处理方式:一是做简单的终止性分析,比如循环变量单调递增且有上界,就认为会终止;二是用“燃料(Fuel)”机制,给每个循环分配固定的执行预算,用完就停;三是把循环展开固定次数,超过就当作发散。
我实际用下来,步数上限加清晰的报错信息是最实用的。报错信息里要包含当前状态和已经执行的步数,方便你判断是真发散还是上限设小了。千万别静默地返回一个错误结果,那比崩溃还糟糕。
6.3 未定义行为的显式建模
前面提过未定义行为,这里展开说。以整数除零为例,很多语言里x / 0是未定义行为。在你的具体执行器里,如果直接让 Python 抛ZeroDivisionError,那你的形式化模型就和语言标准不一致了。正确的做法是:在转移关系里显式定义一个“未定义”状态,当执行到除零时,转移到这个状态,并记录原因。
class Div: def eval(self, state): left = self.left.eval(state) right = self.right.eval(state) if right == 0: return UndefinedState(reason="division by zero", previous=state) return left // right这样,你的轨迹里就会出现一个明确的“未定义”节点,后面做验证时,你可以选择把包含未定义状态的轨迹排除掉,或者单独分析。显式建模未定义行为,是形式化方法区别于“随便跑跑”的关键标志。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 执行结果和预期不符 | 状态建模遗漏变量 | 对比真实程序的所有变量 | 补全影响控制流和性质的变量 |
| 执行器卡死 | 循环无终止或上限过大 | 打印每步状态,看是否在循环 | 加步数上限,检查循环条件 |
| 轨迹中间状态异常 | 转移关系定义有歧义 | 对照语言标准逐条检查 | 显式定义边界情况(溢出、除零) |
| 发散误报 | 上限设太小 | 看报错时的步数和状态 | 调大上限或做终止性分析 |
| 未定义行为被忽略 | 转移关系没处理边界 | 检查所有可能出错的运算 | 加 UndefinedState 分支 |
这张表是我自己踩坑踩出来的,基本上覆盖了具体执行器开发中 80% 的问题。每次遇到新问题,先往这张表里对,对不上再深入分析。
7. 把具体执行用扎实:几个工程上的实用建议
7.1 轨迹要可回放、可序列化
具体执行产生的轨迹,不要只存在内存里。把它序列化成 JSON 或者类似的格式,存到文件里。好处有三个:一是调试时可以反复回放,不用重新跑;二是可以和别人共享,别人能精确复现你的执行;三是可以作为回归测试的基准,每次改完执行器,跑一遍旧轨迹,看结果是否一致。
序列化的格式我一般用:{"steps": [{"pc": 0, "vars": {...}}, ...], "result": "terminated" | "diverged" | "undefined"}。简单直接,人也能读。
7.2 给执行器加断言和不变式检查
在具体执行的过程中,可以插入断言检查。比如你预期某个变量在执行过程中永远不为负,那就在每次状态更新后检查一下,如果为负就报错并打印轨迹。这个机制在调试复杂程序时特别有用,能帮你快速定位是哪一步开始偏离预期的。
断言检查的代价是执行变慢,所以生产环境可以关掉,但开发和验证阶段一定要开。具体执行的价值很大程度上在于它能精确地告诉你“哪一步错了”,断言就是把这个价值放大。
7.3 和测试框架结合,但别混为一谈
具体执行器可以很方便地和测试框架结合:把测试用例的输入喂给执行器,把执行结果和预期对比。但记住,这只是具体执行的一个应用。在形式化方法的语境里,具体执行更重要的是作为语义基准,而不是测试工具。我见过一些团队,把具体执行器当成测试工具用,结果忽略了它在语义定义上的作用,后面做符号执行时发现语义对不上,返工成本很高。
建议的做法是:把具体执行器分成两层,底层是纯语义的执行引擎,上层是测试适配层。底层不依赖任何测试框架,只负责按语义走轨迹;上层负责读测试用例、对比结果、生成报告。这样底层可以复用到符号执行、抽象解释的验证里,不会被测试框架绑死。
7.4 文档化你的语义决策
具体执行阶段做的每一个语义决策——状态里放什么、转移关系怎么定义、发散怎么处理、未定义行为怎么建模——都要写下来。不是写在代码注释里,而是单独写一份语义说明文档。这份文档是后面所有工作的合同:符号执行、抽象解释、模型检测,都要以这份文档为准。如果后面发现语义定义有问题,改文档,然后同步改所有实现。
我自己的习惯是,语义文档用表格加伪代码的形式写,每个语言结构一行,左边是语法,右边是转移规则。这样一目了然,也方便和语言标准对照。
8. 从具体执行出发,下一步往哪走
具体执行跑通了,轨迹能打印了,语义决策也文档化了,接下来自然要问:然后呢?答案取决于你的目标。如果你想验证安全性性质,下一步是抽象解释,把具体状态抽象成抽象域元素,然后计算不变式。如果你想找漏洞,下一步是符号执行,把具体输入换成符号变量,收集路径约束,然后求解。如果你想做模型检测,下一步是把具体执行语义编码成状态转移系统,然后用模型检测算法搜索。
但不管往哪走,具体执行都是你的参照系。抽象解释的结果要包含具体执行的结果,符号执行的解要能跑出具体执行的轨迹,模型检测找到的反例要能对应一条具体执行。具体执行是形式化方法里的“米原器”,所有其他技术都要和它对齐。
我个人在实际项目里的体会是:具体执行这一层花的时间,永远不亏。你在这里多花一天把语义抠清楚,后面就少花一周去修因为语义不一致导致的 bug。形式化方法听起来高深,但它的力量恰恰来自这种对基础的较真。Part A 讲具体执行,讲的就是这种较真。
最后分享一个小技巧:如果你觉得自己的具体执行器行为诡异,但又说不清哪里不对,就找一个最简单的程序——比如只有一条赋值语句——跑一遍,把轨迹打印出来,和手工计算的结果逐字段对比。如果最简单的都对不上,那问题一定在状态建模或转移关系上,不在复杂的控制流上。这个“最小可复现”的排查思路,我在形式化方法里用了无数次,几乎每次都能快速定位问题。