想象一下,你設計了一輛汽車(FPGA設計),在把它開上真正的馬路之前,你肯定要先在測試場地試一試對吧?TestBench就是這個"測試場地"!

在這個虛擬的測試場地裏,你可以:

  • 自由控制輸入:比如模擬踩油門、剎車
  • 觀察輸出結果:看看車速、轉向是否正常
  • 發現潛在問題:在上路前就把Bug修好

❗ 為什麼不能直接下載到板子上測試?

很多初學者(包括我最開始)都犯過這個錯誤:

  1. 寫完代碼直接下載到FPGA板子
  2. 發現不對勁,再改代碼
  3. 再下載,再測試…

這樣做的問題:

  • 時間成本高:每次下載編譯要等很久
  • 調試困難:板子上看不到內部信號
  • 可能損壞硬件:嚴重的邏輯錯誤可能燒壞芯片

正確的流程應該是:

寫代碼 → 寫TestBench → 仿真測試 → 修改完善 → 下載到板子

一個完整的TestBench需要完成這四件事:

1️⃣ 實例化待測模塊(DUT)

DUT = Design Under Test(被測設計),就是把你要測試的模塊"召喚"出來

2️⃣ 編寫測試激勵

給模塊的輸入端口送信號,就像給汽車踩油門一樣

3️⃣ 觀察輸出結果

通過波形窗口或終端打印,看看輸出是否符合預期

4️⃣ 自動化驗證(進階)

讓程序自動比對實際輸出和預期輸出,發現不一致就報警


先來看一個最簡單的TestBench骨架:

`timescale 1ns/1ns  // 時間單位和精度
module testbench;   // TestBench模塊名(通常無輸入輸出)
    // 1. 信號聲明
    reg  輸入信號;    // 輸入用reg類型
    wire 輸出信號;    // 輸出用wire類型
    // 2. 實例化待測模塊
    待測模塊名 實例名(
        .端口1(信號1),
        .端口2(信號2)
    );
    // 3. 生成時鐘信號(如果需要)
    initial begin
        clk = 0;
        forever #10 clk = ~clk;  // 每10個時間單位翻轉一次
    end
    // 4. 生成測試激勵
    initial begin
        // 初始化
        輸入信號 = 0;
        // 施加激勵
        #20 輸入信號 = 1;
        #30 輸入信號 = 0;
        // 結束仿真
        #100 $stop;
    end
    // 5. 監控輸出(可選)
    initial begin
        $monitor("時間=%t, 輸入=%b, 輸出=%b", $time, 輸入信號, 輸出信號);
    end
endmodule

⏱️ 第一關:時間刻度(timescale)

timescale告訴仿真器:"1個時間單位"等於多長的真實時間。

`timescale 時間單位/時間精度
`timescale 10ns/1ns  // 單位10ns,精度1ns
module testbench;
    reg set;
    initial begin
        #1   set = 0;   // 延時1個時間單位 = 10ns
        #1.8 set = 1;   // 延時1.8個時間單位 = 18ns
    end
endmodule

解釋:

  • #1:延時1個時間單位 → 實際延時10ns
  • #1.8:延時1.8個時間單位 → 實際延時18ns(會按精度1ns舍入)

⚠️ 重要提示

時間單位和精度的可選值:

  • 只能是:110100
  • 單位可以是:s(秒)、ms(毫秒)、us(微秒)、ns(納秒)、ps(皮秒)、fs(飛秒)
  • 精度必須 ≤ 時間單位

實用建議:

`timescale 1ns/1ns   // ✅ 推薦:ns級精度足夠大多數場景
`timescale 1ns/1ps   // ❌ 不推薦:精度太高,仿真慢,通常用不到

時序邏輯電路必須有時鐘,TestBench中生成時鐘非常簡單!

方法一:使用forever循環(最常用)

parameter Period = 20;  // 時鐘週期20ns
reg clk;
initial begin
    clk = 0;
    forever #(Period/2) clk = ~clk;  // 每10ns翻轉一次
end

解釋:

  • Period = 20:時鐘週期20ns → 頻率50MHz
  • #(Period/2):延時半個週期(10ns)後翻轉
  • forever:一直循環,直到仿真結束

方法二:使用always(不推薦初學者)

parameter Period = 20;
reg clk;
initial clk = 0;
always #(Period/2) clk = ~clk;

**問題:**如果我想生成一個100MHz的時鐘,週期應該設置為多少?

點擊查看答案

// 100MHz → 週期 = 1/100MHz = 10ns
parameter Period = 10;

initial是TestBench的核心工具,用來編寫測試激勵。

  1. 只執行一次:從仿真開始時(時間0)執行
  2. 不可綜合:只用於仿真,不能下載到FPGA
  3. 可併發運行:可以寫多個initial塊,它們同時開始執行
  4. 塊內順序執行:一個initial塊內部的語句按順序執行
reg reset, enable;
wire [7:0] data_out;
initial begin
    // 初始化信號
    reset = 1;
    enable = 0;
    // 復位10ns
    #10 reset = 0;
    // 100ns後使能
    #100 enable = 1;
    // 200ns後停止仿真
    #200 $stop;
end
// 塊1:生成時鐘
initial begin
    clk = 0;
    forever #5 clk = ~clk;
end
// 塊2:生成復位
initial begin
    reset = 1;
    #15 reset = 0;
end
// 塊3:測試激勵
initial begin
    data_in = 8'h00;
    #30 data_in = 8'hAA;
    #50 data_in = 8'h55;
    #100 $finish;
end

時序圖示意:

時間:    0    5   10   15   20   25   30   ...
clk:    _|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_...
reset:  ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|________________...
data_in: 00              |    AA         ...

️ 第四關:常用系統函數

系統函數都以$開頭,是仿真器提供的特殊功能。

1. $finish vs $stop

函數

功能

使用場景

$finish

停止仿真並退出

測試完成

$stop

暫停仿真

需要手動檢查某個狀態

initial begin
    // ... 測試代碼 ...
    wait(data_ready);  // 等待數據準備好
    $stop;  // 暫停,可以手動查看波形
end

2. $display - 打印信息

initial begin
    $display("仿真開始!");
    // 打印變量值
    $display("時間=%t, a=%b, b=%d", $time, signal_a, signal_b);
    // 自動換行
    $display("Hello");
    $display("World");  // 會在下一行顯示
end

格式説明符:

  • %t:時間(配合FPGA開發筆記-TestBench的編寫(入門)_fpga testbench_Boat_實例化realtime)
  • %b:二進制
  • %d:十進制
  • %h:十六進制
  • %o:八進制
  • %c:ASCII字符
  • %s:字符串

3. $monitor - 監控信號變化

**特點:**只在信號變化時自動打印

initial begin
    $monitor("時間=%t, clk=%b, data=%h", $time, clk, data);
end

對比:

// $display:每次都打印
always @(posedge clk) begin
    $display("clk上升沿");  // 每個時鐘都打印
end
// $monitor:只在變化時打印
initial begin
    $monitor("data=%d", data);  // data變化時才打印
end

4. $timeformat - 格式化時間顯示

initial begin
    // 以ns為單位,小數點後1位,後綴"ns",最小寬度12
    $timeformat(-9, 1, "ns", 12);
    $monitor("%t: clk=%b", $realtime, clk);
    // 輸出: "     10.5ns: clk=1"
end

參數説明:

  • 第1個參數:單位(0=秒, -3=毫秒, -6=微秒, -9=納秒, -12=皮秒)
  • 第2個參數:小數位數
  • 第3個參數:單位後綴字符串
  • 第4個參數:最小顯示寬度

現在我們通過一個完整的例子,把所有知識點串起來!

功能説明:

  • 5位移位寄存器
  • reset=1時清零
  • load=1時加載數據
  • 根據sel選擇移位方向
module shift_reg(
    input clock,
    input reset,
    input load,
    input [1:0] sel,
    input [4:0] data,
    output reg [4:0] shiftreg
);
    always @(posedge clock) begin
        if (reset)
            shiftreg <= 5'b00000;
        else if (load)
            shiftreg <= data;
        else begin
            case (sel)
                2'b00: shiftreg <= shiftreg;        // 保持不變
                2'b01: shiftreg <= shiftreg << 1;   // 左移
                2'b10: shiftreg <= shiftreg >> 1;   // 右移
                default: shiftreg <= shiftreg;
            endcase
        end
    end
endmodule
`timescale 1ns/1ns
module testbench;
    // 1. 信號聲明
    reg clock;
    reg reset;
    reg load;
    reg [1:0] sel;
    reg [4:0] data;
    wire [4:0] shiftreg;
    // 2. 實例化待測模塊
    shift_reg dut (
        .clock(clock),
        .reset(reset),
        .load(load),
        .sel(sel),
        .data(data),
        .shiftreg(shiftreg)
    );
    // 3. 生成時鐘信號(週期100ns, 頻率10MHz)
    initial begin
        clock = 0;
        forever #50 clock = ~clock;  // 每50ns翻轉
    end
    // 4. 生成測試激勵
    initial begin
        // 初始化
        reset = 1;
        load = 0;
        sel = 2'b00;
        data = 5'b00000;
        // 復位200ns
        #200 reset = 0;
        // 加載數據:00001
        load = 1;
        #200 data = 5'b00001;
        // 左移測試
        #100 sel = 2'b01;
        load = 0;
        // 右移測試
        #200 sel = 2'b10;
        // 結束仿真
        #1000 $stop;
    end
    // 5. 監控輸出
    initial begin
        // 設置時間格式
        $timeformat(-9, 1, "ns", 12);
        // 打印表頭
        $display("===========================================");
        $display("Time     | Clk | Rst | Ld | Sel | Data  | ShiftReg");
        $display("===========================================");
        // 監控信號變化
        $monitor("%t | %b   | %b   | %b  | %b  | %b | %b",
                 $realtime, clock, reset, load, sel, data, shiftreg);
    end
endmodule
時間段          | 操作        | shiftreg變化
0-200ns       | 復位        | 00000
200-300ns     | 加載00001   | 00001
300-500ns     | 左移2次     | 00010 → 00100
500-1500ns    | 右移多次    | 00010 → 00001 → 00000

手動看波形太累?讓程序幫你自動檢查!

  1. 預先定義"正確答案"
  2. 在關鍵時刻檢查實際輸出
  3. 不匹配就報錯
initial begin
    // 測試用例1:加法器測試
    a = 4'd5;
    b = 4'd3;
    #10;  // 等待計算完成
    // 檢查結果
    if (sum == 4'd8) begin
        $display("✅ 測試通過:5+3=8");
    end else begin
        $display("❌ 測試失敗:期望8,實際%d", sum);
        $stop;  // 暫停仿真,方便調試
    end
end
integer i;
reg [7:0] test_vectors [0:9];  // 10組測試數據
reg [7:0] expected_results [0:9];
initial begin
    // 初始化測試數據
    test_vectors[0] = 8'h12; expected_results[0] = 8'h24;
    test_vectors[1] = 8'h34; expected_results[1] = 8'h68;
    // ...
    // 批量測試
    for (i = 0; i < 10; i = i + 1) begin
        data_in = test_vectors[i];
        #10;
        if (data_out == expected_results[i]) begin
            $display("測試%0d:通過", i);
        end else begin
            $display("測試%0d:失敗(期望%h,實際%h)",
                     i, expected_results[i], data_out);
            $stop;
        end
    end
    $display("所有測試通過! ");
    $finish;
end

⚠️ 新手常見錯誤

❌ 錯誤1:輸入信號用wire

// ❌ 錯誤
wire data_in;  // 輸入應該用reg!
// ✅ 正確
reg data_in;

**原因:**reg類型可以賦值,wire不能在initial或always塊中直接賦值。

❌ 錯誤2:忘記初始化

// ❌ 錯誤:clk初始值不確定
reg clk;
always #5 clk = ~clk;  // 可能一直是x
// ✅ 正確
reg clk;
initial clk = 0;
always #5 clk = ~clk;

❌ 錯誤3:時間單位太小

// ❌ 不推薦:仿真太慢
`timescale 1ps/1fs
// ✅ 推薦:夠用就行
`timescale 1ns/1ns

❌ 錯誤4:沒有停止條件

// ❌ 錯誤:仿真會一直運行
initial begin
    clk = 0;
    forever #5 clk = ~clk;
end
// 沒有$stop或$finish!
// ✅ 正確
initial begin
    #1000 $finish;  // 1000ns後結束
end

模板1:基礎測試

`timescale 1ns/1ns
module tb_模塊名;
    // 信號聲明
    reg clk, rst;
    // ... 其他信號
    // 實例化DUT
    模塊名 dut (/* 端口連接 */);
    // 生成時鐘
    initial begin
        clk = 0;
        forever #10 clk = ~clk;
    end
    // 測試激勵
    initial begin
        rst = 1;
        #100 rst = 0;
        // ... 測試代碼
        #1000 $finish;
    end
    // 監控輸出
    initial $monitor("%t: ...", $time);
endmodule

模板2:自檢測試

`timescale 1ns/1ns
module tb_模塊名;
    // 信號聲明
    reg clk, rst;
    integer pass_count, fail_count;
    // 實例化DUT
    模塊名 dut (/* 端口連接 */);
    // 時鐘生成
    initial begin
        clk = 0;
        forever #10 clk = ~clk;
    end
    // 測試主程序
    initial begin
        pass_count = 0;
        fail_count = 0;
        // 初始化
        rst = 1;
        #100 rst = 0;
        // 測試用例1
        test_case_1();
        // 測試用例2
        test_case_2();
        // 報告結果
        $display("======== 測試結果 ========");
        $display("通過:%d, 失敗:%d", pass_count, fail_count);
        $finish;
    end
    // 測試用例任務
    task test_case_1;
        begin
            // ... 施加激勵
            #10;
            // 檢查結果
            if (output_signal == expected_value) begin
                $display("✅ 測試1通過");
                pass_count = pass_count + 1;
            end else begin
                $display("❌ 測試1失敗");
                fail_count = fail_count + 1;
            end
        end
    endtask
endmodule

第一階段:掌握基礎(1-2周)

  • ✅ 理解TestBench的作用
  • ✅ 會寫簡單的initial塊
  • ✅ 能生成時鐘和基本激勵
  • ✅ 會用$display打印信息

第二階段:熟練運用(2-4周)

  • ✅ 能為中等複雜度模塊寫TestBench
  • ✅ 會用$monitor監控信號
  • ✅ 理解時間建模
  • ✅ 能看懂仿真波形

第三階段:進階提升(1-2個月)

  • ✅ 掌握自動化驗證技巧
  • ✅ 會寫可重用的TestBench
  • ✅ 學習SystemVerilog驗證方法
  • ✅ 瞭解UVM驗證方法論

練習1:全加器測試

編寫TestBench測試一個1位全加器,要求:

  1. 窮舉所有8種輸入組合
  2. 自動檢查輸出是否正確
  3. 打印測試報告

參考答案

`timescale 1ns/1ns
module tb_full_adder;
    reg a, b, cin;
    wire sum, cout;
    integer i;
    full_adder dut(a, b, cin, sum, cout);
    initial begin
        $display("===== 全加器測試 =====");
        for (i = 0; i < 8; i = i + 1) begin
            {a, b, cin} = i;
            #10;
            $display("a=%b b=%b cin=%b → sum=%b cout=%b",
                     a, b, cin, sum, cout);
        end
        $finish;
    end
endmodule

練習2:計數器測試

為一個4位加法計數器編寫TestBench,要求:

  1. 生成復位信號
  2. 計數20個時鐘週期
  3. 檢查是否按0-15循環計數