3.3 位元碼直譯器:往虛擬機的橋樑#
從「走樹」到「跑指令」#
樹直譯器的根本低效在於:語法分析做過的工作,每次執行都重做。enum match、Box 解引用、遞迴函式呼叫——這些都是「重新發現程式結構」的成本。1966 年 BCPL 的 Martin Richards 提出的解法簡單而深刻:把 AST 預先翻譯成線性指令序列(位元碼),執行時只需沿著指令前進。
graph TD
subgraph 樹直譯器
A[AST] -->|每次執行| E1[遞迴 match]
end
subgraph 位元碼直譯器
B[AST] -->|編譯一次| C[位元碼]
C -->|重複執行| E2[switch 迴圈]
end一個指令序列的執行成本是「一個索引 + 一個 match + 一個循環」,沒有遞迴、沒有樹遍歷。這個執行核心叫直譯迴圈(interpreter loop)——第 5 章虛擬機的主體就是它。本節把 MinLang 編譯成位元碼並寫出直譯迴圈,完成「直譯器 → 虛擬機」的過渡。
位元碼設計的關鍵決策#
沿用第 2.3 節的 Instr enum,現在補上函式呼叫與回傳。設計決策與理由:
- 變數用 slot 索引(
Load(u16)):名字解析已在編譯期完成(3.2 節) - 函式值不再捕獲
Rc<Env>,而是捕獲upvalues(被捕獲變數的索引清單)——Lua 的做法,把閉包變成資料 - 運算元堆疊:中間結果放虛擬堆疊,指令天然短小
// src/ir2.rs
#[derive(Debug, Clone)]
pub enum Instr {
Const(u16), // 從常數池載入
Load { depth: u8, slot: u16 },
Store { depth: u8, slot: u16 },
Add, Sub, Mul, Div, Lt, Eq,
Jump(usize),
JumpIfFalse(usize),
Closure { proto: u16 }, // 由第 proto 個函式原型建閉包
Call { nargs: u8 },
Return,
Drop,
}
pub struct Proto {
pub code: Vec<Instr>,
pub consts: Vec<Value>,
pub n_slots: u16, // 本函式的區域變數數
}
pub struct Module {
pub protos: Vec<Proto>, // protos[0] 是主體
}
Proto/Module 的分層(函式原型 vs. 執行個體)是所有 VM 的標準結構:CPython 的 code object vs. frame、JVM 的 class vs. instance、Lua 的 Proto vs. Closure。編譯產物(原型)與執行狀態(閉包/幀)分離,才能反覆呼叫同一函式而互不干擾。
編譯器:AST → 位元碼#
// src/compiler2.rs
pub struct Compiler {
module: Module,
cur: Proto,
scopes: Vec<HashMap<String, (u8, u16)>>, // 作用域堆疊:名字 → (深度, slot)
}
impl Compiler {
fn gen(&mut self, expr: &Expr) -> Result<(), String> {
match expr {
Expr::Num(n) => {
let idx = self.add_const(Value::Num(*n));
self.emit(Instr::Const(idx));
}
Expr::Var { name, .. } => {
let (d, s) = self.resolve(name)?;
self.emit(Instr::Load { depth: d, slot: s });
}
Expr::Let { name, value, body } => {
self.gen(value)?;
let slot = self.cur.n_slots; self.cur.n_slots += 1;
self.scopes.last_mut().unwrap().insert(name.clone(), (0, slot));
self.emit(Instr::Store { depth: 0, slot });
self.gen(body)?;
// body 值在堆疊上,直接成為 let 的值
}
Expr::BinOp { op, lhs, rhs } => {
self.gen(lhs)?; self.gen(rhs)?;
self.emit(match op {
BinOp::Add => Instr::Add, BinOp::Sub => Instr::Sub,
BinOp::Mul => Instr::Mul, BinOp::Div => Instr::Div,
BinOp::Lt => Instr::Lt, BinOp::Eq => Instr::Eq,
});
}
Expr::If { cond, then_e, else_e } => {
self.gen(cond)?;
let jf = self.emit(Instr::JumpIfFalse(0));
self.gen(then_e)?;
let j = self.emit(Instr::Jump(0));
self.code[jf] = Instr::JumpIfFalse(self.code.len());
self.gen(else_e)?;
self.code[j] = Instr::Jump(self.code.len());
}
Expr::Call { func, args } => {
let proto = self.resolve_fn(func)?;
for a in args { self.gen(a)?; }
self.emit(Instr::Call { nargs: args.len() as u8 });
}
// ... 函式字面值生成 Closure 指令
}
Ok(())
}
}
直譯迴圈:VM 的心臟#
現在寫執行器。核心是一個 loop + match:
// src/vm.rs
pub struct Vm {
pub stack: Vec<Value>, // 運算元堆疊
pub frames: Vec<Frame>, // 呼叫幀堆疊
}
pub struct Frame {
pub proto: Rc<Proto>,
pub closure: Rc<Closure>,
pub pc: usize,
pub slots: Vec<Value>, // 區域變數
pub base: usize, // 本幀在運算元堆疊的基準
}
pub struct Closure {
pub proto: Rc<Proto>,
pub upvals: Vec<Value>,
}
pub type VmResult = Result<Value, VmError>;
#[derive(Debug)]
pub enum VmError {
StackUnderflow,
TypeError { expected: &'static str, got: String },
DivZero,
PcOutOfRange { pc: usize, len: usize },
}
impl Vm {
pub fn run(&mut self, module: &Module) -> VmResult {
let main = Rc::new(Closure { proto: Rc::new(module.protos[0].clone()), upvals: vec![] });
self.frames.push(Frame {
proto: main.proto.clone(), closure: main,
pc: 0, slots: Vec::new(), base: 0,
});
loop {
// 依當前幀取指令
let frame = self.frames.last_mut().unwrap();
let instr = {
let code = &frame.proto.code;
let pc = frame.pc;
frame.pc += 1;
code.get(pc).ok_or(VmError::PcOutOfRange { pc, len: code.len() })?.clone()
};
match instr {
Instr::Const(i) => {
let v = frame.proto.consts[i as usize].clone();
self.stack.push(v);
}
Instr::Load { depth, slot } => {
let v = self.load(depth, slot)?;
self.stack.push(v);
}
Instr::Add => self.binop(|a, b| a.wrapping_add(b), "Add")?,
Instr::Jump(t) => { self.frames.last_mut().unwrap().pc = t; }
Instr::JumpIfFalse(t) => {
let v = self.stack.pop().ok_or(VmError::StackUnderflow)?;
if !matches!(v, Value::Bool(false)) { /* 繼續 */ }
else { self.frames.last_mut().unwrap().pc = t; }
}
Instr::Call { nargs } => self.call(nargs)?,
Instr::Return => {
let ret = self.stack.pop().ok_or(VmError::StackUnderflow)?;
self.frames.pop();
if self.frames.is_empty() { return Ok(ret); }
self.stack.push(ret);
}
Instr::Drop => { self.stack.pop().ok_or(VmError::StackUnderflow)?; }
// ...
}
}
}
fn binop(&mut self, f: fn(i64, i64) -> i64, name: &'static str) -> Result<(), VmError> {
let r = self.stack.pop().ok_or(VmError::StackUnderflow)?.as_num()
.map_err(|e| VmError::TypeError { expected: "num", got: e })?;
let l = self.stack.pop().ok_or(VmError::StackUnderflow)?.as_num()
.map_err(|e| VmError::TypeError { expected: "num", got: e })?;
self.stack.push(Value::Num(f(l, r)));
Ok(())
}
fn call(&mut self, nargs: u8) -> Result<(), VmError> {
let n = nargs as usize;
let stack_len = self.stack.len();
if stack_len < n + 1 { return Err(VmError::StackUnderflow); }
let callee = self.stack[stack_len - n - 1].clone();
let f = match callee { Value::Fn(f) => f, v => return Err(VmError::TypeError { expected: "fn", got: format!("{v:?}") }) };
let args: Vec<Value> = self.stack.split_off(stack_len - n);
self.stack.pop(); // 函式值本身
let base = self.stack.len();
self.frames.push(Frame {
proto: f.proto.clone(), pc: 0,
slots: vec![Value::Num(0); f.proto.n_slots as usize],
base,
closure: f.clone(),
});
// 參數存入 slot
for (i, a) in args.into_iter().enumerate() {
self.frames.last_mut().unwrap().slots[i] = a;
}
Ok(())
}
}
Rust 型別系統在 VM 中的三個作用#
1. 錯誤從 panic 變成值#
VM 會遇到所有壞輸入:堆疊下溢、型別錯誤、PC 越界。C VM 的慣例是 assert 或直接崩潰(或更糟:靜默越界讀寫成為安全漏洞)。Rust 的 Result 讓這些成為被強迫處理的值——code.get(pc) 而不是 code[pc],一行之差決定了惡意位元碼能否造成 UB。執行不可信位元碼的 VM 本質上是一個沙箱,越界檢查是沙箱的地基。
2. Rc<Proto> 表達共享的只讀編譯產物#
多個 Closure、多個 Frame 共享同一份 Proto(編譯產物不可變),Rc 的語義正好。C VM 需手動管理 proto 的生命週期(誰釋放?何時?),JVM 靠 class loader + GC。
3. enum match 的完備性#
給 Instr 加新指令(例如 Mul2),編譯器立即指出 run 裡缺分支。CPython 歷史上多次出現「新增 opcode 忘了更新某個 switch」的 bug,Rust 從型別層面杜絕。
效能驗證與下一步#
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::Lexer;
use crate::parser::Parser;
use crate::compiler2::Compiler;
fn run(src: &str) -> Value {
let ast = Parser::new(src).unwrap().parse().unwrap();
let module = Compiler::new().compile(&ast).unwrap();
Vm::new().run(&module).unwrap()
}
#[test]
fn recursion() {
// let loop = fn(n, acc) { if n == 0 { acc } else { loop(n - 1, acc + n) } };
// loop(10000, 0) → 50005000
assert_eq!(run(SRC), Value::Num(50005000));
}
}
同一個 loop(10000, 0) 測資,典型量級(相對值):
| 執行方式 | 相對耗時 | 每步成本來源 |
|---|---|---|
| 樹直譯(3.1 節) | ~10× | 每節點遞迴 + 字串查詢 + clone |
| 位元碼直譯(本節) | ~2× | switch + 堆疊 push/pop |
| JIT 機器碼(第 4 章) | ~1× | 原生指令 |
位元碼直譯比樹直譯快數倍,但仍比機器碼慢——每次指令分派(match)相當於一次間接跳轉,現代 CPU 的分支預測器對此效率有限。把熱門位元碼序列直接編成機器碼、消滅分派成本,就是 JIT(第 4 章)存在的理由。
同時注意:本節的 Vm 已經是一個「虛擬機」的雛形——有指令集、有堆疊、有幀、有錯誤處理。第 5 章將把它正式化為完整的堆疊虛擬機,並討論 GC 與 WASM。
習題#
- 統計
run迴圈中每種指令的執行次數(在Instr上加計數器),跑loop(10000,0),找出最熱門的指令——這就是 JIT 首先要編譯的目標。 - 位元碼是資料,可以被惡意構造。找出三種能讓
Vmpanic(而非返回 Err)的輸入,修掉它們。 - 實作
Instr::Mul的「快取上一次常數運算元」的內聯快取雛形,測量loop中acc + n的加速比。