8.1 原生測試框架:clock_gen、reset_gen 與斷言#
硬體驗證的歷史負擔#
要理解 Veryl 為什麼要在語言層級內建測試框架,必須先看清楚它繼承的是什麼。
Verilog 時代的 testbench 是純過程式碼:一個 initial 區塊,加上一堆 #delay 等待,偶爾配一個 $monitor 印出訊號。這種寫法對單一情境的煙霧測試夠用,但它有三個結構性缺陷:時間推進散落在程式碼各處(讀者無法一眼看出測試的「劇本」)、期望值與實際值的比對靠人工寫 if 敘述(沒有統一的失敗回報機制)、以及測試本身無法被工具枚舉與批次執行。
SystemVerilog 的回答是 class-based 驗證:把激勵產生、驅動、監控、比對拆成物件,再以 UVM(Universal Verification Methodology)把這些物件組織成可重用的階層。UVM 在大型專案是成功的,但 1.2 節已經論證過它的代價:龐大的學習曲線、繁瑣的樣板碼(一個最小的 UVM 環境動輒數百行),以及「不寫 UVM 就不算做驗證」的團隊文化。對於只需要驗證一個計數器、一個 FIFO、一個 UART 的專案,UVM 是用體積與複雜度支付的過度保險。
Veryl 的選擇是:把「最常用的 20% 驗證需求」做成語言內建。 這 20% 是什麼?時脈與重置的產生、時間的前進、隨機數、簡單的佇列、以及斷言。把這些做成 $tb:: 標準庫,加上 #[test] 屬性與 veryl test 命令,小到中型的專案就可以完全不需要 UVM;而需要 UVM 的場景,則由 9.1 節的 SV 嵌入機制承接。
這個取捨值得用一張表說清楚:
| 面向 | UVM(SystemVerilog) | Veryl 原生測試 |
|---|---|---|
| 最小測試的成本 | 數百行樣板 | 約十行 |
| 學習曲線 | 月 | 小時 |
| 時序斷言語言 | SVA(完整) | 無(僅布林斷言) |
| 約束隨機求解器 | 有 | 無(8.3 節討論補法) |
| 外部模擬器依賴 | 商業模擬器 | 無(內建模擬器,8.2 節) |
| 適用規模 | 大型 SoC | 模組級、中小型專案 |
關鍵的架構事實是:原生測試不需要安裝任何外部模擬器。veryl test 直接以內建模擬器執行測試,這消除了「驗證環境的第一道門檻」——歷史上,光是裝好一套模擬器並讓 testbench 編譯通過,就足以勸退一大批潛在的測試撰寫者。
原生測試的基本結構#
#[test] 屬性與執行方式#
Veryl 用屬性標記測試,而不是用特殊的檔名或目錄約定:
#[test(test_counter_basic)]
module TestCounterBasic {
...
}
標記 #[test(...)] 的 module 不會進入一般的合成產出——veryl build 會跳過它們,veryl test 則以每個測試為單位,用內建模擬器個別執行。測試失敗時,veryl test 以非零退出碼結束,並印出失敗的斷言訊息;這正是 7.2 節 CI pipeline 中 test 閘門的運作基礎。
若某個測試暫時不可執行(例如等一個尚未修好的 bug),可以加上 #[ignore] 屬性:
#[test(test_counter_overflow)]
#[ignore]
module TestCounterOverflow {
...
}
#[ignore] 的測試仍會出現在測試清單中(執行結果標記為「略過」),但實際上不執行。這個設計的用意與軟體測試框架相同:跳過必須是可見的。被靜默刪除的測試會造成覆蓋率的幻覺;被顯式標記的跳過則是一張待辦清單。
一個完整的計數器測試#
以下是最小的完整範例——一個 8-bit 計數器,測試它從重置釋放後每個週期遞增:
#[test(test_counter_basic)]
module TestCounterBasic {
inst clk: $tb::clock_gen #(period: 10);
inst rst: $tb::reset_gen ( clk );
inst cnt: Counter (
clk: clk.clk,
rst: rst.rst,
i_en: 1'b1,
o_cnt: count,
);
var count: logic<8>;
initial {
rst.assert();
clk.next(10);
$assert(count == 8'd10, "count = %d, expected 10", count);
$finish();
}
}
逐行拆解這十幾行裡發生了什麼:
inst clk: $tb::clock_gen #(period: 10);——實例化一個時脈產生器,週期 10 個時間單位。inst rst: $tb::reset_gen ( clk );——重置產生器接上同一個時脈,使重置的斷言與解除都同步於時脈沿。inst cnt: Counter (...)——被測設計(DUT)以一般 Veryl 語法實例化,埠名對接。initial { ... }——測試劇本。initial區塊在模擬開始時執行,內部是線性的腳本。rst.assert();——斷言重置(同步到時脈)。clk.next(10);——時間前進 10 個週期。$assert(...)——比對期望值;失敗則測試失敗。$finish();——結束模擬。
$tb:: 標準庫元件#
$tb:: 是 Veryl 為測試環境預備的標準庫命名空間。它不需要 import,任何測試模組都可直接使用。以下逐一講解每個元件。
$tb::clock_gen:時脈與顯式的時間#
時脈產生器的宣告(概念上)如下:
interface $tb::clock_gen #(
param period: u32 = 10,
) {
var clk: clock;
}
它有一個參數 period(週期),與一個輸出 clk。在測試模組中,用 clk.clk 取出時脈訊號接到 DUT。
它真正的核心方法只有一個:
clk.next(); // 前進一個週期
clk.next(100); // 前進 100 個週期
next(N) 的意義值得停下來討論,因為它是 Veryl 測試模型與 Verilog 測試模型最根本的分歧點。
在 Verilog/SystemVerilog 的 testbench 裡,時間是隱式推進的:
// Verilog 的隱式等待
i_en = 1'b1;
#10;
i_en = 1'b0;
#10 是「等 10 個時間單位」——它與時脈週期沒有必然關係。如果時脈週期是 10,#10 恰好是一個週期;如果有人把 period 從 10 改成 20,整份 testbench 的時間語意就全部漂移了。更糟的是,#10 與 @(posedge clk) 混用時,會產生經典的競態問題:訊號恰好在時脈沿改變,模擬器的排程順序決定了 DUT 看到的是舊值還是新值。
Veryl 的 clk.next(N) 把「時間前進」變成以時脈為單位的顯式操作:
- 時間的單位是「週期」而不是抽象的時間刻度——測試語意與 DUT 的時脈語意綁定,改
period不會漂移測試行為; next()保證在時脈沿之後、且非阻塞賦值已生效的時刻返回——沒有「恰好在邊沿改訊號」的競態;- 測試流程變成線性的、可讀的腳本:從上到下,每行就是「做一件事、等幾個週期、檢查一個值」。
這可以說是「事件式模擬語言」(1.1 節)在測試端的反動:Verilog 把時間藏進事件佇列,Veryl 把時間拉回程式碼的行序。對多數模組級驗證而言,這條線性劇本模型比事件模型更不容易出錯。
$tb::reset_gen:重置與型別抽象的呼應#
重置產生器的用法:
inst rst: $tb::reset_gen #(cycles: 5) ( clk );
參數 cycles 指定上電後重置保持的週期數。它提供兩個方法:
rst.assert(); // 斷言重置(同步於時脈)
rst.assert(20); // 斷言重置並保持 20 個週期
assert() 做的事情是:在下一個時脈沿把重置訊號拉到有效電位。這裡刻意沒有說「拉低」還是「拉高」,因為——正如 4.2 與 4.3 節論證的——重置極性與同步性是系統層級的決定,由 Veryl.toml 的 reset_type 統一設定。測試碼呼叫 rst.assert() 時,不需要知道(也不應該知道)專案用的是 reset_async_low 還是 reset_sync_high。
這是 4.3 節重置型別抽象在驗證端的直接回報:同一份測試碼可以在不同重置策略的專案設定下原樣執行。若在 Verilog 中,testbench 必須手寫 rst_n = 1'b0;,換到高有效重置的專案就要逐行修改測試——測試碼對硬體細節的耦合,正是四十年來 testbench 難以重用的主要原因之一。
$tb::file:檔案輸出#
模擬結果經常需要落地成檔案——產生測試向量、傾印觀測資料、比對黃金檔。$tb::file 提供:
#[test(test_counter_dump)]
module TestCounterDump {
inst clk: $tb::clock_gen #(period: 10);
var f: $tb::file;
initial {
f.open("counter_trace.txt");
for i in 0..8 {
clk.next();
f.write("cnt=%d hex=%h\n", i, i);
}
f.flush();
f.close();
}
}
方法一覽:
| 方法 | 職責 |
|---|---|
f.open(path) | 開啟檔案(覆寫模式) |
f.append(path) | 以附加模式開啟 |
f.write(fmt, ...) | 格式化寫入(%h 十六進位、%d 十進位、%s 字串) |
f.flush() | 沖出緩衝區 |
f.close() | 關閉檔案 |
$tb::random:可重現的隨機數#
隨機數產生器以泛型形式提供:
var r: $tb::random::<bit<12>>;
initial {
r.seed(42);
let a: bit<12> = r.get();
let b: bit<12> = r.get_range(0, 99);
}
三個要點:
- 泛型型別:
$tb::random::<T>的T決定隨機值的型別與位寬——bit<12>產生 12-bit 值、u32產生 32-bit 值。隨機值的型別由宣告顯式指定,沒有「隱含位寬截斷」的陷阱。 seed()是可重現性的核心:迴歸測試中,隨機測試失敗時必須能「用同一個 seed 重跑出同一個失敗」。Veryl 把 seed 的設定做成顯式方法而不是隱含的全域狀態,測試失敗訊息可以直接印出當時的 seed,作為重現的憑據。8.3 節會進一步把這條紀律擴展到完整的隨機測試策略。get_range(lo, hi):產生範圍內的隨機數,這是「約束」最粗略的形態,8.3 節會基於它搭建更完整的約束隨機寫法。
$tb::queue:佇列與記分板的基礎#
var q: $tb::queue::<u32, 256>;
initial {
q.push_back(32'd1);
q.push_back(32'd2);
let v: u32 = q.pop_front(); // 1
}
$tb::queue::<T, N> 是容量上限為 \(N\) 的先進先出佇列,提供 push_back 與 pop_front。單看它是個平凡的資料結構,但它是記分板(scoreboard)模式的基礎建設:驅動端把「期望值」推入佇列,監控端依序取出比對。8.3 節會用這兩個方法實作完整的記分板。
斷言系統#
$assert 與 $assert_continue#
Veryl 的斷言有三種形式:
$assert(cnt == 8'd10); // 基本形式
$assert(cnt == 8'd10, "count = %d, expected 10", cnt); // 帶格式化訊息
$assert_continue(cnt < 100, "overflow-ish: %d", cnt); // 失敗不中止
$assert(cond):條件為假時,測試立即失敗並中止。$assert(cond, fmt, args...):失敗時印出格式化訊息——訊息應該寫成「這個值是什麼、應該是什麼」,因為它是 CI 失敗報告中除錯的第一手材料。$assert_continue(...):失敗時記錄,但不中止。適合統計性的檢查(例如「整段模擬中沒有任何週期違反不變式」),讓一次模擬收集所有違例而不是只報第一個。
三者都與 $finish 搭配:斷言負責「判定」,$finish 負責「結束」。一個測試模組可以有多個 $assert,模擬器按執行順序逐一檢查。
initial 與 final 區塊#
initial 在模擬開始時執行、final 在模擬結束時執行。兩者在邏輯合成中被忽略——它們是純模擬構造,7.2 節的 initial_assign lint 講的就是這個雙面性:在可合成邏輯中誤用 initial 賦值,會造成模擬與矽的不一致;但在測試模組中,initial 與 final 恰恰是它們的合法且主要用途。
final 區塊最典型的用途是覆蓋統計——模擬結束時清點各情境被命中了幾次:
var r_case_hits: logic<32> [4];
always_ff {
case i_op {
Op::Add: { r_case_hits[0] += 1; }
Op::Sub: { r_case_hits[1] += 1; }
Op::And: { r_case_hits[2] += 1; }
Op::Or: { r_case_hits[3] += 1; }
default: {}
}
}
final {
$assert(r_case_hits[0] > 0, "Op::Add never covered");
$assert(r_case_hits[1] > 0, "Op::Sub never covered");
$assert(r_case_hits[2] > 0, "Op::And never covered");
$assert(r_case_hits[3] > 0, "Op::Or never covered");
}
這個模式讓「測試不僅要過,還要真的走到想走的路」成為可檢查的條件。8.3 節會把這個手法發展成土法的情境覆蓋報告。
與 SVA 的對照:誠實面對的侷限#
SystemVerilog Assertions(SVA)是一門嵌入在 SV 中的時序斷言子語言:
// SVA:req 拉高之後的 1 到 3 個週期內 ack 必須拉高
property p_handshake;
@(posedge clk) disable iff (!rst_n)
req |-> ##[1:3] ack;
endproperty
assert property (p_handshake);
SVA 能表達「跨多個時脈週期的時序關係」——蘊含(|->)、延遲窗口(##[1:3])、重複序列等。這是它在協定驗證中不可取代的能力。
Veryl 的 $assert 是簡單布林斷言:檢查「此刻」的條件,不具備時序運算元。要表達上例的握手性質,得在 always_ff 中手動維護一個等待計數器:
always_ff {
if_reset {
r_wait = 0;
} else if i_req && !i_ack {
r_wait += 1;
$assert(r_wait < 4, "ack timeout, waited %d cycles", r_wait);
} else {
r_wait = 0;
}
}
可行,但成本隨時序性質的複雜度上升,而且這段監控邏輯混在測試模組中,可讀性不如 SVA 的宣告式寫法。必須誠實地說:Veryl 目前沒有完整的時序斷言語言,這是它相對 SystemVerilog 的實質能力缺口,與 10.3 節討論的未解問題直接相關。務實的應對有兩條:模組級用計數器模式手寫關鍵性質;協定級的時序檢查,用 8.3 節的 cocotb(Python 有協程,天然適合寫時序監控)或 9.1 節嵌入的 SVA/既有 UVM 環境承接。
測試組織的實務#
一個檔案多個測試#
同一個 .veryl 檔可以放多個 #[test] 模組,veryl test 會個別執行並分別回報:
#[test(test_alu_add)]
module TestAluAdd { ... }
#[test(test_alu_shift)]
module TestAluShift { ... }
#[test(test_alu_flags)]
#[ignore]
module TestAluFlags { ... }
實務上常見的組織是「DUT 一個檔案、測試一個檔案」,但簡單模組也常把測試直接放在 DUT 檔案尾端——因為 #[test] 模組不進合成產出,放在哪裡都不影響 veryl build 的結果。選哪種取決於團隊習慣,唯一要避免的是「測試散落在沒有命名規律的各處」。
多個 initial 區塊的併發行為#
同一個測試模組內可以有多個 initial 區塊,它們在模擬開始時併發啟動,各自線性推進:
initial {
// 劇本:驅動激勵
rst.assert();
clk.next(5);
...
}
initial {
// 監控:每個週期檢查不變式
for i in 0..100 {
clk.next();
$assert_continue(!o_err, "err asserted at cycle %d", i);
}
}
這提供了「驅動」與「監控」分離的最簡形式。但要保持克制:initial 區塊一多,測試就回到 Verilog 時代的「事件佇列心智模型」——多個線性腳本之間的相對推進順序需要推理。一個驅動、一個監控、至多一個 final 統計,是原生測試維持可讀性的上限。
測試命名與組織#
建議的命名慣例:
test_<module>_<scenario>
例如 test_fifo_basic、test_fifo_full_empty、test_fifo_concurrent_wr_rd。命名即文件:從 veryl test 的輸出清單(測試名一欄)就能看出「哪些模組被驗證了、驗證了哪些情境」。情境的粒度建議是「一個測試一個失敗原因類別」——如果兩個情境失敗時需要不同的除錯方向,就拆成兩個測試。
與 CI 的銜接#
7.2 節的 pipeline 中,veryl test 是成本最高、價值也最高的閘門。原生測試在此扮演的角色是:
- 秒到分鐘級的單元測試層:每個 PR 跑全部模組級測試;
- 失敗訊息即除錯入口:
$assert的格式化訊息直接出現在 CI 日誌; - 非零退出碼:任何一個測試失敗即攔截合併。
後續章節的分工是:8.3 節的 cocotb 測試進 CI 時需要 Python 環境(成本略高),8.2 節討論的模擬器選擇則決定這些測試的執行速度。
完整實作範例:簡單 ALU 的測試#
把前面所有要素組合起來。DUT 是一個支援四種運算的 8-bit ALU:
enum Op: logic<2> {
Add,
Sub,
And,
Or,
}
pub module Alu (
i_clk : clock,
i_rst : reset,
i_op : input logic<2>,
i_a : input logic<8>,
i_b : input logic<8>,
o_y : output logic<8>,
o_carry : output logic<1>,
) {
var r_y: logic<9>;
always_ff {
case i_op {
Op::Add: r_y = 9'(i_a) + 9'(i_b);
Op::Sub: r_y = 9'(i_a) - 9'(i_b);
Op::And: r_y = 9'(i_a & i_b);
Op::Or: r_y = 9'(i_a | i_b);
default: r_y = 0;
}
}
assign o_y = r_y[7:0];
assign o_carry = r_y[8];
}
注意 r_y 是 9-bit:加法需要額外的進位位元。測試覆蓋四個情境——基本運算、進位邊界、減法下溢、以及(以 final 統計的)運算覆蓋:
#[test(test_alu_basic)]
module TestAluBasic {
inst clk: $tb::clock_gen #(period: 10);
inst rst: $tb::reset_gen ( clk );
var r_hits: logic<32> [4];
inst u_alu: Alu (
clk: clk.clk,
rst: rst.rst,
i_op: op,
i_a: a,
i_b: b,
o_y: y,
o_carry: carry,
);
var op : logic<2>;
var a : logic<8>;
var b : logic<8>;
var y : logic<8>;
var carry: logic<1>;
initial {
rst.assert();
clk.next(5);
// 情境一:基本加法
op = Op::Add; a = 8'd20; b = 8'd22;
clk.next();
$assert(y == 8'd42, "add: y=%d expected 42", y);
$assert(carry == 1'b0, "add: carry=%b expected 0", carry);
// 情境二:加法進位邊界
op = Op::Add; a = 8'hFF; b = 8'h01;
clk.next();
$assert(y == 8'h00, "add wrap: y=%h expected 00", y);
$assert(carry == 1'b1, "add wrap: carry=%b expected 1", carry);
// 情境三:減法下溢
op = Op::Sub; a = 8'd3; b = 8'd7;
clk.next();
$assert(y == 8'hFC, "sub underflow: y=%h expected FC", y);
// 情境四:邏輯運算
op = Op::And; a = 8'hF0; b = 8'h3C;
clk.next();
$assert(y == 8'h30, "and: y=%h expected 30", y);
op = Op::Or; a = 8'hF0; b = 8'h0F;
clk.next();
$assert(y == 8'hFF, "or: y=%h expected FF", y);
clk.next(2);
$finish();
}
// 覆蓋統計:final 時檢查四種運算都被命中
always_ff {
if_reset {
} else {
case op {
Op::Add: r_hits[0] += 1,
Op::Sub: r_hits[1] += 1,
Op::And: r_hits[2] += 1,
Op::Or: r_hits[3] += 1,
default: {},
}
}
}
final {
$assert(r_hits[0] > 0, "Op::Add never covered");
$assert(r_hits[1] > 0, "Op::Sub never covered");
$assert(r_hits[2] > 0, "Op::And never covered");
$assert(r_hits[3] > 0, "Op::Or never covered");
}
}
這個例子刻意展示了三種層次的檢查:值檢查($assert 在劇本中的每一步)、邊界檢查(進位與下溢)、覆蓋檢查(final 中的情境統計)。模組級測試的完整性,大致就是這三層的組合。
本節小結#
- Verilog 的 testbench 是純過程式碼,UVM 解決了結構問題但代價是複雜度;Veryl 把最常用的驗證需求內建成
$tb::標準庫,讓小到中型專案不需要 UVM。 #[test]標記測試模組,veryl test以內建模擬器直接執行——無外部模擬器依賴;#[ignore]讓跳過可見。clk.next(N)把時間前進變成以週期為單位的顯式操作,消除#delay的時間漂移與競態,測試成為線性腳本。rst.assert()與 4.3 節的重置型別抽象呼應——測試碼不需要知道重置極性。$assert/$assert_continue負責判定;initial/final在合成中被忽略,是測試專用的合法構造;final適合做覆蓋統計。- 誠實的侷限:Veryl 沒有 SVA 式的時序斷言語言,協定級時序檢查需藉助 cocotb(8.3)或嵌入 SV 環境(9.1)。
下一節(8.2)深入這些測試執行的地基:內建模擬器的 IR、兩階段編譯架構,以及它與 Verilator 的效能量測。
下一節:第 8.2 節「內建模擬器:IR、兩階段編譯與效能量測(2026)」