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:

state_machine_tb_diagram

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

Output
[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 initial block with a forever loop, not an always block — 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
Did you find what you were looking for?

Quiz

No quiz questions available for this article.