系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈
- JSVM3 如何实现函数调用、作用域与声明提升
- JSVM3 如何编译循环、跳转与 try/catch/finally(本篇)
前三篇中的指令顺序执行,ip 递增至 exitIp。if、循环、break/continue、return 和异常需要改变这一顺序。本文说明这些控制流结构如何编译为跳转地址和异常表。字节码均来自 transform() 产物的反汇编(hoisting: true, convertES5: false),运行行为已实际执行验证。
Label 与跳转指令:编译期占位,end() 时回填
跳转指令需要目标地址,但 if 的 else 分支长度要在编译后才能确定。jsvm3 使用 Label(src/opcodes/label.ts)保存该地址。它的关键字段是 id 和 ip,另持有 emitter 引用;ip 初始为 null,调用 mark() 后记录当前 emitter.instructions.length,即下一条待发射指令的位置。
发射跳转指令时参数直接放 Label 对象,比如 this.createINS(JMPT, ifTrue)。整个编译结束后,Emitter.end() 遍历所有指令调用 forEachLabel()(src/opcodes/utils.ts),把参数里的 Label 原地替换为它的 ip 数字;如果有 Label 忘了 mark(),直接抛 label has not been marked——编译期的自我检查,不会把悬空跳转带进运行时。
条件/无条件跳转指令只有三条(src/opcodes/ins.ts),语义同样很薄:
export const JMP = createOP(OPCodeIdx.JMP, function (frame, evalStack, scope, realm, args) {
frame.ip = args[0];
});
export const JMPT = createOP(OPCodeIdx.JMPT, function (frame, evalStack, scope, realm, args) {
if (evalStack.pop()) {
frame.ip = args[0];
}
});
// JMPF 与 JMPT 对称,条件为假时跳转JMP 直接改写 frame.ip;JMPT/JMPF 从求值栈弹出条件值后决定是否跳转。条件表达式的临时结果由跳转指令消费。
if 与短路逻辑:两个 Label 就够了
Emitter.IfStatement()(src/compiler/emitter.ts)只用两个 Label:ifTrue 和 end。发射顺序是:条件表达式 → JMPT ifTrue → alternate(else 分支)→ JMP end → ifTrue.mark() → consequent(then 分支)→ end.mark()。也就是说,else 分支在指令流里排在前面,条件为真时跳过去执行 then 分支。三元表达式 ConditionalExpression() 直接复用 IfStatement(),两者生成同一套结构。用 function f(a) { var r; if (a > 1) { r = 1; } else { r = 2; } return r; } 反汇编(省略 LINE/COLUMN/SREXP):
0013 GETL [0, 3] ; a
0015 LITERAL [1]
0016 GT
0017 JMPT -> 0024 ; 为真跳到 then 分支
0020 LITERAL [2] ; else 分支在前
0021 SETL [0, 4] ; r
0023 JMP -> 0029 ; 跳过 then 分支
0026 LITERAL [1] ; 0024: then 分支
0027 SETL [0, 4] ; r
0031 GETL [0, 4] ; r ; 0029: end
0032 RETV短路逻辑表达式走的是同一族指令,但多了一个技巧。Emitter.LogicalExpression():编译左侧 → DUP 复制栈顶 → || 用 JMPT evalEnd、&& 用 JMPF evalEnd → POP → 编译右侧 → evalEnd.mark()。DUP 的意义是:短路命中时,跳转前栈上留的那份副本就是整个表达式的值(a || b 短路时结果即 a);未短路时 POP 丢掉副本,右侧的值成为结果。function f(a) { return a || 2; } 的核心指令:
0011 GETL [0, 3] ; a
0012 DUP
0013 JMPT -> 0017 ; a 为真,结果就是栈顶的 a
0014 POP
0016 LITERAL [2]
0017 RETV逻辑运算符返回某个操作数而非布尔值的语义由 DUP/POP 的指令顺序表达;JMPT 不区分短路逻辑与其他条件跳转。
循环:一个 VmLoop 模板,三种跳转角色
jsvm3 里 while、do...while、for、for...in、for...of 全部汇入同一个 Emitter.VmLoop()(src/compiler/emitter.ts),差异只是传入的发射回调不同:emitInit(初始化)、emitBeforeTest(循环前条件)、emitUpdate(每轮更新)、emitAfterTest(循环后条件,do…while 专用)。模板内部维护三个 Label,正好对应循环语义的三种跳转角色:
start:条件入口,for有 update 时标在条件之前,update 执行完跳回这里;cont:continue的目标。for标在 update 之前(continue 后必须先跑 update);while没有 update,直接标在条件上;brk:循环出口,条件不成立的JMPF和break的JMP都指向它。
用一个带 continue 的 for 循环看实际布局,function f() { var s = 0; for (var i = 0; i < 3; i++) { if (i === 1) { continue; } s += i; } return s; }(省略 LINE/COLUMN/SREXP,以及 SWAP、SR3/LR3 等栈交换和寄存器倒手指令):
0011 LITERAL [0]
0012 SETL [0, 3] ; i init
0013 POP
0014 ... start: 条件入口
0015 GETL [0, 3] ; i
0017 LITERAL [3]
0018 LT
0019 JMPF -> 0049 条件为假,跳到 brk 退出
0022 GETL [0, 3] ; i 循环体开始
0024 LITERAL [1]
0025 CID i === 1
0026 JMPT -> 0028
0027 JMP -> 0030
0029 JMP -> 0038 continue: 跳到 cont
0032 GETL [0, 3] ; i
0033 GETL [0, 4] ; s
0035 ADD
0036 SETL [0, 4] ; s s += i
0038 ... cont: update
0039 GETL [0, 3] ; i
0042 INC
0043 SETL [0, 3] ; i i++
0047 JMP -> 0014 回到 start 重新判断条件
0048 JMP -> 0038 不可达指令(见下)
0049 ... brk: 退出点
0050 GETL [0, 4] ; s
0051 RETVinit 只执行一次;条件位于循环体之前;continue 跳过循环体尾部并进入 update;update 后无条件跳回条件。0048 的 JMP -> 0038 由统一模板生成:blockCleanup 总会追加 JMP cont。while 通过该指令从循环体末尾回到条件,因其 cont 标记在条件处;for 中该指令位于 JMP start 后,不会执行。do...while 的条件位于循环体之后。模板为它创建独立的 contStmt,并在发射循环尾部条件前标记;循环及关联具名标签的 continue 都跳到 contStmt,再重新计算条件。
__tests__/es5/loop.test.ts 覆盖了这套布局的正确性:for/while/do...while/for...in 各自的 base、continue、循环内 return 用例,以及大部分的 break 用例,全部按预期求值。
break/continue:目标地址存在编译期的标签栈里
编译 break 和 continue 时,需要确定目标循环的 brk/cont Label。Emitter 维护编译期标签栈 this.labels;VmLoop() 进入循环体前调用 pushLabel(null, node, brk, cont),编译完成后调用 popLabel()。BreakStatement() 通过 this.label() 取栈顶即最内层循环的标签,并发射 JMP label.brk;ContinueStatement() 发射 JMP label.cont。因此嵌套循环中未命名的 break 作用于最内层循环。
具名标签走的是另一路:LabeledStatement() 把名字压进同一个栈(pushLabel(node.label.name, node.body, brk)),label(name) 按名字查找。VmLoop() 里还有一段容易看漏的逻辑:如果发现当前命名标签的 stmt 正是这个循环节点(outer: for (...)),就把循环的 cont 挂到命名标签上——这样 continue outer 才能拿到外层循环的 continue 点,而不只是 break outer。实测验证:
var out = [];
outer: for (var i = 0; i < 3; i++) {
for (var j = 0; j < 3; j++) {
if (j === 1) { continue outer; }
if (i === 2) { break outer; }
out.push(i + '-' + j);
}
}
// 实际运行结果:['0-0', '1-0']另外还有一个配套机制 addCleanupHook():for...in/for...of 的迭代器对象压在求值栈上,带标签的 break/continue 跨层跳出时需要先把迭代器 POP 掉,清理逻辑以 hook 形式注册到命名标签和外围的 try 语句上,在跳转发射前执行——这是跳转指令之外,为栈纪律做的编译期补偿。
try/finally 为什么普通跳转表达不了
break/continue 的目标地址可在编译期确定,并由一条 JMP 跳转。try/catch/finally 还需要处理以下情况:
- 异常的目标地址编译期不可知。
throw发生在哪条指令、该由哪层catch接,只有运行时知道; finally必须在return之前执行。而RETV的语义是立即结束当前帧(ret()会清空求值栈并把exitIp改写为当前ip,见src/opcodes/utils.ts),如果没人干预,finally根本没机会跑;- 异常可以跨函数传播。内层函数抛出的错误要沿调用栈逐帧找
catch,这已经超出单个指令流的范围。
编译期将 try 的保护区间登记为 Guard 表,运行期由 Fiber.unwind() 按出错位置查询并决定控制转移。THROW 不直接抛出宿主异常:
export const THROW = createOP(OPCodeIdx.THROW, function (frame, evalStack, scope, realm, args) {
throwErr(frame, evalStack.pop());
});
// throwErr:frame.evalError = err; frame.suspended = true;THROW 只是把错误对象挂到 frame.evalError 并挂起当前帧,真正的”抛”是后面 Fiber.run() 主循环发现 evalError 后调 unwind() 完成的。tryStatement.test.ts 里 freeProcess.binding('util') 的例子走的是另一条路径:binding 是数字,callm() 在方法不是函数的类型检查分支创建 JSVMTypeError,再交给 throwErr(),不会进入 callFun()。原生函数调用抛出的宿主异常仍会被收编:callFun()(src/opcodes/utils.ts)把 func.apply() 包在 try/catch 里,捕到的 nativeError 同样交给 throwErr();我额外验证了 VM 调用 function () { throw new TypeError('native boom'); } 的场景,外围 catch 拿到的是这个原生 TypeError。
另外两条配套指令:ENTER_GUARD [guardId] 把 script.guards[guardId] 压入 frame.guards(注意这是两份东西——Script.guards 是编译期静态表,frame.guards 是运行期生效栈,嵌套 try 会叠多层);EXIT_GUARD 仅当指定 Guard 仍是栈顶时才弹出它。PAUSE 仅在 frame.evalError 仍存在时设置 frame.suspended = true,用于 finally 之后继续传播异常。
Guard 的四个字段与 exitIp 改写
Emitter.TryStatement()(src/compiler/emitter.ts)为每个 try 创建四个 Label 组成 Guard(类型定义在 src/vm/types.ts):start(try 块入口)、handler(catch 入口,无 catch 时为 null)、finalizer(finally 入口,无 finally 时为 null)、end(整个语句出口)。end() 阶段这四个 Label 和跳转参数一样被回填成数字地址。指令布局固定为:
ENTER_GUARD [id]
start: try 块
JMP -> finalizer ; 正常走完 try,跳过 handler
handler: catch 块(错误对象在栈顶,绑定给 catch 参数)
finalizer: finally 块
(无 catch 时追加:EXIT_GUARD + PAUSE)
end: EXIT_GUARD [id]看两个真实产物。function f() { try { throw new Error('x'); } catch (e) { return 1; } }:
0002 ENTER_GUARD [0]
0008 NEW [1] ; new Error('x')
0009 THROW
0010 JMP -> 0018
0013 SETL [0, 3] ; e ; 0011 = handler:栈顶错误对象绑定给 e
0016 LITERAL [1]
0017 RETV
0018 EXIT_GUARD [0] ; 0018 = end
; guard: {"start":3,"handler":11,"finalizer":null,"end":18}function f(log) { try { log('try'); return 1; } finally { log('finally'); } }:
0010 ENTER_GUARD [0]
0016 CALL [1, 1] ; log('try')
0020 LITERAL [1]
0021 RETV ; return 1
0022 JMP -> 0023
0028 CALL [1, 1] ; 0023 = finalizer: log('finally')
0030 EXIT_GUARD [0]
0031 PAUSE
0032 EXIT_GUARD [0] ; 0032 = end
; guard: {"start":11,"handler":null,"finalizer":23,"end":32}第二个例子说明 finally 在 return 前执行的过程。RETV 执行后,ret() 将 exitIp 改为当前 ip,但 frame.guards 仍包含未弹出的 Guard。Fiber.run() 在帧完成且 guards 非空时弹出 Guard;若 Guard 有 finalizer,则设置 frame.ip = guard.finalizer 和 frame.exitIp = guard.end,使 Frame 继续执行 finally 块。随后 Frame 正常 popFrame,返回值已由 RETV 写入 fiber.rv。实测该代码返回 1,log 顺序为 ['try', 'finally']。
finally 末尾的 PAUSE 仅在 frame.evalError 仍存在时设置 frame.suspended = true。异常路径随后由 Fiber.run() 的下一轮 unwind() 继续向调用者传播错误。return 路径没有 evalError,PAUSE 不挂起帧;由于 exitIp 已改为 guard.end,finally 结束后 Frame 会被回收。正常落下路径也会继续执行 end 处的 EXIT_GUARD。
unwind():按 IP 查表,逐帧传播
异常路径的另一半在 Fiber.unwind()(src/vm/fiber.ts)。Fiber.run() 每轮循环开头发现帧上有 evalError 就调用它,它从调用栈顶开始逐帧查找:
- 把错误写到当前帧
evalError,取ip = frame.ip - 1(ip已指向下一条指令,要回退一条才是出错位置); - 查
frame.guards栈顶的 Guard,命中条件是guard.start <= ip && ip <= guard.end——出错位置落在保护区间内; - 命中后分三种情况:
- 有 handler 且
ip <= guard.handler(错误发生在 try 块内):把错误对象压入求值栈、清掉evalError、ip = guard.handler——catch 块入口处的第一条有效指令就是把栈顶值赋给catch (e)的参数,错误对象由此完成交接; - 有 handler 但错误发生在 catch 或 finally 区:如果还有
finalizer没跑过,先ip = guard.finalizer把 finally 补跑(错误保留在evalError上);否则popFrame()继续向上传; - 无 handler(纯 try/finally):
ip = guard.finalizer,错误保留——finally 照常执行,异常稍后再传。
- 有 handler 且
- 未命中则
popFrame()弹掉当前帧,到调用者帧上重复上述查找;所有帧都没有 Guard 命中,最后throw err抛给宿主——这就是未被捕获的异常逃出exec()的路径。
EXIT_GUARD + PAUSE 使 finally 执行后继续传播异常:finally 末尾先弹出本层 Guard,PAUSE 挂起帧;Fiber.run() 发现 Frame 未完成且仍有 evalError 后,在下一轮进入 unwind()。此时该 Frame 的 Guard 已为空,popFrame() 将异常传给调用者。实测 function g() { try { throw 7; } finally { trace.push('in-finally'); } } 被外层 try { g(); } catch (e) {...} 调用时,结果为 ['in-try', 'in-finally', 'outer-catch-7'];inner() 抛错、outer() 捕获时,得到 'caught:deep'。
__tests__/es5/tryStatement.test.ts 覆盖了这张表的主要分支:无异常时 catch 不执行、throw 被 catch 捕获、finally 必执行、非法方法调用被捕获(throw function error 用例,IIFE 内 try/catch 包住一次对数字的调用,callm() 产生 JSVMTypeError 后返回 1),以及无 catch 的 try/finally 在正常落下、return 和异常重抛时的行为。
修复:do…while 的 continue 与 try/finally 正常落下路径
do...while 的 continue 现在与 JavaScript 语义一致:无标签和具名 continue 都先跳到循环尾部,再执行条件判断。VmLoop() 为 do...while 创建独立的 contStmt Label,在 emitAfterTest() 之前标记它;循环标签栈保存的 cont 以及关联具名标签的 cont 都指向这个 Label。新增测试覆盖了无条件 continue、条件 continue 和嵌套循环中的 continue,例如 do { i++; continue; } while (i < 10) 最终得到 10,不会跳过 i < 10 的判断。
无 catch 的 try/finally 正常落下路径也已修复。编译器仍在 finally 后发射 EXIT_GUARD + PAUSE,但 PAUSE 现在只在 frame.evalError 存在时挂起帧。正常执行时它是空操作,指令继续走到 end 的 EXIT_GUARD,因此 try { ... } finally { ... } 后面的语句可以继续执行;异常路径保留挂起行为,以便 Fiber.run() 再次调用 unwind() 向上传播错误。新增测试分别验证正常落下、try 中 return,以及 finally 后重新抛出异常的路径。
小结
本文说明了控制流实现。Label 保存跳转目标,end() 回填地址;if 和短路逻辑通过 JMPT/JMPF 分支;VmLoop() 生成循环入口、条件、continue 点和退出点;break/continue 从编译期标签栈获取目标。do...while 为循环尾部条件使用独立的 continue 目标。异常控制流由编译期和运行期共同处理:编译期登记包含 start/handler/finalizer/end 的 Guard;THROW 挂起 Frame 并记录 evalError;Fiber.run() 在 Frame 完成时改写 exitIp,使 finally 在 return 前执行;unwind() 按出错 IP 查询 Guard,进入 handler、执行 finalizer 或通过 popFrame 向上传播。无 catch 的 try/finally 在 finally 后保留 EXIT_GUARD + PAUSE,其中 PAUSE 仅在存在待传播异常时挂起 Frame。
下一篇换个视角,不再讲新机制,而是回答”jsvm3 支持 ES5 到底意味着什么”——用既有测试集的层次来定义一个解释器的语义边界。
