← Back to the series

Verification, From First Principles — Part 8 of 10

Async FIFO Theory: The Problem It Solves

Before writing a single line of testbench, it's worth being completely clear on what an asynchronous FIFO is actually fighting against. That's what makes it a genuinely good verification exercise, not just a convenient one.

What a FIFO is, in one sentence

A FIFO (first-in, first-out) is a memory with a write pointer and a read pointer instead of an address bus. Writing stores data at the write pointer's location and advances it; reading returns data from the read pointer's location and advances it. full means the write pointer has caught back up to the read pointer with no space left; empty means the read pointer has caught up to the write pointer with nothing left to read.

The part that makes it "async"

In a synchronous FIFO, both pointers live in the same clock domain, so comparing them is trivial — they're updated on the same clock edge, so there's never any ambiguity about which value is "current." An asynchronous FIFO is written on one clock (wr_clk) and read on a completely unrelated clock (rd_clk) — different frequency, different phase, no fixed relationship at all. This is extremely common in real chips: crossing from a fast core clock domain to a slower peripheral clock domain, for example.

The problem: to know whether the FIFO is full, the write side needs to know where the read pointer currently is. But the read pointer is a signal generated in a different clock domain. Sampling a signal from a foreign clock domain directly is exactly the setup that causes metastability — the sampling flip-flop can, for a brief window, settle to neither a clean 0 nor a clean 1, and propagate an unpredictable value downstream.

WRITE DOMAIN — wr_clk wptr_gray READ DOMAIN — rd_clk rptr_gray 2-FLOP SYNCHRONIZER
Each pointer is synchronized into the other clock domain before it's compared there.

Why gray code specifically, and not just a synchronizer

A two-flop synchronizer resolves metastability for a single bit reasonably reliably. But a pointer is multiple bits wide, and in ordinary binary counting, incrementing can flip several bits at once — for example 0111 → 1000 flips all four bits together. If a synchronizer samples that transition at the wrong instant, different bits can resolve at different times, and the value it captures might momentarily be neither 0111 nor 1000, but something like 1111 or 0000 — an error far larger than "off by one."

Gray code sidesteps this by construction: consecutive Gray-coded values always differ by exactly one bit. So even in the worst-case sampling instant, a synchronizer can only ever be uncertain about that one changing bit — the captured value is guaranteed to be either the old pointer value or the new one, never something further away. That's the entire reason both pointers are kept in Gray code before they're synchronized across domains, and only converted back to binary (if needed at all) after synchronization.

gray code conversion — the actual trick // binary to gray: XOR each bit with the bit above it function bit [3:0] bin2gray(bit [3:0] bin); return bin ^ (bin >> 1); endfunction // two-flop synchronizer, read domain capturing the write pointer always_ff @(posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) begin wptr_gray_r1 <= '0; wptr_gray_r2 <= '0; end else begin wptr_gray_r1 <= wptr_gray; // first flop: may go metastable wptr_gray_r2 <= wptr_gray_r1; // second flop: has settled by now end end

How full and empty actually get decided

Both pointers are usually made one bit wider than strictly needed to address the memory. That extra bit doesn't address anything — it exists purely to distinguish "the pointers are equal because the FIFO is empty" from "the pointers are equal because the write pointer has wrapped all the way around and lapped the read pointer, so the FIFO is full." The read logic compares its local read pointer against the synchronized write pointer to decide empty; the write logic compares its local write pointer against the synchronized read pointer to decide full — each side is always working with a slightly stale, safely-synchronized view of the other side's pointer, never the live one.

Takeaway

An async FIFO's whole design exists to answer one question safely across two unrelated clocks: "has the other side's pointer caught up to mine?" Gray coding makes a multi-bit pointer safe to synchronize one bit's worth of uncertainty at a time; the extra wrap bit is what lets equal-pointer values mean two different things (empty vs. full) without ambiguity.

Part 9 turns this theory into a verification plan: what specifically has to be proven about this design before anyone should trust it.