字节码 VM 的三件套:栈槽分配消灭环境链查找、upvalue 闭包把捕获的变量从栈搬进堆、调用帧把 ip 和栈窗一起管理。差分模糊测试(300 种子随机程序 × 双后端逐字节对比)是整个工程里最值钱的一段代码。递归 fib 快 2.75x,闭包计数器 3.26x,纯数组操作基本持平。
这是系列的最后一章。前七章我们把 Cha 从一张 EBNF 图纸做到了一条完整流水线:词法器、解析器、resolver、树遍历求值器、REPL、错误体系。树遍历求值器是参照实现——它定义了这门语言「是什么」。这一章给同一门语言装第二台引擎:字节码编译器 + 栈式虚拟机,然后回答一个问题:同一份源码,两台引擎,能不能做到行为逐字一致——包括报错的列号。
全部代码在 cha-lang,在线 Playground 在文章末尾,可以直接玩。
一个值栈,一台机器
树遍历求值器每算一个表达式都要递归一次函数、建一次 Environment 链表节点。栈式 VM 把这层全删了:数据就放在一个数组里,sp 是栈顶指针。
编译器先跑一遍(src/compiler.ts),把 AST 翻译成一条条紧凑指令。fib 函数编译出来长这样:
== fib (arity 1, upvalues 0) ==
0 GET_LOCAL 0 ; 3:9 ← n
3 CONST 0 (2) ; 3:13
6 LT ; 3:11 ← n < 2
7 JUMP_IF_FALSE → 14 ; 3:5
10 GET_LOCAL 0 ; 3:9
11 RETURN ; 3:5
12 CONST 1 (nil) ; 3:5
14 GET_LOCAL 0 ; 3:9 ← fib(n-1) 的递归调用
...
注意每一行末尾的 ; 行:列——编译时把每个指令的源码位置也写进了字节码。这是第 7 章错误体系的延续:VM 运行时报错时,从当前指令就能取回精确的行列号,而不是丢一个 内部错误 出来。
三个指令集设计的取舍:
- 操作数一律 u16(槽位号、常量池下标、跳转偏移)。u8 会溢出,u32 浪费;u16 上限 65535 个槽位/常量,玩具语言绰绰有余。
and/or用 peek 跳转。Cha 的or返回决定结果的那个操作数本身(第 1 章定下的语义),所以左值算完不能弹栈——JUMP_IF_TRUE_PEEK条件为真就带着左值跳走,为假才让编译器安排的POP弹掉它。- 模板插值编译成一条
TEMPLATE指令,字面量片段存进常量池。插值不再是「字符串拼接的语法糖」,而是一等指令。
局部变量 = 栈槽:性能从哪来
树遍历器里 x += 1 要沿环境链找到 x 所在的 Environment、改一个 Map。字节码版里,编译器在编译期就给每个局部变量编好了栈槽号:
GET_LOCAL 3 // 直接读 stack[base + 3],一次数组下标
SET_LOCAL 3 // 直接写回去
resolver 在第 5 章算的「作用域距离」到这里升级成了「槽位号」——同一个思想(编译期能算的别留给运行时),更彻底的执行。这是递归 fib 快 2.75 倍的主要来源:每次递归不再建 Environment 对象,参数直接落在栈窗里。
全局变量没有槽位,仍按名字存进 VM 的全局表(GET_GLOBAL 查 Map)——和树遍历器一模一样,慢也一起慢,一致性优先。
闭包:upvalue 三部曲
闭包是整个工程最烧脑的部分。问题是:内层函数捕获外层局部变量时,那个变量住在栈上,而栈帧迟早要弹掉。
解法是 Lua/clox 一脉的 upvalue 机制,三步:
- 打开态:upvalue 记一个栈槽号,读写直接穿透到栈;
- 关闭:作用域退出(或 break/continue 跳出)时,把栈槽的值搬进 upvalue 自己堆上,槽号置空;
- 共享:同一层作用域里多个闭包捕获同一个变量,指向同一个 upvalue 对象——所以
makeCounter的两个函数看到的是同一个count。
fn makePair() {
var n = 0;
fn inc() { n += 1; return n; }
fn get() { return n; }
return {inc: inc, get: get};
}
var p = makePair();
p.inc(); p.inc(); p.inc();
print(p.get()); // 3 —— inc 和 get 共享同一个 upvalue编译器还要做 upvalue 传递链分析:三层嵌套时最内层捕获的可能是「外层的外层」的局部变量,那就先在外层建一个间接 upvalue,一层层转发下去。
调用帧:把 ip 存对地方
VM 的调用栈是 Frame { closure, ip, base } 数组:base 是本帧在值栈上的窗口起点,ip 是本帧执行到的字节码地址。实现完我第一个跑的测试就挂了——函数返回后程序从头再执行一遍,无限循环。
原因一行就能说清:CALL 时我把返回地址存进了子帧的 ip,而 RETURN 时从父帧的 ip 恢复——父帧的 ip 从来没人更新过,永远是 0。修复就是把协议写对:压子帧前先把 ip 写回父帧:
frame.ip = ip // 返回地址写给父帧
this.frames.push({ closure: fn, ip: 0, base }) // 子帧从头执行调试它靠的是给 VM 加的 trace 回调——每条指令执行前打印地址、栈深、帧基址。十几行代码,把「猜」变成了「看」。
块退出要弹栈:差分测试教我的事
另一个隐蔽 bug:{ var a = ...; } 块退出时我忘了弹栈。编译器给 a 分配的是槽位 5,第一轮循环 a 住在 stack[5];块退出不弹,第二轮又压到 stack[6]——而编译器仍然生成 GET_LOCAL 5。单看每一步都对,合起来是错的。
这类 bug 手写测试根本枚举不完,真正的防线是差分模糊测试(tests/fuzz.test.ts):一个种子化随机程序生成器,生成终止的、合法的 Cha 程序(允许运行期出错),同时喂给两台引擎,输出与错误必须逐字节一致,包括 phase、错误名、行号、列号:
for (let seed = 1; seed <= 300; seed++) {
const src = genProgram(mulberry32(seed))
const tree = run(src)
const vm = runVM(src)
if (JSON.stringify(norm(tree)) !== JSON.stringify(norm(vm))) {
throw new Error(`种子 ${seed} 出现分歧\n${src}\n...`)
}
}300 个种子里它抓到了三个手写测试全漏掉的分歧:
- 复合赋值的报错列号:
v1 -= x里变量未定义,树遍历器把错误挂在变量名上,我挂在赋值表达式上——差一列,模糊器一眼看穿; - peek 跳转双重弹栈:
and/or的假路径多弹一次,栈错位到后面才爆炸,人肉排查极难; - while 的 continue 漏回填:占位符
0xffff留在字节码里变成野跳转,VM 直接飞出程序。
以及一个意外收获:模糊器抓到的第一颗「分歧」其实是语言本身的 bug——resolver 的循环深度没按函数重置,循环体里的函数声明内部写 break 能通过编译期检查,运行时炸到外层循环。树遍历器的行为是「意外的错误行为」,VM 拒绝它。修正了 resolver,两个后端从此共享正确的语义。差分测试不只是测第二台引擎,它把整个语言的规范钉死了。
基准:快了多少
| 用例 | 树遍历 | 字节码 VM | 加速比 |
|---|---|---|---|
| 递归 fib(22) | 81.7ms | 29.1ms | 2.81x |
| 闭包计数器 ×5 万 | 52.1ms | 16.0ms | 3.26x |
| 循环累加 20 万次 | 44.0ms | 29.4ms | 1.50x |
| 数组/map 混合 ×2 万 | 22.3ms | 23.8ms | 0.94x |
符合理论预期:收益来自省掉 AST 递归和环境链,所以递归、闭包受益最大;push/pop 这些内建函数的耗时在两台引擎里是同一份实现(抽到了共享的 builtins.ts),内建占比高的场景自然持平。这份基准里最诚实的数字是最后一个——没有万倍神话,工程里真实的优化收益就是局部的。
交付物
- 指令集 36 条 + 常量池 + 带行列号的反汇编器(
pnpm cha run 文件 --disasm随时看编译产物) runVM()/VMSession():与run()同签名的第二后端,可互换- 122 个测试:100 个旧用例 + 21 个差分用例 + 300 种子模糊测试
- CLI 三模式(
--tree/--disasm/ 默认 VM)与浏览器 Playground - VM
trace回调:单步指令轨迹,本篇的调试利器
系列收官
八章写完,这门语言有两台引擎、122 个测试、一个能在浏览器里直接玩的 Playground——就在下面,切换「树遍历 / 字节码 VM」感受差距,勾选「字节码视图」看你自己代码的编译产物:
留给读者的小练习:给 VM 加一条 SWAP2 指令,把复合下标赋值 a[i] += v 里两次 PICK 换掉,然后在模糊器里把种子上限调到 3000,看看你有没有引入新的分歧——有分歧不可怕,模糊器会告诉你答案。
本系列代码开源在 cha-lang,字节码核心在 src/chunk.ts、src/compiler.ts 与 src/vm.ts。感谢一路看到终章。