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.
2. Installing the free simulators
We use two open-source simulators, one per language, plus a waveform viewer (Chapter 11):
| Tool | Language | Commands you will type |
|---|---|---|
| Icarus Verilog | Verilog (and some SystemVerilog) | iverilog (compile), vvp (run) |
| GHDL | VHDL-87/93/2008 | ghdl -a (analyse), -e (elaborate), -r (run) |
| GTKWave or Surfer | waveform viewer | gtkwave file.vcd / surfer file.vcd |
Linux (Debian, Ubuntu, and WSL on Windows). All three are in the standard repositories:
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.
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 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
endmoduleThe testbench below is the template you will reuse for every clocked design in this course. Read it with the numbered comments:
- 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.
- The DUT instance, with parameters/generics overridden if needed. Signals that the testbench drives are
regin Verilog (they are assigned insideinitial/always); signals the DUT drives arewire. - The clock generator: a process that inverts
clkevery half period. With`timescale 1ns/1ps,#4means 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 whendonebecomes true so the simulation can end by itself (GHDL stops when no events are left). - A task / procedure that packages one step of the test (Section 5).
- Reset:
rststarts at 1 and is released after two clock edges. - Stimulus and checking, then a final verdict and
$finish.
`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$ 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.
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).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.
| Need | Verilog | VHDL |
|---|---|---|
| wait a time | #10; | wait for 10 ns; |
| wait for an edge | @(posedge clk); | wait until rising_edge(clk); |
| wait for N edges | repeat (N) @(posedge clk); | for i in 1 to N loop wait until ...; end loop; |
| wait for a condition | wait (done == 1); | wait until done = '1'; |
| $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:
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.
!= 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
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.
// 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`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
endmodulePASS: 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.
// 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];
endmoduleThe 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:
Click Run to execute the Python code
Code will be executed with Python 3 on the server
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).
`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$ 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.
9. A checklist for every testbench
| Item | Why |
|---|---|
| Testbench named *_tb, no ports | convention; the checker scripts rely on it |
| `timescale in every Verilog file | delays mean what you think they mean |
| Clock from one process; stops at the end | the VHDL simulation ends by itself |
| Reset asserted for at least 2 cycles | reveals a missing or wrong reset |
| Drive and check away from the active edge | no races with the DUT |
| Expected values from an independent model | a copy of the DUT checks nothing |
| !== / === in Verilog checks | X outputs are errors, not passes |
| One PASS/FAIL line, non-zero exit on failure | scripts and humans read it at a glance |
| Break the DUT once on purpose | proves 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
`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?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?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.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?