2.5 時脈與測試平台#
原始碼:
veryl-sim/src/elab.rs($tb::*與 initial)、veryl-sim/src/sim.rs(執行、斷言) 上一節:2.4 四值邏輯位元向量 下一節:2.6 VCD 波型輸出
2.5.1 測試平台的形狀#
Veryl 沒有獨立的 testbench 語言;testbench 就是帶 #[test(...)] 屬性的模組,實例化被測模組(DUT),用兩個內建「元件」驅動時脈與 reset:
#[test(TestCounter)]
module TestCounter {
var clk: clock;
var rst: reset;
inst clk_gen: $tb::clock_gen; // 實例本身就是時脈
inst rst_gen: $tb::reset_gen(clk); // 括號裡告訴它跟哪個時脈
inst dut: Counter; // 被測模組
initial {
rst_gen.assert(); // 拉低 reset 兩個時脈
clk_gen.next(10); // 跑 10 個 posedge
$assert(dut.cnt == 10, "count mismatch");
}
}
veryl-sim 對這兩個 $tb::* 實例有特殊處理(pass1,elab.rs):
// veryl-sim/src/elab.rs pass1()(節錄)
GenerateItem::InstDeclaration(x) => {
...
match kind.as_str() {
"$tb::clock_gen" => {
let mut half = 5;
if let Some(p) = &ci.component_instantiation_opt1 {
... // 參數 PERIOD 可覆寫半週期
}
let ty = TypeInfo::scalar(1);
let id = self.declare(sc, &name, &TypeInfo { kind: Kind::Clock(true), two_state: true, ..ty });
self.procs.push(Proc::ClockGen { sig: id, half });
}
"$tb::reset_gen" => {
let id = self.declare(sc, &name, &TypeInfo { kind: Kind::Reset { low: true, asynch: true }, two_state: true, ..ty });
self.sim.sigs[id].val = Val::new(1, 1); // 上電為 1(高有效的 reset)
// `inst rst: $tb::reset_gen(clk);` — remember the clock
if let Some((_, first)) = conns.first() {
if let Some(c) = sc.sigs.get(&t[0]) { self.tb_clk.insert(id, *c); }
}
}
_ => {}
}
三個細節:
- 「實例就是時脈」:
inst clk_gen: $tb::clock_gen產生的訊號clk_gen本身被宣告成Kind::Clock(true),Proc::ClockGen { sig: id, half }每half個時間單位翻轉它。Testbench 直接引用clk_gen當時脈,不需要額外的assign clk = clk_gen;。AGENTS.md 所述「the inst IS the clock」即此。 - 預設半週期 5(PERIOD 10),時間尺度由
--max-time與 VCD 的1ns標籤解讀。 - reset_gen 記住時脈:連接埠
reset_gen(clk)的第一個參數被存進tb_clk表,之後rst_gen.assert()不帶參數也知道要等哪個時脈。
2.5.2 rst.assert() 的展開#
rst_gen.assert() 不是函式呼叫,而是精細化時降級為三條敘述:
// veryl-sim/src/elab.rs lower_ident_stmt()(節錄)
"assert" => {
let clk = match args.first() {
Some(t) => *sc.sigs.get(&t[0])...,
None => *self.tb_clk.get(&inst).ok_or("rst.assert() needs a clock ...")?,
};
let lv = LValue { sig: inst, sel: Sel::All };
out.push(Stmt::Assign(lv.clone(), Expr::Num(Val::new(1, 0)))); // reset 拉低
out.push(Stmt::Wait(clk, Expr::Num(Val::new(32, 2)))); // 等 2 個 posedge
out.push(Stmt::Assign(lv, Expr::Num(Val::new(1, 1)))); // 釋放
return Ok(());
}
展開結果等價於:
rst = 0; clk.next(2); rst = 1;
低有效非同步 reset 的 DUT 收到 0 就進入 reset 狀態;兩個 posedge 後釋放。寫成內建而非讓使用者手寫,是為了消除「reset 釋放與時脈緣同時」的 race(clk.next(2) 保證在 edge 之間釋放)。
2.5.3 clk.next(n):initial 的等待機制#
clk_gen.next(n)(或任何實例 .next(n))降級為 Stmt::Wait(inst, n),由 compile_initial 編成 Op::Wait:
// veryl-sim/src/sim.rs compile_initial()(節錄)
pub fn compile_initial(stmts: &[Stmt], ops: &mut Vec<Op>, watched: &mut Vec<usize>) {
for s in stmts {
if !has_wait(s) { ops.push(Op::Exec(s.clone())); continue; } // 無等待:內聯
match s {
Stmt::Wait(c, n) => { watched.push(*c); ops.push(Op::Wait(*c, n.clone())); }
Stmt::If(arms, els) => { /* 編成 JmpIfNot / Jmp */ }
Stmt::For { .. } => { /* 編成回圈:Jmp / JmpIfNot */ }
...
}
}
}
含 Wait 的敘述(及其所在的 if/for)被「攤平」為 op 序;不含 Wait 的敘述整塊內聯為 Op::Exec(執行時一次 exec)。控制流程用 Op::Jmp/Op::JmpIfNot——一個小虛擬機,而非遞迴直譯,因為 Wait 需要在任意巢狀深度「暫停並稍後從原處恢復」,平坦 op 序 + pc 是最簡單的暫停點表示。
執行側(2.1.5 節已述):
// veryl-sim/src/sim.rs run_init()(節錄)
Op::Exec(s) => {
self.sim.exec(std::slice::from_ref(&s), false);
self.settle(); // ★ 每條敘述後收斂
}
Op::Wait(clk, n) => {
let n = self.sim.eval(&n, 0).v.sat();
if n > 0 { self.inits[k].wait = Some((clk, n)); return; } // 暫停
}
Wait 的計數在 settle() 每輪偵測到 watched 訊號的 posedge 時遞減;歸零後由 resume_ready() 恢復執行。多個 initial 各自持有 InitState,互不干擾(協作式,不搶佔)。
2.5.4 斷言與 exit code#
$assert(expr, "msg") 的執行(sim.rs call()):
// veryl-sim/src/sim.rs
"$assert" => {
let ok = args.first().map(|e| self.eval(e, 0).truth() == Some(true)).unwrap_or(false);
if !ok {
let msg = if args.len() > 1 { self.format(&args[1..]) } else { String::new() };
self.say(&format!("ASSERTION FAILED at time {} {}", self.time, msg), true);
self.fails += 1;
}
}
重點在 truth() == Some(true):x 條件算失敗。$assert(a == b) 在 a 或 b 含 x 時,== 回 x(2.4.4 節),truth() 回 None,斷言失敗——正確行為,因為「不知道」不該被當成通過。
失敗計入 sim.fails;CLI 依它決定 exit code(main 的收尾:fails > 0 → 1)。這使 cargo test/CI 可以直接用 exit code 判斷模擬結果。$error/$fatal 同樣計入($fatal 額外設 finished 終止模擬);$finish 只終止不計失敗。
flowchart LR
A["$assert(cond)"] --> B{"truth() == Some(true)?"}
B -- "是" --> C["通過"]
B -- "否 (false 或 x)" --> D["fails += 1\nlog: ASSERTION FAILED at time t"]
D --> E["模擬結束"]
E --> F{"fails > 0?"}
F -- "是" --> G["exit 1"]
F -- "否" --> H["exit 0"]2.5.5 為什麼每條 initial 敘述後都收斂#
標準 Verilog 模擬器只在「時間槽」邊界做排程;initial 中連續兩條阻塞敘述之間不會有組合邏輯更新——這導致經典的 testbench 寫法必須在賦值後加 #1 之類的延遲才能觀察組合結果。
veryl-sim 做了不同的選擇:run_init 的 Op::Exec 之後立即 settle()。效果:
initial {
a = 1; // 驅動 DUT 輸入
$display(b); // 立刻看到組合傳播後的 b —— 不需要任何延遲
}
這使得時脈less 組合 testbench 成為自然結果:DUT 是純組合邏輯(無 always_ff)時,testbench 不需要例項化任何時脈,賦值→讀值→斷言一氣呵成,Design::run 的主迴圈甚至沒有時脈事件可排(堆積空,立即結束)。
實作代價:每條敘述一次完整 settle()(組合收斂 + edge 檢查)。對 testbench 語句數量而言可忽略;換來的是語意上「模擬器總是給你穩定後的世界」,testbench 程式碼大幅簡化。這是 AGENTS.md 所述「The sim settles combinational logic after every initial statement (delta cycles), so clock-less comb testbenches work」的來源。
2.5.6 多個 initial 與執行緒間的次序#
多個 initial 區塊的排程是確定性的:Design::run 先依序把每個 initial 跑到第一個 Wait(或跑完):
// veryl-sim/src/sim.rs Design::run(節錄)
self.sim.time = 0;
self.settle(); // t=0:組合邏輯初值
for k in 0..self.inits.len() { self.run_init(k); } // 依宣告順序,各跑到第一個等待
self.settle();
while self.resume_ready() && !self.sim.finished { self.settle(); }
SystemVerilog 說「同一時間槽內多個程序的相對次序未定義」;veryl-sim 選擇宣告順序——相同程式碼永遠得到相同結果,可重現性是驗證工具的德性。需要跨 initial 同步時,用共同的 clk.next(n) 對齊即可(這也是 testbench 的慣用手法)。
一個典型的雙 initial 協作:
initial { rst_gen.assert(); clk_gen.next(4); } // thread A: reset
initial { clk_gen.next(20); $assert(dut.done); } // thread B: 檢查
執行序:A 與 B 各自跑到 next() 掛起 → 主迴圈數時脈 → A 在第 4 個 posedge 恢復並結束 → B 在第 20 個 posedge 恢復、斷言。resume_ready() 在每次 settle() 後檢查有沒有 initial 的等待歸零,歸零者恢復執行直到下一個等待。
2.5.7 Top 模組選擇與執行流程#
CLI 的 top 選擇規則:有 #[test] 模組則全部執行;否則最後一個模組。collect_modules(elab.rs)在收集模組時就記下 is_test:
// veryl-sim/src/elab.rs
pub fn collect_modules<'a>(g: &'a DescriptionGroup, out: &mut Vec<(&'a ModuleDeclaration, bool)>) {
let is_test = g.description_group_list.iter().any(|a| {
let t = toks(|c| c.attribute(&a.attribute));
t.iter().skip(1).any(|x| x == "test")
});
...
}
每個 top 模組各自精細化成一個 Design,依序執行;任何一個失敗使整體 exit code 為 1。--vcd 為每個 Design 開啟 VCD 記錄(2.6 節)。--list 則只印模組與其 is_test 標記,不執行——用來在跑模擬前確認哪些 testbench 會被挑中。
flowchart LR
A["Design::run"] --> B["settle @ t=0"]
B --> C["每個 initial 跑到第一個 Wait"]
C --> D{"時脈事件?"}
D -- "是" --> E["翻轉 clk → settle → resume_ready"]
D -- "否" --> F["結束"]
E --> G{"$finish 或\nmax-time?"}
G -- "否" --> D
G -- "是" --> F2.5.8 與 official veryl test 的差異#
veryl test(官方 0.22)有自己的模擬器。veryl-sim 刻意不追平:
| 面向 | veryl-sim | official veryl test |
|---|---|---|
$tb::clock_gen | inst 即時脈 | 亦支援 |
| 時脈less comb testbench | 天然支援(每敘述收斂) | 需要時脈 |
| x 語意 | 完整 4 值 | 完整 |
| 介面模擬 | 正確(2.3.6 節) | 有已知 bug(TestHs 為 0) |
$assert x 條件 | 失敗 | 失敗 |
AGENTS.md 記錄的介面差異(veryl test 對 TestHs 給 0,iverilog 與 veryl-sim 給 145)正是「自家模擬器」價值的例子:介面成員連接(inst.member 訊號 + modport 拆解)在 veryl-sim 是一等公民。
另外 $display/$write/$info/$warning 的格式化(sim.rs 的 format())支援 %d %h %b %o %t %c %s %m 與寬度/零填充語法,輸出進 Sim::log(RefCell<String>)並可 echo 到 stdout——wasm 版用 log 而不用 stdout,testbench 的可見行為在兩個宿主一致。$random/$urandom 用 xorshift(種子固定 0x2545F4914F6CDD1D),模擬完全可重現;$time/$stime 回傳目前模擬時間;$clog2/$signed/$unsigned/$bits 在 eval_call 中以常數語意處理。
為什麼這樣設計#
- testbench = 模組 + 屬性:不引入第二種檔案格式或語言;
#[test]讓模擬目標可以與 DUT 同檔(examples/counter.veryl一檔即測)。 - 兩個魔術實例(clock_gen/reset_gen)取代 SystemVerilog 的
#10 clk = ~clk;手寫時脈:時脈是排程器的一級公民(Proc::ClockGen+ 事件堆積),不是使用者程序,效率與正確性都更好。 .next()編譯成 VM op 而非函式呼叫:暫停/恢復是 initial 的本質需求,平坦 op 序是最簡表示;compile_initial順便把無 Wait 的程式塊內聯,減少執行期分派。- 每敘述收斂:以模擬器端的固定開銷,換 testbench 端「永不寫 #1」的簡單性;時脈less comb testbench 是免費推論。
- x 條件斷言失敗:與 4 值語意一貫——「未知」不得視為通過,否則 x 傳播的偵錯價值(2.4.1 節)被斷言層丟掉。
本章重點#
inst clk: $tb::clock_gen的 inst 訊號本身就是Kind::Clock訊號,由Proc::ClockGen在事件堆積中自我重排驅動。inst rst: $tb::reset_gen(clk)記下時脈(tb_clk);rst.assert()精細化時降級為「拉低、等 2 個 posedge、釋放」三條敘述。clk.next(n)是Stmt::Wait→compile_initial編成Op::Wait;initial 是平坦 op VM 的協作執行緒。$assert用truth() == Some(true):x 條件即失敗;fails決定 exit code 1。- 每條 initial 敘述後
settle():組合結果立即可見,時脈less 組合 testbench 因此可行。