3.2 測試平台與 initial 區塊的編譯#
對應原始碼:
veryl/sim/src/sim.rs(compile_initial、run_init)、veryl/sim/src/lib.rs(CLI 與函式庫 API)、veryl/sim/src/gates.rs(閘級替換) 上一節:3.1 四態值與 delta cycle 下一節:3.3 Kahn 程序網路
3.2.1 測試平台是什麼#
在硬體工程裡,測試平台(testbench) 是驗證用的激勵與觀測環境。它包含:
- 激勵(stimulus):驅動 DUT 輸入的訊號;
- 觀測(observation):檢查 DUT 輸出;
- 斷言(assertion):失敗時報錯;
- 控制流程:何時施加刺激、何時檢查。
Veryl 用 #[test(...)] 屬性標記測試平台模組:
#[test(fifo_test)]
module TestFifo {
inst clk: $tb::clock_gen;
inst rst: $tb::reset_gen(clk);
// ...
initial {
rst.assert();
$assert(empty == 1'b1, "empty after reset");
push = 1; din = 8'h11; clk.next();
// ...
$finish();
}
}
3.2.2 時脈作為元件#
沒有自由運轉的振盪器。 一個時脈訊號只存在於測試平台裡,而且它的存在方式是一個特殊的元件實例:
// veryl/sim/src/elab.rs
"$tb::clock_gen" => {
let mut half = 5; // PERIOD 預設 10 → 半週期 5
// ... 讀 PERIOD 參數 ...
let id = self.declare(sc, &name, &TypeInfo { kind: Kind::Clock(true), two_state: true, ..ty });
self.procs.push(Proc::ClockGen { sig: id, half });
}
時脈訊號就是這個元件的實例。 測試平台把它接到 DUT 的 i_clk 埠:
inst clk: $tb::clock_gen;
DUT 的 always_ff 透過型別(clock)找到它。
這個設計解決了 Verilog 裡的一個實際問題。 在 Verilog 中:
always #5 clk = ~clk; // 自由運轉的時脈
這是一個無限循環。它讓模擬無法自動結束、讓 $time 變得不可預測、讓測試平台難以控制。Veryl 的做法是:時脈只在測試平台裡存在,而且由測試平台用 clk.next() 主動推進。
3.2.2.1 重置產生器#
"$tb::reset_gen(clk)" => {
// 建立一個初值為 1 的重置訊號
// (對低有效重置而言,1 = 未作用)
// 並記住時脈
self.tb_clk.insert(inst_id, clk_id);
}
tb_clk 這張表的用途是:rst.assert() 不帶參數時,知道要等哪個時脈。
3.2.3 assert 與 release#
rst.assert() 被脫糖成三個陳述句:
// veryl/sim/src/elab.rs lower_ident_stmt
out.push(Stmt::Assign(lv, Expr::Num(Val::new(1, 0)))); // 驅動重置為作用中
out.push(Stmt::Wait(clk, Expr::Num(Val::new(32, 2)))); // 等 2 個時脈邊緣
out.push(Stmt::Assign(lv, Expr::Num(Val::new(1, 1)))); // 解除
注意「等 2 個時脈邊緣」而不是「等 1 個」。這是硬體設計的常規做法:重置解除後,要等一個時脈邊緣讓所有正反器看到「解除後的值」。
Stmt::Wait 是 initial 區塊能暫停的唯一機制,這就引出了下一個問題。
3.2.4 initial 區塊必須被編譯成程式#
initial 區塊裡可以有 clk.next()(等待),所以它不能只是一串陳述句。它必須被編譯成一個小型指令程式:
// veryl/sim/src/sim.rs
pub enum Op {
Exec(Stmt),
Wait(usize, Expr), // 等 clk 上升沿 n 次
Jmp(usize),
JmpIfNot(Expr, usize),
}
pub struct InitState {
pub ops: Vec<Op>,
pub pc: usize,
pub wait: Option<(usize, u128)>,
pub done: bool,
}
為什麼需要跳轉? 因為 if 與 for 裡面可以有等待:
initial {
for i in 0..10 {
din = i;
clk.next();
$assert(dout == i, "mismatch");
}
}
編譯成:
0: Exec(din = i)
1: Wait(clk, 1)
2: JmpIfNot(i < 10, 5)
3: Exec($assert(...))
4: Jmp(0)
5: (結束)
含等待的 if 與 for 都被轉成跳轉。 這是一個小而完整的「結構化程式 → 扁平指令序列」的編譯。
3.2.4.1 執行程式#
fn run_init(&mut self, k: usize) {
// ...
}
每次 Op::Exec 之後會呼叫 settle(),讓後續的讀取看到最新的值:
// 每個 Op::Exec 後呼叫 settle(),讓同一個 initial 區塊裡後面的讀取
// 看到最新的值——這是測試平台依賴的 delta cycle 語意
這是正確性的關鍵。 考慮:
initial {
dout = 1;
$assert(dout == 1, "must be visible immediately");
}
在 Verilog 裡,阻塞指派後立即讀取會看到新值。settle() 呼叫保證了這點。
3.2.4.2 計數等待邊緣#
等待的計數在 settle() 裡遞減:
for st in &mut self.inits {
if let Some((c, ref mut k)) = st.wait {
if pos[c] && *k > 0 { *k -= 1; }
}
}
注意只數上升沿(pos[c]),與 clk.next() 的語意一致。
3.2.5 測試平台的選擇與執行#
// veryl/sim/src/lib.rs
pub fn list(files: &[VFile]) -> Vec<(String, bool)>
pub fn run(files: &[VFile], top: Option<&str>, max_time: u64, vcd: bool, echo: bool) -> RunResult
pub fn run_gates(files: &[VFile], top: Option<&str>, max_time: u64, vcd: bool,
echo: bool, gate: GateFn) -> RunResult
RunResult 包含:
pub struct RunResult {
pub log: String,
pub tops: Vec<TopResult>, // 每個 top 的失敗數與是否結束
pub vcd: Option<(String, String)>,
}
預設的 top 選擇規則:所有帶 #[test(...)] 屬性的模組;如果沒有,就用最後宣告的模組。
CLI:
reda sim [--top MODULE] [--vcd out.vcd] [--max-time N] [--list] a.veryl ...
退出碼:斷言失敗 → 1。--vcd 在多個 top 時會寫成 out_<top>.vcd。
3.2.6 用閘級網表替換 RTL#
這是 veryl/sim/src/gates.rs 的功能,也是整個驗證策略的核心。
pub type GateFn = dyn Fn(&str, &HashMap<String, Val>) -> Result<Option<GateNetlist>, String>;
Elab.gates 是一個回呼。當它對某個模組回傳網表時,elab_gates 就用網表建立該模組的作用域,取代原本的 RTL 展開:
// 每個網表導線變成 1 位元訊號
// 組合單元變成 Proc::Comb 指派
// 正反器變成 Proc::Ff
// RAM 變成有同步埠的陣列
gate_cache 依 module#param=value,... 做記憶化,避免同一個參數化模組被展開多次。
這個機制的意義:
flowchart LR
TB["測試平台<br/>(不變)"] --> SW{"網表提供者"}
SW -->|沒有| RTL["RTL 展開<br/>Proc::Comb / Proc::Ff"]
SW -->|有| GATE["閘級展開<br/>同一組 Proc"]
RTL --> R1["結果 A"]
GATE --> R2["結果 B"]
R1 -.->|"必須相等"| R2同一個測試平台,跑兩次,結果必須相同。 這是「合成正確性」的驗證方式,而且不需要形式方法。
CLI:
reda gatesim opt|ice40|ecp5|asic|sky130 FILE...
opt 是「通用閘(未映射)」,ice40/ecp5/asic/sky130 是映射後的。退出碼 1 表示測試平台失敗。
3.2.6.1 網表提供者#
// veryl/synth/src/gatesim.rs
pub struct Provider<'a> {
pub lib: &'a crate::elab::Lib<'a>,
pub top: String,
pub kind: String,
pub built: RefCell<Vec<(String, usize)>>,
}
Provider::get 為 top 模組合成指定 kind 的網表,並轉成模擬器的 GateNetlist。
注意 kind 是一個字串:"opt" | "ice40" | "ecp5" | "asic" | "sky130"。底層的 eda::netsim::mapped 負責分派:
/// 指定種類的網表,用於閘級模擬:
/// "opt"(通用閘原樣)、"ice40"、"ecp5"、"asic"(vlib 單元)、"sky130"(SKY130 單元)
pub fn mapped(nl: &Netlist, kind: &str) -> Result<Netlist, String> {
match kind {
"opt" | "gate" => Ok(nl.clone()),
"ice40" => crate::fpga::ice40::map(nl),
"ecp5" => crate::fpga::ecp5::map(nl),
"asic" => crate::asic::map::map(nl),
"sky130" => crate::pdk::with_tech(crate::pdk::sky130_builtin()?, || crate::asic::map::map(nl)),
k => Err(format!("unknown netlist kind '{}' (opt, ice40, ecp5, asic, sky130)", k)),
}
}
五個層級的等價性檢查——同一個設計,在五種網表上跑同一個測試平台:
- 通用閘(
$_AND_等) - iCE40(
SB_LUT4、SB_CARRY、SB_RAM40_4K) - ECP5(
LUT4、CCU2C、DP16KD) - ASIC vlib 標準單元
- SKY130
這就是「同一語意、多種實作、交叉驗證」的方法論在硬體上的完整應用。
3.2.7 系統任務#
模擬器支援 Verilog 的一組系統任務:
| 任務 | 行為 |
|---|---|
$display $info $write $warning $error $fatal | 輸出;$error/$fatal 增加失敗數 |
$assert | 檢查條件,失敗則報錯並計數 |
$finish | 結束模擬 |
$stop | 暫停 |
format() 實作了 %d %b %h %x %o %t %c %s %m,含寬度與零填充。
有號除法向零截斷,餘數取被除數的符號(SystemVerilog 語意)。這個細節經常是 bug 的來源。
3.2.8 系統函式#
// veryl/sim/src/sim.rs eval_call
支援 $clog2 $signed $unsigned $bits $time $stime $random $urandom。
$random 是一個 xorshift64(13/7/17),種子 0x2545F4914F6CDD1D。
$clog2 從參數的寬度計算: \(\lceil \log_2 w \rceil\) 。這是硬體設計裡最常用的常數函數(算位址寬度、算移位量)。
3.2.9 函式呼叫:call_user#
Stmt 裡有 Call 與 Return,這是為了支援使用者定義的函式:
// veryl/sim/src/sim.rs
fn call_user(&mut self, name: &str, args: &[Expr]) -> R<Val>
函式主體被編譯成 Vec<Stmt>,執行時:
- 參數綁定到參數訊號(所以函式主體可以對它們
assign); - 執行主體;
- 讀回
ret_val。
ret_now 是提前返回的旗標。
這與合成端的函式內嵌(第 2.3.12 節)是兩種不同的策略,但語意必須一致——這是 reda gatesim 與 reda emu 差異測試要保證的事。
3.2.10 測試平台的寫作模式#
綜合前面的機制,一個典型的 Veryl 測試平台長這樣:
#[test(counter_test)]
module TestCounter {
// 時脈與重置
inst clk: $tb::clock_gen;
inst rst: $tb::reset_gen(clk);
// DUT
inst dut: Counter;
// 連接
connect clk.clk => dut.i_clk;
connect rst.rst => dut.i_rst;
// 觀測點
let count: logic<8> = dut.o_count;
initial {
rst.assert();
// 等一個時脈,看重置是否生效
clk.next();
$assert(count == 8'h00, "counter must reset to 0");
// 推幾個時脈
for i in 0..4 {
clk.next();
}
$assert(count == 8'h04, "counter must count up");
$finish();
}
}
注意 $assert 的訊息是給人看的——它應該說明「什麼條件沒成立」,而不只是「斷言失敗」。這是測試平台可讀性的關鍵。
3.2.11 教學流程的自動化#
veryl/tutorial/ 是一套「虛擬實驗課」,每個實驗都有一個設計與一份檢查腳本:
veryl/tutorial/
├── src/uart.veryl
├── src/knight.veryl
├── src/blinky.veryl
├── labs.tw.md 實驗說明
├── check.sh 自動檢查
├── Veryl.toml
└── icebreaker.pcf
check.sh 會建置、模擬,並檢查結果。這是把教科書變成可執行環境的做法:學生改完程式碼,跑一支腳本就知道對不對。
這也是本專案的一貫信念:工具必須能自我檢驗,否則教學無法進行。