8.1 veryl-emu:週期式編譯模擬#
對應原始碼:
veryl/emu/src/ir.rs、veryl/emu/src/gen.rs、veryl/emu/src/wasm.rs、veryl/emu/src/elab.rs、veryl/emu/tests/check.sh下一節:8.2 fluxa-emu
8.1.1 為什麼需要編譯式模擬#
第 3 章的模擬器是事件驅動直譯器:每次運算走一次語法樹,變數查雜湊表,值是 enum,每個 always_comb 是一段程式碼。
它的優點是精確(包含四態 x);缺點是慢。
對於「跑 Linux」這種工作,需要的是速度。所以需要第二個模擬器:
| 直譯模擬器 | 編譯模擬器 | |
|---|---|---|
| 對應 | Verilog event-driven simulator | Verilator |
| 語意 | 四態 | 二態 |
| 速度 | 慢 | 快 100–1000 倍 |
| 產出 | 日誌 | 可執行檔 |
Verilator 的做法是:把 RTL 轉成 C++,用宿主編譯器編譯。
veryl-emu 的做法更激進:直接產生 WebAssembly 二進位,用自己手寫的編碼器。
flowchart LR
A["Veryl 原始碼"] --> B["emu/src/elab.rs<br/>(複製自 sim 的展開)"]
B --> C["veryl_sim 的 IR<br/>Sim, Proc, Stmt, Expr"]
C --> D["emu/src/ir.rs<br/>型別化 IR"]
D --> E["emu/src/gen.rs<br/>排程 + 程式碼生成"]
E --> F["emu/src/wasm.rs<br/>WebAssembly 編碼器"]
F --> G["out.wasm + out.json"]為什麼不用 Verilator 的路子?
- 不需要宿主編譯器——Rust 產物直接就是 wasm,可以跑在任何瀏覽器裡;
- 不需要 C++ 工具鏈——整個 workspace 只有 Rust;
- 跨平台——同一個
.wasm在 Node、瀏覽器、任何 wasm 執行環境都能跑。
8.1.2 兩態#
編譯模擬器是二態的:沒有 x,也沒有 z。
為什麼可以? 因為在 Verilator 式的模型裡,「x」只出現在:
- 開機時未初始化的正反器;
- 某些不確定的運算結果(除以零、越界)。
Verilator 的做法是給這些值一個預設值(通常是 0),並在特定情況下報警告。
veryl-emu 的做法:
// x / z 變成 0;x 的結果(除以零、越界索引)變成 0
這個取捨是安全的,因為在真實硬體上,開機狀態本來就是不確定的,而把它確定為 0 只是選了一個具體的行為。
8.1.3 型別化 IR#
veryl/emu/src/ir.rs 的核心:
pub enum K {
Const(Vec<u64>), // 小端 64 位元 limb,遮罩到寬度
Sig(usize),
Elem(usize, Box<E>), // 陣列元素(越界:0)
Bit(Box<E>, Box<E>), // base 的第 idx 位(越界:0)
Slice(Box<E>, Box<E>), // base 從第 lo 位起的 w 位
Not(Box<E>), Neg(Box<E>), LNot(Box<E>),
Red(char, bool, Box<E>), // 歸約:'&'、'|'、'^',然後取反?
Bin(&'static str, Box<E>, Box<E>),
Cmp(&'static str, bool, Box<E>, Box<E>), // == != < <= > >=,有號?
LAnd(Box<E>, Box<E>), LOr(Box<E>, Box<E>),
Shift(&'static str, Box<E>, Box<E>),
Tern(Box<E>, Box<E>, Box<E>),
Concat(Vec<E>), // 最高位項目在前
Rep(u32, Box<E>), // n 份的值
Resize(Box<E>), // 轉成節點寬度:截斷或延伸(有號則符號延伸)
Call(usize, Vec<E>),
}
pub struct E { pub k: K, pub w: u32, pub s: bool }
每個運算式都帶著已決定的寬度 w 與符號性 s。 這是「編譯期決定一切」的具體體現。
檔案裡的警告:
寬度規則必須與
veryl_sim::sim::Sim::eval在執行期套用的規則一致
這是「同一語意兩份實作」的必然成本(第 1.2.7 節)。緩解手段是 veryl/emu/tests/check.sh 的差異測試。
8.1.3.1 迴圈展開#
/// 常數邊界且迭代次數不超過這個數的迴圈會被展開
const UNROLL: u32 = 128;
fn subst_e(e: &Expr, var: usize, v: &Val) -> Expr { ... } // 把迴圈變數換成常數
fn subst_s(s: &Stmt, var: usize, v: &Val) -> Stmt { ... }
展開是在模擬器的 IR 上做的(用替換),不是重新實作一套展開器。
好處:陣列索引與位元選取變成固定位址與固定位移,程式碼生成器可以直接最佳化。
迴圈變數在迴圈後保留最後的值,與 veryl-sim 一致。
8.1.4 記憶體佈局#
pub const FINISH: u32 = 0; // $finish 旗標
pub const QPTR: u32 = 4; // 寫入佇列的指標
pub const MUTE: u32 = 16; // 16 + 4k = 第 k 個停止訊號的靜音旗標
const BASE: u32 = 64; // 訊號從這裡開始
const QUEUE_BYTES: u32 = 1 << 20;
每個訊號是一串小端 64 位元 limb。 陣列的元素一個接一個連續放置。寬度以上的位元永遠是 0(「正規化」)。
這個佈局的意義:整個設計的狀態在一塊連續記憶體裡,而這塊記憶體可以直接變成 wasm 的線性記憶體。
8.1.5 程序排程:Tarjan 的強連通分量#
/// 依賴順序的強連通分量(Tarjan,迭代式)
fn sccs(n: usize, succ: &[Vec<usize>]) -> Vec<Vec<usize>> { ... }
依賴圖是從讀寫集合建立的(ir.rs 的 rw_s / reads_e)。
- 無環的程序依賴順序執行;
- 循環群組(程序之間的組合迴路)編譯成一個函式,在執行期重複執行直到輸出不再變化(最多 100 次),用一個暫存區比較群組的輸出。
「迭代直到不變」是 delta cycle 的編譯期版本——它把模擬器的收斂語意直接翻譯成迴圈。
Tarjan 是迭代式實作(不是遞迴),因為遞迴會在深圖上爆棧。
8.1.6 時脈別名#
// 時脈透過埠連接到實例(child.clk = clk):追蹤到根源
let root = |mut s: usize| { let mut n = 0; while let Some(&t) = alias.get(&s) { s = t; n += 1; if n > 100 { break; } } s };
「一個設計只能有一個時脈」 是 Verilator 的限制,veryl-emu 也一樣:
more than one clock (or clock edge) is not supported yet
100 次的上限是防止別名循環無限。
8.1.7 活性分析:只跑「髒」的程序#
/// 儲存 sig 的 limb 到 dest;當它們與訊號目前的值不同時,它的讀取者變成髒的
fn put(&mut self, sig: usize, dest: u32, limbs: &[(u32, u32)]) {
let m = self.marks(sig);
if !m.is_empty() {
let cur = self.lay.off[sig];
for (i, &(k, l)) in limbs.iter().enumerate() {
self.f.i32c(0).load64(cur + 8 * k).local_get(l).op(I64_NE);
if i > 0 { self.f.op(I32_OR); }
}
self.f.if_(); self.emit_marks(&m); self.f.end();
}
for &(k, l) in limbs { self.f.i32c(0).local_get(l).store64(dest + 8 * k); }
}
這是編譯模擬器的核心最佳化:每個組合程序有一個「髒」旗標;只有當它的輸入改變時才重跑。
settle() 會先比較那些「沒有程序寫」的訊號(輸入)與它們的快取值,改變了就標記讀取者。
這個機制讓「一個時脈內只有少數程序需要重算」成為可能,這是編譯模擬器比直譯器快的主要原因。
8.1.8 非阻塞指派:最難的部分#
在 always_ff 裡,指派是非阻塞的。編譯模擬器要模擬這個。
三種情況,三種機制:
| 對象 | 機制 |
|---|---|
某個 always_ff 也會讀取的暫存器 | 影子副本:邊緣時把區域拷到影子,主體寫影子,之後影子拷回 |
某個 always_ff 會讀取的陣列元素 | 寫入佇列:邊緣時把 [位址, limbs, 值…] 加入佇列,之後重放 |
| 其他 | 直接就地寫 |
8.1.8.1 影子副本#
// posedge
if rlen > 0 { pe.i32c(lay.shadow_base).i32c(lay.region.0).i32c(rlen).memcopy(); } // 區域 -> 影子
pe.i32c(0).i32c(lay.qbase).store32(QPTR); // 重置佇列
for &i in &ff_funcs { pe.call(i); } // always_ff 主體
if rlen > 0 { pe.i32c(lay.region.0).i32c(lay.shadow_base).i32c(rlen).memcopy(); } // 影子 -> 區域
// 然後重放寫入佇列
pe.call(settle_i);
為什麼需要影子? 因為 always_ff 主體裡的讀取必須看到舊值。影子區域就是「舊值的副本」。
「被讀取的暫存器集合」是一個靜態分析的結果——如果某個暫存器只被寫不被讀,就不需要影子(直接寫就對了)。
RV64 SoC 的延遲區域很小:30 個暫存器、240 個位元組(history.md v2.12)。
8.1.8.2 寫入佇列#
// 佇列項目:[位址, limbs, 值...]
self.f.local_get(p).local_get(addr).i32c(off as i32).op(I32_ADD).store32(0);
self.f.local_get(p).i32c(v.len() as i32).store32(4);
for (k, &x) in v.iter().enumerate() { self.f.local_get(p).local_get(x).store64(8 + 8 * k as u32); }
self.f.i32c(0).load32(QPTR).i32c(8 + 8 * v.len() as u32).op(I32_ADD).store32(QPTR);
// 溢位:trap
self.f.i32c(0).load32(QPTR).i32c(self.lay.qend as i32).op(I32_GE_U).if_().op(UNREACHABLE).end();
為什麼陣列元素需要佇列而不是影子?
因為一個陣列可能有 256 MB。為了一個元素做影子副本是不可行的。
寫入佇列的方案:把「寫哪個位址、寫什麼值」記錄下來,主體結束後重放。
「重放」的順序很重要:必須按記錄順序重放,才能保證「後寫的覆蓋先寫的」。
溢位時 trap(UNREACHABLE)——這是一個健全性檢查:如果佇列滿了,行為未定義,寧可崩潰。
8.1.9 程式碼生成#
8.1.9.1 值的表示#
用 i64 limb 在區域變數裡。
| 運算 | 實作 |
|---|---|
resize | 符號延伸用 0 - sign_bit;零延伸直接;最頂端 limb 遮罩 |
add / sub | 多 limb 帶進位/借位(借位 = a<b | (d < borrow_in)) |
shr_const / shl_const | 常數位移 = 直接搬 limb |
shr_dyn / shl_dyn | 動態位移分解成 k = amt >> 6 與 r = amt & 63,用 SELECT 選 limb |
cmp | </<=/>/>=:頂端 limb 比較(有號時符號延伸),較低 limb 以字典序折疊 |
divrem | 只有單 limb;無號用 DIV_U/REM_U(除零保護 SELECT(b==0 ? 1 : b, b==0));有號取絕對值( $ |
cond | 條件直接產生 i32 區域變數(Cg::cond 快速路徑),避免繞道 i64 |
divrem 只支援單 limb 是一個限制。因為 wasm 沒有硬體除法指令,多 limb 除法必須用迴圈實作。
cmp 的字典序折疊是正確的:兩個多位元組無號整數的大小由最高不同位元決定。從高到低比較,一旦不同就決定。
8.1.9.2 函式#
函式主體變成獨立的 wasm 函式(fn:<name>),返回值放在一個專門的訊號裡;return 存值然後 ret。
8.1.9.3 除錯輔助#
// VERYL_EMU_CHUNK=1:每個程序有自己的具名函式(給 node --cpu-prof 用)
// VERYL_EMU_COUNT:加上每個程序的執行計數
wasm 的 name 自訂段帶著函式名稱。
這個設計說明一件事:可觀察性是工具的一部分。 如果不能指出「哪個程序在跑」「跑了多少次」,效能問題就無從診斷。
8.1.10 WebAssembly 編碼器#
veryl/emu/src/wasm.rs 是一個手寫的、零相依的 wasm 二進位寫出器。
- 一個記憶體(匯出為
mem),以 64 KB 頁配置; - 兩種函式型別:
() -> ()(0)與(i32) -> i32(1)。
為什麼要自己寫?
- 零相依(符合整個專案的風格);
- 完全控制輸出的位元組(對
web/wasm的整合重要); - 体积最小。
匯出的介面:
// settle():收斂
// posedge():時脈上升沿
// run(n):跑 n 個時脈
「匯出三個函式」是編譯模擬器的最小介面,而且介面小到可以在 JavaScript 裡輕易驅動。
8.1.11 差異測試#
veryl/emu/tests/check.sh 是這個專案最重要的驗證手段之一。
做法:同一個測試平台,分別跑:
veryl/sim(事件驅動直譯器,四態);veryl/emu(編譯模擬器,二態,wasm)。
結果必須完全一致。
# check.sh 的結構(概念)
reda sim tests/check.veryl --vcd sim.vcd
node js/emu.mjs emu.wasm check.vcd # 產生 emu.vcd
diff sim.vcd emu.vcd
比較 VCD 是關鍵:不只是比較最終結果,而是比較完整的波形。
這個差異測試抓到的東西:
- 寬度規則不一致(第 8.1.3 節的警告);
- 非阻塞語意的邊界情況;
- 陣列索引越界的處理;
- 迴圈展開後的語意差異。
這是本章最值得學的一點:當一個語言有兩套實作(直譯 + 編譯)時,差異測試不是可選的工程紀律,而是正確性的一部分。因為語言的語意是由「兩個實作的一致性」定義的。
8.1.12 效能#
| 引擎 | 速度 | 備註 |
|---|---|---|
| veryl-sim(直譯) | 慢 | 四態、精確 |
| veryl-emu(wasm) | 約 500 萬指令/秒 | 二態、快速 |
| riscv-emu(wasm + JIT) | 約 176 MIPS | 指令集層級 |
「500 萬指令/秒」 是在瀏覽器裡。這是「開 Linux」的門檻:Linux 開機大約執行幾百萬到幾千萬條指令,所以需要 1 到 10 秒。
8.1.13 週期式模擬的侷限#
編譯模擬器不能模擬「零延遲迴路」的行為,因為真實硬體上組合迴路會振盪。
編譯模擬器假設設計是同步的(所有狀態變化都在時脈緣發生)。
四態語意被捨棄——這對 RTL 設計驗證(找出 x 相關的 bug)是一個真實的限制。
因此兩個模擬器是互補的,不是取代關係:
| 用在哪裡 | |
|---|---|
reda sim | 開發初期、找 x bug、驗證測試平台 |
reda emu | 系統級、跑 Linux、量效能 |