2.3 中間表示與代碼生成#
為什麼需要中間表示#
第 2.1 節說過:n 種語言 × m 種機器只需要 n+m 個組件,靠的是中間表示(IR)。但 IR 的價值不只是省工——它同時是:
- 最佳化的操作對象:常數摺疊、死碼刪除、指令選擇都在 IR 上做,與語言和機器無關
- 直譯與 JIT 的共同橋樑:AST → IR 之後,可以走位元碼直譯(第 3、5 章),也可以走 JIT(第 4 章)。本節做完 IR,後面四章的岔路就從這裡分出
graph TD
AST[AST] --> IR[IR / 位元碼]
IR --> I1[位元碼直譯器<br/>第3/5章]
IR --> J[JIT 編成機器碼<br/>第4章]
IR --> O[最佳化 pass]
O --> I1
O --> J為 MinLang 設計堆疊式 IR#
最簡單而有用的 IR 是堆疊式位元碼(BCPL 的 O-code、Pascal 的 P-code、JVM、CPython 都是):每條指令從虛擬堆疊取運算元、推回結果。好處是指令很短、代碼生成平凡;壞處是機器碼階段還要再做一次暫存器分配(JIT 章節會處理)。
// src/ir.rs
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Instr {
Push(i64), // 推常數
Load(u16), // 從第 slot 個區域變數載入
Store(u16), // 存入第 slot 個區域變數
Add, Sub, Mul, Div, Lt, Eq,
Jump(usize), // 無條件跳到第 idx 條指令
JumpIfFalse(usize), // 堆疊頂為 0 就跳
Call { func: u16, nargs: u8 },
Return,
Drop, // 丟掉堆疊頂(表達式敘述的值)
}
pub type Program = Vec<Instr>;
這裡已經出現了虛擬機的架構決策(第 5 章會展開):變數用 slot 索引而不是名字——名字解析(符號表查詢)是前端的工作,VM 只做整數索引。這是所有 VM 共同的前後端分工。
代碼生成:AST → 位元碼#
// src/compiler.rs
use crate::ast::{BinOp, Expr};
use crate::ir::{Instr, Program};
use std::collections::HashMap;
pub struct Compiler {
code: Program,
vars: HashMap<String, u16>, // 符號表:名字 → slot
next_slot: u16,
}
impl Compiler {
pub fn new() -> Self {
Compiler { code: Vec::new(), vars: HashMap::new(), next_slot: 0 }
}
pub fn compile(mut self, expr: &Expr) -> Result<Program, String> {
self.gen(expr)?;
Ok(self.code)
}
fn emit(&mut self, i: Instr) -> usize {
self.code.push(i);
self.code.len() - 1
}
fn gen(&mut self, expr: &Expr) -> Result<(), String> {
match expr {
Expr::Num(n) => { self.emit(Instr::Push(*n)); }
Expr::Var(name) => {
let slot = *self.vars.get(name)
.ok_or_else(|| format!("undefined variable: {name}"))?;
self.emit(Instr::Load(slot));
}
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::Let { name, value, body } => {
let slot = self.next_slot;
self.next_slot += 1;
self.gen(value)?;
self.emit(Instr::Store(slot));
self.vars.insert(name.clone(), slot);
self.gen(body)?;
// body 的值留在堆疊上,就是整個 let 的值
}
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)); // 回填位址
let else_pc = self.code.len();
self.code[jf] = Instr::JumpIfFalse(else_pc);
self.gen(else_e)?;
let end_pc = self.code.len();
self.code[j] = Instr::Jump(end_pc);
}
Expr::Call { func, args } => {
for a in args { self.gen(a)?; }
let f = self.funcs.get(func)
.ok_or_else(|| format!("undefined function: {func}"))?;
self.emit(Instr::Call { func: *f, nargs: args.len() as u8 });
}
}
Ok(())
}
}
If 的生成展示了位元碼編譯的經典技巧——回填(backpatching):生成 JumpIfFalse 時還不知道目標位址(else 部分還沒生成),先放 0,記下位置,生成完 else 再回頭修正。這個技巧 1950 年代就有,直到今天 JIT 寫分支時一樣在用。
最佳化:在 IR 上做常數摺疊#
IR 的第一個回報是最佳化。最簡單的是常數摺疊(constant folding)——1954 年 FORTRAN 就有的 pass:
// src/opt.rs
use crate::ast::{BinOp, Expr};
pub fn fold(expr: Expr) -> Expr {
match expr {
Expr::BinOp { op, lhs, rhs } => {
let lhs = fold(*lhs);
let rhs = fold(*rhs);
// 兩邊都是常數就現在算掉
if let (Expr::Num(l), Expr::Num(r)) = (&lhs, &rhs) {
let ok = match op {
BinOp::Add => l.checked_add(*r),
BinOp::Sub => l.checked_sub(*r),
BinOp::Mul => l.checked_mul(*r),
BinOp::Div if *r != 0 => l.checked_div(*r),
_ => None,
};
if let Some(v) = ok { return Expr::Num(v); }
}
// 代數恆等式:0+e=e, 1*e=e, e*0=0
match (op, &lhs, &rhs) {
(BinOp::Add, Expr::Num(0), _) => rhs,
(BinOp::Add, _, Expr::Num(0)) => lhs,
(BinOp::Mul, Expr::Num(1), _) => rhs,
(BinOp::Mul, _, Expr::Num(1)) => lhs,
(BinOp::Mul, Expr::Num(0), _) | (BinOp::Mul, _, Expr::Num(0)) => Expr::Num(0),
_ => Expr::BinOp { op, lhs: Box::new(lhs), rhs: Box::new(rhs) },
}
}
other => other,
}
}
注意 checked_add——編譯器在編譯期做常數運算時不能自己溢位(否則編譯器成了 bug 製造者),也不能讓使用者程式的溢位被摺疊隱藏。這個細節正是 C 編譯器歷史上真實踩過的坑(未定義行為讓最佳化刪掉溢位檢查)。
生成真正機器碼的直覺:堆疊 IR → x86-64#
第 4 章的 JIT 需要把位元碼變成 x86-64 機器碼。這裡先看對應直覺——堆疊機的 Push/Load/Add 其實就是 x86-64 的記憶體與暫存器操作:
| 位元碼 | x86-64(組語示意) | 機器碼位元組 |
|---|---|---|
Push 5 | push 5 | 48 C7 C3 05 00 00 00(mov rbx, 5) |
Add | pop rax; add rbx, rax | 58 + 48 01 C3 |
Ret | ret | C3 |
x86-64 是暫存器機器,堆疊機是虛擬堆疊機器;兩者之間的翻譯要決定「虛擬堆疊放到哪」——最幼稚的做法是真用 CPU 堆疊(push/pop 指令),更好的做法是虛擬堆疊的深度在編譯期可算出時,直接分配暫存器。這個決定就是暫存器分配,編譯器後端最難的部分(Chaitin 1982 把它化約成圖著色問題),本書在第 4 章用「每個 slot 對應一個固定暫存器」的簡化策略跳過理論、直達實作。
連結:從位元碼到「程式」#
完整前端跑一遍:
let x = 2; x * x
產生的位元碼:
0: Push(2)
1: Store(0) ; x → slot 0
2: Load(0)
3: Load(0)
4: Mul
用 dbg! 驗證:
fn main() {
let src = "let x = 2; x * x";
let ast = parser::Parser::new(src).unwrap().parse().unwrap();
let ast = opt::fold(ast);
let code = compiler::Compiler::new().compile(&ast).unwrap();
for (i, instr) in code.iter().enumerate() {
println!("{i:3}: {instr:?}");
}
}
習題#
- 寫一個「死碼刪除」pass:刪除
let x = ...; body中 x 在 body 出現零次的情況(先簡化:只看變數名,不考慮副作用)。 - 為什麼堆疊 IR 的
If需要Jump與JumpIfFalse兩種指令?只留一種行不行? - 運算式敘述
x + 1;的值沒人用,產生Drop指令。若不刪除它,直譯器執行時會發生什麼?