5.3 WASM:以 Rust 寫一個 WebAssembly 執行器#
為什麼是 WASM#
WASM(WebAssembly, 2017 W3C 標準)是行程 VM 的當代旗艦,也是 Rust 與 VM 關係最深的项目:
- WASM 的主力工具鏈就是 Rust:
rustc的wasm32-unknown-unknowntarget 是最成熟的 WASM 前端 - 最流行的 WASM 執行器是 Rust 寫的:Wasmtime(Bytecode Alliance)、Wasmer
- WASM 的設計目標與本書主軸完全重合:安全沙箱、與機器無關、可被 JIT
寫一個(簡化的)WASM 執行器,就是把前面四章的全部知識縫合:
graph LR
A[WASM 二進位<br/>.wasm] --> B[解碼<br/>第2章詞法/語法的二進位版]
B --> C[驗證<br/>第5.1節 sandbox 的地基]
C --> D[實例化<br/>線性記憶體/表格/全域]
D --> E[執行<br/>堆疊機直譯<br/>第5.2節]
E -.->|熱點| F[JIT<br/>第4章 Cranelift<br/>Wasmtime 就是這樣做的]WASM 的執行模型#
與 MinLang VM 的三個關鍵差異,每一個都是精心設計的安全/可攜決策:
1. 線性記憶體(linear memory)#
WASM 程式能觸及的記憶體是一段連續位元組陣列,唯一:
pub struct Instance {
pub memory: Vec<u8>, // 線性記憶體:唯一的資料空間
pub globals: Vec<Value>, // 全域變數
pub table: Vec<Option<u32>>, // 間接呼叫表
pub stack: Vec<ValType>, // 運算元堆疊
pub frames: Vec<WasmFrame>,
}
安全含義:WASM 程式沒有裸指標可逃逸。所有記憶體存取都是「線性記憶體內的偏移」,驗證器只需檢查「偏移 + 大小 ≤ 記憶體長度」就保證不越界。對比 x86:任意位址可讀寫——WASM 把「指標」降級成「帶邊界檢查的索引」,這是沙箱的地基(第 5.1 節的承諾在語言層面的兌現)。
2. 結構化控制流#
WASM 沒有任意 jump——只有 block/loop/if/br(跳到標籤)。這使控制流圖在驗證時可分析,也讓 JIT 不需要處理「跳到任意指令中間」的退化情況。對比 JVM 的 goto(bytecode 層有):WASM 更嚴格,是「CFL 可達性可驗證」的設計。
3. 型別化值堆疊#
WASM 的驗證器對每條指令做型別堆疊追蹤:i32.add 要求堆疊頂兩個是 i32,推回一個 i32。整個函式的位元碼在載入時被證明型別安全——JVM verifier 的思想(1995),WASM 把它精煉成數學上可證明的形式。
最小 WASM 執行器#
一個真實的 WASM 模組是二進位格式(LEB128 變長整數、section 結構)。完整解碼器太長,本節聚焦執行核心,用一個已解碼的最小模組:
// src/wasm.rs — 簡化的 WASM 執行器
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ValType { I32, I64 }
#[derive(Debug, Clone, Copy)]
pub enum WasmVal { I32(i32), I64(i64) }
/// 簡化後的指令集(真實 WASM 的子集)
#[derive(Debug, Clone, Copy)]
pub enum WasmInstr {
I32Const(i32),
I32Add,
I32Sub,
I32Mul,
LocalGet(u32),
LocalSet(u32),
Call(u32), // 函式索引
End, // 函式結尾
}
pub struct WasmFunc {
pub params: Vec<ValType>,
pub results: Vec<ValType>,
pub locals: Vec<ValType>,
pub code: Vec<WasmInstr>,
}
pub struct WasmModule {
pub funcs: Vec<WasmFunc>,
pub memory_pages: u32, // 1 頁 = 64KiB
}
驗證器:型別堆疊追蹤#
pub fn validate(module: &WasmModule) -> Result<(), String> {
for (i, func) in module.funcs.iter().enumerate() {
let mut stack: Vec<ValType> = func.params.clone();
for instr in &func.code {
match instr {
WasmInstr::I32Const(_) => stack.push(ValType::I32),
WasmInstr::LocalGet(idx) => {
let t = func.locals.get(*idx as usize)
.or_else(|| func.params.get(*idx as usize))
.ok_or(format!("func {i}: local {idx} out of range"))?;
stack.push(*t);
}
WasmInstr::I32Add | WasmInstr::I32Sub | WasmInstr::I32Mul => {
let b = stack.pop().ok_or(format!("func {i}: stack underflow"))?;
let a = stack.pop().ok_or(format!("func {i}: stack underflow"))?;
if a != ValType::I32 || b != ValType::I32 {
return Err(format!("func {i}: i32 op requires i32 operands"));
}
stack.push(ValType::I32);
}
WasmInstr::Call(f) => {
let callee = module.funcs.get(*f as usize)
.ok_or(format!("func {i}: call to unknown func {f}"))?;
// 檢查參數型別匹配
if stack.len() < callee.params.len() {
return Err(format!("func {i}: not enough args for call {f}"));
}
for t in callee.params.iter().rev() {
let got = stack.pop().unwrap();
if got != *t { return Err(format!("func {i}: arg type mismatch")); }
}
for t in &callee.results { stack.push(*t); }
}
WasmInstr::End => { /* 檢查回傳型別 */ }
}
}
}
Ok(())
}
這個驗證器展示了 WASM 安全模型的核心:在執行任何一條指令之前,每一條指令的型別行為都已被證明。執行器因此可以省掉大部分執行期檢查(除了記憶體邊界——那是資料依賴的,靜態無法完全確定)。
執行器#
pub struct WasmRuntime {
pub module: WasmModule,
pub memory: Vec<u8>,
}
impl WasmRuntime {
pub fn instantiate(module: WasmModule) -> Result<Self, String> {
validate(&module)?;
Ok(WasmRuntime {
memory: vec![0; module.memory_pages as usize * 65536],
module,
})
}
pub fn invoke(&mut self, func_idx: u32, args: &[WasmVal]) -> Result<WasmVal, String> {
let func = self.module.funcs.get(func_idx as usize)
.ok_or("unknown function")?
.clone();
let mut stack: Vec<WasmVal> = args.to_vec();
let mut locals: Vec<WasmVal> = func.locals.iter()
.map(|t| match t { ValType::I32 => WasmVal::I32(0), ValType::I64 => WasmVal::I64(0) })
.collect();
locals.extend(args.iter().cloned()); // 簡化:參數也算 local
let mut pc = 0;
while pc < func.code.len() {
match &func.code[pc] {
WasmInstr::I32Const(n) => stack.push(WasmVal::I32(*n)),
WasmInstr::I32Add => self.binop_i32(&mut stack, |a, b| a.wrapping_add(b))?,
WasmInstr::I32Mul => self.binop_i32(&mut stack, |a, b| a.wrapping_mul(b))?,
WasmInstr::LocalGet(i) => {
let v = *locals.get(*i as usize).ok_or("local out of range")?;
stack.push(v);
}
WasmInstr::LocalSet(i) => {
let v = stack.pop().ok_or("stack underflow")?;
let slot = locals.get_mut(*i as usize).ok_or("local out of range")?;
*slot = v;
}
WasmInstr::End => break,
WasmInstr::Call(_) => return Err("call not supported in this subset".into()),
}
pc += 1;
}
stack.pop().ok_or_else(|| "no return value".into())
}
fn binop_i32(&mut self, stack: &mut Vec<WasmVal>, f: fn(i32, i32) -> i32) -> Result<(), String> {
let b = match stack.pop() { Some(WasmVal::I32(v)) => v, _ => return Err("type error".into()) };
let a = match stack.pop() { Some(WasmVal::I32(v)) => v, _ => return Err("type error".into()) };
stack.push(WasmVal::I32(f(a, b)));
Ok(())
}
}
記憶體邊界:安全沙箱的執行期一半#
線性記憶體的寫入必須檢查邊界:
impl WasmRuntime {
pub fn load_i32(&self, addr: u32) -> Result<i32, String> {
let a = addr as usize;
if a + 4 > self.memory.len() {
return Err(format!("out of bounds: {addr:#x} + 4 > {}", self.memory.len()));
}
Ok(i32::from_le_bytes(self.memory[a..a+4].try_into().unwrap()))
}
pub fn store_i32(&mut self, addr: u32, val: i32) -> Result<(), String> {
let a = addr as usize;
if a + 4 > self.memory.len() {
return Err(format!("out of bounds: {addr:#x} + 4 > {}", self.memory.len()));
}
self.memory[a..a+4].copy_from_slice(&val.to_le_bytes());
Ok(())
}
}
這個邊界檢查是 WASM 與原生碼的效能差主因之一。工業級實現的優化:
- 記憶體預留 + 保護頁:一次
mmap4GB 虛擬位址空間,末尾放保護頁——大多數存取不需要檢查(越界自然觸發頁錯誤),由訊號處理器轉成 trap。Wasmtime 用此技術。 - JIT 特化:編譯期已知
addr + 4在常數範圍內時刪除檢查(第 4.3 節 Cranelift 的最佳化會做此判斷)
注意這又回到了 OS 層:保護頁、頁錯誤、訊號——VM 的安全性最後還是託付給硬體 MMU 與核心。沙箱不是 VM 自己畫的線,而是 VM、MMU、核心三層合力(第 6、7 章的主題)。
WASM 的生態意義:第四種「通用執行環境」#
| 原生執行 | JVM | 瀏覽器 JS | WASM | |
|---|---|---|---|---|
| 效能 | 1× | ~1.5× | ~10× | ~1.2-2× |
| 啟動 | 快 | 慢 | 快 | 快 |
| 沙箱 | 無 | VM 層 | 瀏覽器層 | VM + 記憶體層 |
| 語言 | 任意 | Java 系 | JS | 任意(LLVM/rustc/Cranelift) |
| 場景 | 伺服器/系統 | 企業應用 | 網頁 | 邊緣計算、插件、區塊鏈 |
WASM 正在成為「插件與沙箱執行的通用載體」:Envoy/Proxy-Wasm 的插件、Shopify Functions、Figma 的核心引擎、區塊鏈合約。Rust 在這個趨勢中同時是最流行的 WASM 生產語言(wasm32 target)與最流行的 WASM 執行器語言(Wasmtime/Wasmer)。
習題#
- 為什麼 WASM 禁止任意
jump?寫一段 JVM bytecode 與對應 WASM 的對照,說明驗證難度的差別。 - 實作
WasmInstr::Loop與WasmInstr::BrIf(受限的迴圈),並思考驗證器如何防止無限迴圈(或解釋為什麼不需要防——「不停是呼叫者的責任」)。 - 用
mmap+ 保護頁實作「無檢查的記憶體存取」,並用訊號處理器把 SIGSEGV 轉成 WASM trap(提示:sigaction+siglongjmp,這是 Wasmtime 的經典技術)。