Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CDC-Safe Asynchronous FIFO with Gray-Code Pointer Synchronization

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.


The Problem

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.

Architecture

                ┌─────────────────────────────────────────────────────────────┐
                │                      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)    │                        │
                │                    └────────────┘                        │
                │                                                            │
                └─────────────────────────────────────────────────────────────┘

Signal Flow Summary

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

Design Decisions & Trade-Offs

1. Gray-Code Pointers (vs. Binary)

Problem: A binary counter changing from 01111000 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 (01000110). 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)

2. Conservative Full/Empty Flags

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.

3. Full Detection in Gray Code

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).

4. Synchronous RAM Read (vs. Combinational)

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.

5. Pointer Width = ADDR_WIDTH + 1

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 Methodology

Verification is structured in three layers, each catching a different class of bugs:

Layer 1: Self-Checking Reference Model

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.

Layer 2: Protocol Assertions (Procedural)

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.

Layer 3: Scenario Coverage

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.

Clock Configuration

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)

Metrics

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

Known Limitations & Future Work

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

File Structure

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)

About

Parameterized dual-clock asynchronous FIFO designed in Verilog for safe Clock Domain Crossing (CDC). Implements Gray-code pointer encoding, 2-flip-flop synchronizers, and conservative full/empty flag logic following Cummings SNUG 2002 architecture. Verified with a 600-line self-checking reference testbench and SVA protocol assertions across 7 test.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages