Loading header...

Exercise: Design an Asynchronous FIFO

An asynchronous FIFO is a standard way to move streaming data between unrelated clock domains. It is also one of the easiest FPGA structures to get almost right and still unsafe. This exercise builds the smallest useful version so you can understand the architecture, write meaningful tests, and review vendor FIFO IP with confidence. For production hardware, prefer a proven FIFO generator or a reusable implementation that has been reviewed, constrained, and verified.

Learning Objectives

You will be able to:

  • separate write-domain and read-domain responsibilities;
  • explain why binary counters should not cross directly between unrelated clocks;
  • use gray-coded pointers with two-flop synchronizers;
  • calculate full and empty from synchronized opposite-domain pointers;
  • verify ordering, overflow protection, underflow protection, and different clock rates.

Prerequisites

  • Clock-domain crossing basics and metastability risk.
  • Synchronous RAM or block RAM concepts.
  • Verilog testbench skills.
  • Ability to inspect simulation waveforms and CDC warnings.

Task

Design a small asynchronous FIFO with these properties:

  • wr_clk, wr_rst, wr_en, wr_data, and full in the write domain;
  • rd_clk, rd_rst, rd_en, rd_data, and empty in the read domain;
  • depth of 16 entries;
  • data width of 8 bits;
  • no writes accepted when full is high;
  • no reads accepted when empty is high;
  • data read order exactly matches accepted write order.

Architecture

flowchart LR subgraph W["write clock domain"] WD["wr_en and wr_data"] --> RAMW["write RAM port"] WB["write pointer binary"] --> WG["write pointer gray"] RGS["read gray synced"] --> FULL["full compare"] end subgraph R["read clock domain"] RE["rd_en"] --> RAMR["read RAM port"] RB["read pointer binary"] --> RG["read pointer gray"] WGS["write gray synced"] --> EMPTY["empty compare"] end WG --> W2R["two-flop sync"] --> WGS RG --> R2W["two-flop sync"] --> RGS

The RAM stores data. The pointers and flags decide whether a transfer is allowed. Each pointer is owned by one clock domain, converted to gray code, and synchronized into the opposite domain for flag calculation.

Why Gray Code Is Used

A binary pointer may change several bits at once. If those bits are sampled during a transition by another clock domain, the sampled value can look like an unrelated pointer. A gray-coded pointer changes only one bit per increment, so the synchronized value is either the old pointer or the new pointer. The two-flop synchronizer reduces metastability risk, while the FIFO tolerates the resulting flag latency.

For an address width AW, the pointer has AW + 1 bits. The extra bit distinguishes empty from full after the address wraps.

Buildable RTL Skeleton

This version is suitable for learning and simulation. Confirm RAM inference style and CDC constraints for your target device before using it in hardware.

module async_fifo #(
    parameter DW = 8,
    parameter AW = 4
) (
    input  wire          wr_clk,
    input  wire          wr_rst,
    input  wire          wr_en,
    input  wire [DW-1:0] wr_data,
    output wire          full,

    input  wire          rd_clk,
    input  wire          rd_rst,
    input  wire          rd_en,
    output reg  [DW-1:0] rd_data,
    output wire          empty
);
    localparam DEPTH = (1 << AW);

    reg [DW-1:0] mem [0:DEPTH-1];

    reg [AW:0] wr_bin, wr_gray;
    reg [AW:0] rd_bin, rd_gray;
    reg [AW:0] rd_gray_w1, rd_gray_w2;
    reg [AW:0] wr_gray_r1, wr_gray_r2;

    function [AW:0] bin2gray;
        input [AW:0] b;
        begin
            bin2gray = (b >> 1) ^ b;
        end
    endfunction

    wire wr_take = wr_en && !full;
    wire rd_take = rd_en && !empty;

    wire [AW:0] wr_bin_next  = wr_bin + wr_take;
    wire [AW:0] rd_bin_next  = rd_bin + rd_take;
    wire [AW:0] wr_gray_next = bin2gray(wr_bin_next);
    wire [AW:0] rd_gray_next = bin2gray(rd_bin_next);

    assign empty = (rd_gray == wr_gray_r2);
    assign full  = (wr_gray_next == {~rd_gray_w2[AW:AW-1], rd_gray_w2[AW-2:0]});

    always @(posedge wr_clk) begin
        if (wr_rst) begin
            wr_bin <= 0;
            wr_gray <= 0;
        end else begin
            if (wr_take)
                mem[wr_bin[AW-1:0]] <= wr_data;
            wr_bin <= wr_bin_next;
            wr_gray <= wr_gray_next;
        end
    end

    always @(posedge rd_clk) begin
        if (rd_rst) begin
            rd_bin <= 0;
            rd_gray <= 0;
            rd_data <= 0;
        end else begin
            if (rd_take)
                rd_data <= mem[rd_bin[AW-1:0]];
            rd_bin <= rd_bin_next;
            rd_gray <= rd_gray_next;
        end
    end

    always @(posedge wr_clk) begin
        if (wr_rst) begin
            rd_gray_w1 <= 0;
            rd_gray_w2 <= 0;
        end else begin
            rd_gray_w1 <= rd_gray;
            rd_gray_w2 <= rd_gray_w1;
        end
    end

    always @(posedge rd_clk) begin
        if (rd_rst) begin
            wr_gray_r1 <= 0;
            wr_gray_r2 <= 0;
        end else begin
            wr_gray_r1 <= wr_gray;
            wr_gray_r2 <= wr_gray_r1;
        end
    end
endmodule

Testbench Plan

Use unrelated clock periods so the edges drift.

always #5  wr_clk = ~wr_clk;  // 100 MHz equivalent
always #7  rd_clk = ~rd_clk;  // about 71.4 MHz equivalent

Drive a monotonically increasing byte sequence into the write side whenever !full. On the read side, pop data whenever !empty and compare it with the next expected byte. Repeat with:

  • write faster than read;
  • read faster than write;
  • random write pauses;
  • random read pauses;
  • reset asserted at startup only, then clean continuous operation.

Expected Behavior

  • empty is high after both resets are released and synchronization latency has settled.
  • Accepted writes appear at the read side in the exact same order.
  • Write attempts while full is high do not corrupt stored data.
  • Read attempts while empty is high do not advance the expected sequence.
  • full and empty may be conservative by a few cycles because opposite-domain pointers are synchronized.
title "Illustrative async FIFO clocks"
time start=0 end=14 unit=cycles divisions=14

WRCLK: square label="wr clk" low=0 high=1 duty=50 cycles=7 unit=logic color=#2563eb
RDCLK: square label="rd clk" low=0 high=1 duty=50 cycles=5 unit=logic color=#16a34a
WREN: pulse label="write burst" low=0 high=1 at=2 width=4 unit=logic color=#dc2626
RDEN: pulse label="read burst" low=0 high=1 at=6 width=5 unit=logic color=#7c3aed

marker FIRST at=2 label="writes"
marker LATER at=6 label="reads"

The waveform is illustrative. Real clock edges are independent, not phase-locked to this drawing.

Verification Steps

  1. Confirm empty is true after reset and before writes.
  2. Write at least DEPTH * 3 accepted values so pointers wrap several times.
  3. Read back all accepted values and compare order.
  4. Force the FIFO toward full and prove writes stop without overwriting unread data.
  5. Force the FIFO toward empty and prove reads stop without duplicating data.
  6. Test faster-write and faster-read cases.
  7. Add assertions: never write when full in the driver, never read when empty in the driver, and every popped value matches the queue.
  8. Review CDC reports and ensure only gray-coded pointer buses cross domains.

Common Failure Symptoms

Symptom Likely cause Debugging move
Duplicated data Read pointer failed to advance or empty flag stale in the wrong direction Trace rd_take and rd_bin
Missing data Write pointer or full calculation is wrong Compare accepted writes against memory addresses
Random order Binary pointer crossed clocks or RAM read style mismatch Inspect CDC paths and RAM behavior
False full forever Full comparison bit inversion is wrong Check the two MSBs of synchronized read pointer
Works only with same clocks Testbench did not create a real CDC stress case Use unrelated periods and pauses

Debugging Guidance

  • Display binary and gray pointers side by side.
  • Keep full logic entirely in the write clock domain.
  • Keep empty logic entirely in the read clock domain.
  • Synchronize pointer buses through exactly staged registers marked appropriately for your FPGA flow.
  • Do not synchronize data bits individually; the RAM plus pointer protocol controls data movement.
  • Review reset release carefully. Many production FIFOs require reset sequencing rules.

Extension Challenge

Add almost_full and almost_empty flags. Define thresholds in entries, such as almost_full at 12 of 16 entries and almost_empty at 4 or fewer. Then extend the testbench queue to check that the flags assert conservatively and never permit overflow or underflow.

Explained Solution

The write side owns the write pointer and updates RAM only on accepted writes. The read side owns the read pointer and reads RAM only on accepted reads. Each domain sends a gray-coded version of its pointer through two synchronizer flops to the other domain. empty is true when the read pointer equals the synchronized write pointer. full is true when the next write pointer equals the synchronized read pointer with the wrap bits inverted. This structure accepts small flag latency because conservative full and empty flags are safer than unsafe direct pointer crossing.

Summary

An asynchronous FIFO is a CDC protocol, not just a memory array. Keep ownership clear, cross only gray-coded pointers, calculate flags in the correct domains, and verify with unrelated clocks and a self-checking queue. In shipped systems, use a proven implementation unless you have strong verification evidence.

Next: Verification and Design Review.

Further Reading

  • Clifford E. Cummings, "Simulation and Synthesis Techniques for Asynchronous FIFO Design"
  • AMD and Intel FIFO IP user guides
  • Verilator and cocotb examples for queue-based scoreboards
  • Vendor CDC methodology guides

Mind Map

mindmap root((Async FIFO)) Core idea Two clock domains RAM plus pointers Gray pointer crossing Safe flags Key formulas Depth equals 2 pow AW Pointer bits AW plus 1 Gray equals bin xor bin shifted Full uses inverted wrap bits Design rules Write owns wr ptr Read owns rd ptr Two flop sync No binary CDC Practical checks Queue scoreboard Faster write test Faster read test Pointer wrap test Common mistakes Off by one full Binary bus crossing Ignoring flag latency Data bit synchronizers