4.3 Cranelift 與自訂位元碼的 JIT 後端#
迷你 JIT 的天花板#
第 4.2 節的 JIT 能跑,但每加一個特性就要查 Intel 手冊、手寫編碼、自己處理:
- 多平台:x86-64 與 AArch64 指令集完全不同
- 暫存器分配:手寫 push/pop 溢出在複雜函式會爆炸
- 呼叫約定:參數暫存器、堆疊對齊、caller/callee-saved 暫存器保存
- 正確性驗證:誰保證生成的機器碼沒有 UB?
這四件事正是 Cranelift 存在的理由。Cranelift 是 Rust 寫的編譯器後端函式庫(起源於 SpiderMonkey 的 WASM 支援實驗,現為 Wasmtime 專案一部分),把「機器碼生成」變成呼叫 API。
架構:把後端當函式庫#
graph LR
subgraph 你的工作
A[自訂位元碼] --> B[翻譯到 CLIF<br/>你的前端]
end
subgraph Cranelift 提供
B --> C[CLIF IR]
C --> D[最佳化 passes]
D --> E[指令選擇]
E --> F[暫存器分配<br/>regalloc2]
F --> G[機器碼緩衝區]
end
G --> H[mmap 可執行記憶體]分工原則:你的前端只負責「把語意變成 IR」,所有機器相依的髒活給 Cranelift。這正是第 2.1 節三段式架構的實踐——IR 是契約。
CLIF:Cranelift 的中間表示#
CLIF(Cranelift IR)的特點:
- SSA 形式(static single assignment):每個變數只賦值一次,讓最佳化(常數傳播、死碼刪除)變得平凡
- 顯式型別:
i32,i64,f64, 指標型別 - 顯式記憶體與控制流:load/store、block/branch,沒有隱藏行為
用 Cranelift 給 MinLang 做 JIT#
# Cargo.toml
[dependencies]
cranelift = "0.110"
cranelift-jit = "0.110"
cranelift-module = "0.110"
把 MinLang 的 Expr::BinOp 翻到 CLIF(以 (a + b) * c 為例):
// src/clif.rs
use cranelift::prelude::*;
use cranelift_jit::{JITBuilder, JITModule};
use cranelift_module::{Linkage, Module};
pub struct MinLangJit {
module: JITModule,
builder_context: FunctionBuilderContext,
ctx: Context,
}
impl MinLangJit {
pub fn new() -> Self {
let flag_builder = settings::builder();
flag_builder.set("use_colocated_libcalls", "false").unwrap();
flag_builder.set("is_pic", "false").unwrap();
let isa_builder = cranelift_native::builder().unwrap();
let builder = JITBuilder::with_isa(isa_builder.finish(target_lexicon::Triple::host()), cranelift_module::default_libcall_names());
MinLangJit {
module: JITModule::new(builder),
builder_context: FunctionBuilderContext::new(),
ctx: Context::new(),
}
}
/// 編譯 fn(a: i64, b: i64) -> i64 { (a + b) * c } 形式的運算式
pub fn compile_add_mul(&mut self) -> *const u8 {
let int = types::I64;
self.ctx.func.signature.params.push(AbiParam::new(int));
self.ctx.func.signature.params.push(AbiParam::new(int));
self.ctx.func.signature.returns.push(AbiParam::new(int));
let func_id = self.module
.declare_function("expr", Linkage::Export, &self.ctx.func.signature)
.unwrap();
{
let mut builder = FunctionBuilder::new(&mut self.ctx.func, &mut self.builder_context);
let entry = builder.create_block();
builder.append_block_params_for_function_params(entry);
builder.switch_to_block(entry);
builder.seal_block(entry);
let (a, b) = {
let params = builder.func.dfg.block_params(entry);
(params[0], params[1])
};
// ---- 這裡就是「AST/位元碼 → CLIF」的翻譯核心 ----
// add = a + b
let sum = builder.ins().iadd(a, b);
// c = 10(MinLang 的常數,經常數池而來)
let c = builder.ins().iconst(int, 10);
// result = add * c
let result = builder.ins().imul(sum, c);
// ------------------------------------------------
builder.ins().return_(&[result]);
builder.finalize();
}
self.module.define_function(func_id, &mut self.ctx).unwrap();
self.module.clear_context(&mut self.ctx);
self.module.finalize_definitions().unwrap();
let code_ptr = self.module.get_finalized_function(func_id);
code_ptr
}
}
呼叫它:
fn main() {
let mut jit = MinLangJit::new();
let f = jit.compile_add_mul();
type Fn2 = unsafe extern "C" fn(i64, i64) -> i64;
let f: Fn2 = unsafe { std::mem::transmute(f) };
println!("{}", unsafe { f(3, 4) }); // (3+4)*10 = 70
}
翻譯器的通用模式:雙向樹遍歷#
真正的 MinLang JIT 前端是對 AST 做一次後序遍歷,每個節點產生 CLIF 值。與第 2.3 節位元碼編譯器對照:
| MinLang 節點 | 位元碼(2.3 節) | CLIF |
|---|---|---|
Num(n) | Push n | iconst |
a + b | gen(a), gen(b), Add | iadd va, vb |
a < b | gen(a), gen(b), Lt | icmp + bint |
if c {t} else {e} | JumpIfFalse/回填 | brif + blocks |
let x = v; body | Store slot | block 參數或棧槽(stack_slot) |
| 呼叫 | Call nargs | call + 簽名 |
注意 if 的翻譯從「回填跳轉」變成「建立 blocks」——CLIF 是顯式控制流圖(CFG),分支目標是具名 block 而非數字位址,省掉了回填,也讓資料流分析(最佳化的基礎)成為可能。
暫存器分配與呼叫約定:看不見的功臣#
上面的例子完全沒碰暫存器——Cranelift 的 regalloc2 自動完成:
- SSA 的虛擬值 → 實體暫存器(含溢出到棧槽)
- System V AMD64 / Win64 / AArch64 呼叫約定(依 host 三元組自動選擇)
- callee-saved 暫存器的保存與恢復(prologue/epilogue)
這些是第 4.2 節手寫 JIT 無法擴展的根本原因,也是 LLVM 時代(2000-)「後端函式庫化」趨勢的核心內容。Cranelift 與 LLVM 的差別在設計目標:
| LLVM | Cranelift | |
|---|---|---|
| 定位 | 全功能 AOT/JIT 後端 | 快速編譯的安全 JIT 後端 |
| 編譯速度 | 慢(深度最佳化) | 快(WASM 的延遲要求) |
| 正確性驗證 | 依賴測試 | IR 設計可形式化(VeriJIT 研究方向) |
| 語言 | C++ | Rust |
熱點偵測:把 VM 與 JIT 縫合#
完整的 JIT 語言執行器是「VM + JIT」的混合體(4.1 節 V8 管線的縮影):
pub struct Engine {
vm: Vm,
jit: MinLangJit,
pub hot: HashMap<u16, u32>, // proto id → 呼叫計數
}
impl Engine {
pub fn call(&mut self, proto_id: u16, args: Vec<Value>) -> Result<Value, String> {
// 計數超過門檻 → 編譯成機器碼,之後直接走 JIT
let count = self.hot.entry(proto_id).or_insert(0);
*count += 1;
if *count == 100 { // 熱點門檻
self.compile_and_cache(proto_id)?;
}
if self.jitted.contains_key(&proto_id) {
return self.call_native(proto_id, args); // 原生路徑
}
self.vm.call(proto_id, args) // 直譯路徑
}
}
門檻值(V8 用 1000 量級)是在「編譯成本」與「執行收益」間的權衡(4.1 節公式)。真實系統還需要 deopt:型別假設破滅時從機器碼退回直譯器,這需要在機器碼中插入 guard 與退路映射表——這是 JIT 工程最難的部分,本書点到為止,讀者可追 Wasmtime 或 V8 源碼。
習題#
- 把 MinLang 的
if翻到 CLIF:建立三個 block(then/else/merge),用brif連接。 - 把熱點門檻從 100 改成 10 與 1000,測量
loop(10000)總時間,解釋為什麼太低與太高都差。 - 閱讀 Cranelift 文件中的
stack_slot,實作let的棧槽版翻譯,與 block 參數版比較生成碼(用clif輸出)。