TS实现基于字节码的JS 虚拟机

2 分钟阅读
·

JSVM3 原理与思考系列第 1 篇:为什么从 jsvm2 的 AST 直驱方案,转向编译为字节码再执行的两阶段架构。

系列目录

「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:

  1. 从 AST 直驱到字节码:JSVM3 的编译与执行分层(本篇)

JSVM2 系列已经说明过 AST 直驱解释器的实现和边界:Babel 解析出 AST,Visitor 前序遍历节点并在遍历中求值。详见《JSVM2:为什么小程序需要一台 JS 虚拟机》《JSVM2:怎么证明一个 JS 引擎是对的》。2022 年我将这套方案重写成 JSVM3:先把源码编译成自定义字节码,再用一个小型虚拟机执行字节码。这篇文章说明这次重写具体做了什么,而不比较跨版本性能;jsvm3 仓库只保留了自身多轮迭代的 benchmark/,没有与 jsvm2 使用统一口径的直接对比基准。

jsvm2 的边界:为什么 AST 直驱不够用

jsvm2 的执行模型可以用一句话概括:AST 既是程序的表示,也是程序的执行载体。每次执行一段代码,都要重新遍历一遍语法树;每个节点的类型判断、子节点递归调用,都发生在”执行”这个动作里。这个模型简单,但在实际落地到小程序/低代码这种需要动态下发代码的场景后,会暴露几类问题:

  • 产物体积:Babel 生成的 AST 节点带有大量与执行无关的字段(locstartendleadingComments 等),一条 console.log(x) 语句展开后的 AST JSON 会比源码本身大出一大截。jsvm2 后来专门做了一层”AST 等价压缩”来缓解下发体积问题。
  • 遍历与执行耦合:想知道”这段代码接下来要执行到第几步”,唯一的办法是看 Visitor 递归到了哪一层调用栈。这对调试、暂停/恢复执行、控制执行预算都不友好。
  • 每次执行都要重新理解语法:AST 直驱本质上是把”识别这是一个二元表达式”和”计算这个二元表达式的值”放在了同一次遍历里,语法解析的开销无法被复用或提前完成。

jsvm3 想解决的核心问题,是把”理解代码在说什么”和”执行代码”这两件事分开:前者在编译期一次性完成,后者在运行期反复发生,二者不必绑在一起。

三段流水线:源码 → AST → Script → 执行结果

jsvm3 的编译入口是 src/compiler/index.tstransform() 函数,整个流水线可以拆成三段。

第一段,源码到 ES5 代码字符串。 transform() 先用 @babel/preset-envtargets.browsers 配置为 safari >= 9android >= 4.4)把源码降级成兼容目标环境的 ES5 代码,这一步顺带完成了箭头函数、解构、class 等新语法向 ES5 等价形式的转换——这也是后面”限制”一节要说明的关键前提:jsvm3 自身的 Emitter 并不直接处理 classclass 是在这一步被 Babel 转换成普通函数和原型链赋值之后,才进入 Emitter 的。之后 transform() 又跑了一轮编译期优化插件:minify-dead-code-eliminationminify-constant-foldingminify-guarded-expressions,以及可选的自定义 hoisting 插件(src/compiler/plugin/hoisting.ts,做 var 与函数声明提升)。这一步的产物仍是字符串形式的 ES5 代码,还没有解析成 AST。

第二段,AST 到 Script 降级和优化后的代码重新用 @babel/parser 解析成 AST,交给 Emittersrc/compiler/emitter.ts,继承自 src/compiler/visitor.tsVisitor 基类)。Emitter.visit() 对每种 AST 节点类型实现同名方法,比如 BinaryExpression()VariableDeclarator()Identifier(),方法内部调用 createINS() 把对应的字节码指令追加到 this.instructions 数组里。遍历结束后调用 emitter.end():这个方法回填所有跳转标签(Label)的具体地址、计算 Guardtry/catch 边界)的起止指令位置、统计整段代码需要的最大求值栈深度(calculateFactor() 逐条累加),最后组装成一个 Script 实例(src/vm/script.ts)返回。

第三段,Script 到执行结果。 拿到 Script 后不再需要 AST 或 Babel。src/vm/vm.ts 里的 JSVM.exec(script) 创建一个 Fibersrc/vm/fiber.ts),Fiber.pushFrame() 为这份 Script 建一个 Framesrc/vm/frame.ts),fiber.run() 驱动 Frame.run() 逐条取指令执行,直到指令指针 ip 走到 exitIp

这三段对应到代码里,就是”AST 只在编译期存在”——运行期看到的只有 Script.instructions 这样一个扁平的指令数组,不再有树形结构需要递归。

JSVM3 编译与执行分层:AST、Emitter、Script、Fiber/Frame 的数据流

Script 里装的是什么

Scriptsrc/vm/script.ts)是 emitter.end() 的产出,也是编译期和运行期之间唯一的契约。它的构造函数字段基本对应编译过程中积累的信息:

  • instructions: Instruction[]:这段代码(可能是整个模块,也可能是一个函数体)编译出的指令序列。
  • children: Script[]:内部定义的每个函数,各自是一个独立的子 Script,在 emitter.end() 里通过 this.children[i] = this.children[i]() 递归编译完成。运行时执行到 FUNCTION 指令,会拿 frame.script.children[scriptIndex] 去创建函数对象(src/opcodes/ins.tsFUNCTION 指令的实现,调用 createFunction())。
  • localNames / localLength / globalNames:局部变量名和全局变量名列表。局部变量在编译期被分配成整数索引(Emitter.declareVar()this.varIndex++),运行期通过索引而不是字符串去 Scope 里存取,避免哈希查找。
  • guards: Guard[]try/catch/finally 的保护区间,字段是 start/handler/finalizer/end(类型定义见 src/vm/types.ts),编译期是 Labelend() 阶段被替换成具体指令地址。
  • stackSize:这段代码执行期间求值栈可能达到的最大深度,用来在 Frame 构造时初始化一个定长的 EvaluationStacksrc/vm/stack.ts),避免运行期动态扩容。

Script 上还有一个 toJSON() 方法(scriptToJson/scriptToJsonObject,定义在 src/utils/convert.ts),意味着编译产物可以序列化成 JSON——这是 AST 直驱方案不具备的能力:AST 本身当然也能序列化,但体积和结构都不适合作为下发协议,而 Script 从设计上就是为了充当这样一份可传输、可再反序列化执行的产物(序列化协议的细节在这里不展开,属于系列后续篇目的内容)。

const a = 1 + 2 看编译产物

大纲里建议用 const a = 1 + 2 作为最小例子,实际编译一下会发现一个值得记录的真实现象:这段代码经过 transform() 默认的编译期优化插件(minify-constant-folding)之后,1 + 2 在编译阶段就被算成了字面量 3,最终指令序列里根本不出现 ADD 指令:

=== test.js ===
stackSize: 3   locals: []

0000  LINE            [1]
0001  COLUMN          [10]
0002  LITERAL         [3]
0003  SETG            [0]  ; a
0004  SREXP

(以上是用 demo/src/disassemble.tsdisassembleScript()transform('const a = 1 + 2;', 'test.js', { hoisting: true, convertES5: false }) 的产物反汇编得到的真实输出,convertES5: true 时结果只是 LINE/COLUMN 的行列号不同,指令序列一致。)

这其实是个不错的例子,正好说明”编译期能确定的事,不必留到运行期再算”——常量折叠这类优化放在编译阶段做,运行时省掉了一次 ADD 指令和相应的栈操作。但为了看到大纲想展示的、真正参与运行期计算的 ADD 指令,需要换一个不会被常量折叠掉的写法,比如把字面量换成变量:

function add(x) { return x + 2; }

用同样的方式反汇编,add 对应的子 Script 是:

  === add ===
  stackSize: 6   locals: [x]

  0000  FUNCTION_SETUP  [true]
  0001  LINE            [1]
  0002  COLUMN          [28]
  0003  LITERAL         [0]
  0004  COLUMN          [18]
  0005  GETL            [0, 1]
  0006  GET
  0007  SETL            [0, 3]  ; x
  0008  SREXP
  0009  LINE            [2]
  0010  COLUMN          [9]
  0011  GETL            [0, 3]  ; x
  0012  COLUMN          [13]
  0013  LITERAL         [2]
  0014  ADD
  0015  RETV

外层模块的指令则是(为聚焦函数定义与赋值这部分核心逻辑,此处省略了紧随其后的 LINE/COLUMN 两条行列号标记指令):

=== test.js ===
stackSize: 3   locals: []

0000  FUNCTION        [0, 0]  ; add
0001  SETG            [0]  ; add
0002  POP

对照 src/opcodes/ins.ts 里的实现可以把这份指令逐条读下来:

  • FUNCTION [0, 0] 让运行时用 frame.script.children[0](也就是上面 add 对应的子 Script)创建一个函数对象压入求值栈,[0]children 数组下标,第二个参数标记是否是 generator(这里是 0,非 generator)。
  • SETG [0] 把栈顶的函数对象写入 globalNames[0](即全局变量 add)对应的槽位;POP 丢掉这条语句作为表达式的返回值。
  • 进入 add 内部,FUNCTION_SETUP [true] 处理调用时压栈的 thisargumentsscope.set(1, ...)argumentsargs[0] 为真时还会把函数自身存到 scope.set(2, ...) 供命名函数表达式内部引用自己)。
  • LITERAL [0]GETL [0, 1](取回 arguments)、GETSETL [0, 3] 这一组,对应的是形参 x 的默认取值逻辑:从 arguments[0] 取值(取不到则用 LITERAL [0] 铺底),再 SETL 写入 x 在局部作用域里的槽位([0, 3] 里的 0 是作用域深度,3 是变量索引)。SREXP 把这一条语句的结果暂存进表达式寄存器,这是参数初始化被 Emitter 当作一条普通赋值语句处理的副产物。
  • return x + 2 对应 GETL [0, 3] 取回 xLITERAL [2] 压入字面量 2ADD 弹出两个栈顶值相加后压回结果,RETV 把栈顶值写入 frame.fiber.rv 并结束这次函数调用。

ADD 本身的实现只有两行(src/opcodes/ins.ts):

export const ADD = createOP(OPCodeIdx.ADD, function (frame, evalStack, scope, realm, args) {
  const [l, r] = evalStack.tail(2);
  evalStack.push(l + r);
});

指令的语义非常薄:从求值栈取两个值、做一次 JS 原生的 +、把结果压回去。之所以能这么薄,是因为”这是一次加法”这件事已经在编译期由 Emitter.BinaryExpression() 判定完毕——它读取 AST 节点的 operator 字段,通过 src/compiler/opMap.tsbinaryOp 表把 '+' 映射到 'ADD',运行期不再需要做任何字符串比较或节点类型判断,只管执行。

编译期和运行期,各自适合做什么

把这套流水线拆开看,编译期和运行期承担的职责其实有清晰的分工。

编译期(Emitter 及其所在的一整套 Babel 插件)适合做一次性、且需要理解整体语法结构的事情:判断一个标识符该用 GETL(局部变量索引)还是 GETG(全局变量名查找)——这依赖 Emitter.scope() 在编译期维护的作用域链信息;判断 1 + 2 能不能直接算成 3——这依赖对整棵表达式子树的分析;给每个局部变量分配一个稳定的整数索引——这需要知道一个作用域里到底声明了哪些变量。这些判断如果放到运行期做,要么要重新解析语法(回到 AST 直驱的老问题),要么要在每次执行时重复付出同样的分析开销。

运行期(Fiber/Framesrc/opcodes/ins.ts 里的指令实现)适合做依赖具体数据、必须在执行那一刻才能确定的事情:x + 2x 到底是多少,只有运行到这一步、Scope 里已经写入了实际值才知道;一个 try 块里到底会不会抛异常、抛到哪一层,只有真的执行到那条指令才能确定,Fiber.unwind() 正是在异常发生时才去查 frame.guards 找处理入口。

这个分工也解释了为什么 Script 要设计成”指令 + 元数据”而不是继续拿 AST 跑:指令数组本身已经不携带语法层面的信息(不知道也不需要知道这曾经是一个 BinaryExpression),Frame.run() 的主循环因此可以写得非常朴素,src/vm/frame.ts 中的实现是这样:

run() {
  let len;
  const frame = this;
  const { instructions } = frame.script;
  while (frame.ip !== frame.exitIp && !frame.suspended && frame.fiber.timeout !== 0) {
    frame.fiber.timeout--;
    const ins = instructions[frame.ip++];
    ins.run(frame, frame.evalStack, this._scope!, frame.realm, ins.args);
  }
  ...
}

取指令、执行、ip 前移,仅此而已。至于函数调用怎样创建新的 Frametry/finally 怎样通过 Guardunwind() 传播异常,这些属于运行期具体机制,留给系列后续文章展开。

限制:字节码不是 JS 规范的替代品

把源码编译成字节码解决的是”执行模型”问题,不等于自动获得完整的 ES 语义覆盖。读 src/compiler/visitor.tsEmitter 的基类)和 src/compiler/emitter.ts 会看到几类节点目前的处理方式,需要在这里如实列出,避免造成”jsvm3 已支持全部现代语法”的误解:

  • class 相关语法本身没有对应的字节码生成逻辑ClassExpression()ClassBody()ClassDeclaration()ClassHeritage()MethodDefinition()Emitter 里都被重新声明为 throw new Error('not implemented') 的抛错桩(src/compiler/emitter.ts),和基类 Visitorsrc/compiler/visitor.ts)中的同名实现内容一样,都是直接抛错,class 相关节点在这两层里都没有真正的字节码生成逻辑。__tests__/es5/class.test.ts 里能跑通的用例(class Parent{...} class Child extends Parent{...}),依赖的是 transform() 第一步里 @babel/preset-env 先把 class 转换成了普通函数和原型链赋值,Emitter 实际接手时看到的已经是转换后的 ES5 代码,从未真正遇到 ClassDeclaration 节点。
  • yield/Generator 未启用src/opcodes/utils.ts 里能找到一段完整注释掉的 createGenerator 实现,Emitter.YieldExpression() 直接 throw new Error('not implemented')。也就是说 Generator 语义在这个仓库里存在过设计尝试,但当前没有接入编译或执行路径。
  • 模板字符串、ES Module 语法未实现TemplateLiteral()TaggedTemplateExpression()TemplateElement()ImportDeclaration()ImportSpecifier()ExportDeclaration()ExportSpecifier()ModuleDeclaration() 在基类 Visitor 里同样是抛错桩实现,Emitter 未覆盖。
  • 严格模式语义不完整__tests__/es5/class.test.ts 里”parasitic combination inheritance”用例的注释直接写了 // @TODO 严格模式?,说明这部分行为已知与规范存在差异,尚待补齐。
  • with 语句未实现WithStatement()Emitter 里同样是抛错桩,这与前作 jsvm2”除 WithStatement 外均支持”的边界是一致的。

字节码只是把”语法怎么落地成可执行指令”这件事往前挪了一步,指令集覆盖到哪些语言特性,仍然完全取决于 Emitter 里实现了多少个节点类型的方法,以及对应操作码在 src/opcodes/ins.ts 里的行为是否经过测试验证。编译目标从”遍历 AST”换成”生成字节码”,并不会自动带来语义完整性,这也是系列第 5 篇要专门讨论”用测试定义语义边界”的原因。

小结

这一篇只做了一件事:把 jsvm3 “怎么把源码变成能跑的东西”这条路径完整走了一遍——transform() 里的三段流水线、Script 携带的编译期产物、Fiber/Frame 如何拿着 Script 逐条执行指令,以及用真实反汇编输出验证了一条加法表达式具体会变成什么样的指令。下一篇会往运行期内部再走一层:Frame 的求值栈、r1/r2/r3 寄存器和左值引用 lref 具体怎么配合完成一次赋值或自增操作。


538 字 · 55 段落
ximing

Written by ximingFollow onGitHub

相关文章