A dual-clock asynchronous FIFO designed for safe data transfer between independent clock domains, verified with protocol assertions and a self-checking reference model across 7 test scenarios.
In any multi-clock SoC — sensor interfaces, USB PHYs, cross-domain bus bridges — data must move between clock domains that have no phase relationship. Naively connecting a signal from one domain to another causes metastability: a flip-flop's output can sit at an indeterminate voltage between logic 0 and 1, potentially propagating corruption through downstream logic.
For single-bit signals, a 2-flip-flop synchronizer provides enough resolution time. But for multi-bit data buses, synchronizing each bit independently fails — different bits may be captured at different instants, producing a corrupted value that never existed in the source domain.
The asynchronous FIFO solves this by decoupling the data path from the control path:
- Data passes through a dual-port RAM (no synchronization needed — each port is accessed by its own clock).
- Control (read/write pointers) crosses domains encoded in Gray code, where at most 1 bit changes per increment — guaranteeing the synchronized pointer is always a valid old or new value, never garbage.
┌─────────────────────────────────────────────────────────────┐
│ async_fifo │
│ │
│ WR_CLK DOMAIN RD_CLK DOMAIN │
│ │
wr_data ─────►│ ┌────────────┐ ┌────────────┐ │
wr_en ──────►│ │ WR Pointer │ wr_gray │ 2-FF Sync │ │
│ │ Binary + ├──────────────────►│ (w → r) ├─┐ │
│ │ Gray Code │ └────────────┘ │ │
│ └─────┬──────┘ │ │
│ │ wr_addr wr_gray_sync │ │
│ ▼ │ │ │
│ ┌─────────────────────────────────┐ ▼ │ │
│ │ Dual-Port RAM │ ┌────────────┐ │
│ │ │ │ EMPTY │ │
│ │ Write Port Read Port │ │ Compare ├──►│──► empty
│ │ (wr_clk) (rd_clk) ─────┼─►│ rd_gray │ │
│ └─────────────────────────────────┘ │ _next == │ │
│ │ │ wr_gray │ │
│ ▲ │ │ _sync? │ │
│ │ rd_addr │ └────────────┘ │
│ ┌─────────────┐ │ ▲ │
full ◄───────│ │ FULL │◄──┐ │ │ │──► rd_data
│ │ Compare │ │ │ ┌────────────┐ │ │
│ │ wr_gray │ │ rd_gray │ │ RD Pointer │ │ │
│ │ _next == │ └─────────────┼───┤ Binary + ├─┘ │◄── rd_en
│ │ ~rd_gray │ rd_gray_sync │ │ Gray Code │ │
│ │ _sync? │ │ └────────────┘ │
│ └────────────┘ ┌────────────┐│ │
│ │ 2-FF Sync ├┘ │
│ │ (r → w) │ │
│ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
| Path | What Crosses | Encoding | Why Safe |
|---|---|---|---|
| Write ptr → Read domain | wr_gray (registered) |
Gray code | ≤1 bit changes per cycle; synchronizer captures valid old or new count |
| Read ptr → Write domain | rd_gray (registered) |
Gray code | Same guarantee |
| Write data → Read data | Through dual-port RAM | Raw binary | No crossing — each port uses its own clock |
Problem: A binary counter changing from 0111 → 1000 flips all 4 bits simultaneously. A 2-FF synchronizer sampling mid-transition could capture 0000, 1100, 0110, or any transient combination — a completely invalid count.
Solution: Gray code changes exactly 1 bit per increment (0100 → 0110). The worst-case capture is the old value or the new value — both valid.
Binary: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
────────────────────────────────────────────────────
Gray: 0 1 3 2 6 7 5 4 12 13 15 14 10 11 9 8
Conversion: gray = binary ^ (binary >> 1)
The synchronized pointer is always 2 clock cycles behind the real pointer (due to the 2-FF synchronizer latency). This means:
| Flag | Conservative Behavior | Safety Implication |
|---|---|---|
| Full | May assert when there are actually 1-2 free slots | Throughput penalty (slight under-utilization), but never overflows |
| Empty | May assert when there are actually 1-2 available entries | Slight read latency, but never underflows |
This is a deliberate design choice, not a limitation. In CDC design, a safe false alarm is always preferable to a missed true alarm.
In binary, full = "write pointer is one full wrap ahead of read pointer." In Gray code, this translates to:
Full condition: wr_gray_next[N:N-1] == ~rd_gray_sync[N:N-1] (top 2 bits inverted)
wr_gray_next[N-2:0] == rd_gray_sync[N-2:0] (remaining bits equal)
The top-2-bit inversion is unique to Gray code and distinguishes the "wrapped around" state (full) from the "same position" state (empty).
| Choice | Pro | Con |
|---|---|---|
| Synchronous read ✓ | Breaks timing path on read side; better Fmax | 1-cycle read latency |
| Combinational read | Zero latency; simpler testbench | Longer critical path; harder to close timing |
Chose synchronous read — real ASICs prioritize timing closure. The 1-cycle latency is documented and accounted for in the testbench.
The extra MSB distinguishes "full" from "empty." Without it, both conditions look identical (both pointers at the same address). With it, full means "same address but different wrap-around count."
FIFO depth = 8 (ADDR_WIDTH = 3)
Pointer width = 4 bits: [3:0]
Empty: wr_gray == rd_gray → same address, same wrap count
Full: wr_gray == {~rd_gray[3:2], → same address, different wrap count
rd_gray[1:0]}
Verification is structured in three layers, each catching a different class of bugs:
A software queue in the testbench mirrors the FIFO's expected behavior:
Write accepted (wr_en & ~full): push wr_data into reference queue
Read accepted (rd_en & ~empty): pop from reference queue, compare with rd_data
This catches any data corruption or ordering violation — the two fundamental correctness properties of a FIFO.
A 1-cycle pipeline register (rd_was_accepted) accounts for the synchronous RAM's read latency, ensuring comparisons happen when rd_data is valid.
Result: 220 data checks, 0 mismatches.
Continuously-monitoring assertions that check invariants — properties that must hold on every single clock cycle, regardless of stimulus:
| Assertion | What It Guards Against | Implementation |
|---|---|---|
| Gray single-bit transition | Gray pointer changing >1 bit/cycle → CDC safety guarantee void | popcount(gray ^ prev_gray) <= 1 on every posedge |
| No write when full | Write pointer advancing despite full → memory overwrite | if (full && wr_en) → pointer must not change |
| No read when empty | Read pointer advancing despite empty → stale data returned | if (empty && rd_en) → pointer must not change |
All assertions include reset-aware logic: saved state is cleared during reset to prevent false violations when pointers are asynchronously forced to zero.
Result: 0 assertion violations across all scenarios.
Seven distinct test scenarios exercise different operating conditions:
| # | Scenario | What It Exercises | Edge Case Targeted |
|---|---|---|---|
| 1 | Single write → read | Basic datapath correctness | Sanity baseline |
| 2 | Fill to full (8 writes) | Full flag assertion timing | Off-by-one in pointer wrap |
| 3 | Drain to empty (8 reads) | Empty flag + full de-assertion | Cross-domain flag propagation delay |
| 4 | Concurrent R+W (200 txns) | Steady-state with backpressure | Race between writer stalling on full and reader draining |
| 5 | Write when full | Full flag must block writes | Pointer must not advance; data must not corrupt |
| 6 | Read when empty | Empty flag must block reads | Pointer must not advance |
| 7 | Reset during active transfer | Async reset correctness | Pointers must zero; flags must re-initialize; FIFO must work after reset |
Result: 7/7 scenarios exercised and passed.
Write and read clocks use prime-ratio periods to prevent phase-locking and ensure diverse sampling relationships:
wr_clk: 14ns period (~71 MHz)
rd_clk: 22ns period (~45 MHz)
Ratio: ~1.57:1 (write faster than read — stresses full flag)
| Metric | Value | Notes |
|---|---|---|
| Total data integrity checks | 220 | Via reference queue comparison |
| Data mismatches | 0 | Across all test scenarios |
| Gray-code assertion violations | 0 | Monitored every cycle in both domains |
| Write-when-full violations | 0 | Pointer stability verified |
| Read-when-empty violations | 0 | Pointer stability verified |
| Test scenarios passed | 7 / 7 | Including concurrent and reset-during-transfer |
| Clock ratios tested | 1 | wr=71MHz, rd=45MHz (wr >> rd) |
| Cell count (DEPTH=8) | Run make synth |
Requires Yosys |
| Flip-flop count | Run make synth |
Requires Yosys |
Being honest about what this project does and doesn't cover:
| Limitation | Why It Exists | What Would Fix It |
|---|---|---|
| No structural CDC analysis | Requires commercial tools (SpyGlass, Meridian) | University SpyGlass license or SpyGlass CDC lint |
| Metastability not simulated | Metastability is an analog phenomenon; RTL simulation cannot model flip-flop regeneration time | SPICE-level analysis with actual cell models |
| Single clock-ratio tested | Time constraint | Add 4 more ratios: rd>>wr, rd≈wr, rd=10×wr, wr=10×rd |
| No FIFO occupancy counter | Adds complexity (Gray-to-binary conversion needed) | Expose fifo_count output for flow-control |
| No almost-full / almost-empty | Requires programmable watermark thresholds | Add afull_thresh / aempty_thresh parameters |
| Depth must be power-of-2 | Gray-code pointer wrapping assumes power-of-2 address space | Non-power-of-2 requires different pointer encoding |
cdc-async-fifo/
├── rtl/
│ ├── sync_2ff.v # 2-FF synchronizer (parameterized width)
│ ├── dual_port_ram.v # Dual-clock RAM (sync write + sync read)
│ └── async_fifo.v # Top: pointers, Gray encoding, full/empty
├── tb/
│ └── async_fifo_tb.v # Self-checking TB: ref queue + 3 assertions
├── syn/
│ └── synth.ys # Yosys synthesis script (generic gates)
├── Makefile # make sim | make wave | make synth | make clean
├── .gitignore
└── README.md
Total RTL: ~180 lines (3 modules) | Total testbench: ~600 lines (7 scenarios, 3 assertions, reference model)