← 返回全部文章

VM 设计 > 幽灵变量

[ Code & Dev ]
[ c ] [ programming-languages ] [ systems-programming ] [ compiler-design ]

当你从零开始构建自己的编程语言时,测试套件就是你的镜子。在编写像计算第 10 个斐波那契数这样的经典基准测试之前,一切在独立的单元测试中似乎都能正常运行——然后解释器要么立刻崩溃,要么输出一个错得理直气壮的结果。

最近,在开发 gem-script(一种轻量级动态脚本语言,包含用 C 语言编写的 AST 解析器和字节码虚拟机)时,我连续遇到了一个令人难忘的 Bug:

  1. 一个**“幽灵变量”逻辑 Bug**,它把第 10 个斐波那契数(F10=55F_{10} = 55)变成了 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. 迭代 1let temp = b 在索引 4 处追加了 ("temp", 1.0)a 变为 1.0
  2. 迭代 2let temp = b 在索引 5 处追加了一个新的 ("temp", 1.0)b 变为 2.0
  3. 陷阱:当 a = temp 运行时,env_get("temp")索引 0 开始扫描。它在第一个匹配项处停止:索引 4
  4. 后续迭代:每次迭代都会追加一个带有最新值的新 temp,但 env_get 总是返回迭代 1 中的那个“幽灵”(1.0)。

因为 a 被永远锁定在了 1.0b 只是简单地线性递增(1,2,3,41, 2, 3, 4\dots),而没有组合成斐波那契数列。

修复方案:为词法遮蔽进行反向扫描

在管理线性表中的作用域时,最近(最内层)的定义必须遮蔽旧的定义

反向扫描可以解决这个问题:

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 个泄漏的值。

栈式虚拟机的经验法则:

每条语句必须让栈保持在它开始时的确切深度,除非它显式地为外层节点生成一个已求值的表达式。