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

2 分钟阅读
·

JSVM3 原理与思考系列第 4 篇:if、循环、break/continue 如何编译成 Label 与跳转指令,以及 try/catch/finally 如何依靠 Guard 表和 Fiber.unwind() 工作。

系列目录

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

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

前三篇讲的机制有一个共同点:指令是直线执行的,ip 一路递增到 exitIp。但真实的代码充满分叉——if 要跳过一段指令,循环要往回跳,break/continue 要跳到循环外或更新点,return 和异常还要穿越 finally。这一篇看这些结构化控制流如何被压平成跳转地址和一张异常表。文中字节码仍全部来自对 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 先从求值栈弹出条件值再决定。注意条件值是被消费掉的——这符合第 2 篇讲的栈纪律:条件表达式在栈上的临时结果,由跳转指令负责清理。

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 循环靠它从循环体末尾跳回条件(whilecont 就标在条件上),而 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:目标地址存在编译期的标签栈里

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,这已经超出单个指令流的范围。

jsvm3 的答案是:编译期把 try 的保护区间登记成一张 Guard 表,运行期由 Fiber.unwind() 按出错位置查表决定控制转移。先看两条相关指令的真实语义(src/opcodes/ins.tssrc/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.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}

第二个例子是理解 finally 机制的关键。RETV 执行后帧已经”结束”(exitIpret() 改成当前 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 路径没有 evalErrorPAUSE 不挂起帧;但此时 exitIp 已被改写为 guard.end,帧会在 finally 结束后直接被回收。正常落下路径同样不会在 PAUSE 处挂起,而是继续执行 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() 的路径。

“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'

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 目标,保证无标签和具名 continue 都会重新判断条件。异常控制流则是编译期与运行期的协作:编译期把保护区间登记为 Guard(start/handler/finalizer/end),THROW 只挂起帧并留下 evalError;运行期 Fiber.run() 在帧结束时改写 exitIpfinally 先于 return 执行,unwind() 按出错 IP 查 Guard 表决定进 handler、补跑 finalizer 还是 popFrame 向上传播。无 catch 的 try/finally 在 finally 后保留 EXIT_GUARD + PAUSE,其中 PAUSE 仅在仍有待传播异常时挂起帧,正常落下路径会继续执行。

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


661 字 · 60 段落
ximing

Written by ximingFollow onGitHub

相关文章