从零编写现代编译器 21 - 差分、属性与模糊测试
手工测试用例覆盖的是写测试的人想得到的错误。想不到的错误藏在组合空间的角落里,等着在生产环境被触发。前面二十篇积累了解释器、编译器、优化 pass 和源码映射,手工正反例加起来几百条,仍然不能保证它们之间没有分歧。
本篇用随机生成的 Sprout 程序同时喂给解释器和编译后的本机二进制,比较两边的输出。任何不一致都意味着 bug。找到之后自动缩减失败样本,留下最小复现,纳入回归语料。
差分测试:两条路径,一个判定
差分测试的核心逻辑只有一句话:同一个程序经过两条独立的执行路径,结果必须一致。
1 | |
比较的不是错误消息的字面文本。解释器报 division by zero at line 5,编译后的运行时报 runtime error: div_zero,这两条消息不同但错误类别相同,算通过。比较三个维度:退出值(整数)、标准输出(逐字节)、错误类别(成功、运行时错误的子类、类型错误)。错误消息的措辞差异不计入失败。
参考解释器和编译器共享词法、语法与类型检查。差分测试检验的主要是降低、优化与后端引入的错误。共享前端自身的 bug——比如类型检查漏放了某种非法程序——不会被差分测试抓到,仍然需要手工定义的正反例。
随机生成合法程序
对着 rand() 拼字符串只会产出大量语法错误。有效的做法是按照 Sprout 文法从根节点递归生成,每一步从当前非终结符的产生式中随机选择一个展开。
生成器需要两个约束参数:深度上限控制嵌套层数,节点上限控制程序总规模。到达上限时,只允许选择终止产生式(字面量、变量引用),防止无限递归。
1 | |
类型正确性在生成时保证,而不是生成后过滤。每一步维护当前上下文中可用的变量及其类型,只生成类型匹配的子表达式。生成 if 条件时选 bool 表达式,生成算术运算时选 i64 操作数。这样大部分生成结果都能通过类型检查,命中率远高于盲目拼接后再丢弃不合法的程序。
生成带控制流的完整函数比只生成表达式更复杂:需要管理变量声明、可变绑定、while 循环的条件与循环体、return 的位置。初始阶段先只生成表达式和单函数程序,验证基础设施工作后再逐步扩展生成器。
执行限制
随机程序可能包含死循环或极深递归。不设限制的话,测试进程会挂起或耗尽栈空间。每次执行都要施加两道防线:
- 时间限制:1 秒超时。解释器和本机程序各自独立计时。超时视为该程序不可判定,跳过,不计入失败。
- 递归深度限制:解释器内维护调用深度计数器,超过阈值(如 256)时产生运行时错误。编译后的程序依赖操作系统的栈大小限制;为保持一致,运行时库也插入深度检查。
两条路径对同一程序都超时,不算分歧。一条超时一条正常完成,也跳过——超时行为不在语义规范内。只有两条路径都在限制内正常完成且结果不同,才算真正的差分失败。
属性测试
差分测试比较的是两条路径之间的一致性。属性测试检查的是单条路径自身应满足的不变量:
- 优化不改变语义:对同一程序,O0 编译结果与 O2 编译结果的输出必须相同。
- 类型检查拒绝非法程序:故意生成类型不匹配的程序(把
bool塞进i64参数位置),类型检查器必须报错。 - 解析往返:解析源码得到 AST,再把 AST 打印回源码,再解析一次,两棵 AST 结构等价。
- SSA 验证器通过:任何经过优化 pass 的 SSA 都必须通过第 12 篇的 verifier——定义支配使用、phi 来边正确、类型一致。
属性测试与差分测试互补。差分测试发现"两边不一致",属性测试发现"单边违反不变量"。两者共享同一套随机生成器。
失败样本缩减
随机生成的失败程序往往有几十行,包含大量与 bug 无关的代码。直接把它丢进 issue tracker 没人愿意看。缩减的目标是找到仍然触发同一失败的最小程序。
缩减策略按粒度从粗到细:
- 删除整个函数(非
main)。 - 删除
main中的单条语句。 - 把子表达式替换为同类型的字面量。
- 把变量引用替换为常量。
每一步之后重新运行差分比较。如果失败仍然复现,保留这次简化;否则回退,尝试下一个候选。循环直到没有任何单步简化能进一步缩小程序。
最终得到的最小程序通常只有三到五行,bug 的根因一目了然。这个缩减后的程序就是回归测试用例。
回归语料与 CI
每个缩减后的失败样本进入 tests/regression/ 目录,附带预期的退出值、标准输出和错误类别。CI 在每次提交后运行全部回归用例。修复一个 bug 时,对应的回归用例从失败变为通过;如果未来的改动让它重新失败,CI 立刻报红。
回归语料只增不减。即使当初触发 bug 的优化 pass 被重写了,保留用例仍有价值——它覆盖的输入模式可能在新代码中以不同方式再次出错。
注入错误验证测试基础设施
测试基础设施本身也可能有 bug。如果差分比较函数写错了——比如只比较退出值忘了比较 stdout——真正的 bug 就会从裂缝里溜走。
验证方法:在某个优化 pass 中故意注入一个错误。例如把常量折叠中 a - b 改成 a + b。然后运行差分测试。如果测试没有报告任何失败,说明基础设施有盲区,需要排查。注入错误后测试必须检出,这是测试系统自身的验收条件。
注入实验不需要改动主线代码。用一个编译期开关或单独的测试配置控制,跑完即恢复。它的作用类似于消防演习:确认报警系统真的能响。
练习
设计一个只生成 Sprout 表达式的文法引导生成器,支持 i64 字面量、二元算术运算(+、-、*、/)和括号。深度上限设为 5,每次生成一个 fn main() -> i64 { return <expr>; } 形式的程序。运行 1000 个生成程序,分别通过解释器和编译后的二进制执行,统计通过数、跳过数(超时或除零两边一致)和失败数。如果出现失败,手工缩减到最小复现。
上一篇:20 - 让错误回到源程序。下一篇:22 - 测量编译与运行性能。
