系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈(本篇)
第 1 篇说明了源码编译为 Script 字节码的过程,并介绍了 Frame.run() 取指令、执行和前移 ip 的主循环。本文讨论运行期创建的对象、各对象保存的状态,以及表达式中间结果的流转。所有字节码序列均来自 demo/src/disassemble.ts 对 transform() 产物的反汇编。
从 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 交给 Fiber;fiber.pushFrame(script, this.realm.globalObj) 为顶层 Script 建立第一个 Frame。pushFrame()(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)且未挂起时,取得栈顶 Frame 并调用 frame.run()。每轮开始先检查帧上的错误;若存在错误,则调用 unwind() 沿调用栈查找 Guard。frame.run() 返回后,若错误为 JSVMError,先由 injectStackTrace() 注入错误栈,再检查当前 Frame 是否完成。已完成的 Frame 会处理 finally、构造函数返回值,并由 popFrame() 将 rv 压回调用者的求值栈;未完成则表示刚发生函数调用,下一轮改为执行新的栈顶 Frame。Fiber.run() 选择当前 Frame,Frame.run() 执行该 Frame 的指令。
两个返回值通道值得注意:fiber.rv 传递函数调用的返回值(RETV 指令写入,popFrame() 后压给调用者),fiber.rexp 保存最后一条表达式语句的结果(SREXP 指令写入),exec() 最终返回的就是 rexp——这让 exec() 的行为类似 eval,能拿到整段代码最后一个表达式的值。
Frame 保存什么:一次调用的全部执行状态
Frame(src/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,指向当前Scope(src/vm/scope.ts)。Scope本体是一个parentScope指针加一个定长data数组,GETL/SETL按整数索引读写data,不做名字哈希查找。_scope在帧生命周期内可变:ENTER_SCOPE/EXIT_SCOPE指令会切换它(块级作用域)。 - 左值引用:
lref: any[],一个被当作栈用的数组,配合SLHS/LLHS指令暂存”赋值目标是谁”,下文obj.count++的例子会用到。 - 异常与控制:
guards(当前生效的try保护区间栈)、suspended、evalError、finalizer,以及line/column(由LINE/COLUMN指令维护,Fiber.injectStackTrace()生成错误栈时读取)。 - 调用元信息:
script、fName、construct(是否是new调用,影响返回值处理)。
Frame.run() 在 ip 到达 exitIp、帧被挂起或 fiber.timeout 减至 0 时退出。正常结束时,调试断言要求求值栈为空,否则抛出 eStack has ${len} items。因此每条语句产生的临时值必须被 POP 丢弃、由 SREXP 保存,或通过 SET* 写回。
求值栈如何完成 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 的指令,再发射外层 ADD;MUL 和 ADD 都只执行取两个值、计算和压回结果的操作。二元指令统一以 evalStack.tail(2) 取值:它一次将 idx 减 2 并返回两个元素,替代两次 pop() 调用。GET/SET 中仍保留被注释的连续 pop() 写法;第 6 篇会讨论这类实现的性能验证边界。
为什么不是纯栈机:r1/r2/r3 与 lref
仅使用求值栈处理 obj.count++ 时,读取旧值和写入新值都需要 obj 与属性名;纯栈机需要用 DUP/SWAP 调整栈内容,并在嵌套求值时保存这些值。jsvm3 在求值栈外增加两组状态。
第一组是 Fiber 上的三个临时寄存器 r1/r2/r3,配套指令是 SR1/SR2/SR3(弹栈存入寄存器)和 LR1/LR2/LR3(把寄存器压回栈)。注意它们挂在 Fiber 而不是 Frame 上,是跨帧共享的临时槽位,适合在一小段连续指令内传递”放不进栈顺序里”的值。另外还有表达式寄存器 rexp(SREXP 写入)和返回值通道 rv,前面已经提过。
第二组是 Frame 上的左值引用栈 lref,配套指令是 SLHS 和 LLHS(src/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] 一对值整体收进 lref;LLHS 再把它取回来,同时顺手把 key 和 obj 分别写进 r1、r2,并把两者压回求值栈供 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 旧实现。当前实现以寄存器传递短生命周期的值,以 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/LR3,SET 压回的新值直接作为表达式结果。前后缀差异由编译期生成的指令序列表达,SET 与 INC 不需要区分两种形式。
顺带一提,如果自增目标是局部变量而不是属性(count++),指令会短得多:GETL 取值、SR3/LR3 存取旧值、INC、SETL 写回,最后同样 POP/LR3——变量索引本身就是稳定的”左值地址”,不需要 lref 参与。
两道保险丝:maxDepth 与 timeout
Fiber 的 maxDepth 与 timeout 限制受限环境中下发代码的执行范围。
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) 传入新预算后,保留在 Frame 中的 ip、evalStack 与 lref 会继续被使用,无需重建状态。
另外 Fiber 上还有 maxTraceDepth = 50,只影响 injectStackTrace() 生成错误栈时往回追溯的帧数,不影响执行本身。
小结
本文说明了运行时结构:JSVM.exec() 创建 Fiber;Fiber 保存 Frame 调用栈及跨帧的 r1/r2/r3/rexp/rv;每个 Frame 保存 ip、evalStack、_scope 与 lref。表达式以栈机方式求值,优先级在编译期确定。左值同时需要读取和写入时,寄存器与 lref 在栈外保存赋值目标。maxDepth 和 timeout 分别限制调用深度与指令数。
到这里还有一个关键问题没展开:函数调用时新 Frame 怎么建、参数怎么传、闭包变量怎么跨作用域找到——GETL [0, 3] 里第一个 0 是”沿 parentScope 走几层”,这个寻址机制和声明提升的实现,是下一篇的内容。
