当你从零开始构建自己的编程语言时,测试套件就是你的镜子。在编写像计算第 10 个斐波那契数这样的经典基准测试之前,一切在独立的单元测试中似乎都能正常运行——然后解释器要么立刻崩溃,要么输出一个错得理直气壮的结果。
最近,在开发 gem-script(一种轻量级动态脚本语言,包含用 C 语言编写的 AST 解析器和字节码虚拟机)时,我连续遇到了一个令人难忘的 Bug:
- 一个**“幽灵变量”逻辑 Bug**,它把第 10 个斐波那契数()变成了
1.0。
以下是这两个问题发生的原因、我们追踪它们的过程,以及为任何在 C 语言中构建解释器的人提供的关键经验教训。
1. 基准测试:经典斐波那契
为了测试变量声明(let)、重新赋值、算术运算和 while 循环,我编写了 fibonacci.gem:
let n = 10
let a = 0
let b = 1
let i = 0
while (i < n) {
let temp = b
b = a + b
a = temp
i = i + 1
}
a
Bug :符号表中的幽灵
脚本运行了,但输出结果令人困惑:
$ ./gem ./test/fibonacci.gem
VM: POP: 10.000000
VM: POP: 0.000000
VM: POP: 1.000000
VM: POP: 0.000000
VM: POP: 10.000000
VM: POP: 1.000000 <-- 期望输出 55.000000!
(注:虚拟机输出多行 POP 是因为顶层语句是在类似 REPL 的编译循环中被求值并返回的。)
最终表达式 a 的求值结果变成了 1.0 而不是 55.0。为什么循环未能计算出正确的序列呢?
追踪环境
在 gem-script 中,变量存储在一个 Environment 结构体中,该结构体包含一个符号数组:
void env_define(Environment *env, char *name, Value val) {
Symbol symbol = {.val = val};
strncpy(symbol.name, name, 64);
env->symbols[env->count++] = symbol; // 追加到末尾
}
而变量解析使用的是正向线性扫描:
bool env_get(Environment *env, char name[64], Value *ret_val) {
for (int i = 0; i < env->count; i++) {
if (strcmp(env->symbols[i].name, name) == 0) {
*ret_val = env->symbols[i].val;
return true;
}
}
return false;
}
Bug 的机制
在循环体内部:
let temp = b
b = a + b
a = temp
看看在循环迭代期间符号表发生了什么:
graph TD
subgraph "迭代 1"
E1["索引 4: temp = 1.0"]
end
subgraph "迭代 2"
E2["索引 5: temp = 1.0"]
end
subgraph "迭代 3"
E3["索引 6: temp = 2.0"]
end
- 迭代 1:
let temp = b在索引4处追加了("temp", 1.0)。a变为1.0。 - 迭代 2:
let temp = b在索引5处追加了一个新的("temp", 1.0)。b变为2.0。 - 陷阱:当
a = temp运行时,env_get("temp")从索引 0 开始扫描。它在第一个匹配项处停止:索引 4。 - 后续迭代:每次迭代都会追加一个带有最新值的新
temp,但env_get总是返回迭代 1 中的那个“幽灵”(1.0)。
因为 a 被永远锁定在了 1.0,b 只是简单地线性递增(),而没有组合成斐波那契数列。
修复方案:为词法遮蔽进行反向扫描
在管理线性表中的作用域时,最近(最内层)的定义必须遮蔽旧的定义。
反向扫描可以解决这个问题:
bool env_get(Environment *env, char name[64], Value *ret_val) {
for (int i = env->count - 1; i >= 0; i--) { // 从最新的开始!
if (strcmp(env->symbols[i].name, name) == 0) {
*ret_val = env->symbols[i].val;
return true;
}
}
return false;
}
有了反向查找后:
$ ./gem ./test/fibonacci.gem
...
VM: POP: 55.000000
4. 额外架构经验:虚拟机中的栈卫生
在调查符号表时,字节码解释器中又浮现出另一个微妙的问题:
case OP_DEFINE_GLOBAL: {
uint8_t nameIdx = *vm->ip++;
Value nameVal = vm->chunk->constants.values[nameIdx];
Value val = *(vm->stackTop - 1); // 偷窥而不是弹出!
env_define(&vm->globals, nameVal.string, val);
break;
}
通过偷窥栈而不是弹出表达式结果,循环内的语句在每次迭代时都会在栈上留下孤儿值。对于一个迭代 1,000 次的循环,虚拟机栈会悄无声息地积累 4,000 个泄漏的值。
栈式虚拟机的经验法则:
每条语句必须让栈保持在它开始时的确切深度,除非它显式地为外层节点生成一个已求值的表达式。