Writing Testbenches

Installing Icarus Verilog and GHDL, clock and reset generation, stimulus, self-checking tests, exhaustive testing of small blocks, and a Python reference model.

1. Why a testbench, and what it is

So far (Chapters 7–9) you have written HDL that describes hardware. To know whether the description is right, you run it in a simulator: a program that computes, instant by instant, the value every signal would have in the real circuit. The simulator needs something to drive the inputs and watch the outputs. That something is the testbench — an HDL module that is never turned into hardware. It exists only in simulation, so it may use every language feature: delays, loops over time, files, printing.

The circuit being tested is called the DUT (device under test). A good testbench does four jobs: it generates a clock, applies a reset, feeds stimulus (input values), and checks the outputs automatically, ending with a clear PASS or FAIL. This chapter builds those four jobs one at a time, in both languages, and finishes with a reference model written in Python that the HDL testbench reads from a file.

counter_tb (no ports)clock generatoralways #4 clk = ~clkresetrst = 1 for 2 cyclesstimulusen pattern, vectorsDUTcounter #(4)(becomes hardware)clkrstencheckercount !== expected ?count errorscount, wrapexpected value(expected + 1) % 16same stimulus drives the modelPASS / FAIL
Anatomy of a testbench. The testbench has no ports: it creates every signal itself, drives the DUT's inputs, and compares the DUT's outputs with values it computes independently.

2. Installing the free simulators

We use two open-source simulators, one per language, plus a waveform viewer (Chapter 11):

ToolLanguageCommands you will type
Icarus VerilogVerilog (and some SystemVerilog)iverilog (compile), vvp (run)
GHDLVHDL-87/93/2008ghdl -a (analyse), -e (elaborate), -r (run)
GTKWave or Surferwaveform viewergtkwave file.vcd / surfer file.vcd

Linux (Debian, Ubuntu, and WSL on Windows). All three are in the standard repositories:

Debian / Ubuntu
sudo apt update
sudo apt install iverilog ghdl gtkwave
iverilog -V | head -1      # Icarus Verilog version 12.0 (stable)
ghdl --version | head -1   # GHDL 4.1.0 ...

macOS. Install Homebrew, then brew install icarus-verilog. GHDL publishes ready-made macOS archives (for Apple silicon and Intel) on its GitHub releases page; unpack one and add its bin directory to your PATH. The Homebrew packages for GHDL and GTKWave have at times been unavailable or blocked by Apple's Gatekeeper, so for viewing waveforms on a Mac the simplest choice is Surfer, a modern viewer with native builds (surfer-project.org) that reads the same VCD files.

Windows. The least painful route is WSL (Windows Subsystem for Linux): run wsl --install in an administrator terminal, reboot, open the Ubuntu app and use the apt line above. Native alternatives exist — an Icarus Verilog installer that bundles GTKWave, and Windows builds of GHDL on its GitHub releases page — but every command in this course is written for a Unix-style shell. The YosysHQ OSS CAD Suite is another option on all three systems: one download containing Icarus, GTKWave and many other open-source FPGA tools.

Key idea: simulation is free, fast and total
A simulation of a few thousand clock cycles takes well under a second, costs nothing, and lets you see every internal signal. A hardware build of the same design takes minutes, and on the board you only see the pins you wired to LEDs. Every design in this course is simulated before Vivado sees it.

3. Clock, reset and the DUT

Our first DUT is a 4-bit counter with a synchronous reset rst and an enable en(Chapter 9). The extra output wrap is high during the cycle in which the counter is about to roll over from 15 to 0 — useful for chaining counters.

counter — the device under test
// Counter with synchronous reset and enable; wrap pulses on the last count
module counter #(
    parameter WIDTH = 4
) (
    input  wire             clk,
    input  wire             rst,
    input  wire             en,
    output reg  [WIDTH-1:0] count = 0,
    output wire             wrap
);
    assign wrap = en && (count == {WIDTH{1'b1}});

    always @(posedge clk) begin
        if (rst)
            count <= 0;
        else if (en)
            count <= count + 1'b1;
    end
endmodule

The testbench below is the template you will reuse for every clocked design in this course. Read it with the numbered comments:

  1. No ports. A testbench is the top of the simulation; nothing is outside it. In VHDL the entity is empty; in Verilog the module has no port list.
  2. The DUT instance, with parameters/generics overridden if needed. Signals that the testbench drives are reg in Verilog (they are assigned inside initial/always); signals the DUT drives are wire.
  3. The clock generator: a process that inverts clk every half period. With `timescale 1ns/1ps, #4 means 4 ns, so the period is 8 ns = 125 MHz like the PYNQ-Z2. In VHDL, clk <= not clk after 4 ns when not done; does the same, and stops when done becomes true so the simulation can end by itself (GHDL stops when no events are left).
  4. A task / procedure that packages one step of the test (Section 5).
  5. Reset: rst starts at 1 and is released after two clock edges.
  6. Stimulus and checking, then a final verdict and $finish.
counter_tb — clock, reset, stimulus, self-checking
`timescale 1ns/1ps
module counter_tb;                       // 1. no ports: the testbench is the whole world
    reg        clk = 0, rst = 1, en = 0;
    wire [3:0] count;
    wire       wrap;
    integer    expected = 0, errors = 0, wraps = 0, i;

    counter #(.WIDTH(4)) dut (.clk(clk), .rst(rst), .en(en),   // 2. the DUT
                              .count(count), .wrap(wrap));

    always #4 clk = ~clk;                // 3. clock: 8 ns period (125 MHz)

    // 4. a task: wait one cycle, update the expected value, compare
    task step(input e);
        begin
            en = e;
            @(posedge clk);
            if (e) begin
                if (expected == 15) wraps = wraps + 1;
                expected = (expected + 1) % 16;
            end
            @(negedge clk);              // look half a period after the edge
            if (count !== expected) begin
                $display("ERROR at %0d ns: count=%0d expected=%0d", $time, count, expected);
                errors = errors + 1;
            end
        end
    endtask

    initial begin
        repeat (2) @(negedge clk);       // 5. reset for two cycles
        rst = 0;
        for (i = 0; i < 40; i = i + 1)   // 6. stimulus: enable 3 cycles out of 4
            step(i % 4 != 3);
        if (errors == 0) $display("PASS: 40 cycles checked, %0d wrap(s), %0d ns", wraps, $time);
        else             $display("FAIL: %0d error(s)", errors);
        $finish;
    end
endmodule
Run both
$ iverilog -g2012 -o tb counter.v counter_tb.v && vvp -n tb
PASS: 40 cycles checked, 1 wrap(s), 336 ns
counter_tb.v:37: $finish called at 336000 (1ps)

$ ghdl -a --std=08 counter.vhd counter_tb.vhd
$ ghdl -e --std=08 counter_tb && ghdl -r --std=08 counter_tb
counter_tb.vhd:53:13:@336ns:(report note): PASS: 40 cycles checked, 1 wrap(s), 336 ns

The steps differ slightly: iverilog compiles all files into one executable that vvpruns (-n makes $stop behave like $finish, so it never waits for keyboard input). GHDL analyses each file into a library called work (-a),elaborates the top unit — builds the full hierarchy (-e) — then runs it (-r). Analyse files in dependency order: the DUT before the testbench that instantiates it.

4. Time in a simulator

A simulator keeps a current time and a queue of future events. #4 or wait for 4 nsmeans “suspend this process and resume 4 ns later”. Nothing else happens between events, which is why simulating a millisecond of a quiet circuit is quick.

In Verilog the unit of # is set by `timescale unit/precision. With `timescale 1ns/1ps, #4 is 4 ns and #0.25 is rounded to the nearest picosecond. The directive applies to every module compiled after it, so put it at the top of each testbench file. $time returns the time in the module's own unit (here ns), which is why counter_tb prints it with %0d; %t would print it in the global precision (ps). VHDL has a physical type time with real units: 4 ns, 2.5 us, and now is the current time.

Inside a single time step, the simulator can run many delta cycles: one signal update wakes another process, which updates another signal, and so on, all at the same simulated instant. This is how a chain of combinational logic settles in zero time. It is also the source of the most common testbench bug: a race between the testbench and the DUT at the clock edge.

Common mistake: driving and sampling on the active edge
If the testbench changes en or reads count at @(posedge clk), it runs in the same time step as the DUT's flip-flops, and the result can depend on which process the simulator happens to run first. Change inputs and check outputs half a period away from the active edge — counter_tb sets en, waits for the rising edge, then checks at the falling edge. Real hardware has the same rule, called setup and hold time (Chapter 15).
clkencount01234checkcheckcheckcheckchecken = 0: count holds at 3rising edges: DUT updates
counter_tb timing: en is driven before the rising edge, the DUT updates count on the edge, and the testbench checks at the falling edge, far from any change.

5. Tasks and procedures: name the steps of your test

Writing the same “set inputs, wait, compare” lines forty times is error-prone. A Verilog task or VHDL procedure is a named block of statements that may contain waits. In counter_tb, step(e) drives the enable, waits for a clock edge, updates the expected value exactly as the hardware should, and compares at the falling edge. The main sequence then reads like a description of the test.

Two language details matter. In Verilog, a task declared inside the testbench module can read and write the module's variables directly (expected, errors). In VHDL, a procedure declared inside a process can assign that process's variables and drive the signals the process drives (en) — which is why step lives in the declarative part of stimulus and not in the architecture.

NeedVerilogVHDL
wait a time#10;wait for 10 ns;
wait for an edge@(posedge clk);wait until rising_edge(clk);
wait for N edgesrepeat (N) @(posedge clk);for i in 1 to N loop wait until ...; end loop;
wait for a conditionwait (done == 1);wait until done = '1';
print$display("x=%0d", x);report "x=" & to_string(x);
stop$finish;std.env.finish; (or stop the clock)

6. Self-checking: let the testbench decide

A testbench that only prints values forces you to read them, every time, forever. A self-checking testbench computes the expected value itself, compares, counts mismatches, and prints one verdict line. It turns a test into something you can rerun after every edit in a second.

The expected value must come from an independent description of the behaviour. In counter_tb that is the integer expression (expected + 1) % 16 — written differently from the DUT's count + 1'b1 on a 4-bit register, so a misunderstanding in one is unlikely to be repeated in the other. To see that the check has teeth, change the DUT to add 2 instead of 1:

the same testbench against a broken counter
ERROR at 24 ns: count=2 expected=1
...
ERROR at 336 ns: count=12 expected=14
FAIL: 39 error(s)

Two comparison rules keep checks honest. Verilog signals have four values (0, 1, X unknown, Z high-impedance; Chapter 11). The ordinary == and != return X if either side contains X or Z, and if (X) silently takes the else branch. The case equalityoperators === and !== compare the four values literally and always return 0 or 1, so an unknown output is reported as an error instead of passing. In VHDL, = on std_logic_vector already compares element by element, so "XXXX" /= "0011" is true.

Common mistake: a test that cannot fail
Checking with != in Verilog, comparing the DUT against a copy of its own code, or forgetting to print anything when the error count is non-zero all produce tests that pass whatever the hardware does. Every time you write a new testbench, break the DUT on purpose once and watch it fail.

For scripts and continuous integration it also helps if a failing test returns a non-zero exit status. In Verilog, $fatal(1, "msg") stops the simulation and makes vvp exit with code 1. In VHDL, report ... severity failure stops GHDL with a non-zero exit code; severity error prints but continues, which is what you want while counting mismatches.

7. Exhaustive testing of a small combinational block

A combinational block has no memory: its outputs depend only on its present inputs. If it has\(n\) input bits, there are exactly

\[ N_\text{tests} = 2^{\,n} \]

possible input patterns, and trying all of them proves the block correct — there is nothing left to go wrong. A 4-bit adder with carry in has \(n = 4 + 4 + 1 = 9\), so 512 tests, which simulate in a few milliseconds. The trick is to run one counter i from 0 to 511 and split its bits across the inputs: {cin, a, b} = i.

adder4 — 4-bit adder with carry
// 4-bit adder with carry in and carry out (purely combinational)
module adder4 (
    input  wire [3:0] a,
    input  wire [3:0] b,
    input  wire       cin,
    output wire [3:0] sum,
    output wire       cout
);
    assign {cout, sum} = a + b + cin;
endmodule
adder4_tb — all 512 input combinations
`timescale 1ns/1ps
module adder4_tb;
    reg  [3:0] a, b;
    reg        cin;
    wire [3:0] sum;
    wire       cout;
    integer    i, expected, errors = 0;

    adder4 dut (.a(a), .b(b), .cin(cin), .sum(sum), .cout(cout));

    initial begin
        // 9 input bits -> 512 combinations: try every one of them
        for (i = 0; i < 512; i = i + 1) begin
            {cin, a, b} = i;
            #1;                                   // let the combinational logic settle
            expected = a + b + cin;               // reference: integer arithmetic
            if ({cout, sum} !== expected) begin
                $display("ERROR: %0d + %0d + %0d gave %0d", a, b, cin, {cout, sum});
                errors = errors + 1;
            end
        end
        if (errors == 0) $display("PASS: all 512 input combinations correct");
        else             $display("FAIL: %0d of 512 wrong", errors);
        $finish;
    end
endmodule
Output
PASS: all 512 input combinations correct            (Icarus, at 512 ns)
adder4_tb.vhd:36:13:@512ns:(report note): PASS: all 512 input combinations correct

Notice the #1 / wait for 1 ns after applying each input. Combinational logic in a simulator settles in delta cycles at the same time step, but the testbench process must suspend for the simulator to propagate the new values; reading sum without waiting would see the result for the previous inputs.

Exhaustive testing stops being practical around 30 input bits (\(2^{30} \approx 10^9\)). Two 32-bit operands give \(2^{64}\) combinations — thousands of years. Larger blocks are tested with chosen corner cases (zero, one, maximum, minimum, sign boundaries) plus many random vectors, checked against a reference model. That is the next section.

8. A reference model in Python, vectors through a file

For anything with real arithmetic, the cleanest expected values come from a reference model: a short program in a high-level language that says what the block computes, without any hardware detail. Python is ideal. The model writes test vectors and expected results into a text file; the HDL testbench reads the file, drives the DUT and compares. The two sides share nothing but the file format.

Our DUT is a saturating adder: it adds two signed 8-bit numbers (range −128 to 127), but instead of wrapping around on overflow — 100 + 100 would give −56 in plain 8-bit arithmetic — it clamps the result to the nearest representable value and raises ovf. Audio and image processing use this constantly, because a loud sample that clips sounds far better than one that flips sign.

\[ y = \min\!\big(127,\ \max(-128,\ a + b)\big), \qquad \text{ovf} = [\,y \ne a + b\,] \]
sat_add8 — signed saturating adder
// Signed 8-bit adder that saturates instead of wrapping around
module sat_add8 (
    input  wire signed [7:0] a,
    input  wire signed [7:0] b,
    output wire signed [7:0] y,
    output wire              ovf     // 1 when the true sum did not fit
);
    wire signed [8:0] s = a + b;     // one extra bit: never overflows

    assign ovf = (s > 9'sd127) || (s < -9'sd128);
    assign y   = (s > 9'sd127)  ? 8'sd127 :
                 (s < -9'sd128) ? -8'sd128 : s[7:0];
endmodule

The Python side generates 81 corner-case pairs (every combination of nine dangerous values) and 175 random pairs, computes the expected outputs with ordinary Python integers, and writes one line per test: four bytes in hex — a, b, expected y, expected ovf. Run it to see the vectors and the picture of what the DUT must do:

Python
script.py47 lines

Click Run to execute the Python code

Code will be executed with Python 3 on the server

vectors.hex (first lines)
80808001      a = -128, b = -128 -> y = -128 (0x80), ovf = 1
80818001      a = -128, b = -127 -> y = -128, ovf = 1
80c08001      a = -128, b =  -64 -> y = -128, ovf = 1
80ff8001      a = -128, b =   -1 -> y = -128, ovf = 1
...

On the HDL side, Verilog's $readmemh loads a whole hex file into a memory array in one call; each line becomes one 32-bit word, and a concatenation on the left of = splits it into fields. VHDL reads text files with the std.textio package: readline gets one line, hread parses hex digits into a std_logic_vector (in VHDL-2008 it lives in std_logic_1164 itself).

sat_add8_tb — reads vectors.hex and compares
`timescale 1ns/1ps
module sat_add8_tb;
    localparam N = 256;
    reg  [31:0] vec [0:N-1];          // memory filled from the file
    reg  signed [7:0] a, b;
    wire signed [7:0] y;
    wire        ovf;
    reg  signed [7:0] y_exp;
    reg         ovf_exp;
    integer     i, errors = 0;

    sat_add8 dut (.a(a), .b(b), .y(y), .ovf(ovf));

    initial begin
        $readmemh("vectors.hex", vec);   // one 32-bit hex word per line
        for (i = 0; i < N; i = i + 1) begin
            {a, b, y_exp} = vec[i][31:8];
            ovf_exp       = vec[i][0];
            #1;
            if (y !== y_exp || ovf !== ovf_exp) begin
                $display("ERROR vector %0d: %0d + %0d -> y=%0d ovf=%b, expected %0d %b",
                         i, a, b, y, ovf, y_exp, ovf_exp);
                errors = errors + 1;
            end
        end
        if (errors == 0) $display("PASS: %0d vectors from vectors.hex match the Python model", N);
        else             $display("FAIL: %0d mismatches", errors);
        $finish;
    end
endmodule
The whole flow
$ python3 gen_vectors.py
wrote 256 vectors to vectors.hex
$ iverilog -g2012 -o tb sat_add8.v sat_add8_tb.v && vvp -n tb
PASS: 256 vectors from vectors.hex match the Python model
$ ghdl -a --std=08 sat_add8.vhd sat_add8_tb.vhd && ghdl -e --std=08 sat_add8_tb
$ ghdl -r --std=08 sat_add8_tb --ieee-asserts=disable-at-0
sat_add8_tb.vhd:39:13:@256ns:(report note): PASS: 256 vectors from vectors.hex match the Python model

Without --ieee-asserts=disable-at-0, GHDL prints a few warnings “NUMERIC_STD.">": metavalue detected” at time 0. They are harmless: before the first delta cycle the internal sum s is still 'U' (uninitialised), and the comparison functions warn about it. The option silences such warnings at time 0 only, so real ones later are still shown.

To prove the check works, make the Verilog DUT wrap on positive overflow instead of saturating: the testbench reports FAIL: 27 mismatches — exactly the random and corner vectors whose sum exceeds 127.

Key idea: two independent descriptions
Verification is the comparison of two descriptions of the same behaviour written in different ways, ideally by different people. The HDL says how the hardware computes; the reference model says what it should compute. A mismatch means one of them is wrong — and finding out which is where the learning happens.

9. A checklist for every testbench

ItemWhy
Testbench named *_tb, no portsconvention; the checker scripts rely on it
`timescale in every Verilog filedelays mean what you think they mean
Clock from one process; stops at the endthe VHDL simulation ends by itself
Reset asserted for at least 2 cyclesreveals a missing or wrong reset
Drive and check away from the active edgeno races with the DUT
Expected values from an independent modela copy of the DUT checks nothing
!== / === in Verilog checksX outputs are errors, not passes
One PASS/FAIL line, non-zero exit on failurescripts and humans read it at a glance
Break the DUT once on purposeproves the test can fail

A passing test tells you only that the cases you tried work. When a test fails, the next step is to look inside: Chapter 11 dumps every signal to a waveform file and walks through the bugs you will meet most often. Chapter 12 then uses these testbenches on finite state machines.

Practice Problems

Progress: 0/5 (0%)
1
⭐ Easy
A testbench contains `timescale 1ns/1ps and the line always #5 clk = ~clk;. What is the clock period and frequency? What would you write to simulate the PYNQ-Z2's 125 MHz clock?
2
⭐ Easy
A student checks a result with if (count != expected) errors = errors + 1;. The DUT has a forgotten reset, so count is 4'bxxxx for the whole simulation. How many errors are reported, and how do you fix the check?
3
⭐⭐ Medium
How many input combinations does an exhaustive test of (a) a 4-bit adder with carry in, (b) an 8-bit adder with carry in, (c) a 16-bit multiplier, (d) a 32-bit adder need? If the simulator evaluates 10 million vectors per second, how long does each take?
4
⭐⭐ Medium
In counter_tb the stimulus changes en and then waits for @(posedge clk), and the comparison happens at the following @(negedge clk). Explain what could go wrong if the testbench instead changed en and compared count right at the rising edge, in the same time step as the DUT's flip-flops.
5
⭐⭐⭐ Hard
Extend the reference-model flow of Section 8 to an unsigned 8-bit multiply block mul8 with a 16-bit output. (a) What would one line of vectors.hex contain and how many hex digits is that? (b) Write the Python expression that packs a line. (c) Which corner values would you always include?

Problem Set Statistics

Total Problems
5
Attempted
0
Completion
0%
Share:XRedditLinkedIn
Rate this chapter: