Two days before sign-off, the structural CDC run returns 40 findings. Read one at a time, that is a week of debate; sorted by class, most of them close in an afternoon, because each class has one standard fix or one kind of waiver.
Every finding names a source register, a destination register and their two clocks. The class says what kind of crossing it is, and the tree turns the class into an action.
Run each finding through the tree from the top. Only one leaf is a waiver, and it needs evidence, not an opinion.
One synchronised bit: usually clean. Confirm the instance is the approved module and that nothing taps its first flop.
No synchroniser: add one, unless the value cannot change while the destination uses it. That case is a waiver, and an assertion is its proof.
Several bits: per-bit synchronisers are wrong whenever the bits change on the same edge, whether they form one bus or were synchronised apart and meet again later.
The design below is small on purpose. Domain A runs on clk_a, domain B on clk_b, and the clocks are unrelated. Each class from the tree appears once.
// Two-clock example for the CDC report walk-through. clk_a: source domain, clk_b: destination.
module sync2 (input logic clk, rst_n, d, output logic q); // the approved 2-flop synchroniser
logic q1;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin q1 <= 1'b0; q <= 1'b0; end
else begin q1 <= d; q <= q1; end
endmodule
module dom_a (
input logic clk_a, rst_a_n,
input logic start, err, step, req, last, cfg_we,
input logic [7:0] cfg_d,
input logic run_b, // from domain B
output logic start_a, err_a, req_a, last_a,
output logic [3:0] cnt_a,
output logic [7:0] cfg_a
);
logic busy_a;
sync2 u_sync_busy (.clk(clk_a), .rst_n(rst_a_n), .d(run_b), .q(busy_a));
always_ff @(posedge clk_a or negedge rst_a_n)
if (!rst_a_n) begin
start_a <= 1'b0; err_a <= 1'b0; req_a <= 1'b0; last_a <= 1'b0;
cnt_a <= 4'd0; cfg_a <= 8'd0;
end else begin
start_a <= start; err_a <= err; req_a <= req; last_a <= last;
if (step) cnt_a <= cnt_a + 4'd1;
if (cfg_we && !start_a && !busy_a) cfg_a <= cfg_d; // only while B is idle
end
endmodule
module dom_b (
input logic clk_b, rst_b_n,
input logic start_a, err_a, req_a, last_a,
input logic [3:0] cnt_a,
input logic [7:0] cfg_a,
output logic run_b, err_b,
output logic [1:0] cmd_b,
output logic [3:0] cnt_b,
output logic [7:0] acc_b
);
logic req_s, last_s;
sync2 u_sync_start (.clk(clk_b), .rst_n(rst_b_n), .d(start_a), .q(run_b));
always_ff @(posedge clk_b or negedge rst_b_n) // err_a captured directly
if (!rst_b_n) err_b <= 1'b0; else err_b <= err_a;
for (genvar i = 0; i < 4; i++) begin : g_cnt // one synchroniser per bit
sync2 u_sync_cnt (.clk(clk_b), .rst_n(rst_b_n), .d(cnt_a[i]), .q(cnt_b[i]));
end
sync2 u_sync_req (.clk(clk_b), .rst_n(rst_b_n), .d(req_a), .q(req_s));
sync2 u_sync_last (.clk(clk_b), .rst_n(rst_b_n), .d(last_a), .q(last_s));
assign cmd_b = {req_s, last_s}; // the two bits meet again here
always_ff @(posedge clk_b or negedge rst_b_n) // cfg_a used without a synchroniser
if (!rst_b_n) acc_b <= 8'd0;
else if (run_b) acc_b <= acc_b + cfg_a;
endmodule
module cdc_demo (
input logic clk_a, rst_a_n, clk_b, rst_b_n,
input logic start, err, step, req, last, cfg_we,
input logic [7:0] cfg_d,
output logic run_b, err_b,
output logic [1:0] cmd_b,
output logic [3:0] cnt_b,
output logic [7:0] acc_b
);
logic start_a, err_a, req_a, last_a;
logic [3:0] cnt_a;
logic [7:0] cfg_a;
dom_a u_a (.*);
dom_b u_b (.*);
endmoduleCrossing | How it crosses | Class |
|---|---|---|
|
| Synchronised single bit |
|
| Synchronised single bit (status back to A) |
| Straight into a | Unsynchronised single bit |
| Four | Bus through per-bit synchronisers |
| Two | Reconvergence |
| None; written only while B is idle | Quasi-static |
No CDC tool runs in this course's flow, so the report below uses an illustrative, vendor-neutral format written for this lesson. Real tools word things differently, but each gives these facts per finding: source, destination, both clocks, a class and a severity.
ILLUSTRATIVE FORMAT, written for this lesson; not tool output
design cdc_demo clocks clk_a, clk_b (asynchronous)
F1 info synchronised single bit
from u_a.start_a (clk_a)
to u_b.u_sync_start (clk_b)
F2 info synchronised single bit
from u_b.run_b (clk_b)
to u_a.u_sync_busy (clk_a)
F3 violation unsynchronised single bit
from u_a.err_a (clk_a)
to u_b.err_b (clk_b)
F4 violation bus through per-bit synchronisers
from u_a.cnt_a[3:0] (clk_a)
to u_b.g_cnt[0:3].u_sync_cnt (clk_b)
F5 caution reconvergence of synchronised bits
from u_a.req_a, u_a.last_a (clk_a)
to u_b.cmd_b via u_sync_req, u_sync_last (clk_b)
F6 violation multi-bit crossing, no synchroniser
from u_a.cfg_a[7:0] (clk_a)
to u_b.acc_b (clk_b)
summary: 2 info, 1 caution, 3 violationsThe severities follow the Rules lesson: info is a crossing the tool recognised, a caution is one it cannot prove safe, and a violation has no recognised structure. Work through the violations first, then the cautions, then skim the info lines.
For each finding: say what it means, confirm it in the RTL, then fix it or waive it. The numbers come from a hostile simulation of this design, listed under Testbench: 10 ns and 7.13 ns clocks with jitter, and route delays of 0 to 1.2 ns on the crossing wires.
The tool found its approved synchroniser. Confirm that the instance is sync2, that both flops are on the destination clock, and that only q leaves the module. Then check the stability rule: start_a and run_b are levels that hold for many cycles, so two flops are enough.
Do not skip info lines. A crossing you expected here but cannot find means the tool did not recognise your structure, and it is reported somewhere else as a violation.
Follow err_b's D input back: it reaches a clk_a register with nothing in between. In the simulation, 341 of 7,922 changes of err_a landed less than 0.3 ns before a clk_b edge. In silicon each of those is a setup violation on err_b, which can then go metastable.
The fix is a sync2 if err is a level that holds for at least two clk_b periods. A one-cycle pulse needs a pulse synchroniser or a handshake, because two flops can miss it. Never waive this class.
Each bit of cnt_a has its own synchroniser, and the source is a binary counter, so 0111 to 1000 changes four bits on one edge. The four g_cnt instances on one vector are the sign to look for in the RTL.
In the simulation, 1,940 of 20,968 changes seen at cnt_b were not +1. The first went 7, then 6, then 8: bit 0 arrived one edge before the others, so the destination read 6, a value the source was not holding, and the counter appeared to run backwards.
A counter that steps by one crosses as registered Gray code: the bench sends the same counter as Gray code over the same wires and sees 0 bad changes. Arbitrary data needs a 4-phase handshake or an async FIFO. A third flop per bit does not help: route skew still splits the bits across edges.
req_a and last_a are each synchronised correctly, and then they meet again in cmd_b. In the RTL, find where the two synchroniser outputs join, then ask whether the source changes both bits on the same edge. Here it does: {req, last} only moves between 00 and 11.
In the simulation, cmd_b read 01 or 10 in 1,680 clk_b cycles, although the source never sent either. The tool reports a caution because it cannot know whether the bits are related; the designer can.
Consolidate before synchronising. If the destination only needs "last word of a request", register req_a && last_a in domain A and send that one bit. If both bits carry information, send them as one word under a handshake.
Eight bits of cfg_a enter domain B with no synchroniser, and acc_b reads them on every run cycle. Now read the write side: dom_a writes cfg_a only when start_a and busy_a are both low, and busy_a is run_b synchronised back into A. So cfg_a changes only while B is idle, and it has settled long before run_b rises again.
This is the one class where a waiver is right: the value does not change while it is used, so a synchroniser would add nothing. The simulation agrees: 0 changes of cfg_a while run_b was high, in 56,087 run cycles, while 2,139 of 4,018 write attempts arrived during a run and were blocked.
A waiver is a claim that a finding is safe, plus its proof. Start from the general fields in Waiver rules that scale; a CDC waiver adds the evidence and the condition that voids it.
Field | What to write |
|---|---|
Finding | The report line, with source, destination and both clocks |
Signal | The exact register, |
Reason it is safe | Why the value cannot change while the destination uses it |
Evidence | The RTL condition that enforces the reason, and the assertion that checks it in every regression |
Owner | The engineer who defends it at review |
Review date | When it was last checked against the RTL |
Expiry | The change that voids it, such as a new writer of the register |
// snippet: waiver evidence for F6, bound into dom_b
a_cfg_quiet: assert property (@(posedge clk_b) disable iff (!rst_b_n)
run_b |-> $stable(cfg_a));The assertion lives in domain B, where the value is used: while run_b is high, cfg_a must not change.
BAD
F6 cfg_a is quasi-static, only written while start is low. Safe.
GOOD
finding F6 u_a.cfg_a[7:0] (clk_a) -> u_b.acc_b (clk_b), no synchroniser
signal u_a.cfg_a[7:0]
reason written only while B is idle, so stable whenever B samples it
evidence write condition cfg_we && !start_a && !busy_a in dom_a, where
busy_a is run_b synchronised to clk_a (u_sync_busy);
assertion a_cfg_quiet bound in every regression: 0 failures
owner dom_b RTL owner
review 2026-10-17
expires any new writer of cfg_a, any change to its write condition or
to u_sync_busy, or any use of cfg_a outside run_bThe bad waiver states a belief; the good one names the mechanism and the check. The gap is real. With busy_a removed from the write condition, so that cfg_a is written whenever start_a is low, the same check counted 22 changes of cfg_a while B was still running: run_b falls up to two clk_b edges after start_a, and a write in that gap reaches B mid-run. The bad waiver would have hidden that bug.
No. A third flop lowers the chance that one bit stays metastable; it does not make four bits with different route delays land on the same edge. The destination can still read a mixed value. Use registered Gray code for the counter, or a handshake or async FIFO for data.
Waive it as quasi-static, with evidence: the only write happens while B is in reset, and an assertion in B checks that mode is stable whenever B is out of reset. The per-bit synchronisers are not what makes it safe. The waiver expires if mode ever becomes writable at run time; then it needs a handshake.
The bench repeats sync2, dom_a and dom_b so it runs on its own. It adds jitter on clk_b, route delays on the crossing wires, a registered Gray copy of the counter as the reference fix, and one check per finding.
`timescale 1ns/1ps
// Two-clock example for the CDC report walk-through. clk_a: source domain, clk_b: destination.
module sync2 (input logic clk, rst_n, d, output logic q); // the approved 2-flop synchroniser
logic q1;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin q1 <= 1'b0; q <= 1'b0; end
else begin q1 <= d; q <= q1; end
endmodule
module dom_a (
input logic clk_a, rst_a_n,
input logic start, err, step, req, last, cfg_we,
input logic [7:0] cfg_d,
input logic run_b, // from domain B
output logic start_a, err_a, req_a, last_a,
output logic [3:0] cnt_a,
output logic [7:0] cfg_a
);
logic busy_a;
sync2 u_sync_busy (.clk(clk_a), .rst_n(rst_a_n), .d(run_b), .q(busy_a));
always_ff @(posedge clk_a or negedge rst_a_n)
if (!rst_a_n) begin
start_a <= 1'b0; err_a <= 1'b0; req_a <= 1'b0; last_a <= 1'b0;
cnt_a <= 4'd0; cfg_a <= 8'd0;
end else begin
start_a <= start; err_a <= err; req_a <= req; last_a <= last;
if (step) cnt_a <= cnt_a + 4'd1;
if (cfg_we && !start_a && !busy_a) cfg_a <= cfg_d; // only while B is idle
end
endmodule
module dom_b (
input logic clk_b, rst_b_n,
input logic start_a, err_a, req_a, last_a,
input logic [3:0] cnt_a,
input logic [7:0] cfg_a,
output logic run_b, err_b,
output logic [1:0] cmd_b,
output logic [3:0] cnt_b,
output logic [7:0] acc_b
);
logic req_s, last_s;
sync2 u_sync_start (.clk(clk_b), .rst_n(rst_b_n), .d(start_a), .q(run_b));
always_ff @(posedge clk_b or negedge rst_b_n) // err_a captured directly
if (!rst_b_n) err_b <= 1'b0; else err_b <= err_a;
for (genvar i = 0; i < 4; i++) begin : g_cnt // one synchroniser per bit
sync2 u_sync_cnt (.clk(clk_b), .rst_n(rst_b_n), .d(cnt_a[i]), .q(cnt_b[i]));
end
sync2 u_sync_req (.clk(clk_b), .rst_n(rst_b_n), .d(req_a), .q(req_s));
sync2 u_sync_last (.clk(clk_b), .rst_n(rst_b_n), .d(last_a), .q(last_s));
assign cmd_b = {req_s, last_s}; // the two bits meet again here
always_ff @(posedge clk_b or negedge rst_b_n) // cfg_a used without a synchroniser
if (!rst_b_n) acc_b <= 8'd0;
else if (run_b) acc_b <= acc_b + cfg_a;
endmodule
F3 err_a : 341 of 7922 changes inside a 0.3 ns setup window
F4 cnt_a : 1940 of 20968 changes were not +1 (first 7 -> 6 -> 8)
F4 Gray fix : 0 bad changes
F5 req, last : 1680 cycles showed 01 or 10 (only 00 and 11 sent)
F6 cfg_a : 0 changes while run_b = 1 in 56087 cycles
F6 gate : 2139 of 4018 write attempts blocked
PASS: per-bit bus and reconvergence corrupt as reported; Gray and the gated cfg stay cleanIcarus Verilog 13.0, 2026-10-17: iverilog -g2012 -s tb_cdc_report + vvp -n on the Testbench listing prints the six counts shown and PASS (10 ns clk_a, 7.13 ns clk_b with up to 0.3 ns of half-period jitter, start offset 1.37 ns, route delays 0 to 1.2 ns, 800 us simulated). The design listing compiles with iverilog -g2012.
Mutant run, same bench and date: with !busy_a removed from the cfg_a write condition, the check counted 22 changes of cfg_a while run_b was high (FAIL). With every route delay removed (zero-delay variant), F4 showed 0 bad changes in 19,998 and F5 0 cycles, which is the basis of the pitfall.
Verilator 5.048, 2026-10-17: the same bench with the snippet assertion a_cfg_quiet added (verilator --binary --timing --assert) gave 0 assertion failures; with !busy_a removed, 27 failures (Verilator's random sequence differs from Icarus, hence 27 versus 22).
Scope and limits: the CDC report is an illustrative, vendor-neutral format written for this lesson, not the output of any tool; no structural CDC tool was run. The simulation models route skew as fixed per-bit delays and window hits as timing exposure only; it does not model metastability resolution, and its counts are for this seed and these clocks.