The linear and file-based testbenches from the previous articles have a shared blind spot: they apply inputs on a fixed schedule, regardless of what the DUT is doing. That works for a combinational adder where the output is always ready on the next clock edge. It breaks immediately for any design that uses a handshake — where the testbench must wait for the DUT to signal readiness before sending the next transaction.
A state machine testbench solves this by giving the testbench its own FSM. Each state corresponds to a phase of the test sequence — initialise, send request, wait for acknowledgment, check result, done. The testbench only advances to the next state when the DUT has signalled it is ready, making the stimulus protocol-aware rather than time-blind.
11 min read | Intermediate Level
What You'll Learn
- Why handshake protocols require a state machine testbench instead of a linear one
- How to encode testbench states and transitions to sequence multi-phase stimulus
- How to wait for DUT acknowledgment before proceeding to the next phase
- A complete example driving a memory interface through write and read cycles
The Problem: Protocols That Require Waiting
Consider a simple synchronous memory with a handshake interface. To perform a write, the testbench must: assert wr_en with a valid address and data, then wait until the memory asserts ack to confirm the write completed before releasing the bus. Only then can the next transaction begin.
A linear testbench cannot handle this. It would de-assert wr_en after a fixed number of clock cycles regardless of whether ack arrived. If the memory is busy — perhaps finishing a previous operation — the write either gets dropped or a second transaction is launched while the first is still pending. Both produce wrong behaviour.
The state machine testbench eliminates this problem. Instead of counting cycles, the testbench stays in the "wait for ack" state for as many clock cycles as necessary, only advancing when the DUT signals completion.
The DUT: A Simple Memory with Handshake
The DUT is a small synchronous memory: 8 locations, each 8 bits wide. It accepts a write or read request on any cycle, processes it for one or two cycles (simulating a realistic latency), then asserts ack for one cycle to indicate completion. The testbench must not issue a new request until ack has been received for the current one.
// DUT: 8x8 synchronous memory with acknowledge handshake
// Write: assert wr_en=1, provide addr and wr_data, wait for ack
// Read: assert rd_en=1, provide addr, wait for ack, then sample rd_data
module mem_dut (
input clk,
input rst,
input wr_en, // write enable
input rd_en, // read enable
input [2:0] addr, // 3-bit address (8 locations)
input [7:0] wr_data, // data to write
output [7:0] rd_data, // data read out
output ack // pulsed high for one cycle when done
);
reg [7:0] mem [0:7]; // the 8-byte storage array
reg [7:0] rd_data;
reg ack;
integer i;
always @(posedge clk) begin
ack <= 0; // default: ack is low
rd_data <= 0;
if (rst) begin
for (i = 0; i < 8; i = i + 1)
mem[i] <= 8'h00;
end
else if (wr_en) begin
mem[addr] <= wr_data; // store the data
ack <= 1; // acknowledge one cycle later
end
else if (rd_en) begin
rd_data <= mem[addr]; // fetch the data
ack <= 1; // acknowledge one cycle later
end
end
endmodule
The Testbench State Machine
The testbench needs to drive this DUT through a complete write-then-read sequence and verify the read returns the same data that was written. Breaking this into phases makes the sequencing explicit and easy to follow:
The orange WRITE_WAIT and DONE states are the key addition over a linear testbench. WRITE_WAIT holds the testbench in place until the DUT signals completion — it could take one cycle or ten, and the testbench does not care. DONE is a terminal state that performs the output check and ends the simulation.
Encoding the State Machine in the Testbench
A testbench FSM lives inside an initial block, not an always block. The initial block runs once at the start of simulation, stepping through states sequentially using a case statement inside a forever loop. Each state waits for the next rising clock edge before checking conditions and deciding whether to advance.
module tb_mem;
// ── DUT signals ────────────────────────────────────────────────
reg clk;
reg rst;
reg wr_en;
reg rd_en;
reg [2:0] addr;
reg [7:0] wr_data;
wire [7:0] rd_data;
wire ack;
// ── DUT instantiation ──────────────────────────────────────────
mem_dut dut (
.clk (clk),
.rst (rst),
.wr_en (wr_en),
.rd_en (rd_en),
.addr (addr),
.wr_data(wr_data),
.rd_data(rd_data),
.ack (ack)
);
// ── Clock ──────────────────────────────────────────────────────
initial clk = 0;
always #5 clk = ~clk;
// ── State encoding ─────────────────────────────────────────────
// Simple integer constants — one per testbench phase
parameter IDLE = 0;
parameter WRITE_REQ = 1;
parameter WRITE_WAIT = 2;
parameter READ_REQ = 3;
parameter DONE = 4;
integer state;
// ── Testbench FSM ──────────────────────────────────────────────
initial begin
// ── Initialise all signals ─────────────────────────────────
state = IDLE;
rst = 1;
wr_en = 0;
rd_en = 0;
addr = 0;
wr_data = 0;
// Hold reset for two clock cycles then release
@(posedge clk);
@(posedge clk);
rst = 0;
// ── Step through each state on every rising clock edge ─────
forever begin
@(posedge clk);
case (state)
// ── IDLE: deassert everything, move to first write ──
IDLE: begin
wr_en = 0;
rd_en = 0;
$display("[%0t] State: IDLE — starting write transaction", $time);
state = WRITE_REQ;
end
// ── WRITE_REQ: put address and data on the bus ──────
WRITE_REQ: begin
wr_en = 1; // assert write enable
addr = 3'h4; // target address 4
wr_data = 8'hA5; // data to store
$display("[%0t] State: WRITE_REQ — addr=0x%0h data=0x%0h",
$time, addr, wr_data);
state = WRITE_WAIT;
end
// ── WRITE_WAIT: hold signals, wait for DUT to ack ──
// This state loops until ack arrives — could be 1 or
// many cycles depending on DUT implementation
WRITE_WAIT: begin
if (ack) begin
// DUT has confirmed the write completed
wr_en = 0; // release the bus
$display("[%0t] State: WRITE_WAIT — ack received, write complete",
$time);
state = READ_REQ;
end
// If ack not yet asserted: stay in WRITE_WAIT,
// loop back to @(posedge clk) and check again
end
// ── READ_REQ: request data back from same address ───
READ_REQ: begin
rd_en = 1; // assert read enable
addr = 3'h4; // same address we wrote
$display("[%0t] State: READ_REQ — addr=0x%0h", $time, addr);
state = DONE;
end
// ── DONE: wait for ack, then check the result ───────
DONE: begin
if (ack) begin
rd_en = 0;
$display("[%0t] State: DONE — rd_data=0x%0h", $time, rd_data);
// Verify read-back matches what was written
if (rd_data !== 8'hA5)
$error("MISMATCH: wrote 0xA5, read back 0x%0h", rd_data);
else
$display("PASS: read-back correct");
$finish;
end
// Stay in DONE until ack arrives
end
endcase
end
end
endmodule
Simulation Log
[5] State: IDLE — starting write transaction [15] State: WRITE_REQ — addr=0x4 data=0xA5 [25] State: WRITE_WAIT — ack received, write complete [35] State: READ_REQ — addr=0x4 [45] State: DONE — rd_data=0xA5 PASS: read-back correct
Quick Check
Q: In WRITE_WAIT, the testbench checks if (ack) on every rising clock edge and stays in the same state if ack is not yet asserted. What would happen if this check were missing and the testbench unconditionally advanced to READ_REQ after one cycle — and the DUT happened to take three cycles to ack?
Show Answer
The testbench would issue a read request while the write was still in progress inside the DUT. Depending on the DUT's internal implementation, this could cause the read to return stale data (the old value before the write completed), corrupt the in-progress write, or produce undefined behaviour if the DUT does not support overlapping requests. The state machine's wait loop exists precisely to prevent this — the testbench advances only when the DUT confirms readiness, not on a fixed timer.
Advantages Over the Linear Testbench
Protocol correctness. The testbench cannot advance to the next transaction until the DUT has acknowledged the current one. This makes it impossible to produce illegal stimulus by accident — the FSM enforces the protocol by construction.
Latency independence. The WRITE_WAIT state will wait one cycle or a hundred cycles with equal correctness. If the DUT's ack latency changes between RTL revisions — say, a performance optimisation reduces it from two cycles to one — the testbench adapts automatically. No hardcoded delay values need updating.
Readable sequencing. Each state has a single, clear responsibility. Reading the FSM from IDLE to DONE gives a direct description of the test sequence: initialise, write, wait, read, check. Adding a new phase — a second write, a burst, an error injection — means adding new states rather than restructuring the entire stimulus block.
The state machine testbench is not just for memory interfaces. Any DUT with a request/acknowledge, valid/ready, or command/response protocol benefits from this style. The pattern — assert request, wait for response, check result, advance — applies directly to AXI, APB, SPI, I2C, and most other bus protocols.
Extending the FSM: Multiple Transactions
The single write-read sequence above is a foundation. Real testbenches extend the FSM to cover multiple addresses, error conditions, and back-to-back transactions. The extension is straightforward — add a counter and loop the FSM back through WRITE_REQ for each new transaction:
// ── Extended FSM: write to all 8 addresses, then read them back
integer tx_count; // tracks which address we are currently testing
// In IDLE, initialise the counter:
// tx_count = 0;
// Replace WRITE_REQ with address derived from counter:
WRITE_REQ: begin
wr_en = 1;
addr = tx_count[2:0]; // cycle through addresses 0..7
wr_data = tx_count * 8'h11; // unique data per address
state = WRITE_WAIT;
end
// In WRITE_WAIT, after ack, decide whether to loop or proceed:
WRITE_WAIT: begin
if (ack) begin
wr_en = 0;
if (tx_count < 7) begin
tx_count = tx_count + 1;
state = WRITE_REQ; // write next address
end else begin
tx_count = 0;
state = READ_REQ; // all writes done, start reads
end
end
end
The same ack-wait pattern repeats for every transaction. The FSM handles latency variation on each individual write without any change to the loop structure.
Quick Check
Q: The extended FSM writes to all 8 addresses before starting reads. What is the minimum number of clock cycles this requires, assuming the DUT always asserts ack exactly one cycle after each request — and counting from when reset is released?
Show Answer
Each write takes two cycles (one in WRITE_REQ to assert the signals, one in WRITE_WAIT to receive ack). Eight writes = 16 cycles. Then one cycle in IDLE at start and one WRITE_WAIT check per write. In practice: 2 reset cycles + 1 IDLE cycle + (8 × 2 write cycles) + (8 × 2 read cycles) = 2 + 1 + 16 + 16 = 35 cycles minimum. The important point is the testbench calculates none of this — the FSM's ack-wait loop handles variable latency automatically regardless of the actual count.
Limitations
Still hand-crafted stimulus. Every address, data value, and transaction sequence in the FSM was written by the verification engineer. The FSM ensures correct sequencing but does not generate novel test cases. Discovering bugs outside the written sequence requires adding more states — which does not scale to large input spaces.
One scenario per FSM. The testbench above tests one specific write-read pattern. Testing a second pattern — for example, back-to-back writes to the same address, or a read before any write — requires a new FSM or a significant restructuring of the existing one.
These limitations motivate the next evolution: adding randomisation to the stimulus so the FSM sequences are correct but the values they carry are unpredictable — combining protocol correctness with coverage breadth.
Common Mistakes
Mistake #1: Advancing state unconditionally instead of waiting for the DUT's acknowledgment.
Wrong: Checking if (ack) once and advancing to the next state regardless — or worse, removing the check entirely and using a fixed #20 delay to "wait long enough."
Right: Keeping the testbench in the wait state on every clock edge until ack is actually observed, with the state transition inside the if (ack) branch.
Why? A fixed delay that works on today's RTL silently breaks when the design is optimised and latency changes. The FSM wait loop is latency-independent by design — it always works regardless of how many cycles the DUT takes to respond.
Mistake #2: Forgetting to de-assert request signals after receiving acknowledgment.
Wrong: Leaving wr_en or rd_en asserted after ack is received and transitioning directly to the next state — causing the DUT to see a second unintended request on the following cycle.
Right: De-asserting all request signals (wr_en = 0; rd_en = 0;) as the first action when ack is observed, before transitioning to the next state.
Why? Most handshake protocols define that holding the request signal asserted after ack initiates a new transaction. Forgetting to de-assert produces spurious second requests that corrupt the DUT's internal state and produce false failures that are difficult to trace back to the testbench.
Mistake #3: Using an always block instead of an initial block for the testbench FSM.
Wrong: Placing the testbench FSM in an always @(posedge clk) block, which makes the testbench look and behave like synthesisable RTL — running indefinitely and never reaching a $finish condition cleanly.
Right: Using an initial block with a forever loop and @(posedge clk) waits, which gives the testbench explicit control over when to advance, when to check, and when to terminate.
Why? An always block cannot be conditionally stopped — it runs for the entire simulation. An initial block can call $finish, disable itself, or simply fall off the end. Testbench control logic belongs in initial blocks; always blocks are for clock generation and DUT-style sequential logic.
Practice Exercise
Challenge: Add an Error-Check State
The current DONE state checks the read-back value but immediately calls $finish. Extend the FSM to handle a second scenario: after the successful read-back, write a different value (8'hFF) to address 4, read it back, and verify the second write correctly overwrote the first.
You will need two new states: WRITE2_REQ, WRITE2_WAIT, READ2_REQ, and a DONE2 final check. Sketch the new state sequence and write the WRITE2_REQ state block.
Hint
The pattern is identical to the first write sequence — assert wr_en, set addr = 3'h4 and wr_data = 8'hFF in WRITE2_REQ, then wait for ack in WRITE2_WAIT before advancing to READ2_REQ. The DONE state from the first sequence becomes the transition point: instead of calling $finish, change state = WRITE2_REQ.
Sample Solution
// Replace $finish in DONE with a transition:
DONE: begin
if (ack) begin
rd_en = 0;
if (rd_data !== 8'hA5)
$error("First read MISMATCH: expected 0xA5, got 0x%0h", rd_data);
else
$display("First read PASS");
state = WRITE2_REQ; // continue to second write
end
end
// New states:
WRITE2_REQ: begin
wr_en = 1;
addr = 3'h4;
wr_data = 8'hFF; // overwrite with new value
$display("[%0t] State: WRITE2_REQ — overwriting addr 4 with 0xFF", $time);
state = WRITE2_WAIT;
end
WRITE2_WAIT: begin
if (ack) begin
wr_en = 0;
state = READ2_REQ;
end
end
READ2_REQ: begin
rd_en = 1;
addr = 3'h4;
state = DONE2;
end
DONE2: begin
if (ack) begin
rd_en = 0;
if (rd_data !== 8'hFF)
$error("Second read MISMATCH: expected 0xFF, got 0x%0h", rd_data);
else
$display("Second read PASS — overwrite confirmed");
$finish;
end
end
Summary
- A state machine testbench gives the testbench its own FSM, sequencing through phases — request, wait, check — that react to DUT signals rather than running on a fixed timer
- The wait state is the key advance over linear and file-based testbenches: the testbench stays in place until the DUT asserts its acknowledgment, regardless of how many cycles that takes
- The FSM lives in an
initialblock with aforeverloop, not analwaysblock — giving the testbench explicit control over sequencing and termination - De-asserting request signals immediately after receiving acknowledgment is essential — leaving them asserted triggers unintended second transactions
- The pattern extends naturally to multiple transactions by looping the FSM through the request/wait states with a counter — latency variation is handled automatically
- Core limitation: all stimulus values are still hand-crafted; the FSM ensures correct sequencing but does not generate novel test scenarios