阅读视图

发现新文章,点击刷新页面。

阶段二:为什么先设计指令集,编译器和运行时才能稳定对齐?

本章目标

这一章的任务是把“协议层”设计清楚。读完以后,你应该能回答:

  1. 一条 VM 指令由哪些部分组成?
  2. 为什么不能简单地“一个语法点对应一个 opcode”?
  3. registerslotconstant pool 为什么必须分离?
  4. 为什么 INIT_SLOTSTORE_SLOT 要从一开始就分开?

先看地图:指令集在整条链路中的位置

flowchart LR
    A["Lowering<br/>生成 IR"] --> B["Instruction Set<br/>定义动作协议"]
    B --> C["Emit<br/>编码为字节码"]
    B --> D["Runtime<br/>按协议解释执行"]

指令集不是实现细节,而是编译器和运行时共享的一份合同:

  • lowering 依赖它决定“我能发出哪些动作”。
  • emit 依赖它决定“这些动作如何编码”。
  • runtime 依赖它决定“数字该怎么解释”。

因此,指令集一旦混乱,三个阶段会一起变得难以维护。


为什么“一个语法点一个 opcode”不是好设计

JavaScript 的语法种类很多,但 VM 需要的不是“语法名录”,而是“可组合的基础动作”。例如:

var x = 40 + 2;

从源码角度看,它是“变量声明 + 二元表达式”;从 VM 角度看,它只需要拆成下面几步:

load_const  r0, 40
load_const  r1, 2
binary      r2, r0, r1, +
init_slot   slot0, r2

也就是说,高层语法会在 lowering 阶段被拆开,而底层 opcode 更适合围绕“最小动作”设计。

更稳的设计思路

类别 代表指令 作用
加载类 LOAD_CONST LOAD_SLOT LOAD_GLOBAL 把值加载到寄存器
存储类 INIT_SLOT STORE_SLOT STORE_GLOBAL 把寄存器结果写回某处
运算类 BINARY UNARY 在寄存器之间做计算
控制流 JUMP JUMP_IF_FALSE RETURN 改变执行路径

这类分层的好处是:语法可以继续扩,底层协议不必同步膨胀。


一条指令到底由什么组成

先看最小例子:

LOAD_CONST r0, 3

它至少包含两部分:

组成部分 含义
opcode 做什么
operand 对谁做、结果放哪、额外参数是什么

编码以后,同一条指令可能变成:

[1, 0, 3]

这里的关键不在数字本身,而在“读写规则必须一致”:

  • emit 写入几个数字
  • runtime 就必须按同样顺序读出几个数字

这也是为什么指令宽度要尽早固定。否则运行时的 pc 很容易错位。


为什么 registerslotconstant pool 要分离

第二章第一次把变量系统补上后,最容易混淆的就是这三类存储位置。

概念 典型内容 负责的问题
Register r0, r1, r2 当前表达式算到了哪里
Slot slot0, slot1 某个变量绑定住在哪里
Constant Pool 40, 2, "__result" 字节码中会重复引用哪些常量

三者分离后,系统会得到三个直接收益:

  1. 表达式求值不必和变量绑定耦合。
  2. 字节码不必反复内嵌相同字面量。
  3. 运行时的数据流与环境模型可以各自演进。

为什么 INIT_SLOTSTORE_SLOT 不能合并

这两个动作表面都像“往 slot 写值”,但语义完全不同:

指令 语义时机 后续扩展价值
INIT_SLOT 绑定第一次被初始化 let / const / TDZ 留出状态位
STORE_SLOT 已存在绑定被再次赋值 为可变绑定建立正常写路径

教程第二步对应的示例文件是:

  • docs/examples/tutorial-jsvm/02-slots-and-env.js

里面最关键的不是 opcode 数量,而是变量写入被拆成了两个阶段:

function writeSlot(env, slot, value, isInit) {
  if (isInit) {
    env.values[slot] = value
    env.states[slot] = 1
    return value
  }

  if (!env.states[slot]) {
    throw new Error(`slot ${slot} is not initialized`)
  }

  env.values[slot] = value
  return value
}

这段代码体现的是“状态机”思维,而不是“赋值就是覆盖”的直觉式实现。


第二章的最小成果:让变量第一次拥有自己的位置

教程示例中,下面这段源码:

var x = 40 + 2;
__result = x;

会被手工写成如下 program

const program = {
  slotCount: 1,
  constants: [40, 2, '__result'],
  bytecode: [
    OPCODES.LOAD_CONST, 0, 0,
    OPCODES.LOAD_CONST, 1, 1,
    OPCODES.BINARY, 2, 0, 1, BINARY_OPS.ADD,
    OPCODES.INIT_SLOT, 0, 2,
    OPCODES.LOAD_SLOT, 3, 0,
    OPCODES.STORE_GLOBAL, 2, 3,
    OPCODES.RETURN, 3,
  ],
}

如果按“执行视图”观察,它对应的是一条非常清晰的流水线:

步骤 指令 状态变化
1 LOAD_CONST r0, 40 把常量放进寄存器
2 LOAD_CONST r1, 2 再准备第二个操作数
3 BINARY r2, r0, r1, + 得到临时结果
4 INIT_SLOT slot0, r2 把变量 x 初始化到环境中
5 LOAD_SLOT r3, slot0 把变量值取回寄存器
6 STORE_GLOBAL "__result", r3 把结果写回宿主对象

这里最关键的结构变化是:变量值第一次不再“寄宿”于寄存器,而是进入了 env.values[slot]


指令集设计时,应该优先守住哪些原则

原则一:让运行时读取规则尽可能稳定

指令的编码规则一旦固定,pc 才能可预测地推进。

原则二:让高层语义拆成少量可复用动作

这样 lowering 才不会和 opcode 表一起失控膨胀。

原则三:为后续语义提前留接口

INIT_SLOT/STORE_SLOT 的分离,就是为提升、TDZ、不可变绑定预留空间。

原则四:让调试时能看出数据流

寄存器式 IR 与字节码最大的工程价值之一,就是更容易观察每一步的输入输出。


本章小结

这一章真正建立的是“协议意识”:

  • 指令集不是随手起名,而是编译器与运行时的共享合同。
  • opcode 设计应围绕最小动作,而不是围绕语法表面名称。
  • register / slot / constant pool 的分离,是系统稳定扩展的前提。
  • INIT_SLOTSTORE_SLOT 的区分,为 JavaScript 变量语义留出了落地空间。

下一章开始,我们就不再手写 program 对象,而是把源码真正降成 IR。

阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM

本章目标

这一章要先把“机器全貌”搭出来。读完以后,你应该能回答:

  1. JSVMJSVMP、编译器、解释器之间是什么关系?
  2. 一段普通 JavaScript 进入系统后,会依次经历哪些形态?
  3. 为什么本项目选择寄存器机,而不是栈机?
  4. registerslotenv 为什么必须分离?

先看整机:一段源码在系统里的生命周期

flowchart LR
    A["Source<br/>var x = 40 + 2"] --> B["AST<br/>语法树"]
    B --> C["IR<br/>线性执行步骤"]
    C --> D["Bytecode<br/>数字协议"]
    D --> E["Runtime<br/>解释器循环"]
    E --> F["Result<br/>执行结果"]

这条流水线说明了一件事:JSVMP 不是“把源码塞进一段混淆代码里”,而是把源码翻译成另一套执行协议,再由内嵌虚拟机解释执行。

更精确地说,JSVMP = 编译期翻译 + 运行时重放语义


为什么 VM 的核心,其实只是一个状态机

先看最小解释器骨架:

function run(program) {
  const regs = []
  const code = program.bytecode
  let pc = 0

  while (pc < code.length) {
    const op = code[pc++]

    switch (op) {
      case OPCODES.LOAD_CONST:
        // ... 读操作数,写寄存器 ...
        break
      case OPCODES.BINARY:
        // ... 取寄存器,做运算,写回结果 ...
        break
      case OPCODES.RETURN:
        // ... 结束并返回 ...
        break
    }
  }
}

这段代码足以暴露 VM 的三件基础事实:

  • pc 负责指出“下一条指令从哪里开始读”。
  • regs 负责保存表达式求值过程中的中间结果。
  • switch(op) 负责把数字协议还原成真实动作。

从架构角度看,VM 的本质并不神秘。真正的难点在于:编译器输出的协议,必须和这个状态机逐项对齐。


为什么 AST 之后还要有 IR 这一层

先看同一段代码在两种表示下的差异:

源码

var x = 40 + 2;
__result = x;

AST 视角:强调“结构”

{
  "type": "VariableDeclaration",
  "declarations": [
    {
      "id": { "name": "x" },
      "init": {
        "type": "BinaryExpression",
        "left": { "type": "NumericLiteral", "value": 40 },
        "right": { "type": "NumericLiteral", "value": 2 }
      }
    }
  ]
}

IR 视角:强调“顺序”

load_const   r0, 40
load_const   r1, 2
binary       r2, r0, r1, +
init_slot    slot0, r2
load_slot    r3, slot0
store_global "__result", r3

两者都重要,但职责不同:

表示层 擅长表达什么 不擅长表达什么
AST 源码的嵌套结构 线性执行顺序
IR 逐步执行的动作序列 高层语法层次

这也是本系列教程把“AST -> IR”单独拿出来讲的原因。


为什么这里选择寄存器机,而不是栈机

同样是计算 40 + 2,两类 VM 的指令风格完全不同。

栈机:中间结果隐含在栈顶

PUSH 40
PUSH 2
ADD

寄存器机:中间结果显式落在目标位

LOAD_CONST r0, 40
LOAD_CONST r1, 2
BINARY     r2, r0, r1, +

本项目选择寄存器机,不是因为它“更高级”,而是因为它更贴合 lowering 的输出习惯:

  • AST 展平之后会自然产生大量临时值。
  • 这些临时值在寄存器模型里可以拥有稳定编号。
  • 当控制流、函数调用、对象访问逐步加入后,寄存器式 IR 更容易检查和调试。

两种模型的对比

维度 栈机 寄存器机
中间结果位置 隐含在栈顶 显式写在目标寄存器
指令长度 通常更短 通常更长
可读性 需要追踪栈变化 直接看到数据流向
调试体验 更依赖心算 更适合打印状态

为什么变量不能直接“住在寄存器里”

从执行角度看,表达式结果和变量绑定是两类完全不同的东西。

概念 作用 生命周期
Register 保存临时计算结果 通常只覆盖当前表达式
Slot 保存变量绑定对应的位置 伴随作用域存活
Env 管理一组 slot,并串成作用域链 伴随函数/块级作用域存活

可以把它们理解成三种不同的存储设施:

  • register 是桌面便签,适合临时放中间结果。
  • slot 是编号抽屉,适合保存变量绑定。
  • env 是整组抽屉组成的文件柜,负责向外层作用域链接。

这组分层会直接决定后面如何实现闭包与提升。


编译期和运行时为什么必须保持同构

编译器在 lowering 阶段会算出一个变量应该如何被访问:

load_slot dst=r4 depth=1 slot=0

这条指令其实已经携带了运行时假设:

  • 当前函数的环境不是目标环境。
  • 需要沿着 env.parent 向外走 1 层。
  • 到达目标环境后,从 slot0 读取值。

因此,编译器里的作用域分析和运行时里的环境链必须描述同一件事。它们不是“相似”,而是“同构”。

一旦两者对不齐,就会出现这类问题:

  • 编译期认为变量在外层,运行时却找错了层级。
  • 编译期把某个绑定当成可读,运行时却仍处于未初始化状态。

从最小示例看整机如何第一次跑通

教程第一步对应的配套文件是:

  • docs/examples/tutorial-jsvm/01-handwritten-register-vm.js

它只做一件事:用 LOAD_CONSTBINARYRETURN 三种指令跑通 40 + 2

const program = {
  constants: [40, 2],
  bytecode: [
    OPCODES.LOAD_CONST, 0, 0,
    OPCODES.LOAD_CONST, 1, 1,
    OPCODES.BINARY, 2, 0, 1, BINARY_OPS.ADD,
    OPCODES.RETURN, 2,
  ],
}

这个例子之所以重要,不在于它功能多,而在于它第一次把下面四个零件同时摆上桌面:

  1. 指令协议
  2. 运行时状态
  3. 字节码输入
  4. 返回出口

后面的章节,都是在这个最小框架上逐步补语义能力。


本章小结

这一章真正要建立的是“坐标系”:

  • JSVMP 是一条完整的编译执行流水线,不是单点技巧。
  • AST、IR、Bytecode、Runtime 各自负责不同层次的问题。
  • 寄存器机更适合承载 lowering 之后的线性步骤。
  • register / slot / env 的边界,是后续所有运行时语义的基础。

带着这套坐标再进入下一章,指令集就不再只是“列一张 opcode 表”,而会成为连接编译器与运行时的协议层。

从零构建寄存器式 JSVMP:实战教程导读

先说人话:这套教程到底在解决什么问题?

你大概率见过这种场景:

  • 业务代码一上线,核心逻辑很快就被别人扒走
  • 明明会用 Babel 写 AST 插件,但一碰到“怎么把 AST 变成可执行字节码”就卡住
  • 知道闭包、作用域链、this 这些概念,可一旦要自己实现一个运行时,脑子里全是结

这套教程就是奔着这个痛点来的。我们不会停在“JSVMP 是什么”的概念介绍,而是带你把一个能跑起来的寄存器式 JSVMP,从编译到执行,一步一步拆开。

你会亲手做出什么?

一个最小可用的 JavaScript 虚拟化保护编译器:

  • 输入是一段普通 JavaScript
  • 中间会经过 AST、IR、字节码几个阶段
  • 输出是一段自包含的 JS 文件,里面带着字节码和解释器
flowchart LR
    A["源码<br/>var x = 1 + 2"] --> B["Frontend<br/>解析成 AST"]
    B --> C["Lowering<br/>展平成 IR"]
    C --> D["Emit<br/>编码成数字字节码"]
    D --> E["Pack<br/>拼上运行时"]
    E --> F["最终输出<br/>一段可执行 JS"]

如果你把它类比成“翻译系统”,会更好理解:

  • AST 像语法分析后的句子结构
  • IR 像翻译过程中的中间稿
  • 字节码像只给内部员工看的工单编号
  • VM 解释器像真正干活的执行班组

外面的人看到的是一堆编号,但系统内部知道每个编号该怎么做。

为什么这条路线值得学?

因为它会把很多平时“会用但说不清”的东西,逼着你真正吃透。

  • 你会真正理解编译器后端在干什么,而不是只停在 AST 改写
  • 你会知道闭包为什么本质上是“函数 + 活着的环境对象”
  • 你会知道 var 提升、let 的 TDZ、this 绑定这些语义,运行时到底该怎么还原

但也先把丑话说前面:这条路线不轻松。它不像写个 Babel 插件那样当天就能见效,中间会反复遇到“看上去只差一行,结果整个 VM 跑偏”的问题。也正因为这样,这套教程才适合想进阶的人。

这套项目的真实边界

这里不装全能。

  • 项目核心目标是讲清楚 JSVMP 主链路,不是造一个完整 JS 引擎
  • 当前重点覆盖的是 ES5 核心语义,以及项目里已经实现的对象、异常、闭包、thisarguments 等能力
  • 某些高级语法、复杂解构、完整语言边角行为,不是这套代码当前阶段的重点

换句话说,它更像一台“教学级但能跑真代码”的样机,而不是拿来直接替代浏览器引擎。

这反而是它的价值所在。东西做得太大,读者只会被淹没;东西做得刚好,你才能看清每一个齿轮怎么咬合。

阅读方式建议

这套教程最好按顺序看,因为后面的章节会反复用到前面建立起来的几个核心心智模型:

  1. 寄存器是“中间结果的临时工位”
  2. slot 是“变量在环境里的固定抽屉”
  3. env 链是“运行时版本的作用域链”
  4. 字节码是“给 VM 执行的数字化操作清单”

如果你跳着看,单章也能读懂一部分,但很容易出现“每句话都认识,连起来不知道在说什么”的情况。

教程地图

阶段 文件 你会带走什么
00 00-tutorial-guide.md 先把整条路线和预期建立起来
01 01-architecture-overview.md 理解 JSVMP 是什么,为什么选寄存器机
02 02-instruction-set-design.md 搞懂 opcode、寄存器、slot、常量池怎么配合
03 03-compiler-ast-to-ir.md 看懂 AST 为什么要先降成 IR,以及 lowering 怎么写
04 04-emit-and-runtime.md 把符号化 IR 编成数字字节码,再交给 VM 跑起来
05 05-es5-core-features.md 闭包、thisarguments、提升这些硬骨头怎么落地
06 06-testing-and-debugging.md 怎么验证你的 VM 不是“看起来能跑,其实语义错了”

配套示例怎么跑?

仓库已经准备好了按章节拆开的示例。建议你一边看文档,一边跑对应例子,不要只看不动手。

pnpm build
node docs/examples/01-architecture/01-hello-vmp.js
node docs/examples/02-instruction-set/02-bytecode-decoder.js
node docs/examples/04-emit-and-runtime/01-step-by-step-execution.js

如果你把教程当成“视频字幕”来扫,收获会很有限。最有效的方式,是边读边猜结果,再运行示例验证自己的理解。

读代码前,先记住这几个核心文件

graph TD
    A["src/compiler/frontend.ts<br/>源码 -> AST"] --> B["src/compiler/lowering.ts<br/>AST -> IR"]
    B --> C["src/compiler/emit.ts<br/>IR -> 字节码"]
    C --> D["src/compiler/runtime-gen.ts<br/>生成解释器源码"]
    D --> E["src/compiler/pack.ts<br/>打成最终 JS"]
  • lowering.ts 是编译器最考验基本功的地方
  • emit.ts 负责把“人能看懂的指令”压成数字
  • runtime-gen.ts 是最容易出细碎 bug 的地方,因为这里的 pc、env、寄存器都得严丝合缝

你应该带着什么问题往下读?

我建议你边读边盯住这 4 个问题:

  1. AST 为什么不能直接执行,非得先变成 IR?
  2. 变量名为什么不直接塞进寄存器,而要分成 slot 和寄存器两套体系?
  3. 闭包捕获的到底是“值”,还是“环境对象”?
  4. 为什么 VM 里最难查的 bug,往往不是算法错,而是状态没对齐?

后面的每一章,都会围着这几个问题慢慢把账算清楚。

一句话记住这套教程

这不是一套“介绍 JSVMP”的文档,而是一套带你把编译器、字节码和运行时真正接起来的工程化拆解。

❌