JSVM3 如何编译循环、跳转与 try/catch/finally

📅
2 分钟阅读
·

系列目录

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

  1. 从 AST 直驱到字节码:JSVM3 的编译与执行分层
  2. JSVM3 如何执行字节码:指令、栈帧与求值栈
  3. JSVM3 如何实现函数调用、作用域与声明提升
  4. JSVM3 如何编译循环、跳转与 try/catch/finally(本篇)

前三篇中的指令顺序执行,ip 递增至 exitIpif、循环、break/continuereturn 和异常需要改变这一顺序。本文说明这些控制流结构如何编译为跳转地址和异常表。字节码均来自 transform() 产物的反汇编(hoisting: true, convertES5: false),运行行为已实际执行验证。

Label 与跳转指令:编译期占位,end() 时回填

跳转指令需要目标地址,但 ifelse 分支长度要在编译后才能确定。jsvm3 使用 Labelsrc/opcodes/label.ts)保存该地址。它的关键字段是 idip,另持有 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.ipJMPT/JMPF 从求值栈弹出条件值后决定是否跳转。条件表达式的临时结果由跳转指令消费。

if 与短路逻辑:两个 Label 就够了

Emitter.IfStatement()src/compiler/emitter.ts)只用两个 Label:ifTrueend。发射顺序是:条件表达式 → JMPT ifTrue → alternate(else 分支)→ JMP endifTrue.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 evalEndPOP → 编译右侧 → 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 里 whiledo...whileforfor...infor...of 全部汇入同一个 Emitter.VmLoop()src/compiler/emitter.ts),差异只是传入的发射回调不同:emitInit(初始化)、emitBeforeTest(循环前条件)、emitUpdate(每轮更新)、emitAfterTest(循环后条件,do…while 专用)。模板内部维护三个 Label,正好对应循环语义的三种跳转角色:

  • start:条件入口,for 有 update 时标在条件之前,update 执行完跳回这里;
  • contcontinue 的目标。for 标在 update 之前(continue 后必须先跑 update);while 没有 update,直接标在条件上;
  • brk:循环出口,条件不成立的 JMPFbreakJMP 都指向它。

用一个带 continuefor 循环看实际布局,function f() { var s = 0; for (var i = 0; i < 3; i++) { if (i === 1) { continue; } s += i; } return s; }(省略 LINE/COLUMN/SREXP,以及 SWAPSR3/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 contwhile 通过该指令从循环体末尾回到条件,因其 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:目标地址存在编译期的标签栈里

编译 breakcontinue 时,需要确定目标循环的 brk/cont Label。Emitter 维护编译期标签栈 this.labelsVmLoop() 进入循环体前调用 pushLabel(null, node, brk, cont),编译完成后调用 popLabel()BreakStatement() 通过 this.label() 取栈顶即最内层循环的标签,并发射 JMP label.brkContinueStatement() 发射 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 还需要处理以下情况:

  1. 异常的目标地址编译期不可知throw 发生在哪条指令、该由哪层 catch 接,只有运行时知道;
  2. finally 必须在 return 之前执行。而 RETV 的语义是立即结束当前帧(ret() 会清空求值栈并把 exitIp 改写为当前 ip,见 src/opcodes/utils.ts),如果没人干预,finally 根本没机会跑;
  3. 异常可以跨函数传播。内层函数抛出的错误要沿调用栈逐帧找 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.tsfreeProcess.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}

第二个例子说明 finallyreturn 前执行的过程。RETV 执行后,ret()exitIp 改为当前 ip,但 frame.guards 仍包含未弹出的 Guard。Fiber.run() 在帧完成且 guards 非空时弹出 Guard;若 Guard 有 finalizer,则设置 frame.ip = guard.finalizerframe.exitIp = guard.end,使 Frame 继续执行 finally 块。随后 Frame 正常 popFrame,返回值已由 RETV 写入 fiber.rv。实测该代码返回 1log 顺序为 ['try', 'finally']

finally 末尾的 PAUSE 仅在 frame.evalError 仍存在时设置 frame.suspended = true。异常路径随后由 Fiber.run() 的下一轮 unwind() 继续向调用者传播错误。return 路径没有 evalErrorPAUSE 不挂起帧;由于 exitIp 已改为 guard.end,finally 结束后 Frame 会被回收。正常落下路径也会继续执行 end 处的 EXIT_GUARD

unwind():按 IP 查表,逐帧传播

异常路径的另一半在 Fiber.unwind()src/vm/fiber.ts)。Fiber.run() 每轮循环开头发现帧上有 evalError 就调用它,它从调用栈顶开始逐帧查找:

  1. 把错误写到当前帧 evalError,取 ip = frame.ip - 1ip 已指向下一条指令,要回退一条才是出错位置);
  2. frame.guards 栈顶的 Guard,命中条件是 guard.start <= ip && ip <= guard.end——出错位置落在保护区间内;
  3. 命中后分三种情况:
    • 有 handler 且 ip <= guard.handler(错误发生在 try 块内):把错误对象压入求值栈、清掉 evalErrorip = guard.handler——catch 块入口处的第一条有效指令就是把栈顶值赋给 catch (e) 的参数,错误对象由此完成交接;
    • 有 handler 但错误发生在 catch 或 finally 区:如果还有 finalizer 没跑过,先 ip = guard.finalizer 把 finally 补跑(错误保留在 evalError 上);否则 popFrame() 继续向上传;
    • 无 handler(纯 try/finally)ip = guard.finalizer,错误保留——finally 照常执行,异常稍后再传。
  4. 未命中则 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'

try/finally 中抛错的控制转移路径:Guard 命中与 unwind

__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...whilecontinue 现在与 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 存在时挂起帧。正常执行时它是空操作,指令继续走到 endEXIT_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 并记录 evalErrorFiber.run() 在 Frame 完成时改写 exitIp,使 finallyreturn 前执行;unwind() 按出错 IP 查询 Guard,进入 handler、执行 finalizer 或通过 popFrame 向上传播。无 catch 的 try/finally 在 finally 后保留 EXIT_GUARD + PAUSE,其中 PAUSE 仅在存在待传播异常时挂起 Frame。

下一篇换个视角,不再讲新机制,而是回答”jsvm3 支持 ES5 到底意味着什么”——用既有测试集的层次来定义一个解释器的语义边界。


626 字 · 60 段落
ximing

Follow onGitHub

相关文章