ADCakeyuan's blog

手写一门编程语言(八·终):给茶装上字节码虚拟机——同一个语言,两种引擎

Favicon 800x800.png
Published on
//
11 分钟读完
/
TL;DR

字节码 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 机制,三步:

  1. 打开态:upvalue 记一个栈槽号,读写直接穿透到栈;
  2. 关闭:作用域退出(或 break/continue 跳出)时,把栈槽的值搬进 upvalue 自己堆上,槽号置空;
  3. 共享:同一层作用域里多个闭包捕获同一个变量,指向同一个 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 个种子里它抓到了三个手写测试全漏掉的分歧:

  1. 复合赋值的报错列号:v1 -= x 里变量未定义,树遍历器把错误挂在变量名上,我挂在赋值表达式上——差一列,模糊器一眼看穿;
  2. peek 跳转双重弹栈:and/or 的假路径多弹一次,栈错位到后面才爆炸,人肉排查极难;
  3. while 的 continue 漏回填:占位符 0xffff 留在字节码里变成野跳转,VM 直接飞出程序。

以及一个意外收获:模糊器抓到的第一颗「分歧」其实是语言本身的 bug——resolver 的循环深度没按函数重置,循环体里的函数声明内部写 break 能通过编译期检查,运行时炸到外层循环。树遍历器的行为是「意外的错误行为」,VM 拒绝它。修正了 resolver,两个后端从此共享正确的语义。差分测试不只是测第二台引擎,它把整个语言的规范钉死了。

基准:快了多少

用例树遍历字节码 VM加速比
递归 fib(22)81.7ms29.1ms2.81x
闭包计数器 ×5 万52.1ms16.0ms3.26x
循环累加 20 万次44.0ms29.4ms1.50x
数组/map 混合 ×2 万22.3ms23.8ms0.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。感谢一路看到终章。