HomeCDC GuideDay 11
DAY 11 · CDC VERIFICATION & TOOLS

CDC Testbenches — Catching Bugs Before Silicon

By EcrioniX · Updated Jun 23, 2026

RTL simulation is excellent at catching functional bugs, but it is almost useless at catching CDC bugs on its own. Metastability is a probabilistic physical event that simply does not exist in a digital simulator. To find CDC bugs before tapeout, you need deliberate injection techniques, smarter testbench architecture, targeted coverage, and well-placed SVA assertions. This day covers every strategy in depth.

CDC Testbench Architecture Overview Clock Generator Configurable ratio Injection Wrapper Metastability / X / Glitch DUT Multi-clock RTL + Synchronizers CDC Monitor Latency + assertions UVM Scoreboard — Delay Queue Model Expected values shifted N cycles for crossing latency
Figure — CDC testbench layers: clock generator → injection wrapper → DUT → CDC monitor → delay-queue scoreboard.

1. Why Simulation Alone Cannot Catch CDC Bugs

Standard RTL simulation is a purely digital world: every signal is 0, 1, X, or Z. Metastability — the analog settling of a flip-flop after a setup-time violation — does not exist there. The simulator picks one of two edges and drives a clean digital transition.

To put numbers on the problem: a real flip-flop operating at 500 MHz sees a potential metastability event roughly once every 107 to 109 clock cycles, depending on the synchronizer MTBF target. Running a simulation to 109 cycles takes hundreds of hours and still may not trigger the exact cycle where the crossing causes downstream corruption. The three real risks that simulation misses are:

The simulation gap

Teams that rely entirely on functional simulation to verify CDC typically find bugs at bring-up or in customer returns. Industry data from TSMC reliability reports consistently shows CDC violations in the top-3 causes of first-silicon re-spins.

2. Metastability Injection

The most effective simulation technique is to insert a small random sub-cycle delay on the crossing signal just before it reaches the synchronizer first flip-flop. The delay is chosen uniformly in the range (0.1, 0.9) × destination clock period. This forces the flip-flop to sample an input that changed within its aperture window — the exact condition that produces metastability in silicon.

What this verifies: even if the synchronizer first stage nearly misses, the second stage (one full destination clock later) must output a clean, stable value. If any downstream logic uses the first-stage output directly, the random delay will expose it with X-like timing jitter.

cdc_meta_inject.sv — Metastability injection wrapper
// Metastability injection wrapper — insert before synchronizer in TB only
// Connect: sig_src -> meta_inject -> synchronizer input
// Do NOT synthesise this module

`timescale 1ns/1ps

module cdc_meta_inject #(
    parameter real CLK_DST_PERIOD = 4.0   // destination clock period (ns)
)(
    input  logic sig_src,   // signal from source domain
    output logic sig_out    // signal fed to synchronizer first FF
);

    real   inject_delay;
    logic  sig_prev;

    initial sig_prev = 1'b0;

    always @(sig_src) begin
        // New transition detected — apply a random sub-cycle delay
        inject_delay = 0.1 * CLK_DST_PERIOD +
                       ($urandom_range(0, 799) / 1000.0) * CLK_DST_PERIOD;
        // Clamp to [0.1, 0.9] * period
        if (inject_delay > 0.9 * CLK_DST_PERIOD)
            inject_delay = 0.9 * CLK_DST_PERIOD;
        #(inject_delay);
        sig_out  = sig_src;
        sig_prev = sig_src;
    end

endmodule

Instantiate one wrapper per CDC boundary in your testbench. The synthesis tool must never see this module — use `ifndef SYNTHESIS guards or keep it in a testbench-only file list.

3. X-Injection on CDC Crossings

A complementary technique is X-injection: force the first-stage synchronizer output to 1'bx for exactly one destination clock cycle after each crossing edge, then release it. Any combinational path that uses this output will propagate X downstream, immediately flagging unprotected paths as X in simulation waveforms.

This technique is especially powerful because it is deterministic and does not require long runtimes. It can be applied on every CDC crossing every time the input transitions.

cdc_x_inject.sv — X-injection on first synchronizer stage
// X-injection monitor: forces FF1 output to X for one clk_dst cycle
// Bind or instantiate alongside synchronizer in simulation only

`timescale 1ns/1ps

module cdc_x_inject #(
    parameter real CLK_DST_PERIOD = 4.0
)(
    input  logic clk_dst,
    input  logic sig_in,     // signal entering synchronizer FF1
    output logic ff1_out     // monitored / injected FF1 output
);
    logic ff1_int;
    logic inject_active;

    initial begin
        ff1_int       = 1'b0;
        inject_active = 1'b0;
    end

    // Detect any crossing edge asynchronously
    always @(posedge sig_in or negedge sig_in) begin
        inject_active = 1'b1;
        @(posedge clk_dst);
        inject_active = 1'b0;
    end

    // Normal FF1 behaviour
    always @(posedge clk_dst)
        ff1_int <= sig_in;

    // Override output with X during injection window
    assign ff1_out = inject_active ? 1'bx : ff1_int;

endmodule

What to look for with X-injection

Run your functional test suite with X-injection enabled. Look for X propagation into: state machine next-state logic, FIFO control counters, data paths. Each X arrival means the signal bypasses proper synchronization or is consumed one cycle too early.

4. Clock Relationship Testing

A single functional test run with two specific clocks is not enough. You must run at least four clock configurations to expose different classes of CDC bug:

Clock ConfigurationRatioBugs Exposed
Fast-to-slowclk_a = 3x, clk_b = 1xMulti-cycle data valid in slow domain; pulse stretching bugs
Slow-to-fastclk_a = 1x, clk_b = 3xHandshake timing; ack arrives before req is stable
Same frequency, different phase1:1 at 90 deg or 180 degSynchronizer hold margin; near-simultaneous edges
Irrational ratioclk_a = 100 MHz, clk_b = 133 MHzWandering phase — every crossing latency from 1 to N+1 cycles exercised

The irrational-ratio configuration is the most valuable: because the phase between clocks drifts continuously, the crossing latency naturally varies between 1 and 2 destination cycles (for a 2-FF synchronizer), exercising all latency scenarios without manual intervention.

tb_clocks.sv — Configurable dual-clock generator
// Parameterised dual-clock generator with configurable ratio mode
// MODE: 0=fast_to_slow  1=slow_to_fast  2=same_phase  3=irrational

`timescale 1ns/1ps

module tb_clocks #(
    parameter int MODE = 3
)(
    output logic clk_a,
    output logic clk_b
);
    localparam real TA_FAST = 2.5;    // 400 MHz
    localparam real TB_SLOW = 7.5;    // 133 MHz
    localparam real TA_SLOW = 7.5;
    localparam real TB_FAST = 2.5;
    localparam real TA_IRR  = 5.0;    // 200 MHz
    localparam real TB_IRR  = 3.759;  // ~266 MHz — irrational ratio
    localparam real T_SAME  = 5.0;    // 200 MHz, 90-deg offset

    real pa, pb, phase_b;

    initial begin
        case (MODE)
            0: begin pa = TA_FAST; pb = TB_SLOW; phase_b = 0.0;    end
            1: begin pa = TA_SLOW; pb = TB_FAST; phase_b = 0.0;    end
            2: begin pa = T_SAME;  pb = T_SAME;  phase_b = 1.25;   end // 90 deg
            3: begin pa = TA_IRR;  pb = TB_IRR;  phase_b = 0.0;    end
            default: begin pa = 5.0; pb = 5.0;   phase_b = 0.0;    end
        endcase
        clk_a = 0;
        clk_b = 0;
        #(phase_b);
        forever #(pb / 2.0) clk_b = ~clk_b;
    end

    initial begin
        clk_a = 0;
        forever #(pa / 2.0) clk_a = ~clk_a;
    end

endmodule

5. Glitch Injection

Clock enable signals and asynchronous resets that cross domains are frequent sources of silent corruption. A 1-cycle glitch on a clock enable can latch an incorrect value. A glitch on a reset can release logic before the crossing handshake completes.

Glitch injection procedure:

  1. Identify every clock enable, async reset, and power-good signal that crosses a domain boundary.
  2. For each, insert a 1-cycle pulse (0 to 1 to 0 within a single source clock cycle) at a random simulation time.
  3. Check: does any data register in the destination domain capture an incorrect value? Does any state machine enter an invalid state?
  4. Repeat at both domain power-up and steady-state operation.
glitch_inject_task.sv — 1-cycle glitch injection task
// Task: inject a 1-cycle glitch on any control signal
// Usage: inject_glitch(clk_src, cen_signal, 50);

task automatic inject_glitch(
    input  logic       clk_src,
    ref    logic       target_sig,     // control signal to glitch
    input  int unsigned wait_cycles    // source cycles to wait before glitch
);
    // Wait the requested number of cycles
    repeat(wait_cycles) @(posedge clk_src);
    // Glitch: assert high for exactly one clock cycle then release
    @(negedge clk_src);
    target_sig = 1'b1;
    @(negedge clk_src);
    target_sig = 1'b0;
endtask

6. CDC-Aware UVM Scoreboard

A standard UVM scoreboard compares expected and actual values cycle-by-cycle or transaction-by-transaction. In a multi-clock design, the destination domain receives data N cycles after the source sends it, where N depends on the clock ratio and synchronizer depth. If the scoreboard does not account for this, it will generate false mismatches or, worse, accidentally align wrong expected/actual pairs and mask bugs.

The correct approach is a delay queue model: expected values are pushed into a queue in the source domain and popped N cycles (in destination domain time) later for comparison.

cdc_scoreboard.sv — UVM delay-queue scoreboard sketch
// CDC-aware scoreboard — SystemVerilog / UVM
// Parameterise N = crossing latency in destination clock cycles

class cdc_scoreboard #(int N = 3) extends uvm_scoreboard;
    `uvm_component_param_utils(cdc_scoreboard #(N))

    uvm_analysis_imp #(data_txn, cdc_scoreboard #(N)) src_port;
    uvm_analysis_imp #(data_txn, cdc_scoreboard #(N)) dst_port;

    // Delay queue: expected values with source-side timestamp
    data_txn expected_q[$];
    longint   timestamp_q[$];   // source arrival time (ps)

    int unsigned crossing_errors = 0;
    real DST_CLK_PERIOD = 4000.0;  // ps — set per crossing

    // Called by src_port monitor each time source domain sends data
    function void write(data_txn txn);
        expected_q.push_back(txn);
        timestamp_q.push_back($time);
    endfunction

    // Called by dst_port monitor each time destination domain latches data
    function void write_dst(data_txn actual);
        data_txn  expected;
        longint   expected_time;
        real      latency_cycles;

        if (expected_q.size() == 0) begin
            `uvm_error("CDC_SB", "Destination received data — queue empty")
            return;
        end

        expected      = expected_q.pop_front();
        expected_time = timestamp_q.pop_front();
        latency_cycles = ($time - expected_time) / DST_CLK_PERIOD;

        if (actual.data !== expected.data) begin
            `uvm_error("CDC_SB", $sformatf(
                "Mismatch after CDC: expected 0x%0h got 0x%0h (latency=%.1f cyc)",
                expected.data, actual.data, latency_cycles))
            crossing_errors++;
        end

        // Flag unexpectedly long crossing — possible synchronizer failure
        if (latency_cycles > N + 1) begin
            `uvm_warning("CDC_SB", $sformatf(
                "Crossing latency %.1f > %0d+1 cycles — check synchronizer",
                latency_cycles, N))
        end
    endfunction

endclass

Calibrating N

Run a directed test with a single known data transaction. Use $time at source send and destination receive. Divide the difference by the destination clock period. Round up and add one cycle of margin. For a 2-FF synchronizer at a 2:1 clock ratio, N is typically 3–4. Parameterise it so regression can sweep multiple values per crossing type.

7. Coverage for CDC

Coverage prevents the "we ran a lot of simulation hours" fallacy. Without CDC-specific coverage bins, you do not know if the critical scenarios were exercised. Define the following covergroup on every CDC boundary:

Coverage BinScenarioWhy It Matters
direction_a_to_bData flows A to BConfirms basic crossing works
direction_b_to_aData flows B to A (return path)Often untested in uni-directional designs
min_latencyCrossing latency = 2 dst cyclesBest case; synchronizer just barely resolves
max_latencyCrossing latency = 3 dst cyclesWorst case; consumer must still work correctly
simultaneous_crossingTwo boundaries cross in same dst cycleExposes ordering dependencies
crossing_during_powerdownData sent while dst domain gates clockPower management interaction — must not corrupt
back_to_backTwo crossings with no idle cycle betweenTests FIFO full / handshake pipeline depth
cdc_coverage.sv — CDC functional covergroup
covergroup cdc_crossing_cg (string boundary_name) @(posedge clk_dst);

    // Crossing latency distribution
    cp_latency: coverpoint crossing_latency_cycles {
        bins min_lat  = {2};
        bins nominal  = {3};
        bins max_lat  = {4};
        bins overrun  = {[5:$]};   // should never be hit — flag immediately
    }

    // Data values at boundary
    cp_data: coverpoint data_at_boundary {
        bins zero    = {8'h00};
        bins max_val = {8'hFF};
        bins mid_val = {[8'h40 : 8'hBF]};
        bins others  = default;
    }

    // Simultaneous crossings across multiple boundaries
    cp_sim_cross: coverpoint sim_crossing_count {
        bins none     = {0};
        bins one      = {1};
        bins two_plus = {[2:$]};   // stress scenario
    }

    // Back-to-back crossings
    cp_b2b: coverpoint back_to_back_flag {
        bins isolated    = {1'b0};
        bins consecutive = {1'b1};
    }

    // Power domain state during crossing
    cp_pwr: coverpoint dst_domain_active {
        bins active = {1'b1};
        bins gated  = {1'b0};   // crossing during clock gate — must not corrupt
    }

    // Cross latency vs power state
    cx_lat_pwr: cross cp_latency, cp_pwr;

endgroup

8. SVA Assertions for CDC

SystemVerilog Assertions placed at CDC boundaries run throughout simulation and catch violations the moment they occur, rather than during scoreboard comparison at test end. Three categories of assertion are essential:

cdc_assertions.sv — SVA properties for CDC boundaries
// -------------------------------------------------------
// ASSERTION 1: Synchronizer output stable after crossing
// sync_q1 = FF1 output; sync_q2 = FF2 output (safe data)
// No downstream logic should consume sync_q1 directly
// -------------------------------------------------------
property p_sync_stability;
    @(posedge clk_dst)
    $rose(sync_q1) |->
        ##1 (sync_q2 !== 1'bx);
endproperty
a_sync_stable: assert property(p_sync_stability)
    else $error("[CDC] sync_q2 undefined 1 cycle after sync_q1 rose");

// -------------------------------------------------------
// ASSERTION 2: Gray-code pointer changes by exactly 1 bit
// For async FIFO gray pointer crossing — 8-bit example
// -------------------------------------------------------
function automatic int popcount_diff(
    input logic [7:0] a, input logic [7:0] b);
    logic [7:0] d = a ^ b;
    int cnt = 0;
    for (int i = 0; i < 8; i++) cnt += int'(d[i]);
    return cnt;
endfunction

property p_gray_code;
    logic [7:0] prev_ptr;
    @(posedge clk_dst)
    (1, prev_ptr = wr_ptr_gray) |->
        ##1 (popcount_diff(wr_ptr_gray, prev_ptr) <= 1);
endproperty
a_gray_ptr: assert property(p_gray_code)
    else $error("[CDC] Gray pointer changed by more than 1 bit!");

// -------------------------------------------------------
// ASSERTION 3: FIFO overrun / underrun protection
// -------------------------------------------------------
property p_no_overrun;
    @(posedge clk_src)
    wr_en |-> !fifo_full;
endproperty
a_no_overrun: assert property(p_no_overrun)
    else $error("[CDC FIFO] Write to full FIFO — overrun!");

property p_no_underrun;
    @(posedge clk_dst)
    rd_en |-> !fifo_empty;
endproperty
a_no_underrun: assert property(p_no_underrun)
    else $error("[CDC FIFO] Read from empty FIFO — underrun!");

// -------------------------------------------------------
// ASSERTION 4: Two-phase handshake — req held until ack
// -------------------------------------------------------
property p_req_held;
    @(posedge clk_src)
    $rose(req) |-> req throughout (##[1:20] $rose(ack));
endproperty
a_req_held: assert property(p_req_held)
    else $error("[CDC] req deasserted before ack — handshake violated!");

9. CDC Testbench Architecture — Bringing It Together

A complete CDC testbench has three distinct layers that operate simultaneously:

  1. Clock and injection layer — the configurable clock generator drives all domain clocks. An injection wrapper per CDC boundary sits between source logic and the synchronizer, selectable between no injection, metastability injection, and X-injection via a top-level parameter.
  2. Monitoring layer — a CDC monitor component watches each boundary. It timestamps every crossing, measures latency in destination clock cycles, accumulates functional coverage bins, and fires assertion violations immediately.
  3. Checking layer — the delay-queue scoreboard receives transactions from both source and destination monitors. It aligns them using N-cycle modelling and flags mismatches or timing outliers.

The test scenarios themselves (four clock modes + directed stress sequences + glitch injection) should be run as a regression matrix: four clock modes x three injection types (none, metastability, X) x the functional test suite. A fully passing regression with all CDC coverage bins green and zero assertion failures is the minimum bar before CDC sign-off.

CDC Regression Matrix — Sign-off Criteria Test Scenario No Inject Meta Inject X Inject Coverage Target Fast to Slow PASS PASS PASS min_lat + max_lat bins Slow to Fast PASS PASS PASS handshake bins Irrational Ratio PASS PASS PASS all latency bins Glitch + Power-down PASS PASS PASS crossing_during_powerdown
Figure — CDC regression matrix. All cells pass and all coverage bins hit = simulation sign-off achieved.

10. Complete Testbench Skeleton

Below is a minimal but complete testbench skeleton wiring all components: clock generator, injection wrapper, DUT, SVA bind, and scoreboard instantiation. Expand each section for your specific design.

tb_cdc_top.sv — Complete CDC testbench skeleton
`timescale 1ns/1ps

module tb_cdc_top;

    // ---- Parameters ----
    parameter int  CLK_MODE    = 3;     // 3 = irrational ratio
    parameter int  META_ENABLE = 1;     // 1 = metastability injection
    parameter int  X_ENABLE    = 1;     // 1 = X injection
    parameter int  SB_LATENCY  = 3;     // scoreboard delay queue depth
    parameter real DST_CLK_NS  = 4.0;  // destination clock period (ns)

    // ---- Clocks ----
    logic clk_a, clk_b;
    tb_clocks #(.MODE(CLK_MODE)) u_clkgen (
        .clk_a(clk_a),
        .clk_b(clk_b)
    );

    // ---- DUT signals ----
    logic        req_src, req_injected;
    logic [7:0]  data_src, data_dst;
    logic        ack;

    // ---- Metastability injection on req crossing ----
    generate
        if (META_ENABLE) begin : gen_meta
            cdc_meta_inject #(.CLK_DST_PERIOD(DST_CLK_NS)) u_meta (
                .sig_src(req_src),
                .sig_out(req_injected)
            );
        end else begin : gen_no_meta
            assign req_injected = req_src;
        end
    endgenerate

    // ---- DUT ----
    cdc_design u_dut (
        .clk_a   (clk_a),
        .clk_b   (clk_b),
        .req_in  (req_injected),
        .data_in (data_src),
        .req_out (/* connected to monitor */),
        .data_out(data_dst),
        .ack     (ack)
    );

    // ---- Bind SVA assertions ----
    bind cdc_design cdc_assertions u_sva (
        .clk_dst (clk_b),
        .sync_q1 (u_dut.sync_ff1),
        .sync_q2 (u_dut.sync_ff2),
        .req     (req_injected),
        .ack     (ack)
    );

    // ---- CDC functional coverage ----
    cdc_crossing_cg cg_req ("req_crossing");

    // ---- Test stimulus ----
    integer seed = 42;
    initial begin
        req_src  = 0;
        data_src = 8'h00;
        repeat(10) @(posedge clk_a);

        // Directed: fast crossing sequence
        repeat(20) begin
            @(posedge clk_a);
            data_src = $urandom(seed);
            req_src  = 1'b1;
            @(posedge clk_a);
            req_src  = 1'b0;
            // Wait for ack with timeout
            fork
                begin : wait_ack
                    @(posedge ack);
                end
                begin : timeout_guard
                    #(500ns);
                    $error("[TB] Handshake timeout — ack not received");
                    disable wait_ack;
                end
            join_any
            disable fork;
        end

        // Glitch inject on clock enable
        inject_glitch(clk_a, u_dut.cen, 15);

        repeat(20) @(posedge clk_b);
        $display("[TB] Simulation complete. Check coverage and assertions.");
        $finish;
    end

    task automatic inject_glitch(
        input logic clk, ref logic sig, input int w);
        repeat(w) @(posedge clk);
        @(negedge clk); sig = 1;
        @(negedge clk); sig = 0;
    endtask

endmodule

11. Key Takeaways

Day 11 takeaways

FAQ

Why can't standard simulation catch CDC metastability bugs?

Standard simulation uses ideal 0/1 logic with no analog voltage. Metastability is a probabilistic physical event — a flip-flop settling slowly due to a near-simultaneous input and clock edge. You need deliberate injection techniques (random delays, X propagation) to model the effect in simulation.

What is metastability injection in a CDC testbench?

A wrapper module inserts a small random delay (typically 0.1 to 0.9 of one clock cycle) on the crossing signal before the synchronizer first flip-flop. This forces the synchronizer to resolve a near-metastable input, exposing timing issues and verifying that downstream logic does not use data too early.

How do you calibrate the N-cycle delay in a CDC UVM scoreboard?

Run a directed test with a known data sequence. Use $time at source send and destination receive. Divide the difference by the destination clock period. Round up and add one cycle margin. Parameterise the result so regressions can sweep multiple values per crossing.

Previous
← Day 10: Multi-Clock Hierarchies

← Full course roadmap