|

Async FIFO Verification, Part 1: The Design, Gray Pointers and a Starter UVM Bench

In short: An asynchronous FIFO moves data between two blocks that run on different, unrelated clocks. The writer fills it on one clock and the reader empties it on another. The hard part is the two flags, full and empty, because each side has to learn where the other side’s pointer is across a clock boundary. The standard design sends Gray-coded pointers through two-flop synchronizers, so the flags are always safe, just a little late. This part builds that design, writes the verification plan a reviewer would expect, and runs a starter UVM bench on VCS that proves the test really reached full and empty. Parts 2 and 3 go after the bugs that only show up across clocks.

This is the first part of a three-part series. It’s also the base for the Async FIFO Verification Lab (more at the end).

The analogy: a conveyor belt between two shifts

Picture a bakery. The night shift loads trays onto a short conveyor belt. The morning shift takes them off. The two shifts never meet, and their watches don’t agree.

  • The belt is the FIFO memory. It holds 16 trays.
  • The loader’s counter is the write pointer. The unloader’s counter is the read pointer.
  • “Belt full, stop loading” is wfull. “Belt empty, nothing to take” is rempty.
  • Passing a note to the other shift is the clock-domain crossing. Each shift only sees the other’s count through a note that takes a moment to arrive.

The rule that keeps it safe: if your note is a little old, act as if the belt is fuller (for the loader) or emptier (for the unloader) than it really is. You lose a moment, but you never drop a tray or grab one that isn’t there.

Why it matters in a real project

  • Almost every SoC has asynchronous FIFOs: between a CPU cluster and a slower bus, between a PHY and the controller, between any two blocks on separate clocks.
  • They are small, so they get less review time than they deserve.
  • Their worst bugs depend on the clock ratio and on timing, so a single passing test on one ratio proves very little.

Why async FIFOs are hard: two clocks, one set of pointers

Inside one clock domain, a FIFO is easy. Keep a write pointer and a read pointer. Full means “the writer is a whole lap ahead”, and empty means “they’re equal”. The trouble starts when the writer and the reader run on different clocks.

Problem 1: metastability

When a flop samples a signal that changes right at its clock edge, it can settle late, or settle to the wrong value. That is metastability. You can’t remove it, but you can make it very unlikely to cause harm. The usual way is two flops in a row on the destination clock, called a two-flop synchronizer. The first flop may go metastable, and it has a whole clock cycle to settle before the second flop samples it.

  • Analogy: a note passed between shifts goes through a tray first. You only read it after it has sat in the tray for a moment, so you never read it while it’s still being written.

Problem 2: a multi-bit pointer changes many bits at once

A two-flop synchronizer works on one bit. A 5-bit binary pointer going from 7 (00111) to 8 (01000) changes four bits in one step. If the other clock samples in the middle, each bit can land on its old or new value. The reader could see any mix of the two, like 01111 (15), which was never the real pointer.

Gray code fixes this. Counting in Gray code changes exactly one bit per step. If the sample lands mid-change, the receiver sees either the old value or the new one. Both are real pointer values, so the worst case is “a little old”.

  • Analogy: an odometer that can only flip one digit at a time. Glance at it mid-flip and you read either the old mileage or the new one, never nonsense.
Async FIFO structure: write and read clock domains, Gray-coded pointers crossing through two-flop synchronizers, full and empty compares, and the shared memory
Only Gray-coded pointers cross. Each flag compares against a pointer that is about 2 cycles old.

Problem 3: the flags are always late, and that’s on purpose

The write side compares its own pointer with a copy of the read pointer that’s about two write-clock cycles old. In that time the reader may have taken more items. So wfull can stay high a little after space opens up. In the same way, rempty can stay high a little after new data lands.

This is called pessimism, and it is correct behaviour. A late flag only costs a few cycles. An early flag would lose or invent data. When you write checks, you have to allow for this delay. A check that says “full must drop the moment a read happens” is wrong, and it will flag a correct design.

Why the pointers have one extra bit

A 16-entry FIFO needs 4 address bits, but the pointers are 5 bits. The extra top bit counts laps:

  • Empty: both pointers are equal, all 5 bits.
  • Full: the addresses match, but the writer is one lap ahead, so the top bit differs.

In Gray code, “one lap ahead” shows up as the top two bits inverted and the rest equal. That’s the full compare in the design below. This is the structure in Clifford Cummings’ widely cited paper on asynchronous FIFO design (linked in the references).

The design used in this series

It’s a 16-deep, 8-bit FIFO in the classic style: binary pointers for addressing, Gray pointers for crossing, and two-flop synchronizers in each direction. Read data is combinational, so rdata always shows the word at the head of the FIFO, and a read pops it on the next rclk edge.

module async_fifo #(parameter int DSIZE = 8, parameter int ASIZE = 4) (
  input  logic             wclk, wrst_n, winc,
  input  logic [DSIZE-1:0] wdata,
  output logic             wfull,
  input  logic             rclk, rrst_n, rinc,
  output logic [DSIZE-1:0] rdata,
  output logic             rempty
);
  localparam int DEPTH = 1 << ASIZE;

  logic [DSIZE-1:0] mem [DEPTH];
  logic [ASIZE:0]   wbin, wptr, rbin, rptr;          // binary and Gray, one extra wrap bit
  logic [ASIZE:0]   wq1_rptr, wq2_rptr, rq1_wptr, rq2_wptr;
  logic [ASIZE:0]   wbinnext, wgraynext, rbinnext, rgraynext;
  logic [ASIZE-1:0] waddr, raddr;
  logic             wfull_val, rempty_val;

  // memory: write on wclk, read is combinational
  assign rdata = mem[raddr];
  always_ff @(posedge wclk) if (winc && !wfull) mem[waddr] <= wdata;

  // two-flop synchronizers: Gray pointers cross, binary never does
  always_ff @(posedge wclk or negedge wrst_n)
    if (!wrst_n) {wq2_rptr, wq1_rptr} <= '0;
    else         {wq2_rptr, wq1_rptr} <= {wq1_rptr, rptr};

  always_ff @(posedge rclk or negedge rrst_n)
    if (!rrst_n) {rq2_wptr, rq1_wptr} <= '0;
    else         {rq2_wptr, rq1_wptr} <= {rq1_wptr, wptr};

  // write pointer and full flag
  assign waddr     = wbin[ASIZE-1:0];
  assign wbinnext  = wbin + (winc & ~wfull);
  assign wgraynext = (wbinnext >> 1) ^ wbinnext;
  assign wfull_val = (wgraynext == {~wq2_rptr[ASIZE:ASIZE-1], wq2_rptr[ASIZE-2:0]});

  always_ff @(posedge wclk or negedge wrst_n)
    if (!wrst_n) begin wbin <= '0; wptr <= '0; wfull <= 1'b0; end
    else         begin wbin <= wbinnext; wptr <= wgraynext; wfull <= wfull_val; end

  // read pointer and empty flag
  assign raddr      = rbin[ASIZE-1:0];
  assign rbinnext   = rbin + (rinc & ~rempty);
  assign rgraynext  = (rbinnext >> 1) ^ rbinnext;
  assign rempty_val = (rgraynext == rq2_wptr);

  always_ff @(posedge rclk or negedge rrst_n)
    if (!rrst_n) begin rbin <= '0; rptr <= '0; rempty <= 1'b1; end
    else         begin rbin <= rbinnext; rptr <= rgraynext; rempty <= rempty_val; end
endmodule

Five lines carry the whole idea:

  • (wbinnext >> 1) ^ wbinnext converts binary to Gray. The pointer is kept in both forms, and only the Gray form leaves the domain.
  • winc & ~wfull and rinc & ~rempty are the guards. A write while full and a read while empty must both be ignored. Planted bug 1 in this part removes the second guard.
  • The full compare inverts the top two Gray bits of the synced read pointer: same position, one lap ahead.
  • The empty compare is an exact match with the synced write pointer.
  • Both flags are registered from the *next* pointer value. That way a flag is right on the cycle after the write or read that changed it, instead of one cycle later.

The demo file also has a small bug switch inside the design so you can plant a bug from the command line. A real design wouldn’t have that, and it isn’t shown above.

What to verify: the plan before the bench

In a design review, the first question about a FIFO bench is rarely “does it pass?” It’s “what did it prove?” Write the plan first. Each row needs a check that can fail, and a way to show the condition was actually reached.

#FeatureWhat must holdHow it’s checkedCovered in
1Data integrityEvery word written comes out once, in order, unchangedIn-order scoreboard queuePart 1
2FullA write while full is ignored, nothing is overwrittenScoreboard, plus a count of writes held off by fullPart 1
3EmptyA read while empty is ignored, no word is inventedScoreboard underflow check, plus a count of reads asked while emptyPart 1
4Clock ratiosRows 1 to 3 hold for a fast writer, a fast reader and near-equal clocksClock periods as plusargs, one run per ratioPart 1 (two ratios), more in part 2
5Flag timingFlags may be late (pessimistic), never earlyAssertions with an allowed windowPart 2
6Gray pointersEach pointer changes at most one bit per clockAssertion on the Gray pointerPart 2
7ResetEach side can be reset safely; pointers and flags come out rightDirected reset testsPart 3
8CoverageFull reached, empty reached, pointers wrapped, every fill level seenCounters now, covergroups laterPart 1 (counters), part 3
9CDC structureOnly Gray pointers cross; every crossing has a synchronizerStatic CDC tool, not simulationMentioned only

Two things a reviewer will look for:

  • Proof that full and empty happened. A FIFO test that never fills the FIFO says nothing about the full logic. That’s why the bench prints counters, not just “0 errors”.
  • Honesty about what simulation can’t show. RTL simulation doesn’t model metastability. A two-flop synchronizer in simulation is just two flops. Whether the design is CDC-safe is answered by structure (row 9) and by static CDC tools, not by a passing test. Saying that out loud in a review earns trust.
  • Analogy: a passing test is a car that drove fine around the block. The plan is the checklist that says whether you also tried the brakes, the reverse gear and a full tank.

A starter UVM bench for two clocks

Async FIFO starter UVM bench: write sequence and driver on wclk, read driver on rclk, a monitor on each side, both feeding an in-order scoreboard
Each side has its own driver and monitor on its own clock; one scoreboard compares what went in with what came out.

The bench is kept flat on purpose: one env with a sequencer, two drivers, two monitors and a scoreboard. In a project you would wrap each side in an agent. The ideas don’t change, and flat is easier to read the first time.

The write side: hold the request until the FIFO takes it

vif.winc <= 1; vif.wdata <= req.data;
do @(posedge vif.wclk); while (vif.wfull === 1'b1);   // accepted on an edge where full was low
seq_item_port.item_done();

The driver puts the word on the pins and keeps winc high until it sees a clock edge where wfull was low. That’s the handshake a real producer uses. The monitor uses the same rule: a write counts only when winc is high and wfull is low on the same edge.

The sequence sends traffic in three moods: a burst with no gaps (to fill the FIFO), normal traffic, and sparse traffic with 4 to 8 idle cycles (to let it run empty). The sparse part switches off the default dist on idle first:

req.c_idle.constraint_mode(0);           // the dist would clash with idle in [4:8]
if (!req.randomize() with { idle inside {[4:8]}; }) `uvm_fatal("SEQ", "randomize failed (sparse)")

Without that line, randomization would fail every time. That’s cause 3 in why SystemVerilog randomization fails.

The read side: ask for reads even when empty

The read driver raises rinc at random on each rclk, including while rempty is high. That’s legal stimulus: the spec says a read while empty must be ignored, so the bench should try it. A reader that politely waits for !rempty would never test that guard.

forever begin
  @(negedge vif.rclk);
  vif.rinc <= ($urandom_range(99) < rate);
end

The read monitor samples on the rclk edge. A pop happens when rinc is high and rempty is low, and the word popped is the rdata value at that edge.

The scoreboard: one queue

function void write_wr(fifo_txn t);
  q.push_back(t.data); n_wr++;
  if (q.size() > max_level) max_level = q.size();
endfunction

function void write_rd(fifo_txn t);
  logic [7:0] exp;
  n_rd++;
  if (q.size() == 0) begin
    n_under++;
    if (n_mis + n_under <= 5)
      `uvm_error("SB", $sformatf("read #%0d returned %0h but nothing was waiting (underflow)", t.n, t.data))
    return;
  end
  exp = q.pop_front();
  if (t.data !== exp) begin
    n_mis++;
    if (n_mis + n_under <= 5)
      `uvm_error("SB", $sformatf("read #%0d: got %0h, expected %0h", t.n, t.data, exp))
  end
endfunction

A FIFO’s reference model is just a queue. Two details matter more than they look:

  • logic data and !== instead of !=. An unwritten memory word reads as X. A bit field would turn it into 0, and != against X gives X, so the if is false. Either one hides the error. Run C below shows what happens when the field is bit.
  • Only the first 5 errors are printed. A broken FIFO fails on almost every read. Five errors tell you everything, and the rest would only bury the summary line. A final error states the total, so the UVM error count can’t be mistaken for “only 5”.

Proof that full and empty happened

The monitors count the moments that matter and print them at the end:

  • writes held off because the FIFO was full
  • read requests that arrived while the FIFO was empty
  • the highest fill level the scoreboard ever saw

If any of these is 0, the run didn’t test that feature, however green it looks.

The complete file (design, interface, bench and top) is in the download below.

Run it: four runs on VCS

vcs -full64 -sverilog -ntb_opts uvm-1.2 async_fifo_p1.sv -R +BUG=0
RunOptionsWhat it shows
A+BUG=0Clean, slow reader (wclk 10 ns, rclk 17 ns). Full should be hit often.
B+BUG=0 +WCLK_NS=23 +RCLK_NS=10Clean, fast reader. Empty should be hit often.
C+BUG=1Design bug: a read while empty still moves the read pointer.
D+BUG=2Bench bug: the read monitor samples rdata just after the clock edge.

Every log starts with async_fifo_p1: planted bug = N, wclk = .. ns, rclk = .. ns, so you can check that the options reached the run.

What VCS printed

All runs use the same seed and send 300 writes. Runs C and D were run at both clock ratios.

Runwclk / rclkRead errorsReadsUnderflowMax levelWrites held off by fullReads asked while empty
A, clean10 / 17 ns0300016338101
B, clean23 / 10 ns0300090872
C, bug 110 / 17 ns39039393173348
C, bug 123 / 10 ns112811358358540
D, bug 210 / 17 ns299300016338101
D, bug 223 / 10 ns299300090872

Run A is the clean baseline. The FIFO filled to all 16 entries, 338 writes waited for space, and 101 reads arrived while it was empty and were correctly ignored.

Run B is green, and it never tested full. With a fast reader, the FIFO never got past 9 entries. wfull was never high, and no write ever waited. If B were your only test, the full logic would ship untested. That’s why the bench prints counters, and why you need more than one clock ratio.

Counters for a clean run, a design bug and a bench bug: identical first errors, but the design bug shows 393 reads, 93 underflows and a fill level of 17
Same first errors, different counters. Run D matches the clean run; run C is impossible for a correct FIFO.

Runs C and D at the default ratio print the same first five errors:

UVM_ERROR ... [SB] read #0: got 89, expected c6
UVM_ERROR ... [SB] read #1: got b2, expected 89
UVM_ERROR ... [SB] read #2: got 96, expected b2

Every read returns the next word. From the errors alone, you can’t tell a design bug from a bench bug. The counters can:

  • Run D’s counters match run A exactly: 300 reads, 0 underflow, max level 16, 101 reads asked while empty. The FIFO behaved. Only what the monitor sampled changed. That points at the bench.
  • Run C’s counters are impossible for a correct FIFO: 393 reads for 300 writes, and a max level of 17 in a 16-entry FIFO. The read side popped words that weren’t there. That points at the design, and at the empty guard in particular.

Run C with a fast reader is louder:

UVM_ERROR ... [SB] read #0: got xx, expected c6

The reader asks for reads before anything is written. The broken guard moves the read pointer anyway, so the FIFO hands out memory that was never written: X. 835 of the 1135 reads were underflows. Two counters also look strange. Only 40 reads arrived while empty (the clean run had 872), and full was seen even though the bench never held more than 8 items. Both happen because the read pointer ran a lap ahead, so the flags stopped meaning what they say.

A bench bug we found on the way. The first time we ran C with a fast reader, the log said got 0, not got xx. The monitor stored the data in a bit [7:0] field, which turned X into 0 before the scoreboard ever saw it. A 0 looks like real data, so the error looked like a wrong value instead of a read of empty memory. The fix was one word: make the field logic [7:0]. The download has the fixed version.

Run it yourself

The download is one file, async_fifo_p1.sv, with the design, interface, bench and top. On EDA Playground, choose Synopsys VCS with UVM 1.2 and put all options for a run in Run Options. Try reading only the counters of runs C and D before you look at the errors, and see whether you can name the bug.

Common mistakes, from real forum threads

1. One driver for both sides, with the reads and writes interleaved

  • What happens: a single driver that alternates reads and writes never produces a read and a write in the same instant. In a Verification Academy thread on async FIFO verification, replies make exactly this point: interleaving is not simultaneity.
  • Fix 1: give each side its own driver running on its own clock, as in this bench.
  • Fix 2: if you do use one agent type, instantiate it twice, once per port.

2. Generating the clocks inside a class

  • What happens: clocks built in a UVM component start and stop with phases, and they’re awkward to change per test. The same thread advises against it.
  • Fix: generate clocks in the top module or the interface, and take the periods from plusargs or config, as here (+WCLK_NS, +RCLK_NS).

3. Expecting the flags to react on the very next cycle

  • What happens: a check says “after a read, wfull must drop next cycle”. The design is correct, the check fails. The flags are late by design, as described above. Ben Cohen makes the same point about pessimistic full and empty flags in that thread.
  • Fix 1: check the safe direction strictly: no write is accepted while really full, no read while really empty. The scoreboard does that.
  • Fix 2: check the late direction with a window: the flag must clear within a few clocks of the other domain. Part 2 adds these assertions.

4. A reader that never asks while empty

  • What happens: the read driver waits for !rempty before asking. The empty guard in the design is never tested. Planted bug 1 would pass forever.
  • Fix: let the reader ask at random, and count how often it asked while empty. Do the same for writes while full.

5. A green run that never reached full

  • What happens: with a fast reader and a slow writer, the FIFO never fills. The full logic isn’t exercised, and nobody notices because nothing failed.
  • Fix 1: print counters (as here) or add cover properties, as also suggested in another Verification Academy thread.
  • Fix 2: run more than one clock ratio. Run A fills the FIFO, run B drains it.

6. Letting an X turn into 0 before the compare

  • What happens: an underflow reads memory that was never written, so the data is X. If the monitor stores it in a bit field, the X quietly becomes 0. The error then says got 0, which looks like real data. It happened in this very bench (see run C above).
  • Fix 1: keep data 4-state (logic) from the pins all the way to the scoreboard.
  • Fix 2: compare with !==. With !=, comparing against X gives X, the if is false, and the error is skipped.

How AI helps (and where it doesn’t)

  • A first draft of the plan. Ask for a verification plan for a dual-clock FIFO and you’ll get most of the rows in the plan table. Then check what’s missing. Clock ratios, flag pessimism and “proof that full happened” are the rows a draft most often leaves out.
  • Boilerplate. The queue scoreboard, the analysis imps and the counters are routine code. An AI writes them fast, and you review them.
  • Where it doesn’t help: deciding how late a flag is allowed to be. That number comes from the synchronizer depth and the clock ratio in your design, and the designer has to agree with it. An AI-written check will often demand an exact cycle, and fail a correct design. It also can’t tell you whether the design is CDC-safe. That takes the structure review and a CDC tool.

More in AI in ASIC verification and the AI-written vs hand-written UVM testbench experiment.

What’s next in the series

  • Part 2 (January): assertions for the flags, with the allowed lateness written down; a Gray-pointer check; more clock ratios; and why a passing simulation still says nothing about metastability.
  • Part 3 (February): reset on one side at a time, coverage that proves every fill level and pointer wrap, and a sign-off checklist.

The Async FIFO Verification Lab

Parts 1 to 3 teach the method. The lab is where you practise it: hands-on bug hunts on this same FIFO, each with a planted bug from a real project pattern, a self-check script, and a senior reviewer’s notes on how to find it. It’s built as a two-week project sprint, so you finish with work you can talk about in an interview.

Want early access? Join the lab waitlist below. Waitlist members hear first when the pilot opens, and get the early-access price.

Async FIFO Lab waitlist. Leave your email and you will hear first when the pilot opens. No spam, unsubscribe any time.

Prefer another way in? Subscribe to the newsletter for every new guide, or talk to me on Topmate.

FAQ

Why not just use a binary pointer with a two-flop synchronizer? Several bits of a binary pointer can change on one clock. The other domain can sample a mix of old and new bits and see a pointer value that never existed. With Gray code only one bit changes per step, so a late sample is still a real value.

Why is the FIFO depth a power of two? So the Gray pointer wraps cleanly. Odd depths are possible, but they need a different pointer encoding.

Why does wfull stay high after a read? The write side sees the read pointer through a two-flop synchronizer, so it learns about the read a couple of write clocks later. That delay is safe. It’s called pessimism.

Can simulation prove my FIFO is CDC-safe? No. RTL simulation doesn’t model metastability. Simulation proves the logic; a structure review and a static CDC tool prove the crossings.

Do I need two UVM agents? You need two drivers and two monitors, each on its own clock. Whether you wrap them in one agent type used twice or in two agents is a style choice. The forum threads in the references argue both ways.

Can one driver and one monitor handle both sides? Yes, but only if each side runs in its own forked thread on its own clock. What fails is one sequential loop that does a write, then a read. Writes and reads can never overlap, the clocks get tied to one rhythm, and when the writer waits on wfull, the reads that would make space are blocked too, so the test hangs or skips the case that matters. A single monitor that samples both sides on one clock misses or double-counts pops. A forked single component is fine, it’s just two drivers in one class. Teams usually keep them separate anyway: one agent class used twice lets each side be active or passive on its own (useful at SoC level when real RTL drives one side), gives each side its own sequencer, and keeps the log clear about which clock failed. For a synchronous FIFO, one driver is the natural choice, because there’s only one clock.

How many clock ratios should I run? At least three: a fast writer, a fast reader, and clocks that are close but not equal. This part runs the first two.

References

Read next

Leave a Reply

Your email address will not be published. Required fields are marked *