JSVM3 如何执行字节码:指令、栈帧与求值栈

2 分钟阅读
·

JSVM3 原理与思考系列第 2 篇:编译产物交给运行时之后,Fiber、Frame、求值栈和临时寄存器如何配合,逐条把指令执行完。

系列目录

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

  1. 从 AST 直驱到字节码:JSVM3 的编译与执行分层
  2. JSVM3 如何执行字节码:指令、栈帧与求值栈(本篇)

第 1 篇走完了一条代码从源码到 Script 字节码的编译路径,最后停在 Frame.run() 那个”取指令、执行、ip 前移”的主循环上。这一篇往运行期内部再走一层:拿到 Script 之后,运行时到底创建了哪些对象、每个对象保存什么状态、一条表达式在中间结果层面是如何流动的。文中所有字节码序列都来自 demo/src/disassemble.tstransform() 真实产物的反汇编,不是示意。

从 JSVM.exec 到 fiber.run():调用链上发生了什么

运行时的入口非常短,src/vm/vm.ts 里的 JSVM.exec() 核心动作只有三步:创建 Fiber、向其中压入第一个 Frame、调用 fiber.run()

exec(script: Script, timeout = -1) {
  const fiber = this.createFiber(script, timeout);
  fiber.run();
  if (!fiber.suspended) {
    return fiber.rexp;
  }
}

createFiber()(同文件)做两件有实际语义的事:new Fiber(this.realm, timeout) 把执行预算 timeout 交给 Fiberfiber.pushFrame(script, this.realm.globalObj) 为顶层 Script 建立第一个 FramepushFrame()src/vm/fiber.ts)内部会先 checkCallStack() 检查调用栈深度,然后 new Scope(parent, script.localNames, script.localLength) 创建作用域、把 globalObj 写入作用域的 0 号槽位(也就是 this),最后 new Frame(...) 并放进 callStack[++this.depth]

Fiber.run()src/vm/fiber.ts)是一个外层循环:只要调用栈不空(this.depth >= 0)且没有挂起,就取栈顶 Frameframe.run()。每轮循环开头先检查帧上是否挂着错误,有则走 unwind() 沿调用栈找 Guard;frame.run() 返回后,若帧上的错误是 JSVMErrorinjectStackTrace() 注入错误栈,然后检查当前 Frame 是否执行完——执行完则处理 finally、构造函数返回值,再 popFrame() 把返回值 rv 压回调用者的求值栈;没执行完说明刚才发生了函数调用,frame 改指新的栈顶 Frame 进入下一轮。所以整体结构是两层循环:Fiber.run() 管”哪个 Frame 在跑”,Frame.run() 管”这个 Frame 跑到哪条指令”

两个返回值通道值得注意:fiber.rv 传递函数调用的返回值(RETV 指令写入,popFrame() 后压给调用者),fiber.rexp 保存最后一条表达式语句的结果(SREXP 指令写入),exec() 最终返回的就是 rexp——这让 exec() 的行为类似 eval,能拿到整段代码最后一个表达式的值。

Frame 保存什么:一次调用的全部执行状态

Framesrc/vm/frame.ts)对应一次函数调用(顶层模块也算一次”调用”),它的字段可以按用途分成几组:

  • 指令位置ip(Instruction Pointer,指向下一条待执行指令)和 exitIp(出口地址,构造时初始化为 script.instructions.length)。Frame.isDone() 就是判断 ip === exitIp。注意 exitIp 不是永远等于指令数:Fiber.run() 在处理”try 中间 return”时会把 exitIp 临时改成 Guard 的 end,让 finally 块先跑完。
  • 求值状态evalStack,一个 EvaluationStack 实例(src/vm/stack.ts),构造时按 script.stackSize 创建定长数组。第 1 篇说过 stackSize 是编译期 calculateFactor() 逐条累加出的最大深度,运行期 push() 时如果 idx 到达数组长度会直接抛 max _estack size——求值栈不做动态扩容。
  • 变量存取_scope,指向当前 Scopesrc/vm/scope.ts)。Scope 本体是一个 parentScope 指针加一个定长 data 数组,GETL/SETL 按整数索引读写 data,不做名字哈希查找。_scope 在帧生命周期内可变:ENTER_SCOPE/EXIT_SCOPE 指令会切换它(块级作用域)。
  • 左值引用lref: any[],一个被当作栈用的数组,配合 SLHS/LLHS 指令暂存”赋值目标是谁”,下文 obj.count++ 的例子会用到。
  • 异常与控制guards(当前生效的 try 保护区间栈)、suspendedevalErrorfinalizer,以及 line/column(由 LINE/COLUMN 指令维护,Fiber.injectStackTrace() 生成错误栈时读取)。
  • 调用元信息scriptfNameconstruct(是否是 new 调用,影响返回值处理)。

Frame.run() 主循环的退出条件有三个:ip 走到 exitIp、帧被挂起、或 fiber.timeout 减到 0。循环结束后还有一个调试断言:正常结束时求值栈必须是空的,否则抛 eStack has ${len} items。这个断言实际上定义了一条栈纪律——每条语句执行完,它在求值栈上的临时结果必须被消费干净(被 POP 丢弃、被 SREXP 收走、或被 SET* 写回),不留残渣。

JSVM3 运行时结构:Fiber、Frame、Scope 与求值栈

求值栈如何完成 1 + 2 * 3

先验证一个和第 1 篇呼应的真实现象:const r = 1 + 2 * 3; 经过 transform() 的常量折叠插件后,整个表达式在编译期就算完了,反汇编结果里既没有 MUL 也没有 ADD

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

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

要观察求值栈参与的真实运算,得让表达式里有编译期算不出来的值。用 function f(a) { return 1 + a * 3; } 反汇编,return 对应的核心指令序列是:

0011  LITERAL         [1]
0013  GETL            [0, 3]  ; a
0015  LITERAL         [3]
0016  MUL
0017  ADD
0018  RETV

(省略了 LINE/COLUMN 行列号指令。)假设调用时 a = 2,求值栈的内容随指令逐步变化:

指令 执行后栈内容(栈底在左) 说明
LITERAL [1] [1] 压入字面量 1
GETL [0, 3] [1, 2] 从当前作用域 data[3]a
LITERAL [3] [1, 2, 3] 压入字面量 3
MUL [1, 6] tail(2) 取走 2、3,压回 6
ADD [7] tail(2) 取走 1、6,压回 7
RETV [] 栈顶写入 fiber.rv 并结束当前帧

这里有两个值得停一下的细节。第一,运算符优先级在运行期不存在* 先于 + 这个知识已经由 Emitter.BinaryExpression() 递归遍历 AST 的顺序编码进了指令序列——先发射 a * 3 的指令、后发射外层的 ADDMULADD 两条指令本身都只是”弹两个值、算一下、压回去”(src/opcodes/ins.ts),互相不知道对方的存在。第二,二元指令统一用 evalStack.tail(2) 取值,它一次性把 idx 减 2 并返回切出的两个元素,相当于把两次 pop() 合并成一次调用,减少一次方法调用和返回值往返;这是后来性能优化的痕迹(GET/SET 的实现里还留着被注释掉的连续 pop() 旧写法),第 6 篇讲优化时会再提到这类改动。

为什么不是纯栈机:r1/r2/r3 与 lref

如果只有求值栈,obj.count++ 这类操作会很别扭:既要读旧值(需要 obj 和属性名),又要写新值(还需要一遍 obj 和属性名),纯栈机只能靠 DUP/SWAP 反复腾挪,而且中间一旦穿插别的求值就很容易丢东西。jsvm3 的解法是在栈机之外加两组状态。

第一组是 Fiber 上的三个临时寄存器 r1/r2/r3,配套指令是 SR1/SR2/SR3(弹栈存入寄存器)和 LR1/LR2/LR3(把寄存器压回栈)。注意它们挂在 Fiber 而不是 Frame 上,是跨帧共享的临时槽位,适合在一小段连续指令内传递”放不进栈顺序里”的值。另外还有表达式寄存器 rexpSREXP 写入)和返回值通道 rv,前面已经提过。

第二组是 Frame 上的左值引用栈 lref,配套指令是 SLHSLLHSsrc/opcodes/ins.ts,以下引用有删节):

export const SLHS = createOP(OPCodeIdx.SLHS, function (frame, evalStack, scope, realm, args) {
  // [key, obj]
  frame.lref.push(evalStack.tail(2));
});

export const LLHS = createOP(OPCodeIdx.LLHS, function (frame, evalStack, scope, realm, args) {
  const [key, obj] = frame.lref.pop();
  frame.fiber.r1 = key;
  frame.fiber.r2 = obj;
  evalStack.push(key);
  evalStack.push(obj);
});

SLHS 把栈顶的 [key, obj] 一对值整体收进 lrefLLHS 再把它取回来,同时顺手把 keyobj 分别写进 r1r2,并把两者压回求值栈供 GET 使用。lref 之所以是栈而不是单槽,是因为左值求值可以嵌套:外层左值已经压入 lref,右值求值过程中又出现另一个左值操作。比如 a.b = c.d++,反汇编的关键片段是(中间省略了内层自增的指令):

0016  SLHS     ; 外层 a.b 的 [key, obj] 压入 lref
0020  SLHS     ; 内层 c.d 又压入一层
0021  LLHS     ; 内层弹出,完成 c.d++
...
0031  LLHS     ; 外层弹出
0032  SET      ; 把右值结果写回 a.b

两对 SLHS/LLHS 严格嵌套,需要能一层层压入再弹出。

Emitter.UpdateExpression()src/compiler/emitter.ts)里留着一段被注释掉的 SR2/SR1/LR1/LR2 旧实现,说明早期版本确实直接用寄存器倒手,后来才演进成 SLHS/LLHS 这一对指令——寄存器负责短距离传值,lref 负责跨求值过程的保存,分工是从实际代码里长出来的。

完整走一遍 obj.count++

这是本篇的核心例子。var obj = {count: 1}; obj.count++; 的后半句反汇编出来是:

0008  STRING_LITERAL  [0]  ; "count"
0009  GETG            [0, 0]  ; obj
0010  SLHS
0011  LLHS
0012  GET
0013  SR3
0014  LR3
0015  INC
0016  LR1
0017  LR2
0018  SET
0019  POP
0020  LR3
0021  SREXP

GETG [0, 0] 的第一个参数是 globalNames 表索引,第二个参数是 ignoreNotDefined 标记——为 0 时全局变量不存在会抛 ReferenceError。)逐条对照求值栈和寄存器的变化(设 obj.count 当前为 1):

指令 求值栈 其它状态变化
STRING_LITERAL [0] ["count"] 压入属性名
GETG [0, 0] ["count", obj] 压入对象
SLHS [] lref 存入 ["count", obj]
LLHS ["count", obj] r1="count"r2=obj
GET [1] 读出旧值 obj["count"]
SR3 [] r3=1,保存旧值
LR3 [1] 旧值压回栈
INC [2] 加一得到新值
LR1 [2, "count"] 取回属性名
LR2 [2, "count", obj] 取回对象
SET [2] obj["count"] = 2,压回赋值结果
POP [] 丢弃新值(仅后缀形式有)
LR3 [1] 表达式结果是旧值
SREXP [] 旧值写入 rexp,栈清空

保存顺序一目了然:对象和属性名进 lref(并镜像到 r1/r2),旧值进 r3,新值在栈顶诞生后被 SET 写回。后缀 ++ 的语义”表达式取旧值”靠最后三条 POP/LR3/SREXP 实现——扔掉 SET 的返回值,把 r3 里的旧值作为整条表达式的结果。前缀形式 ++obj.count 反汇编出来没有这两条 POP/LR3SET 压回的新值直接就是表达式结果,语义差异完全由编译期生成的指令不同来表达,SET/INC 自身不用区分前后缀。

顺带一提,如果自增目标是局部变量而不是属性(count++),指令会短得多:GETL 取值、SR3/LR3 存取旧值、INCSETL 写回,最后同样 POP/LR3——变量索引本身就是稳定的”左值地址”,不需要 lref 参与。

两道保险丝:maxDepth 与 timeout

最后说 Fiber 上两个和执行预算有关的字段,它们决定了 jsvm3 在受限环境(比如小程序里跑下发代码)里的安全边界。

maxDepth 限制调用栈深度。 Fiber 构造时固定为 1000。每次 pushFrame() 之前 checkCallStack() 检查 depth === maxDepth,达到上限就给当前帧写入 JSVMError('maximum cStack size.') 并挂起 Fiber。所以递归失控不会拖垮宿主的真实调用栈——jsvm3 自己的 Frame 是堆上的对象,调用深度只受这个计数器限制,与宿主 JS 引擎的原生栈无关。

timeout 是指令条数预算,不是时间。 JSVM.exec(script, timeout) 的第二个参数默认 -1 表示不限;传入正数后,Frame.run() 每取一条指令就 fiber.timeout--,减到 0 时循环退出、帧和 Fiber 一起挂起,Fiber.run() 末尾抛出 JSVMTimeoutError。它防止的是死循环类代码长时间独占线程。与挂起配套的还有 Fiber.suspend()/resume() 机制:resume(timeout) 可以带着新的预算让 Fiber 从断点继续执行——ipevalStacklref 全都还留在 Frame 上,恢复执行不需要重建任何状态。这也是字节码方案相对 AST 直驱的一个结构性优势:执行位置是一个整数和一组平铺的数据结构,天然可中断、可恢复。

另外 Fiber 上还有 maxTraceDepth = 50,只影响 injectStackTrace() 生成错误栈时往回追溯的帧数,不影响执行本身。

小结

这一篇把运行时的静态结构讲完了:JSVM.exec() 创建 FiberFiber 持有一个 Frame 调用栈和 r1/r2/r3/rexp/rv 这些跨帧寄存器;每个 Frame 持有自己的指令指针 ip、求值栈 evalStack、作用域 _scope 和左值引用栈 lref。表达式求值是标准的栈机模型,优先级由编译期决定;而一旦涉及”既要读又要写”的左值操作,就靠寄存器和 lref 在栈之外暂存赋值目标。maxDepthtimeout 则分别在调用深度和指令条数两个维度上给执行划了边界。

到这里还有一个关键问题没展开:函数调用时新 Frame 怎么建、参数怎么传、闭包变量怎么跨作用域找到——GETL [0, 3] 里第一个 0 是”沿 parentScope 走几层”,这个寻址机制和声明提升的实现,是下一篇的内容。


501 字 · 47 段落
ximing

Written by ximingFollow onGitHub

相关文章