系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈
- JSVM3 如何实现函数调用、作用域与声明提升
- JSVM3 如何编译循环、跳转与 try/catch/finally(本篇)
前三篇讲的机制有一个共同点:指令是直线执行的,ip 一路递增到 exitIp。但真实的代码充满分叉——if 要跳过一段指令,循环要往回跳,break/continue 要跳到循环外或更新点,return 和异常还要穿越 finally。这一篇看这些结构化控制流如何被压平成跳转地址和一张异常表。文中字节码仍全部来自对 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 先从求值栈弹出条件值再决定。注意条件值是被消费掉的——这符合第 2 篇讲的栈纪律:条件表达式在栈上的临时结果,由跳转指令负责清理。
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 RETV布局一目了然:init 只执行一次;条件在循环体之前;continue 跳过循环体尾部直奔 update;update 之后无条件跳回条件。0048 那条 JMP -> 0038 是模板统一产物——blockCleanup 最后总会补一条 JMP cont,while 循环靠它从循环体末尾跳回条件(while 的 cont 就标在条件上),而 for 循环里它排在 JMP start 之后,永远执行不到,属于编译器留下的一条无害死代码。do...while 的条件位于循环体之后。模板为它单独创建 contStmt,在发射循环尾部条件前 mark();循环自身的 continue 目标和关联具名标签的 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,这已经超出单个指令流的范围。
jsvm3 的答案是:编译期把 try 的保护区间登记成一张 Guard 表,运行期由 Fiber.unwind() 按出错位置查表决定控制转移。先看两条相关指令的真实语义(src/opcodes/ins.ts 与 src/utils/opcodes.ts)——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 机制的关键。RETV 执行后帧已经”结束”(exitIp 被 ret() 改成当前 ip),但 frame.guards 里还挂着没弹出的 Guard。Fiber.run()(src/vm/fiber.ts)在每轮循环里检查:帧执行完且 guards 非空时,弹出 Guard,若它有 finalizer,就改写帧的出口——frame.ip = guard.finalizer; frame.exitIp = guard.end,然后 continue 让帧再跑一段。finally 块因此以”把帧的出口临时搬到 guard.end“的方式执行完,之后照常 popFrame,返回值早已由 RETV 存进 fiber.rv,不受影响。实测这段代码返回 1,且 log 的顺序是 ['try', 'finally']。
这条路径上 PAUSE 的作用也清楚了:finally 末尾执行 EXIT_GUARD 后,PAUSE 只在 frame.evalError 仍存在时设置 frame.suspended = true。异常路径借此把控制权交回 Fiber.run(),下一轮 unwind() 会继续向调用者传播错误。return 路径没有 evalError,PAUSE 不挂起帧;但此时 exitIp 已被改写为 guard.end,帧会在 finally 结束后直接被回收。正常落下路径同样不会在 PAUSE 处挂起,而是继续执行 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()的路径。
“finally 跑完接着抛”靠的正是上一节布局里那组 EXIT_GUARD + PAUSE:finally 末尾先弹掉本层 Guard,PAUSE 挂起帧;Fiber.run() 发现帧没执行完且 evalError 仍在,下一轮又进 unwind()——此时本帧的 Guard 已空,popFrame() 把异常传给调用者。实测两层结构 function g() { try { throw 7; } finally { trace.push('in-finally'); } } 外层 try { g(); } catch (e) {...},运行结果是 ['in-try', 'in-finally', 'outer-catch-7']——顺序严格符合”先 finally 后捕获”;跨函数传播也验证过: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 目标,保证无标签和具名 continue 都会重新判断条件。异常控制流则是编译期与运行期的协作:编译期把保护区间登记为 Guard(start/handler/finalizer/end),THROW 只挂起帧并留下 evalError;运行期 Fiber.run() 在帧结束时改写 exitIp 让 finally 先于 return 执行,unwind() 按出错 IP 查 Guard 表决定进 handler、补跑 finalizer 还是 popFrame 向上传播。无 catch 的 try/finally 在 finally 后保留 EXIT_GUARD + PAUSE,其中 PAUSE 仅在仍有待传播异常时挂起帧,正常落下路径会继续执行。
下一篇换个视角,不再讲新机制,而是回答”jsvm3 支持 ES5 到底意味着什么”——用既有测试集的层次来定义一个解释器的语义边界。

