Module 7: Advanced RTL Design Techniques · 7.4 FIFOs — Synchronous & Asynchronous

Verifying an Asynchronous FIFO

The previous article built the asynchronous FIFO. This one tests it.

Module 6 briefly introduced multi-clock testbenches. Verifying an async FIFO is where that gets exercised for real. The two sides of the design do not share a time reference, and @(posedge clk) is no longer a meaningful thing to wait on, because “which clock?” has two correct answers depending on which side you are driving.

Two clocks, deliberately unrelated

To stress the synchronizers the two clocks should run at different, non-integer-related periods. Using the same period for both, or a clean 2:1 ratio, can hide a bug by making the edges land in the same convenient alignment every cycle.

`timescale 1ns / 1ps

module tb_async_fifo;
    parameter DATA_WIDTH = 8, DEPTH = 8;

    reg wr_clk = 0, rd_clk = 0;
    reg wr_rst_n, rd_rst_n;
    reg wr_en, rd_en;
    reg [DATA_WIDTH-1:0] wdata;
    wire [DATA_WIDTH-1:0] rdata;
    wire full, empty;

    async_fifo #(.DATA_WIDTH(DATA_WIDTH), .DEPTH(DEPTH)) dut (
        .wr_clk(wr_clk), .wr_rst_n(wr_rst_n), .wr_en(wr_en), .wdata(wdata), .full(full),
        .rd_clk(rd_clk), .rd_rst_n(rd_rst_n), .rd_en(rd_en), .rdata(rdata), .empty(empty)
    );

    // Deliberately unrelated periods: 7 ns and 11 ns. No clean common multiple
    // for many cycles, so the edges drift relative to each other and the
    // synchronizers see many different phase relationships instead of one.
    always #3.5  wr_clk = ~wr_clk;
    always #5.5  rd_clk = ~rd_clk;

That comment matters more than it looks. A testbench with related clock periods can pass while hiding a real CDC bug, purely because the edges always land in the same safe phase. Unrelated periods force the crossing to be exercised across the whole range of alignments over the run.

Two independent stimulus processes

The write side and the read side get their own initial blocks, each looping on its own clock, running concurrently. Both drive their control signals at the falling edge of their own clock, so the DUT samples stable values at the rising edge and the checks happen on settled data.

    localparam N = 100;
    reg [DATA_WIDTH-1:0] expected_queue [0:N-1];   // scoreboard: what should come out, in order
    integer push_idx = 0, pop_idx = 0;

    // Write-side process, in the wr_clk domain
    initial begin
        wr_rst_n = 0; wr_en = 0; wdata = 0;
        repeat (3) @(negedge wr_clk);
        wr_rst_n = 1;

        while (push_idx < N) begin
            @(negedge wr_clk);
            if (!full && ($random % 3 != 0)) begin   // write most cycles, not every cycle
                wdata = push_idx[DATA_WIDTH-1:0];
                wr_en = 1;
                expected_queue[push_idx] = wdata;    // record what we expect to see, in order
                push_idx = push_idx + 1;
            end else begin
                wr_en = 0;
            end
        end
        @(negedge wr_clk);
        wr_en = 0;
    end

    // Read-side process, in the rd_clk domain, on a completely different clock
    integer errors = 0;

    initial begin
        rd_rst_n = 0; rd_en = 0;
        repeat (3) @(negedge rd_clk);
        rd_rst_n = 1;

        while (pop_idx < N) begin
            @(negedge rd_clk);
            // If rd_en was high across the edge just passed, the DUT popped an
            // entry there and rdata now holds it.
            if (rd_en) begin
                if (rdata !== expected_queue[pop_idx]) begin
                    $display("FAIL at read #%0d: expected %0d, got %0d", pop_idx, expected_queue[pop_idx], rdata);
                    errors = errors + 1;
                end
                pop_idx = pop_idx + 1;
            end
            rd_en = (!empty && ($random % 4 != 0));   // decide about the next edge
        end
        rd_en = 0;

        if (errors == 0)
            $display("ALL %0d READS PASSED: order preserved across 7 ns / 11 ns clocks", N);
        else
            $display("%0d ERRORS across %0d reads", errors, N);

        $finish;
    end

endmodule

Three choices here are deliberate. Both sides write and read on most cycles but not every cycle ($random % 3 != 0 and $random % 4 != 0). A testbench that writes and reads on every possible cycle never leaves the FIFO sitting at a partial fill level for long, and partial fill is where the full/empty edge cases live. The scoreboard records data purely in push order, independent of either clock, because ordering is the one property that must survive a clock domain crossing exactly, whatever the individual edge alignments happened to be. And the two initial blocks run concurrently, each governed by its own clock, which is possible only because Verilog’s simulation model is concurrent, the mental model Module 5 built at the start of behavioural RTL.

The !empty check in the read process is safe to make at the falling edge because empty depends only on registers clocked by rd_clk, so it cannot change between the falling edge and the next rising edge. The same holds for full on the write side.

Verification teams run this style of test, often called a CDC stress test, for every async FIFO in a design, and frequently sweep the two periods across many unrelated ratios in a regression. One fixed pair of periods run once is not considered enough coverage for silicon.

Practice Exercise

Modify the testbench so the write clock period stays fixed while the read clock period is swept across five values in one simulation run, resetting the FIFO between sweeps, to confirm the design holds across a range of ratios.

Solution

The read clock generator takes its half-period from a variable, and an outer loop changes it between passes. The stimulus loops are wrapped in a task so they can be re-run per pass.

real rd_half = 5.5;
always begin
    #(rd_half) rd_clk = ~rd_clk;      // half-period re-read on every toggle
end

real periods [0:4];
integer p;
initial begin
    periods[0] = 4.0; periods[1] = 5.5; periods[2] = 6.3; periods[3] = 8.1; periods[4] = 9.7;
    for (p = 0; p < 5; p = p + 1) begin
        rd_half = periods[p];
        // reset both sides and the scoreboard indices
        wr_rst_n = 0; rd_rst_n = 0; wr_en = 0; rd_en = 0;
        push_idx = 0; pop_idx = 0; errors = 0;
        repeat (3) @(negedge wr_clk); repeat (3) @(negedge rd_clk);
        wr_rst_n = 1; rd_rst_n = 1;
        // run the write and read loops from the main testbench here (as tasks,
        // started with fork...join so they run concurrently)
        $display("rd half-period %.1f ns: %0d errors", rd_half, errors);
    end
    $finish;
end

Because the always block re-reads rd_half on each toggle, changing the variable changes the clock frequency from the next edge, without stopping and restarting a clock generator. This is why swept-frequency clock generation is a little more involved than the fixed-frequency case in Module 6.

Verifying a design with two independent clocks means dropping the single-clock model from Module 6: two concurrent stimulus processes, each on its own clock, with periods chosen to be unrelated, and a scoreboard that checks pure ordering independent of either clock. That closes Section 7.4. In the next article, Section 7.5 takes the two-flop synchronizer and the Gray code discipline we have been using and treats clock domain crossing as a subject in its own right.

← The Asynchronous FIFO Metastability and MTBF →