系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈(本篇)
第 1 篇走完了一条代码从源码到 Script 字节码的编译路径,最后停在 Frame.run() 那个”取指令、执行、ip 前移”的主循环上。这一篇往运行期内部再走一层:拿到 Script 之后,运行时到底创建了哪些对象、每个对象保存什么状态、一条表达式在中间结果层面是如何流动的。文中所有字节码序列都来自 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 是否执行完——执行完则处理 finally、构造函数返回值,再 popFrame() 把返回值 rv 压回调用者的求值栈;没执行完说明刚才发生了函数调用,frame 改指新的栈顶 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 两条指令本身都只是”弹两个值、算一下、压回去”(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 上,是跨帧共享的临时槽位,适合在一小段连续指令内传递”放不进栈顺序里”的值。另外还有表达式寄存器 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 旧实现,说明早期版本确实直接用寄存器倒手,后来才演进成 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/LR3,SET 压回的新值直接就是表达式结果,语义差异完全由编译期生成的指令不同来表达,SET/INC 自身不用区分前后缀。
顺带一提,如果自增目标是局部变量而不是属性(count++),指令会短得多:GETL 取值、SR3/LR3 存取旧值、INC、SETL 写回,最后同样 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 从断点继续执行——ip、evalStack、lref 全都还留在 Frame 上,恢复执行不需要重建任何状态。这也是字节码方案相对 AST 直驱的一个结构性优势:执行位置是一个整数和一组平铺的数据结构,天然可中断、可恢复。
另外 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 走几层”,这个寻址机制和声明提升的实现,是下一篇的内容。

