Skip to content

RTL Design Patterns · Chapter 13 · Case Studies

Dual-Clock Streaming via Async FIFO

This final case study proves that the three hardest ideas of the track, clock-domain crossing, the handshake, and the FIFO, snap together into one block you can trust. A producer streams words on one clock, a consumer takes them on an unrelated clock, the oscillators drift, and no word may be lost or duplicated. You already own every piece. The asynchronous FIFO does the crossing with Gray pointers, two-flop synchronizers, and conservative full and empty flags so it never overflows or underflows. The valid/ready handshake is how each side talks to the FIFO locally, so the writer treats full as backpressure and the reader treats empty as its flow condition, and each domain sees an ordinary single-clock handshake. The capstone teaches the discipline of composition, where each flag is evaluated in its own domain. You will integrate it, break it two ways, and fix both, verified in SystemVerilog, Verilog, and VHDL.

Advanced20 min readRTL Design PatternsClock Domain CrossingAsynchronous FIFOvalid/readyBackpressureConservative FlagsDual-Clock Streaming

Chapter 13 · Section 13.5 · Case Studies

1. The Engineering Problem

You have a producer that generates a stream of words on clk_a and a consumer that takes them on clk_b, and the two clocks share no relationship — different sources, different frequencies, drifting phase. A video front-end running at pixel rate feeds a memory controller running at DRAM rate; a network MAC at line rate hands packets to a fabric at core rate; a sensor pipeline at one PLL output streams into a DSP at another. In every case the shape is identical: a stream must cross from one clock domain to another, and the contract is absolute — every word arrives exactly once, in order, and none is lost or duplicated, no matter how the two rates differ or drift.

You cannot just register the data bus from clk_a into clk_b. That is the trap Chapter 11 spent itself proving: a multi-bit value sampled by an unrelated clock can be caught mid-transition and resolve to a phantom the source never held, and a single-cycle producer strobe can be missed entirely by a slower consumer clock. Direct sampling across an asynchronous boundary loses data or invents it. And you cannot make the two sides take turns, because neither controls the other's schedule — the producer emits when its work is done, the consumer accepts when it has room, and those events do not line up. A fast producer bursting into a slow consumer must be absorbed somewhere; a slow producer feeding a fast consumer must let the consumer wait without reading garbage.

So the requirement decomposes into three that you have already solved separately. You need a buffer that absorbs the rate mismatch and preserves order — a FIFO (7.1). You need that FIFO to work across two clocks without corrupting a flag or a pointer — the asynchronous FIFO (11.6), whose gray pointers and synchronizers make its full/empty flags conservative so it never overflows or underflows. And you need each side to talk to that buffer with a clean, local, composable interface that carries backpressure and flow — the valid/ready handshake (9.1). The capstone is wiring exactly these three together so the producer in clk_a and the consumer in clk_b each see nothing but an ordinary handshake, while the crossing is hidden inside the FIFO. Nothing new is invented; the whole skill is where each piece goes and which domain each flag lives in.

the-need.v — a stream must cross two unrelated clocks, exactly once, in order
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // clk_a and clk_b are UNRELATED. A producer streams words in clk_a; a consumer
   // takes them in clk_b. Not one word may be lost or duplicated across the boundary.
 
   //   producer (clk_a):  in_valid / in_ready / in_data     <- writes when NOT full
   //   consumer (clk_b):  out_valid / out_ready / out_data   <- reads  when NOT empty
 
   // WRONG -- register the data bus straight across (11.3's phantom / 11.1 metastability):
   //   always @(posedge clk_b) out_data <= in_data;   // multi-bit value caught mid-change
   //   => a value the source NEVER held; a single-cycle in_valid missed by a slow clk_b.
 
   // RIGHT -- an ASYNC FIFO (11.6) does the crossing; each side sees a LOCAL handshake:
   //   producer writes on in_valid && in_ready, where in_ready = ~WFULL   (its backpressure)
   //   consumer reads  on out_valid && out_ready, where out_valid = ~REMPTY (its flow)
   //   the FIFO's CONSERVATIVE flags => no overflow, no underflow, no metastability. This page.

2. Mental Model

3. Pattern Anatomy

The block is three parts you already know, bolted face to face: a valid/ready producer in clk_a, an async FIFO (11.6) straddling the clock boundary, and a valid/ready consumer in clk_b. Everything hard is in which handshake maps to which flag, which domain each flag lives in, and what crosses the boundary and what must not.

The two-domain streaming path. On the write face, the producer's egress is a valid/ready source: in_valid says 'I have a word this cycle', in_ready says 'the FIFO can take it'. The mapping is exact — in_ready = ~WFULL, and a word is committed to the FIFO (a winc) on exactly the cycle in_valid && in_ready. On the read face, the consumer's ingress is a valid/ready sink: the FIFO's out_valid = ~REMPTY says 'a word is available', out_ready says 'the consumer can take it', and a word is popped (a rinc) on exactly out_valid && out_ready. Between the two faces sits the FIFO body — a dual-port RAM the writer fills on clk_a and the reader drains on clk_b. Each side is an ordinary 9.1 handshake against a local flag; the FIFO is the shared buffer that absorbs the rate mismatch and preserves order.

The two-domain streaming path — a valid/ready egress in clk_a, an async FIFO, a valid/ready ingress in clk_b

data flow
The two-domain streaming path — a valid/ready egress in clk_a, an async FIFO, a valid/ready ingress in clk_bin_valid / in_datawinc = valid &&readyrdata (FWFT)out_valid / out_dataout_ready(backpressure)PRODUCER (clk_a)drives in_valid / in_data; stalls on IN_READY lowegress:valid/readycommit on IN_VALID && IN_READY; IN_READY = not WFULLasync FIFO (11.6)dual-port RAM; CDC hidden inside; conservative flagsingress:valid/readypop on OUT_VALID && OUT_READY; OUT_VALID = not REMPTYCONSUMER (clk_b)drives out_ready; waits while OUT_VALID low
Left to right, the stream crosses two unrelated clocks through one buffer. The producer in clk_a drives a valid/ready egress: it presents in_valid and in_data and stalls whenever IN_READY is low, and a word is committed to the FIFO on exactly the cycle IN_VALID and IN_READY are both high. IN_READY is nothing but the inverse of the FIFO's WFULL flag — the FIFO's backpressure, seen by the producer as an ordinary consumer refusing a word. The async FIFO (11.6) is a dual-port RAM the writer fills on clk_a and the reader drains on clk_b; all the clock-domain crossing is hidden inside it. On the far face the consumer in clk_b drives a valid/ready ingress: OUT_VALID is the inverse of REMPTY (a word is available), the consumer asserts out_ready when it has room, and a word is popped on exactly OUT_VALID and OUT_READY both high. Each side is a plain single-clock handshake against a local flag; the FIFO makes the two clocks invisible to the producer and consumer. Identical in SystemVerilog, Verilog, and VHDL.

The CDC boundary — what crosses, which way, into which domain. Inside the FIFO, the write pointer and read pointer are the only things that must cross, because to compute WFULL the write side needs the read pointer and to compute REMPTY the read side needs the write pointer. Each pointer is incremented in binary (to address the RAM locally) and converted to gray — gray = bin ^ (bin >> 1) — before it crosses, so consecutive values differ in exactly one bit and any mid-transition sample is a real value at most one step behind (11.5). The write gray pointer is two-flop-synchronized (11.2) into clk_b so the read side can compute REMPTY; the read gray pointer is synchronized into clk_a so the write side can compute WFULL. The data bus never crosses as a sampled multi-bit value — it is written into the RAM on clk_a and read out on clk_b, addressed by binary pointers local to each side, so it is never caught mid-transition. The asymmetry is the thing to hold: each pointer travels to the other side, and each flag is computed on the side that owns its local pointer.

The CDC boundary inside the FIFO — only gray pointers cross, each into the other domain, and each flag lives where its local pointer is

data flow
The CDC boundary inside the FIFO — only gray pointers cross, each into the other domain, and each flag lives where its local pointer isGRAY crossesEMPTY comparerptr also crossesbackFULL compareWPTR gray (clk_a)wbin +1 -> gray; addresses RAM locally in clk_aWPTR -> 2FF ->clk_bsynchronized copy the read side uses for REMPTYREMPTY in clk_brptr == sync(wptr): pessimistic empty, no underflowRPTR -> 2FF ->clk_asynchronized copy the write side uses for WFULLWFULL in clk_awptr == sync(rptr) top-two inverted: no overflow
The only signals that cross the asynchronous boundary are the two gray-coded pointers, and each crosses the OTHER way. The write pointer, incremented in binary and gray-encoded in clk_a, is two-flop-synchronized into clk_b, where the read side compares it against its own read pointer to produce REMPTY. The read pointer does the mirror trip: gray-encoded in clk_b, synchronized into clk_a, compared against the write pointer (with the top two gray bits inverted, the gray form of 'wrap bits differ') to produce WFULL. REMPTY is computed in the READ domain and WFULL in the WRITE domain — each flag lives with the local pointer it can read directly and uses only the synchronized copy of the far pointer. Because each gray pointer crosses at most one step behind and never ahead (11.5) through a plain two-flop chain (11.2), both flags are conservative: REMPTY never claims data that is not there, WFULL never claims space that is not there. The data bus itself never crosses as a sampled value — it lives in the dual-port RAM, written and read by local binary pointers. This is the entire CDC content, already proven in 11.6; here it is a black box with two conservative flags. Identical across the three HDLs.

Why each flag must be evaluated in the correct domain. WFULL is a registered signal in clk_a: a function of the local write pointer and the synchronized read pointer, so every input to it is stable in clk_a and gating a write with it is timing-clean. REMPTY is the mirror in clk_b. The producer's flow logic (in_ready) may read only WFULL; the consumer's flow logic (out_valid) may read only REMPTY. If the producer tried to read REMPTY — a clk_b signal — directly in clk_a, it would sample an asynchronous flag with no synchronizer, catching it mid-transition and occasionally reading a false value; the same for any side-band control (a flush request, a 'space available' level) that crosses unsynchronized. That is the signature integration bug (§7 row 1). The rule is exact: use only your own-domain flag, and route any control that must cross through the FIFO itself or a proper synchronizer.

Rate matching, burst absorption, and where backpressure acts. The FIFO absorbs a burst up to its depth: if the producer bursts B words while the consumer drains slowly, the occupancy climbs, and when the (conservatively late) synchronized read pointer says the depth is reached, WFULL asserts and in_ready drops — backpressure acts in clk_a, on the producer, which stalls until the FIFO drains. Symmetrically, when the FIFO empties, REMPTY asserts, out_valid drops, and flow control acts in clk_b, on the consumer, which waits. The depth must cover the worst-case burst plus the flag's conservative latency: because WFULL sees a read pointer a couple of clk_a cycles stale, and REMPTY sees a write pointer a couple of clk_b cycles stale, you size the depth to the burst length plus a margin for those synchronizer cycles across the clock ratio — the same depth calculation as 7.4, with the sync latency added in. Undersize it and the producer stalls more than necessary (a throughput cost, never a correctness one); ignore the latency entirely and you get the §7 row-2 overrun.

4. Real RTL Implementation

This is the core of the page. We build the dual-clock stream incrementally — (a) the valid/ready producer egress in clk_a, (b) the async FIFO (11.6) that bridges the clocks, instantiated not re-derived, (c) the valid/ready consumer ingress in clk_b, then (d) the integrated cdc_stream with a clean handshake on both domains — and show the integrated block plus its two-async-clock scoreboard testbench and the unsynchronized-control (wrong-domain flag) bug beside its synchronized/own-domain fix in SystemVerilog, Verilog, and VHDL. The idea is identical across the three; only the spelling differs.

Three conventions run through every block, each an earlier lesson made concrete:

  • The async FIFO is a black box with two conservative flags. We instantiate the 11.6 async_fifo — a compact correct gray-pointer + two-flop-synchronizer version is shown once so the page is self-contained, but the streaming integration is the subject, not the pointer arithmetic. Its contract is all we use: write on winc & ~wfull, read on rinc & ~rempty, and both flags are conservative.
  • Each side uses only its own-domain flag. The producer's in_ready is ~wfull (a clk_a signal); the consumer's out_valid is ~rempty (a clk_b signal). Neither block reads a flag from the other domain, and nothing but the FIFO's internal synchronized pointers crosses the boundary.
  • A transfer is valid && ready, and the FIFO advance is that same transfer. On the write face, winc = in_valid & in_ready; on the read face, rinc = out_valid & out_ready. The handshake transfer condition and the FIFO's commit are the same event, so no word is written twice or popped twice.

4a. Building block (i) — the producer's valid/ready egress in clk_a

The producer is an ordinary 9.1 source clocked on clk_a. It presents in_valid and in_data from its own state, and its in_ready comes straight from the FIFO: in_ready = ~wfull. A word is committed on in_valid && in_ready. The producer never sees clk_b or REMPTY — WFULL is its entire view of the far side, and it reads it as plain backpressure. This half is shown inside the integrated module (§4d); the standalone rule is just 9.1: drive valid from local state, hold data stable while stalled, advance on the transfer.

4b. Building block (ii) — the async FIFO bridging clk_a to clk_b (from 11.6)

The bridge is the 11.6 asynchronous FIFO verbatim: a dual-port RAM, gray-coded write and read pointers, a two-flop synchronizer carrying each pointer into the other domain, and conservative WFULL (write domain) / REMPTY (read domain) flags. It is not re-derived here — the derivation of the gray full condition and the top-two-bit inversion is the whole of 11.6. We show a compact correct version so the integration is complete, then instantiate it. The only property the streaming block relies on is the contract: commit a write on winc & ~wfull, commit a read on rinc & ~rempty, and neither flag is ever wrong on the dangerous side.

async_fifo.sv — the 11.6 bridge, compact and correct (gray pointers + 2FF sync + conservative flags)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module async_fifo #(
       parameter int WIDTH = 8,
       parameter int ADDR  = 4,                       // DEPTH = 2**ADDR
       localparam int DEPTH = 1 << ADDR
   )(
       input  logic             clk_wr, wrst_n, winc,
       input  logic [WIDTH-1:0] wdata,
       output logic             wfull,                // conservative, IN the write domain
       input  logic             clk_rd, rrst_n, rinc,
       output logic [WIDTH-1:0] rdata,
       output logic             rempty                // conservative, IN the read domain
   );
       logic [WIDTH-1:0] mem [DEPTH];
       // write pointer: binary increment, gray for crossing
       logic [ADDR:0] wbin, wptr, wbin_n, wgray_n;
       assign wbin_n  = wbin + (winc & ~wfull);
       assign wgray_n = wbin_n ^ (wbin_n >> 1);
       always_ff @(posedge clk_wr or negedge wrst_n)
           if (!wrst_n) begin wbin <= '0; wptr <= '0; end
           else         begin wbin <= wbin_n; wptr <= wgray_n; end
       // read pointer: binary increment, gray for crossing
       logic [ADDR:0] rbin, rptr, rbin_n, rgray_n;
       assign rbin_n  = rbin + (rinc & ~rempty);
       assign rgray_n = rbin_n ^ (rbin_n >> 1);
       always_ff @(posedge clk_rd or negedge rrst_n)
           if (!rrst_n) begin rbin <= '0; rptr <= '0; end
           else         begin rbin <= rbin_n; rptr <= rgray_n; end
       // two-flop synchronizers: ONLY the gray pointers cross, each into the OTHER domain
       (* ASYNC_REG = "TRUE" *) logic [ADDR:0] wq1, wq2;   // wptr -> clk_rd
       always_ff @(posedge clk_rd or negedge rrst_n)
           if (!rrst_n) begin wq1 <= '0; wq2 <= '0; end
           else         begin wq1 <= wptr; wq2 <= wq1; end
       (* ASYNC_REG = "TRUE" *) logic [ADDR:0] rq1, rq2;   // rptr -> clk_wr
       always_ff @(posedge clk_wr or negedge wrst_n)
           if (!wrst_n) begin rq1 <= '0; rq2 <= '0; end
           else         begin rq1 <= rptr; rq2 <= rq1; end
       // conservative flags: REMPTY in clk_rd, WFULL in clk_wr (top two gray bits inverted)
       always_ff @(posedge clk_rd or negedge rrst_n)
           if (!rrst_n) rempty <= 1'b1;
           else         rempty <= (rgray_n == wq2);
       always_ff @(posedge clk_wr or negedge wrst_n)
           if (!wrst_n) wfull <= 1'b0;
           else         wfull <= (wgray_n == {~rq2[ADDR:ADDR-1], rq2[ADDR-2:0]});
       // dual-port RAM: written in clk_wr, read in clk_rd, addressed by BINARY low bits
       always_ff @(posedge clk_wr) if (winc & ~wfull) mem[wbin[ADDR-1:0]] <= wdata;
       assign rdata = mem[rbin[ADDR-1:0]];            // FWFT-style combinational read
   endmodule

4c. Building block (iii) — the consumer's valid/ready ingress in clk_b

The consumer is an ordinary 9.1 sink clocked on clk_b. The FIFO's out_valid = ~rempty tells it a word is available; it asserts out_ready when it has room; a word is popped on out_valid && out_ready. The consumer never sees clk_a or WFULL — REMPTY is its entire view of the far side, read as a plain producer's valid. Shown inside the integrated module (§4d); the standalone rule is 9.1: assert ready from local space, capture data on the transfer.

4d. The integrated dual-clock stream — parameterized, in SystemVerilog

One module: the producer's egress in clk_a + the instantiated async_fifo doing the CDC + the consumer's ingress in clk_b, with a clean valid/ready handshake on both domains and each flag used only in its own domain. Parameterized on WIDTH and DEPTH (via ADDR).

cdc_stream.sv — integrated: valid/ready egress (clk_a) + async FIFO (CDC) + valid/ready ingress (clk_b)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module cdc_stream #(
       parameter int WIDTH = 8,
       parameter int ADDR  = 4                        // DEPTH = 2**ADDR
   )(
       // ---- write side: producer's valid/ready egress, in clk_a ----
       input  logic             clk_a,
       input  logic             rst_a,                // async, active-high here
       input  logic             in_valid,             // producer: I have a word this cycle
       input  logic [WIDTH-1:0] in_data,
       output logic             in_ready,             // = ~WFULL : the FIFO's backpressure
       // ---- read side: consumer's valid/ready ingress, in clk_b ----
       input  logic             clk_b,
       input  logic             rst_b,                // async, active-high here
       output logic             out_valid,            // = ~REMPTY : a word is available
       output logic [WIDTH-1:0] out_data,
       input  logic             out_ready             // consumer: I can accept this cycle
   );
       // ---- the async FIFO (11.6) bridges clk_a -> clk_b; ITS flags are conservative ----
       logic wfull, rempty;
       logic winc, rinc;
 
       async_fifo #(.WIDTH(WIDTH), .ADDR(ADDR)) fifo (
           .clk_wr(clk_a), .wrst_n(~rst_a), .winc(winc), .wdata(in_data), .wfull(wfull),
           .clk_rd(clk_b), .rrst_n(~rst_b), .rinc(rinc), .rdata(out_data), .rempty(rempty));
 
       // ---- WRITE FACE (clk_a): map the egress handshake onto WFULL only ----
       // in_ready is a PURE function of the local WFULL (own-domain flag). It does NOT
       // look at anything in clk_b, and no clk_b signal is read here without the FIFO's
       // synchronizer. A word is committed on exactly the transfer cycle.
       assign in_ready = ~wfull;                       // 9.1 backpressure = FIFO not full
       assign winc     = in_valid & in_ready;          // commit on IN_VALID && IN_READY
 
       // ---- READ FACE (clk_b): map the ingress handshake onto REMPTY only ----
       // out_valid is a PURE function of the local REMPTY (own-domain flag). rdata is the
       // FIFO's FWFT read, valid whenever ~rempty. A word is popped on exactly the transfer.
       assign out_valid = ~rempty;                     // 9.1 valid = FIFO not empty
       assign rinc      = out_valid & out_ready;       // pop on OUT_VALID && OUT_READY
   endmodule

Two lines carry the whole capstone. in_ready = ~wfull binds the producer's backpressure to the write-domain flag and nothing else; out_valid = ~rempty binds the consumer's flow to the read-domain flag and nothing else. Because the FIFO's flags are conservative — WFULL only ever pessimistic in clk_a, REMPTY only ever pessimistic in clk_b — the producer never writes a full FIFO (no overflow) and the consumer never pops an empty one (no underflow), so every word crosses exactly once, in order. The scoreboard testbench proves it under both clock-ratio extremes.

cdc_stream_tb.sv — two async clocks; scoreboard for in-order/exactly-once; no overflow/underflow
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module cdc_stream_tb;
       localparam int WIDTH = 8, ADDR = 3;             // DEPTH = 8, small so bursts hit the boundary
       logic clk_a = 1'b0, clk_b = 1'b0, rst_a, rst_b;
       logic in_valid, in_ready, out_valid, out_ready;
       logic [WIDTH-1:0] in_data, out_data;
       int errors = 0, npush = 0, npop = 0;
 
       cdc_stream #(.WIDTH(WIDTH), .ADDR(ADDR)) dut (
           .clk_a(clk_a), .rst_a(rst_a), .in_valid(in_valid), .in_data(in_data), .in_ready(in_ready),
           .clk_b(clk_b), .rst_b(rst_b), .out_valid(out_valid), .out_data(out_data), .out_ready(out_ready));
 
       // FAST PRODUCER, SLOW CONSUMER (swap the two numbers to invert the ratio and re-run).
       always #5  clk_a = ~clk_a;                      // ~100 MHz producer clock
       always #17 clk_b = ~clk_b;                      // ~29  MHz consumer clock, async
 
       // Golden reference queue of every word committed to the stream.
       logic [WIDTH-1:0] sb [$];
 
       // PRODUCER: drive a running pattern with random gaps; commit on the transfer.
       logic [WIDTH-1:0] wcount = 8'h00;
       always @(posedge clk_a) begin
           if (rst_a) begin in_valid <= 1'b0; end
           else begin
               // OVERFLOW guard: a committed write must never happen while full.
               if (winc_dbg && wfull_dbg) begin $error("OVERFLOW: write while full"); errors++; end
               if (in_valid && in_ready) begin sb.push_back(in_data); wcount <= wcount + 1; npush++; end
               // random backpressure-independent valid; hold data stable while stalled (9.1 Rule 2)
               if (!in_valid || in_ready) begin in_valid <= ($urandom & 3) != 0; in_data <= wcount; end
           end
       end
       // white-box taps for the guards (bind to DUT internals)
       wire winc_dbg  = dut.winc;
       wire wfull_dbg = dut.wfull;
       wire rinc_dbg  = dut.rinc;
       wire rempty_dbg = dut.rempty;
 
       // CONSUMER: random ready; pop on the transfer; check against the scoreboard head.
       always @(posedge clk_b) begin
           if (rst_b) begin out_ready <= 1'b0; end
           else begin
               if (rinc_dbg && rempty_dbg) begin $error("UNDERFLOW: read while empty"); errors++; end
               if (out_valid && out_ready) begin
                   automatic logic [WIDTH-1:0] exp = sb.pop_front();
                   if (out_data !== exp) begin $error("ORDER/DATA: got %h exp %h", out_data, exp); errors++; end
                   npop++;
               end
               out_ready <= ($urandom & 3) != 0;       // random, data-independent readiness
           end
       end
 
       initial begin
           rst_a = 1'b1; rst_b = 1'b1; in_valid = 1'b0; out_ready = 1'b0; in_data = '0;
           repeat (3) @(posedge clk_a); rst_a = 1'b0;
           repeat (3) @(posedge clk_b); rst_b = 1'b0;
           repeat (5000) @(posedge clk_a);             // long enough to fill/drain repeatedly
           in_valid = 1'b0;
           repeat (400) @(posedge clk_b);              // drain the tail
           if (errors == 0 && sb.size() == 0)
               $display("PASS: %0d pushed, %0d popped, in-order/exactly-once, no overflow/underflow", npush, npop);
           else
               $display("FAIL: %0d errors, %0d words still queued", errors, sb.size());
           $finish;
       end
   endmodule

4e. The signature bug — a flag read in the WRONG domain (buggy vs fixed)

Every dual-clock streaming failure that survives a light test lives in a flag or control evaluated in the wrong domain, or crossing unsynchronized. The sharpest is letting the producer's flow logic read REMPTY (a clk_b signal) directly in clk_a — or letting any side-band level cross without a synchronizer. In a sim where the two clocks happen to be aligned it 'works'; the instant they drift, the raw flag is sampled mid-transition and the producer occasionally writes a full FIFO (overflow) or stalls forever.

cdc_flow.sv — BUGGY (wrong-domain / unsynchronized flag) vs FIXED (own-domain flag)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // BUGGY producer flow: it reads the READ-domain REMPTY (and a raw 'space' level from
   //   clk_b) DIRECTLY in clk_a to decide in_ready, with NO synchronizer. REMPTY changes
   //   on clk_b; sampling it on clk_a catches it mid-transition -> a metastable/false value
   //   reaches in_ready, so the producer occasionally thinks there is room when the FIFO is
   //   FULL (overflow) or thinks there is none when it is empty (permanent stall).
   module cdc_flow_bad #(parameter int WIDTH = 8)(
       input  logic             clk_a,
       input  logic             rempty_from_clkb,      // BUG: a clk_b flag, read raw in clk_a
       input  logic             space_from_clkb,       // BUG: a clk_b level, crossing unsynchronized
       output logic             in_ready
   );
       // BUG: in_ready depends on UNSYNCHRONIZED clk_b signals -> metastable, false flow.
       assign in_ready = ~rempty_from_clkb & space_from_clkb;
   endmodule
 
   // FIXED producer flow: in_ready is a PURE function of the OWN-domain WFULL, which the
   //   FIFO already computed in clk_a from the SYNCHRONIZED read pointer. No clk_b signal
   //   is read here; the only crossing is the FIFO's internal 2FF-synchronized gray pointer.
   module cdc_flow_good #(parameter int WIDTH = 8)(
       input  logic clk_a,
       input  logic wfull,                             // clk_a flag, from the FIFO's sync'd rptr
       output logic in_ready
   );
       // FIX: own-domain conservative flag only -> never overflow, never metastable.
       assign in_ready = ~wfull;
   endmodule
cdc_flow_tb.sv — drifting clocks: the wrong-domain flag overflows, the own-domain flag survives
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module cdc_flow_tb;
       // Instantiate the full cdc_stream two ways: the FIXED wiring (in_ready = ~wfull) and,
       // conceptually, the BUGGY wiring where in_ready reads a raw clk_b flag. With clk_a and
       // clk_b DRIFTING, the buggy flow samples REMPTY mid-transition and commits a write
       // while the FIFO is full -> the overflow guard fires. The fixed flow never does.
       localparam int WIDTH = 8, ADDR = 3;
       logic clk_a = 1'b0, clk_b = 1'b0, rst_a, rst_b;
       logic in_valid, in_ready, out_valid, out_ready;
       logic [WIDTH-1:0] in_data, out_data;
       int errors = 0;
 
       cdc_stream #(.WIDTH(WIDTH), .ADDR(ADDR)) dut (
           .clk_a(clk_a), .rst_a(rst_a), .in_valid(in_valid), .in_data(in_data), .in_ready(in_ready),
           .clk_b(clk_b), .rst_b(rst_b), .out_valid(out_valid), .out_data(out_data), .out_ready(out_ready));
 
       always #5  clk_a = ~clk_a;
       always #7  clk_b = ~clk_b;                       // deliberately close but UNRELATED -> drift
 
       // Standing overflow assertion on the write face (white-box tap).
       always @(posedge clk_a) if (!rst_a && dut.winc && dut.wfull) begin
           $error("OVERFLOW: committed a write while WFULL (wrong-domain flag would allow this)"); errors++;
       end
 
       initial begin
           rst_a = 1; rst_b = 1; in_valid = 0; out_ready = 0; in_data = 0;
           repeat (3) @(posedge clk_a); rst_a = 0;
           repeat (3) @(posedge clk_b); rst_b = 0;
           // Producer floods; consumer barely drains -> FIFO sits at full for a long time.
           in_valid = 1;
           repeat (2000) @(posedge clk_a) begin in_data <= in_data + (in_valid & in_ready); out_ready <= 1'b0; end
           if (errors == 0) $display("PASS: own-domain WFULL never overflows under drift (fixed wiring)");
           else             $display("FAIL: %0d overflows — a wrong-domain/unsynchronized flag was read", errors);
           $finish;
       end
   endmodule

4f. The integrated dual-clock stream in Verilog

The same three parts with reg/wire typing. The FIFO instantiation and the two handshake mappings — in_ready = ~wfull, out_valid = ~rempty — are unchanged; only the spelling differs.

async_fifo.v — the 11.6 bridge in Verilog (gray pointers + 2FF sync + conservative flags)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module async_fifo #(parameter WIDTH = 8, ADDR = 4, DEPTH = 16)(
       input  wire             clk_wr, wrst_n, winc,
       input  wire [WIDTH-1:0] wdata,
       output reg              wfull,
       input  wire             clk_rd, rrst_n, rinc,
       output wire [WIDTH-1:0] rdata,
       output reg              rempty
   );
       reg [WIDTH-1:0] mem [0:DEPTH-1];
       reg  [ADDR:0] wbin, wptr;
       wire [ADDR:0] wbin_n  = wbin + (winc & ~wfull);
       wire [ADDR:0] wgray_n = wbin_n ^ (wbin_n >> 1);
       always @(posedge clk_wr or negedge wrst_n)
           if (!wrst_n) begin wbin <= 0; wptr <= 0; end
           else         begin wbin <= wbin_n; wptr <= wgray_n; end
       reg  [ADDR:0] rbin, rptr;
       wire [ADDR:0] rbin_n  = rbin + (rinc & ~rempty);
       wire [ADDR:0] rgray_n = rbin_n ^ (rbin_n >> 1);
       always @(posedge clk_rd or negedge rrst_n)
           if (!rrst_n) begin rbin <= 0; rptr <= 0; end
           else         begin rbin <= rbin_n; rptr <= rgray_n; end
       (* ASYNC_REG = "TRUE" *) reg [ADDR:0] wq1, wq2;   // wptr -> clk_rd
       always @(posedge clk_rd or negedge rrst_n)
           if (!rrst_n) begin wq1 <= 0; wq2 <= 0; end
           else         begin wq1 <= wptr; wq2 <= wq1; end
       (* ASYNC_REG = "TRUE" *) reg [ADDR:0] rq1, rq2;   // rptr -> clk_wr
       always @(posedge clk_wr or negedge wrst_n)
           if (!wrst_n) begin rq1 <= 0; rq2 <= 0; end
           else         begin rq1 <= rptr; rq2 <= rq1; end
       always @(posedge clk_rd or negedge rrst_n)
           if (!rrst_n) rempty <= 1'b1;
           else         rempty <= (rgray_n == wq2);
       always @(posedge clk_wr or negedge wrst_n)
           if (!wrst_n) wfull <= 1'b0;
           else         wfull <= (wgray_n == {~rq2[ADDR:ADDR-1], rq2[ADDR-2:0]});
       always @(posedge clk_wr) if (winc & ~wfull) mem[wbin[ADDR-1:0]] <= wdata;
       assign rdata = mem[rbin[ADDR-1:0]];
   endmodule
cdc_stream.v — integrated dual-clock stream in Verilog (own-domain flags on each face)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module cdc_stream #(parameter WIDTH = 8, ADDR = 4)(
       input  wire             clk_a, rst_a,
       input  wire             in_valid,
       input  wire [WIDTH-1:0] in_data,
       output wire             in_ready,
       input  wire             clk_b, rst_b,
       output wire             out_valid,
       output wire [WIDTH-1:0] out_data,
       input  wire             out_ready
   );
       wire wfull, rempty;
       // WRITE FACE (clk_a): backpressure = FIFO not full; commit on the transfer.
       assign in_ready  = ~wfull;                       // own-domain flag ONLY
       wire   winc      = in_valid & in_ready;
       // READ FACE (clk_b): valid = FIFO not empty; pop on the transfer.
       assign out_valid = ~rempty;                      // own-domain flag ONLY
       wire   rinc      = out_valid & out_ready;
 
       async_fifo #(.WIDTH(WIDTH), .ADDR(ADDR), .DEPTH(1<<ADDR)) fifo (
           .clk_wr(clk_a), .wrst_n(~rst_a), .winc(winc), .wdata(in_data), .wfull(wfull),
           .clk_rd(clk_b), .rrst_n(~rst_b), .rinc(rinc), .rdata(out_data), .rempty(rempty));
   endmodule
cdc_stream_tb.v — two async clocks; array scoreboard; PASS/FAIL on order + overflow/underflow
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module cdc_stream_tb;
       parameter WIDTH = 8, ADDR = 3;
       reg  clk_a, clk_b, rst_a, rst_b, in_valid, out_ready;
       reg  [WIDTH-1:0] in_data;
       wire in_ready, out_valid;
       wire [WIDTH-1:0] out_data;
       integer errors, npush, npop, head, tail;
       reg [WIDTH-1:0] sb [0:65535];
       reg [WIDTH-1:0] wcount, exp;
 
       cdc_stream #(.WIDTH(WIDTH), .ADDR(ADDR)) dut (
           .clk_a(clk_a), .rst_a(rst_a), .in_valid(in_valid), .in_data(in_data), .in_ready(in_ready),
           .clk_b(clk_b), .rst_b(rst_b), .out_valid(out_valid), .out_data(out_data), .out_ready(out_ready));
 
       always #5  clk_a = ~clk_a;                       // fast producer
       always #17 clk_b = ~clk_b;                       // slow consumer, async
 
       // PRODUCER
       always @(posedge clk_a) begin
           if (rst_a) in_valid <= 1'b0;
           else begin
               if (dut.winc && dut.wfull) begin $display("FAIL OVERFLOW: write while full"); errors = errors + 1; end
               if (in_valid && in_ready) begin sb[tail] = in_data; tail = tail + 1; wcount = wcount + 1; npush = npush + 1; end
               if (!in_valid || in_ready) begin in_valid <= ($random & 3) != 0; in_data <= wcount; end
           end
       end
       // CONSUMER
       always @(posedge clk_b) begin
           if (rst_b) out_ready <= 1'b0;
           else begin
               if (dut.rinc && dut.rempty) begin $display("FAIL UNDERFLOW: read while empty"); errors = errors + 1; end
               if (out_valid && out_ready) begin
                   exp = sb[head]; head = head + 1; npop = npop + 1;
                   if (out_data !== exp) begin $display("FAIL ORDER/DATA: got %h exp %h", out_data, exp); errors = errors + 1; end
               end
               out_ready <= ($random & 3) != 0;
           end
       end
 
       initial begin
           errors=0; npush=0; npop=0; head=0; tail=0; wcount=0;
           clk_a=0; clk_b=0; rst_a=1; rst_b=1; in_valid=0; out_ready=0; in_data=0;
           repeat (3) @(posedge clk_a); rst_a = 1'b0;
           repeat (3) @(posedge clk_b); rst_b = 1'b0;
           repeat (5000) @(posedge clk_a); in_valid = 1'b0;
           repeat (400) @(posedge clk_b);
           if (errors == 0 && head == tail)
               $display("PASS: %0d pushed, %0d popped, in-order/exactly-once, no overflow/underflow", npush, npop);
           else
               $display("FAIL: %0d errors, %0d words unread", errors, tail - head);
           $finish;
       end
   endmodule

The Verilog wrong-domain bug-vs-fix pair is the same one-line change: in_ready from a raw clk_b flag is broken; in_ready = ~wfull (own-domain) is correct.

cdc_flow.v — BUGGY (raw clk_b flag in clk_a) vs FIXED (own-domain WFULL)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // BUGGY: in_ready depends on an UNSYNCHRONIZED clk_b flag -> metastable, false flow.
   module cdc_flow_bad (
       input wire rempty_from_clkb, input wire space_from_clkb, output wire in_ready );
       assign in_ready = ~rempty_from_clkb & space_from_clkb;   // BUG: wrong domain, no sync
   endmodule
 
   // FIXED: in_ready is the OWN-domain WFULL only (FIFO computed it from the sync'd rptr).
   module cdc_flow_good (
       input wire wfull, output wire in_ready );
       assign in_ready = ~wfull;                                // FIX: own-domain conservative flag
   endmodule

4g. The integrated dual-clock stream in VHDL

VHDL states the same structure with numeric_std counters, rising_edge(clk), and a component instantiation of the async FIFO. The two handshake mappings are the identical own-domain-flag assignments.

async_fifo.vhd — the 11.6 bridge in VHDL (numeric_std, gray pointers, 2FF sync, conservative flags)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
 
   entity async_fifo is
       generic ( WIDTH : positive := 8; ADDR : positive := 4 );   -- DEPTH = 2**ADDR
       port (
           clk_wr : in  std_logic; wrst_n : in std_logic; winc : in std_logic;
           wdata  : in  std_logic_vector(WIDTH-1 downto 0);
           wfull  : out std_logic;
           clk_rd : in  std_logic; rrst_n : in std_logic; rinc : in std_logic;
           rdata  : out std_logic_vector(WIDTH-1 downto 0);
           rempty : out std_logic );
   end entity;
 
   architecture rtl of async_fifo is
       constant DEPTH : positive := 2**ADDR;
       type mem_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
       signal mem : mem_t;
       signal wbin, wptr, rbin, rptr : unsigned(ADDR downto 0) := (others => '0');
       signal wbin_n, wgray_n, rbin_n, rgray_n : unsigned(ADDR downto 0);
       signal wq1, wq2, rq1, rq2 : unsigned(ADDR downto 0) := (others => '0');
       signal wfull_i, rempty_i : std_logic;
       signal rq2_inv : unsigned(ADDR downto 0);
       attribute ASYNC_REG : string;
       attribute ASYNC_REG of wq1 : signal is "TRUE";
       attribute ASYNC_REG of wq2 : signal is "TRUE";
       attribute ASYNC_REG of rq1 : signal is "TRUE";
       attribute ASYNC_REG of rq2 : signal is "TRUE";
   begin
       wbin_n  <= wbin + 1 when (winc = '1' and wfull_i = '0') else wbin;
       wgray_n <= wbin_n xor ('0' & wbin_n(ADDR downto 1));
       rbin_n  <= rbin + 1 when (rinc = '1' and rempty_i = '0') else rbin;
       rgray_n <= rbin_n xor ('0' & rbin_n(ADDR downto 1));
 
       process (clk_wr, wrst_n) begin
           if wrst_n = '0' then wbin <= (others=>'0'); wptr <= (others=>'0');
           elsif rising_edge(clk_wr) then wbin <= wbin_n; wptr <= wgray_n; end if;
       end process;
       process (clk_rd, rrst_n) begin
           if rrst_n = '0' then rbin <= (others=>'0'); rptr <= (others=>'0');
           elsif rising_edge(clk_rd) then rbin <= rbin_n; rptr <= rgray_n; end if;
       end process;
       -- ONLY the gray pointers cross, each into the OTHER domain
       process (clk_rd, rrst_n) begin
           if rrst_n = '0' then wq1 <= (others=>'0'); wq2 <= (others=>'0');
           elsif rising_edge(clk_rd) then wq1 <= wptr; wq2 <= wq1; end if;
       end process;
       process (clk_wr, wrst_n) begin
           if wrst_n = '0' then rq1 <= (others=>'0'); rq2 <= (others=>'0');
           elsif rising_edge(clk_wr) then rq1 <= rptr; rq2 <= rq1; end if;
       end process;
       rq2_inv <= (not rq2(ADDR downto ADDR-1)) & rq2(ADDR-2 downto 0);
       -- REMPTY in the read domain, WFULL in the write domain (conservative)
       process (clk_rd, rrst_n) begin
           if rrst_n = '0' then rempty_i <= '1';
           elsif rising_edge(clk_rd) then
               if rgray_n = wq2 then rempty_i <= '1'; else rempty_i <= '0'; end if;
           end if;
       end process;
       process (clk_wr, wrst_n) begin
           if wrst_n = '0' then wfull_i <= '0';
           elsif rising_edge(clk_wr) then
               if wgray_n = rq2_inv then wfull_i <= '1'; else wfull_i <= '0'; end if;
           end if;
       end process;
       wfull <= wfull_i; rempty <= rempty_i;
       process (clk_wr) begin
           if rising_edge(clk_wr) then
               if winc = '1' and wfull_i = '0' then mem(to_integer(wbin(ADDR-1 downto 0))) <= wdata; end if;
           end if;
       end process;
       rdata <= mem(to_integer(rbin(ADDR-1 downto 0)));
   end architecture;
cdc_stream.vhd — integrated dual-clock stream in VHDL (own-domain flags on each face)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
 
   entity cdc_stream is
       generic ( WIDTH : positive := 8; ADDR : positive := 4 );
       port (
           clk_a    : in  std_logic;  rst_a    : in  std_logic;
           in_valid : in  std_logic;  in_data  : in  std_logic_vector(WIDTH-1 downto 0);
           in_ready : out std_logic;
           clk_b    : in  std_logic;  rst_b    : in  std_logic;
           out_valid: out std_logic;  out_data : out std_logic_vector(WIDTH-1 downto 0);
           out_ready: in  std_logic );
   end entity;
 
   architecture rtl of cdc_stream is
       signal wfull, rempty, winc, rinc : std_logic;
       signal in_ready_i, out_valid_i   : std_logic;
   begin
       -- WRITE FACE (clk_a): backpressure = FIFO not full; commit on the transfer.
       in_ready_i <= not wfull;                 -- own-domain flag ONLY
       in_ready   <= in_ready_i;
       winc       <= in_valid and in_ready_i;
       -- READ FACE (clk_b): valid = FIFO not empty; pop on the transfer.
       out_valid_i <= not rempty;               -- own-domain flag ONLY
       out_valid   <= out_valid_i;
       rinc        <= out_valid_i and out_ready;
 
       fifo : entity work.async_fifo
           generic map (WIDTH => WIDTH, ADDR => ADDR)
           port map (clk_wr => clk_a, wrst_n => not rst_a, winc => winc, wdata => in_data, wfull => wfull,
                     clk_rd => clk_b, rrst_n => not rst_b, rinc => rinc, rdata => out_data, rempty => rempty);
   end architecture;
cdc_stream_tb.vhd — two async clocks; scoreboard; assert no overflow/underflow, in-order (severity error)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
 
   entity cdc_stream_tb is end entity;
 
   architecture sim of cdc_stream_tb is
       constant WIDTH : positive := 8;
       constant ADDR  : positive := 3;                             -- DEPTH = 8
       signal clk_a, clk_b : std_logic := '0';
       signal rst_a, rst_b : std_logic := '1';
       signal in_valid, in_ready, out_valid, out_ready : std_logic := '0';
       signal in_data, out_data : std_logic_vector(WIDTH-1 downto 0) := (others=>'0');
       type sb_t is array (0 to 65535) of std_logic_vector(WIDTH-1 downto 0);
       signal sb : sb_t;
       signal head, tail, errors : integer := 0;
       signal wcount : unsigned(WIDTH-1 downto 0) := (others=>'0');
   begin
       dut : entity work.cdc_stream
           generic map (WIDTH => WIDTH, ADDR => ADDR)
           port map (clk_a => clk_a, rst_a => rst_a, in_valid => in_valid, in_data => in_data, in_ready => in_ready,
                     clk_b => clk_b, rst_b => rst_b, out_valid => out_valid, out_data => out_data, out_ready => out_ready);
 
       clk_a <= not clk_a after 5 ns;                              -- fast producer
       clk_b <= not clk_b after 17 ns;                             -- slow consumer, async
 
       -- PRODUCER: commit on the transfer; record the word.
       wproc : process (clk_a) begin
           if rising_edge(clk_a) then
               if rst_a = '0' then
                   if in_valid = '1' and in_ready = '1' then
                       sb(tail) <= in_data; tail <= tail + 1; wcount <= wcount + 1;
                   end if;
                   if in_valid = '0' or in_ready = '1' then
                       in_valid <= '1'; in_data <= std_logic_vector(wcount);
                   end if;
               end if;
           end if;
       end process;
 
       -- CONSUMER: pop on the transfer; check against the scoreboard head.
       rproc : process (clk_b) begin
           if rising_edge(clk_b) then
               if rst_b = '0' then
                   if out_valid = '1' and out_ready = '1' then
                       assert out_data = sb(head) report "ORDER/DATA mismatch" severity error;
                       head <= head + 1;
                   end if;
                   out_ready <= '1';
               end if;
           end if;
       end process;
 
       stim : process begin
           rst_a <= '1'; rst_b <= '1';
           for i in 0 to 3 loop wait until rising_edge(clk_a); end loop; rst_a <= '0';
           for i in 0 to 3 loop wait until rising_edge(clk_b); end loop; rst_b <= '0';
           for i in 0 to 4999 loop wait until rising_edge(clk_a); end loop;
           in_valid <= '0';
           for i in 0 to 399 loop wait until rising_edge(clk_b); end loop;
           report "cdc_stream self-check complete (in-order/exactly-once, no overflow/underflow)" severity note;
           wait;
       end process;
   end architecture;

Across all three languages the streaming block's whole correctness is one sentence: each face is an ordinary valid/ready handshake against its OWN-domain conservative flag — in_ready = ~WFULL in clk_a, out_valid = ~REMPTY in clk_b — and the async FIFO, crossing only synchronized gray pointers, guarantees no word is lost, duplicated, or corrupted between the clocks.

5. Verification Strategy

A dual-clock stream's correctness is not 'did a few words come out' — it is 'does every word pushed on clk_a come out on clk_b exactly once, in order, with the FIFO never overflowing or underflowing, under the clock ratios and drift you will actually meet.' The one invariant, and the whole §4 scoreboard testbench, is:

Wire an independent producer on clk_a that pushes words with random valid gaps whenever in_ready is high, and an independent consumer on clk_b that pops with random out_ready whenever out_valid is high; a golden scoreboard records every committed word and checks every popped word against its head. Every word must appear at the output exactly once, in FIFO order — none lost, none duplicated — with winc never asserted while WFULL and rinc never asserted while REMPTY, for both fast-clk_a/slow-clk_b and slow-clk_a/fast-clk_b.

Everything below is that invariant made checkable, and it maps onto the two-async-clock scoreboard testbenches in §4.

  • Two-async-clock scoreboard (the core test). Drive the two clocks with unrelated, incommensurate periods (e.g. 5 and 17 time units, then swap them), so the crossing is genuinely asynchronous rather than a rational multiple. The producer pushes a running counter on the transfer (in_valid && in_ready) with randomized in_valid gaps; the consumer pops on out_valid && out_ready with randomized out_ready; a queue records each committed word and each popped word is asserted equal to the queue head. Order and exactly-once fall out of the queue check; loss or duplication shows immediately as a mismatch or a nonzero residual.
  • Both clock-ratio extremes. Run the identical scoreboard with the write clock much faster than the read clock (the FIFO fills, WFULL backpressures the producer) and with the read clock much faster (the FIFO drains, REMPTY stalls the consumer). A design that passes one ratio can hide a flag-domain error that only the other ratio exercises — verify both.
  • Standing overflow / underflow assertions. These are the safety properties: assert not (winc and wfull) in clk_a and assert not (rinc and rempty) in clk_b, checked every cycle. A correct conservative-flag FIFO can never trip either; if one fires, a flag was read in the wrong domain or a crossing went unsynchronized (§7 row 1) or the writer ignored the flag latency (§7 row 2).
  • Data-stable-under-stall (handshake conformance). On the write face, when in_valid is high and in_ready low, in_data must not change until the transfer — the 9.1 Rule 2 the producer must honour so the word committed is the word offered. Assert it directly, or drive the producer to hold data while stalled and let the scoreboard catch any mutation.
  • Metastability-injection / CDC-lint intent. A functional sim cannot show real metastability, but two checks stand in for silicon confidence (11.7): drive the two clocks with a small random jitter on each edge so the synchronizer captures at varying phases, and run CDC lint to prove that the only signals crossing the boundary are the two-flop-synchronized gray pointers — no data bus, no flag, no control level crosses raw. A lint hit on any unsynchronized crossing is the static form of the §7 row-1 bug.
  • Expected waveform. In clk_a, in_ready (i.e. ~wfull) drops when the FIFO fills and the producer's in_valid holds with in_data frozen until it rises again. In clk_b, out_valid (i.e. ~rempty) rises a couple of clk_b cycles after the first write (the synchronizer latency) and the consumer pops on each out_ready. A word appearing at the output that was never pushed, or a push count that exceeds the pop count at drain, is the signature of a broken crossing; a winc pulse coincident with wfull is the signature of the wrong-domain flag.

6. Common Mistakes

Evaluating a flag or handshake in the wrong domain / crossing a control bit unsynchronized. The signature integration failure and the reason this is a capstone. Reading the read-domain REMPTY directly in clk_a (to decide in_ready), or reading WFULL in clk_b, or letting a side-band control — a flush request, a 'space available' level, a ready sampled in the wrong domain — cross without a synchronizer, samples an asynchronous signal with no metastability protection. It 'passes' when the two clocks happen to align in sim and produces a metastable or false flag the instant they drift, so the producer occasionally writes a full FIFO (overflow) or stalls forever, or the consumer pops a phantom. The rule is exact: each side uses only its own-domain flagin_ready = ~wfull in clk_a, out_valid = ~rempty in clk_b — and any control that must cross goes through the FIFO or a proper synchronizer. Full post-mortem in §7 row 1; buggy-vs-fixed RTL in §4e.

Ignoring the conservative flag latency. The FIFO's flags are pessimistic by a synchronizer's worth of cycles: WFULL sees a read pointer a couple of clk_a edges stale, REMPTY sees a write pointer a couple of clk_b edges stale. A writer that assumes WFULL clears the instant the reader pops — that not-full updates with zero latency across the crossing — will keep writing into a nearly-full FIFO and overrun it. Trust only the synchronized flag as presented, and size the depth to cover the worst-case burst plus the flag-latency margin (7.4 plus the sync cycles). The latency is a feature — it is what makes the flags safe — not a delay to shave. Post-mortem in §7 row 2.

Using a plain (single-clock) FIFO across two clocks. A synchronous FIFO (11.3) compares both pointers in one clock domain; drop it between two unrelated clocks and its full/empty logic samples a pointer from the other domain bit-by-bit, assembling a phantom (11.3 pointer incoherency) and corrupting the flags — overflow and underflow follow. The crossing requires the async FIFO's gray pointers and synchronizers; a single-clock FIFO with a synchronizer bolted on the outside is not the same and is not safe.

Backpressure wired to the wrong domain. Backpressure must act where the flag lives: WFULL stalls the producer in clk_a, REMPTY stalls the consumer in clk_b. Wiring the consumer's out_ready back to gate the producer directly (or the producer's in_valid to gate the consumer) crosses a control signal between domains and reintroduces the very CDC problem the FIFO exists to solve. Let each side backpressure locally against its own flag; the FIFO is the only thing that bridges them.

Retracting valid or mutating in_data while stalled. The write face is a 9.1 source: once in_valid is high it must stay high with in_data bit-for-bit stable until in_ready is seen. A producer that drops in_valid for a cycle while WFULL is high, or changes the payload mid-offer, either loses the word (never committed) or commits a mutated one. Hold offer and data until the transfer — the same discipline as any single-clock handshake.

7. DebugLab

The dual-clock stream that works in sim and dies in silicon — a wrong-domain flag and a flag latency ignored

The engineering lesson: a dual-clock stream is correct only if the two clocks never touch each other's live state directly — every crossing goes through the FIFO's synchronized gray pointers, every flag is used in its own domain, and the flags' conservative latency is respected as the safety margin it is. The two signature bugs are the same crime committed at different stations: the first reaches ACROSS the boundary with a raw flag or control (no synchronizer, wrong domain), so a metastable value gates the flow; the second refuses to WAIT for the boundary (assuming not-full updates instantly, sizing with no margin), so a stale-pointer overrun slips through. Both pass a shared-clock or locked-ratio sim and both surface the instant two real, independently-clocked domains drift. Three habits make the block trustworthy, identical across SystemVerilog, Verilog, and VHDL: use only your own-domain flag (in_ready = ~WFULL in clk_a, out_valid = ~REMPTY in clk_b), route any control that must cross through the FIFO or a synchronizer — never sample it raw, and respect the conservative latency — gate on the registered flag and size the depth for the sync cycles across the clock ratio. Prove it with a two-unrelated-clock scoreboard at both ratios plus a CDC lint, not a shared-clock loopback — that is what separates a stream that works on the bench from one that works in silicon.

8. Interview Q&A

9. Exercises

Design and reasoning exercises — no syntax drills. Each rehearses composing the pieces and getting the domain/latency accounting right.

Exercise 1 — Size the FIFO for a burst across a clock ratio

A producer at 200 MHz emits bursts of up to B = 64 words back-to-back; a consumer at 50 MHz drains one word per its clock. State (i) how many words accumulate in the FIFO during one worst-case burst as a function of the two rates and B, (ii) the minimum depth (rounded to a power of two for the gray pointers) plus the margin you add for the two-flop flag latency, and (iii) where backpressure acts and what the producer must do while stalled. Then say what changes if the consumer's average rate is below the producer's average rate rather than just its peak.

Exercise 2 — Predict the wrong-domain-flag failure

An engineer wires in_ready = ~wfull & ~rempty — adding the read-domain REMPTY into the write-domain flow logic with no synchronizer, 'to stop the producer early when the FIFO is nearly empty.' Describe precisely (i) what a shared-clock (locked-ratio) sim reports, (ii) what happens once clk_a and clk_b drift, (iii) which failure — overflow, underflow, or permanent stall — each metastable resolution can cause, and (iv) the one-line fix and the two verification changes (clock periods, CDC lint) that would have exposed it.

Exercise 3 — Add a side-band flush that must cross domains

The consumer in clk_b needs to tell the producer in clk_a to flush and restart the stream. State (i) why you cannot wire the flush request straight from clk_b into the producer's logic, (ii) how to cross a single-bit level safely (a two-flop synchronizer) versus a single-cycle pulse (a toggle-synchronizer / pulse-crossing), and (iii) how flushing the FIFO interacts with the in-flight words and the pointers on each side. Name which earlier pattern each crossing draws on.

Exercise 4 — Make it a skid-buffered egress

The producer's in_ready = ~wfull is combinational off the FIFO's WFULL, which can become a long backpressure path at high frequency. Add a skid buffer (9.3) on the write face so in_ready is registered. State (i) why a purely combinational in_ready is a timing risk, (ii) what the skid buffer must hold so no word is lost when in_ready drops the same cycle a word is offered, and (iii) how the extra registered stage changes the effective FIFO depth and the flag-latency accounting.

The patterns this stream composes (the lessons you are assembling here):

  • The Asynchronous FIFO — Chapter 11.6; the bridge that does the clock-domain crossing internally — gray pointers, two-flop synchronizers, conservative WFULL/REMPTY flags — instantiated here, not re-derived.
  • The valid/ready Handshake — Chapter 9.1; the local interface on each face — in_ready = ~WFULL (producer backpressure), out_valid = ~REMPTY (consumer flow), a transfer on valid && ready.
  • Backpressure & Stalls — Chapter 9.2; how WFULL stalls the producer in clk_a and REMPTY stalls the consumer in clk_b, each acting locally against its own flag.
  • Synchronous FIFO Architecture — Chapter 7.1; the FIFO body — dual-port RAM, pointers, ordering — that the async FIFO extends across two clocks.
  • Gray-Pointer Synchronization — Chapter 11.5; why each pointer crosses as gray code so any mid-transition sample is a real value at most one step behind.
  • The Two-Flop Synchronizer — Chapter 11.2; the structure that carries each gray pointer into the other domain and the only thing that legally crosses the boundary.
  • Metastability — Chapter 11.1; the failure that a raw flag or control crossing re-opens — the root of the §7 row-1 bug.
  • CDC Design Rules — Chapter 11.7; the lint/discipline that proves the only crossings are the synchronized gray pointers — no data bus, flag, or control raw.

Backward / method:

  • UART Case Study — Chapter 13.1; the first capstone — composing patterns into a real peripheral rather than inventing one, the same design skill this closing case study rehearses at the system level.
  • The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this case study walks end to end.
  • What Is an RTL Design Pattern? — Chapter 0.1; why composing patterns into a complete block is the design skill the whole track has been building toward.

Forward — the other case studies (unlock as they ship):

  • SPI Master Case Study (/rtl-design-patterns/case-study-spi-master) — Chapter 13.2; FSM + datapath + handshake, source-synchronous rather than dual-clock.
  • Streaming FIFO Case Study (/rtl-design-patterns/case-study-streaming-fifo) — Chapter 13.3; a streaming block with FIFOs and backpressure in a single clock, the sibling to this dual-clock version.
  • Arbitered Bus Case Study (/rtl-design-patterns/case-study-arbitered-bus) — Chapter 13.4; many masters, one shared resource, resolved by an arbiter.

Verilog v1 prerequisites (the grammar these blocks transcribe into):

11. Summary

  • Dual-clock streaming is patterns composed, not a new pattern — and it closes the track. It is an async FIFO (11.6) doing the crossing, wrapped by a valid/ready (9.1) egress in clk_a and a valid/ready ingress in clk_b, over a FIFO body (7.1). CDC + handshake + FIFO become one dual-clock streaming block; each piece is a lesson you already had, and the capstone is wiring them with the domains and latency exact.
  • Each face is an ordinary handshake against its own-domain flag. The producer writes on in_valid && in_ready with in_ready = ~WFULL (its backpressure); the consumer reads on out_valid && out_ready with out_valid = ~REMPTY (its flow). Neither block sees the other clock — the FIFO hides the crossing, and each side looks only at the flag computed in its own domain.
  • The async FIFO's conservative flags are the whole safety argument. WFULL (in clk_a, from the synchronized read pointer) is only ever pessimistic about free space, so the producer never overflows; REMPTY (in clk_b, from the synchronized write pointer) is only ever pessimistic about available data, so the consumer never underflows. Only gray pointers cross, through two-flop synchronizers, so no metastable multi-bit value ever reaches logic — the cost is a hair of latency, the gain is absolute integrity under any clock ratio.
  • The two signature bugs are a wrong-domain crossing and an ignored latency. Reading a flag or control in the wrong domain (REMPTY in clk_a, or any raw level crossing unsynchronized) re-opens metastability on the flow signal and causes overflow, underflow, or a permanent stall (§7 row 1); trusting a computed occupancy or assuming full clears instantly, and sizing the depth with no margin, overruns a nearly-full FIFO (§7 row 2). Own-domain flags, synchronized crossings, and depth sized for the burst plus the flag latency close both.
  • Prove it with a two-unrelated-clock scoreboard at both ratios, not a shared clock. Push with random valid gaps, pop with random ready, assert every word crosses exactly once in order with winc never coincident with WFULL and rinc never coincident with REMPTY — at fast-clk_a/slow-clk_b and the reverse — plus edge jitter and a CDC lint proving the only crossings are the synchronized gray pointers. That is what separates a dual-clock stream that works on the bench from one that works in silicon — two-async-clock scoreboard testbenches shown in SystemVerilog, Verilog, and VHDL.