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.
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:
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.
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.
// 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.
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.
// 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
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.
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 Configuration | Ratio | Bugs Exposed |
|---|---|---|
| Fast-to-slow | clk_a = 3x, clk_b = 1x | Multi-cycle data valid in slow domain; pulse stretching bugs |
| Slow-to-fast | clk_a = 1x, clk_b = 3x | Handshake timing; ack arrives before req is stable |
| Same frequency, different phase | 1:1 at 90 deg or 180 deg | Synchronizer hold margin; near-simultaneous edges |
| Irrational ratio | clk_a = 100 MHz, clk_b = 133 MHz | Wandering 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.
// 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
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:
// 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
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-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
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.
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 Bin | Scenario | Why It Matters |
|---|---|---|
| direction_a_to_b | Data flows A to B | Confirms basic crossing works |
| direction_b_to_a | Data flows B to A (return path) | Often untested in uni-directional designs |
| min_latency | Crossing latency = 2 dst cycles | Best case; synchronizer just barely resolves |
| max_latency | Crossing latency = 3 dst cycles | Worst case; consumer must still work correctly |
| simultaneous_crossing | Two boundaries cross in same dst cycle | Exposes ordering dependencies |
| crossing_during_powerdown | Data sent while dst domain gates clock | Power management interaction — must not corrupt |
| back_to_back | Two crossings with no idle cycle between | Tests FIFO full / handshake pipeline depth |
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
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:
// -------------------------------------------------------
// 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!");
A complete CDC testbench has three distinct layers that operate simultaneously:
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.
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.
`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
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.
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.
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.