Archipelago

Lec 15低级虚拟机 I(Low-Level Virtual Machines I)——从 AST 解释器到字节码 VM

讲义完成更新于 2026-09-16

Lec 15 低级虚拟机 I(Low-Level Virtual Machines I)——从 AST 解释器到字节码 VM

进入 Phase 4。本讲回答两个问题:为什么要用虚拟机(而不是直接解释 AST),以及 VM 的组织结构(代码 / 栈 / 堆)。以 MITScript 的字节码 VM 为主线,对照 Crafting Interpreters 第 14~15 章。

TL;DR

  • AST 树遍历解释器简单但会重复执行节点分派、递归与通用数据结构操作,字节码 VM 先将这些结构工作编码为紧凑指令流。
  • VM 的代码区保存指令与常量,操作数栈承载临时值和调用状态,堆保存记录、闭包与长寿命对象。
  • 栈机指令集编码紧凑、解释器简单,但会产生较多 push / pop;这个表示权衡也决定后续优化与机器码生成的难度。

0. 课程版图与本讲定位

整个 MITScript 项目自上而下分为五个阶段:

阶段主题对应理论
Phase 1Parser(解析器)形式文法与解析理论(L2–L5)
Phase 2Interpreter(解释器)程序语义(L6–L11)
Phase 3Garbage Collector(垃圾回收)内存管理(L12–L14)
Phase 4Execution Organization(执行组织)语法制导翻译(L15 起)
Phase 5Code Generation & Optimization(代码生成与优化)效率(L18 起)

本讲开始把"程序怎么运行"从递归遍历 AST转向在一台抽象机器上执行指令。

定义(虚拟机的两个层次)

  • 语言("What"):语法 + 语义,描述"程序是什么、应得到什么结果"。

  • 虚拟机("How"):描述"怎么算出来"。包含

  • 指令集 (Instruction Set):每条指令做一个单一操作;

  • 执行组织 (Execution Organization):代码、栈、堆的布局;

  • 托管运行时 (Managed Runtime):数据类型的表示 + 垃圾回收。

最终再往下还有机器码 (Machine Code):也有自己的指令集和执行组织(段、栈、页)。

层级图:Language → High-Level VM → Low-Level VM → Machine Code。本讲聚焦 High-Level VM(字节码层)。


1. 从源代码到字节码:一个例子

源程序:

f = fun(y) {
    x = y + 2;
};
f(1);

编译后得到两个 function 对象(嵌套函数也是一个独立的 function):

外层(顶层)函数:

function {
  functions  = [ <内层函数> ],
  constants  = [None, 0, 1],
  names      = [f],
  instructions = [
    load_const 1     # 压入常量 1
    load_func  0     # 压入内层函数
    alloc_closure    # 用函数 + 自由变量做出闭包
    store_global 0   # 存到全局 f
    load_global  0   # 读 f
    load_const   2   # 压入实参 1(constants[2])
    call 1           # 调用,1 个参数
    pop              # 丢弃返回值
    load_const 0     # None
    return
  ]
}

内层函数 fun(y){ x = y + 2; }:

function {
  local_vars = [y, x],
  constants  = [None, 2],
  instructions = [
    load_local 0     # 读 y
    load_const 1     # 压入 2
    add              # y + 2
    store_local 1    # 存到 x
    load_const 0     # None
    return
  ]
}

关键观察:变量名在编译期就被换成了索引——y→局部槽 0,x→局部槽 1,全局 f→names[0]。运行时不再需要按名字查找,只按下标存取。


2. 为什么不直接解释 AST?

2.1 递归解释器(Recursive / Tree-Walking Interpreter)

Phase 2 的解释器长这样——语义即递归推理规则,几乎逐条映射成递归函数:

class Binop : public Expr {
  enum BINOP { PLUS, SUB, MUL, DIV };
  BINOP op;
  Expr *left, *right;
};

int eval_binop(Frame *f, Binop *e) {
  switch (e->op) {
    case PLUS: return eval_plus(f, e);
    case SUB : return eval_sub (f, e);
    case MUL : return eval_mul (f, e);
    case DIV : return eval_div (f, e);
  }
}
int eval_plus(Frame *f, Binop *e) {
  int n1 = eval_expr(f, e->left);
  int n2 = eval_expr(f, e->right);
  return n1 + n2;
}

**优点:**语义是递归推理规则,"很容易"直接映射成递归解释器;实现与语义一一对应(非结构化控制流稍乱,但基本贴合)。

缺点(很慢!):

  • 解释器在不停地遍历一个庞大的数据结构(AST);

  • 深层递归调用开销很高;

  • 一句简单的 x = 2+2 可能要执行成千上万条机器指令。 :::

2.2 动态语言更慢:一次加法的真实代价

在 C 里 x = y + 2(假设 y 在寄存器 r1,x 在 r2):

mov $r2, 2
add $r2, $r1   ; y + 2

两条指令搞定。但在动态语言里同一句要做:

  1. 检查 y 的值是什么类型;
  2. 从 y 的值对象里取出整数值;
  3. 为常量 2 分配一个值对象;
  4. 检查 2 的值对象的类型;
  5. 取出 2 的整数值;
  6. 为 y + 2 的结果分配一个新的整数值对象;
  7. 让 x 指向那个新对象。

这就是动态类型的"税":装箱(boxing)、类型检查、内存分配。后续优化(拆箱、类型特化、内联缓存)都在和这条清单作斗争。

2.3 翻译的连续层级

同一个 fun(y){ x = y+2; } 在不同层级的样子:

  • 字节码 VM(上面的 instructions);
  • 中间表示 IR(带 SSA 风味的伪指令):
function __function_1 (y) {
  %x = alloca int64_t
  %0 = %y
  %1 = 2
  %2 = add %0 %1
  store %x %2
  %3 = 0
  return %3
}
  • 机器码(x86-64 风格):
__function_1:
  sub  8, %rsp
  mov  %rsp, %rbp
  mov  (%rbp,2,8), %rax
  mov  2, %rcx
  add  %rcx, %rax
  mov  %rax, (%rbp)
  mov  0, %rax
  ret

越往下越贴近硬件,但也越难做与机器无关的分析。


3. 为什么用虚拟机?(VM 的价值)

::: theorem 定理(Virtual Machine: Why?)

  • 更快:解释字节码比遍历原始 AST 快得多(指令紧凑、线性、无需指针追逐);

  • 抽象层 / 可移植:在代码与硬件之间插一层抽象,同一份字节码可跑在任意微处理器上;

  • 一次性优化机会:可在这一层做与机器无关的分析与优化,且只做一次——例如类型检查消除、死代码消除、代数化简;

  • 更易编译到机器码:相对 AST,从字节码/IR 生成机器码要容易得多。 :::

历史注脚:Java 字节码当年的卖点正是"平台中立"——机顶盒等嵌入式设备处理器五花八门(成本/性能/体积各异),字节码能跑在任意微处理器上是成功关键。P-code(1960s)是最早的字节码虚拟机思想;LLVM 更像是编译器中间表示而非可执行 VM。


4. VM 的执行组织:代码、栈、堆

::: definition 定义(三大区域)

  • 代码 (Code):指令、函数、元数据(constants / names / functions 列表)。只读、静态。

  • 栈 (Stack):每次函数调用一个帧 (Frame),帧内含:

  • Locals:局部变量槽;

  • IP(Instruction Pointer):当前指令指针;

  • Operand Stack(操作数栈):指令的临时计算栈。

  • 堆 (Heap):关联数组式存储 + Globals(全局变量);闭包、Record 等对象都分配在这里。 :::

这是一台基于栈的虚拟机 (stack-based VM):指令不写寄存器号,而是隐式地从操作数栈弹出输入、把结果压回栈。Python VM、JVM 都是这种风格。

4.1 逐指令走一遍(Python 风格示意)

以 f = fun(y){x=y+2;}; f(1); 为例,顶层函数执行轨迹(IP 从 0 走到 9):

IP指令操作数栈/状态变化
0load_const 1压入常量 1
1load_func 0压入内层函数对象
2alloc_closure弹出函数→在堆上建 Closure{f:…},压回闭包
3store_global 0弹出闭包→存入 Globals 的 f
4load_global 0读 f 压栈
5load_const 2压入实参 1
6call 1新建一个帧:把实参绑到被调函数的 Locals(y:1, x:Uninit),IP 归 0
—(进入内层)load_local 0→load_const 1→add→store_local 1→load_const 0→return算出 x=3,返回 None,弹出帧
7pop丢弃返回值 None
8load_const 0压入 None
9return顶层返回

注意 call 的语义:它压一个新帧到调用栈,新帧有自己独立的 Locals / IP / Op Stack;return 把帧弹掉、把返回值留给调用者的操作数栈。这正是"显式控制流"。


5. 指令集的规格写法

MITScript 用注释精确规定每条指令的操作数与栈效应。几个代表:

// load_const i —— 把常量压栈
// Operand 0: 常量在所属函数 constants 列表中的下标
// Stack: S => S :: f.constants()[i]
LoadConst

// load_func i —— 把函数压栈
// Stack: S => S :: f.functions()[i]
LoadFunc

// load_local i —— 读局部变量压栈
// Stack: S => S :: value_of(f.local_vars[i])
LoadLocal

// store_local i —— 把栈顶存入局部变量
// Operand 0: 目标局部变量下标
// Stack: S :: v => S
StoreLocal

// add(sub/mul/div 同理)—— 二元运算,语义同 Assignment #2
// Stack: S :: a :: b => S :: op(a, b)
Add

::: definition 定义(栈式指令集的一般特征)

  • 显式寻址存储:globals、locals 各有对应的存取指令(load/store_global、load/store_local);

  • 每条简单指令只做一个操作;

  • 显式控制流:通过 call/return 和跳转(jump,下一讲)实现。 :::

记法约定:S :: x 表示栈 S 顶上再压一个 x;二元运算从栈顶弹两个、压一个结果回去。


6. 现实世界的实现版图

同一张"Language → HL VM → LL VM → Machine Code"的图上,各语言走法不同:

  • Java:Java → JVM 字节码(HL VM),再 JIT 到 x86/ARM/SPARC/PowerPC;
  • Python:Python → Python VM 字节码,多为解释执行;
  • Swift:Swift → SIL(HL)→ LLVM IR(LL/MC)→ 机器码;
  • C/C++ (Clang):→ LLVM IR → 机器码;gcc:→ GIMPLE(HL) → GIMPLE(LL, SSA) → 机器码;
  • JavaScript:解释 + JIT;
  • Ethereum:→ EVM 字节码。

要点:HL VM 偏可移植与分析,LL VM/IR(如 LLVM、SSA 形式的 GIMPLE)偏优化与代码生成。我们的 MITScript 也会经历同样的下沉过程。


7. 与 Crafting Interpreters 的对应(参考补充)

  • 第 14 章 Chunks of Bytecode:定义 Chunk(指令 + 常量池)、操作码编码、行号信息——对应本讲的"代码区/元数据"。
  • 第 15 章 A Virtual Machine:实现 vm.run() 解释循环、操作数栈 push/pop、二元运算——对应本讲的"操作数栈 + 逐指令执行"。
  • 书中强调 bytecode VM 相对 tree-walking 的两大动机正是本讲 §2–§3:更紧凑/更快 与 更易做后续编译。
  • MITScript 与书的差异:书里是单 Chunk 的简化模型,MITScript 显式区分 function 对象、locals/globals、闭包分配,更接近 Python/JVM 的真实组织。

8. 本讲小结

  • AST 递归解释器贴合语义但慢:不断遍历大数据结构、深递归开销大;动态语言更要为每次运算付出类型检查 + 装箱 + 分配的代价。
  • 虚拟机用线性字节码替代树遍历,带来四大好处:更快、可移植抽象层、一次性机器无关优化、更易编译到机器码。
  • VM 的执行组织 = 代码(只读指令/元数据)+ 栈(每调用一帧:Locals/IP/操作数栈)+ 堆(Globals 与对象);MITScript 是基于栈的 VM。
  • 编译期把变量名换成索引,运行时按下标存取;call/return 显式压弹帧实现控制流。
  • 实现版图:HL VM 偏移植与分析,LL VM/IR 偏优化与代码生成。下一讲(L16)继续低级 VM:跳转/分支与更完整的指令语义。