A subsystem has two reset domains on one clock: a responder that firmware can reset alone, and an always-on block that mirrors some of its registers. Two reports arrive in one week. After a soft reset of the responder, the always-on block sometimes holds a value nobody wrote. On a new board revision, nearly half of all cold boots hang waiting for the responder's first answer.
The CDC report is clean: there is only one clock. Every simulation passes. What do both reports have in common?
They look for a logic bug in the mirror and the boot handshake, find none, and run more boot simulations, which pass because the bench releases all resets in the same cycle. Then they blame the new board. Nobody asks what each domain is doing while the other one's reset changes.
Bug | What you see | When |
|---|---|---|
Asynchronous assertion into another domain | a register outside the reset holds a value that is neither the old value nor the reset value, or an FSM jumps; X at gate level | only around a reset of the other domain, at a rate that depends on where in the cycle the reset lands |
Release out of order | a hang or a lost first transaction right after boot; one side waits for a response the other never saw | only for some boots: those where the requester leaves reset first |
Release not synchronised | flops in one domain leave reset in different cycles: a partial reset state | boot only; follows reset timing and temperature on silicon |
The common clue: the failure is tied to a reset event, not to traffic. On the debug loop this is Localise and Hypothesise: if you can line the failure up with a reset, the suspect list is the reset tree. The third row is the reset synchroniser's job, covered in reset deassertion; this lesson reproduces the first two.
Draw the reset domains. For every reset, list the flops it resets and every flop outside it that samples one of them. Each such path is a reset-domain crossing, whether or not the clocks differ.
Line the failure up with reset events. Log every reset assertion and release with its time. Does the corruption appear within a cycle of a reset of the other domain? Does the hang follow one release order?
Make reset hostile in the bench. Assert each reset at random points in the cycle with a metastability model on the receiving flops, and release resets in random order. A bug that appears only then is a reset-domain bug.
Everything runs on one 10 ns clock. In bug 1, mirror has a register in domain A with an asynchronous reset and a copy in domain B, built from the CDC lesson's meta_ff. A soft reset of A arrives at a random point in the cycle. In bug 2, req_resp has a requester in B that sends one request pulse after reset and a responder in A; the two resets are released by their own synchronisers at clock edges, 1 to 8 cycles after the start, in either order.
`timescale 1ns/1ps
// meta_ff: a flop with a hostile-simulation model of metastability (as in the CDC lesson):
// a bit of D that changed less than WIN ns before the edge resolves to old or new at random.
module meta_ff #(parameter int W = 1, parameter int SALT = 0, parameter real WIN = 0.5) (
input logic clk, meta_en,
input logic [31:0] seed,
input logic [W-1:0] d,
output logic [W-1:0] q
);
logic [W-1:0] d_q = '0, old_v = '0, cap;
real t_chg [W];
logic [31:0] rng;
bit seeded = 1'b0;
initial begin
q = '0;
for (int i = 0; i < W; i++) t_chg[i] = -100.0;
end
always @(d) begin
for (int i = 0; i < W; i++)
if (d[i] !== d_q[i]) begin t_chg[i] = $realtime; old_v[i] = d_q[i]; end
d_q = d;
end
always @(posedge clk) begin
cap = d;
for (int i = 0; i < W; i++)
if (meta_en && $realtime - t_chg[i] < WIN) begin
if (!seeded) begin rng = seed * 32'h9E37_79B9 + 32'(SALT) * 32'h85EB_CA6B + 32'h7F4A_7C16; seeded = 1'b1; end
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
cap[i] = rng[0] ? d[i] : old_v[i];
end
q <= cap;
end
endmodule
// Bug 1: a register in reset domain A (async reset rst_a_n) feeds a register in domain B,
// which is never reset by rst_a_n. Same clock: no clock-domain crossing anywhere.
// FIX = 0: B samples A every cycle. FIX = 1: B holds while iso_b is high (reset isolation).
module mirror #(parameter bit FIX = 0, parameter int SALT = 0) (
input logic clk, rst_a_n, meta_en, iso_b, we,
input logic [31:0] seed,
input logic [7:0] wdata,
output logic [7:0] cfg_a, cfg_b
);
logic [7:0] d_b;
always_ff @(posedge clk or negedge rst_a_n)
if (!rst_a_n) cfg_a <= 8'h00;
else if (we) cfg_a <= wdata;
assign d_b = (FIX && iso_b) ? cfg_b : cfg_a;
meta_ff #(.W(8), .SALT(SALT)) u_b (.clk, .meta_en, .seed, .d(d_b), .q(cfg_b));
endmodule
// Bug 2: a requester in domain B sends one request pulse right after its reset ends; a responder
// in domain A must answer it. FIX = 0: nothing orders the two releases.
// FIX = 1: B also waits until A says it is out of reset (a_ready), so B can never start first.
module req_resp #(parameter bit FIX = 0) (
input logic clk, rst_a_n, rst_b_n,
output logic done, waiting
);
logic req, ack, pend, sent, a_ready;
always_ff @(posedge clk or negedge rst_a_n) // responder, domain A
if (!rst_a_n) begin pend <= 1'b0; ack <= 1'b0; a_ready <= 1'b0; end
else begin
a_ready <= 1'b1;
ack <= pend;
pend <= req ? 1'b1 : (ack ? 1'b0 : pend);
end
always_ff @(posedge clk or negedge rst_b_n) // requester, domain B
if (!rst_b_n) begin sent <= 1'b0; done <= 1'b0; end
else begin
if (!sent && (!FIX || a_ready)) sent <= 1'b1; // one pulse: the boot request
if (ack) done <= 1'b1;
end
assign req = rst_b_n && !sent && (!FIX || a_ready);
assign waiting = sent && !done;
endmodule
$ iverilog -g2012 -o r.vvp rdc_tb.sv
$ vvp -n r.vvp
bug 1, model off: 0 of 400 resets corrupt B (bad), 0 (fix)
bug 1, model on: 15 of 400 resets corrupt B (bad), 0 (fix)
bug 2, resets together: 0 of 200 boots hang (bad), 0 (fix)
bug 2, random order: 90 of 200 boots hang (bad), 0 (fix)
PASS: both reset-domain bugs need hostile reset timing to show; both fixes pass
rdc_tb.sv:200: $finish called at 368005000 (1ps)
$ vvp -n r.vvp +BAD=1
FAIL time=3685.1 ns: domain B holds 06; A held 47, and its reset value is 00
FAIL time=7585.1 ns: domain B holds 05; A held 75, and its reset value is 00
...
FATAL: rdc_tb.sv:174: bug 1: 13 of 400 runs failed
Time: 60005000 Scope: rdc_tb.$unm_blk_14Zero-delay simulation: 400 soft resets, B always holds either A's value or 00, a clean pass. With the model on, 15 of 400 resets leave B holding something else. Read the first failure: A held 47 (0100 0111) and was reset to 00. B holds 06 (0000 0110): bits 6 and 0 took the new value, bits 2 and 1 kept the old one. The reset hit A's flops just before B's clock edge, and B captured a mix. That is one reset in 27 here; a real flop's window is far narrower, so on silicon it would be rarer and much harder to catch. The fixed mirror holds its value while iso_b is high, so A's reset never reaches a D pin that B is sampling: 0 of 400.
$ vvp -n r.vvp +BAD=2 +SAME
PASS: buggy design, bug 2 did not show
rdc_tb.sv:180: $finish called at 123995000 (1ps)
$ vvp -n r.vvp +BAD=2
FAIL time=1235.0 ns: boot request unanswered after 50 cycles (A released at cycle 6, B at cycle 5)
FAIL time=3715.0 ns: boot request unanswered after 50 cycles (A released at cycle 8, B at cycle 7)
FAIL time=4955.0 ns: boot request unanswered after 50 cycles (A released at cycle 4, B at cycle 1)
...
FATAL: rdc_tb.sv:174: bug 2: 83 of 200 runs failed
Time: 123995000 Scope: rdc_tb.$unm_blk_14With +SAME, the way most benches reset, all 200 boots pass. With independent release times, 83 of 200 hang, and in every one of the 83 B was released before A. That is a prediction you can check before reading the RTL: with each release drawn from 1 to 8, B goes first in 7/16 of boots, 87.5 of 200, and the runs gave 83 and 90. The requester sent its only pulse while the responder was held in reset. The fixed requester waits for a_ready, a flag A sets when it leaves reset, and none of the 200 boots hang. Verilator 5.048 prints the same lines for both bugs.
$ sed -n 312,314p tb/dbglab_tb.sv
repeat (4) tick();
rst_n = 1'b1;
@(posedge pclk) #1 prst_n = 1'b1;
$ sed -n 19p rtl/periph_pd.sv
periph_sink u_sink (.pclk(pclk), .prst_n(prst_n && pwr_on), .en(en), .rdata(rdata), .rempty(rempty),dbglab's bench releases its two resets, rst_n and prst_n, once and in a fixed order, so its regression cannot see this class. Check 1 does flag one structure: the sink's reset is prst_n && pwr_on, released by a bus-clock register write rather than on a pclk edge; it models a supply switch, and in silicon it would need a reset synchroniser. None of B1 to B5 is a reset-domain bug.
Both failures need reset timing the default bench never produces: an assertion mid-cycle, or releases in different cycles. Each then fails on demand at a rate the mechanism predicts, and each fix passes the same hostile runs. The board's two reports are now two named crossings with fixes.
Asynchronous assertion: reset isolation. Stop B sampling A (here iso_b makes B hold), then assert and release A's reset, let A settle, and remove isolation (reset isolation). Or reset B too, if its state need not survive.
Release order: sequence the releases (the responder first) or make the requester wait for a ready flag from the responder, as here. See release order across domains.
Unsynchronised release: release every asynchronous reset through a reset synchroniser clocked by its domain (asynchronous assert, synchronous deassert).
A structural RDC check, which lists every path from a flop with one reset to a flop with another, same clock or not; each needs isolation, sequencing or a written reason. Reading RDC findings covers the report.
Reset-aware tests: random reset assertion times during traffic, random release order, and a soft reset of each domain alone (reset sequences and random resets).
Assertions: "while A is in reset, B's copy does not change"; "no request is issued while the responder's ready is low".
Review question: "When this reset asserts, who is still running? When it releases, who might already be talking to it?"
Line the hang up with the reset event: if it always follows the DMA reset, the suspect is a crossing from the DMA's reset domain into the memory controller, such as a request or a pending count that the controller samples and that the DMA reset clears asynchronously, or a handshake left half-finished. Then draw the reset domains and list every path from DMA-reset flops into the controller, and check each one for isolation.
Yes for this case: a level held until acknowledged is still there when A leaves reset, which is why four-phase handshakes survive release-order differences that one-cycle pulses do not. Still check that A does not act on a request in its first cycle out of reset, before its own state is initialised.
rdc_tb.sv as shown, 2026-10-18: iverilog -g2012 -o r.vvp rdc_tb.sv and vvp -n r.vvp (Icarus Verilog 13.0, 0.26 s): bug 1, 0 of 400 resets corrupt B with the model off and 15 of 400 with it on (fix 0); bug 2, 0 of 200 boots hang with the resets released together and 90 of 200 in random order (fix 0); PASS. +BAD=1: 13 of 400 fail; +BAD=2: 83 of 200, all with B released before A; +BAD=2 +SAME passes; +FIX=1 and +FIX=2 pass. Seeds 1 to 5: bug 1, 6 to 17 of 400; bug 2, 78 to 105 of 200; fixes 0.
verilator --binary --timing --assert -Wno-fatal --top-module rdc_tb (Verilator 5.048) prints the same lines for the default run, +BAD=1 and +BAD=2. dbglab at HEAD 49c5f04: sed -n 312,314p tb/dbglab_tb.sv and sed -n 19p rtl/periph_pd.sv, output as shown.
Limits: the metastability model's 0.5 ns window is exaggerated and says nothing about silicon rates; reset release here is modelled at clock edges, as from a reset synchroniser. No RDC tool is installed, so no structural report is shown.