跳轉到

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();
    }
}

逐行拆解這十幾行裡發生了什麼:

  1. inst clk: $tb::clock_gen #(period: 10);——實例化一個時脈產生器,週期 10 個時間單位。
  2. inst rst: $tb::reset_gen ( clk );——重置產生器接上同一個時脈,使重置的斷言與解除都同步於時脈沿。
  3. inst cnt: Counter (...)——被測設計(DUT)以一般 Veryl 語法實例化,埠名對接。
  4. initial { ... }——測試劇本。initial 區塊在模擬開始時執行,內部是線性的腳本。
  5. rst.assert();——斷言重置(同步到時脈)。
  6. clk.next(10);——時間前進 10 個週期。
  7. $assert(...)——比對期望值;失敗則測試失敗。
  8. $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);
}

三個要點:

  1. 泛型型別:$tb::random::<T> 的 T 決定隨機值的型別與位寬——bit<12> 產生 12-bit 值、u32 產生 32-bit 值。隨機值的型別由宣告顯式指定,沒有「隱含位寬截斷」的陷阱。
  2. seed() 是可重現性的核心:迴歸測試中,隨機測試失敗時必須能「用同一個 seed 重跑出同一個失敗」。Veryl 把 seed 的設定做成顯式方法而不是隱含的全域狀態,測試失敗訊息可以直接印出當時的 seed,作為重現的憑據。8.3 節會進一步把這條紀律擴展到完整的隨機測試策略。
  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 是成本最高、價值也最高的閘門。原生測試在此扮演的角色是:

  1. 秒到分鐘級的單元測試層:每個 PR 跑全部模組級測試;
  2. 失敗訊息即除錯入口:$assert 的格式化訊息直接出現在 CI 日誌;
  3. 非零退出碼:任何一個測試失敗即攔截合併。

後續章節的分工是: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 中的情境統計)。模組級測試的完整性,大致就是這三層的組合。


本節小結#

  1. Verilog 的 testbench 是純過程式碼,UVM 解決了結構問題但代價是複雜度;Veryl 把最常用的驗證需求內建成 $tb:: 標準庫,讓小到中型專案不需要 UVM。
  2. #[test] 標記測試模組,veryl test 以內建模擬器直接執行——無外部模擬器依賴;#[ignore] 讓跳過可見。
  3. clk.next(N) 把時間前進變成以週期為單位的顯式操作,消除 #delay 的時間漂移與競態,測試成為線性腳本。
  4. rst.assert() 與 4.3 節的重置型別抽象呼應——測試碼不需要知道重置極性。
  5. $assert / $assert_continue 負責判定;initial / final 在合成中被忽略,是測試專用的合法構造;final 適合做覆蓋統計。
  6. 誠實的侷限:Veryl 沒有 SVA 式的時序斷言語言,協定級時序檢查需藉助 cocotb(8.3)或嵌入 SV 環境(9.1)。

下一節(8.2)深入這些測試執行的地基:內建模擬器的 IR、兩階段編譯架構,以及它與 Verilator 的效能量測。


下一節:第 8.2 節「內建模擬器:IR、兩階段編譯與效能量測(2026)」