Digital Design Notes
Clock Domain Crossing (CDC) & Reset Domain Crossing (RDC)
A practical, code-illustrated walkthrough of why signals go metastable when they cross clock and reset boundaries — and the synchronizer patterns silicon teams use to keep them safe, modeled in Python.
Fig. 1 — DATA transitions asynchronously to CLK_B. If it changes inside CLK_B's setup/hold window, the capturing flop can go metastable before resolving high or low.
01 What CDC and RDC actually are
Clock Domain Crossing (CDC) is any point in a design where a signal generated in one clock domain is sampled by logic running on a different, asynchronous clock. Reset Domain Crossing (RDC) is the same problem applied to resets: a reset asserted or released asynchronously with respect to the clock (or a different reset domain) that captures it.
Both are consequences of one physical fact: a flip-flop needs its input to be stable for a setup/hold window around the clock edge. If the input changes inside that window, the flop's output can settle into a voltage that is neither a clean 0 nor a clean 1 — metastability — for an unbounded amount of time before resolving.
Modeling metastability resolution time in Python
The probability that a flop is still metastable after time t decays exponentially. This gives the classic Mean Time Between Failures (MTBF) equation used to size synchronizer chains:
# Estimate synchronizer MTBF using the standard metastability model # MTBF = exp(t_resolve / tau) / (T0 * f_clk * f_data) import math def mtbf_seconds(f_clk_hz, f_data_hz, t_resolve_s, tau_s=50e-12, t0_s=1e-10): """ f_clk_hz : capturing clock frequency (Hz) f_data_hz : rate of asynchronous data transitions (Hz) t_resolve_s : settling time budget available (e.g. one extra flop stage) tau_s : flop's metastability decay constant (process-dependent) t0_s : flop's characteristic metastability window """ return math.exp(t_resolve_s / tau_s) / (t0_s * f_clk_hz * f_data_hz) # A 2-FF synchronizer gives ~1 clock period of extra resolution time f_clk = 500e6 # 500 MHz destination clock f_data = 10e6 # async signal toggles up to 10 MHz t_res = 1 / f_clk # one clock period budget from the 2nd FF years = mtbf_seconds(f_clk, f_data, t_res) / (3600 * 24 * 365) print(f"Estimated MTBF: {years:,.2e} years") # Estimated MTBF: 3.98e+10 years -> comfortably safe for one 2-FF sync
The key takeaway from the formula: MTBF grows exponentially with the resolution time you give the signal. That's exactly what a synchronizer chain buys you — extra clock periods for the metastable voltage to settle before anything downstream depends on it.
02 CDC pattern 1 — the 2-flop (double-flop) synchronizer
The workhorse for a single-bit signal (an enable, a flag, a level). Two flops in series in the destination clock domain give the metastable value one extra clock period to resolve before it fans out to logic.
Fig. 2 — Two flops clocked by clk_b. FF1 absorbs the metastability risk; FF2 only ever samples an already-resolved value.
# Cycle-accurate model of a 2-FF synchronizer sampling an # asynchronous single bit signal that can change at any time. import random class TwoFFSynchronizer: def __init__(self): self.ff1 = 0 self.ff2 = 0 def clock_edge(self, async_in): # On every destination-clock edge, shift the value through. # A real flop's metastable output isn't modeled at the bit # level here -- the point of the 2-FF chain is that by the # time FF2 samples it, FF1 has already resolved. self.ff2 = self.ff1 self.ff1 = async_in return self.ff2 def simulate(n_cycles=20, toggle_prob=0.3): sync = TwoFFSynchronizer() async_signal = 0 trace = [] for cycle in range(n_cycles): if random.random() < toggle_prob: # async source flips async_signal ^= 1 sync_out = sync.clock_edge(async_signal) trace.append((cycle, async_signal, sync.ff1, sync_out)) return trace for cycle, a_in, ff1, out in simulate(): print(f"cycle {cycle:2d} | async_in={a_in} | ff1={ff1} | sync_out={out}")
03 Why multi-bit buses need more than 2 flops
If you naively run a 2-FF synchronizer on every bit of an N-bit bus independently, different bits can resolve on different cycles. The destination domain can briefly observe a value that was never actually driven by the source — a transient, illegal code.
| cycle | source bus (binary) | what dest domain might sample |
|---|---|---|
| t0 | 011 (3) | 011 (3) |
| t1 | 100 (4) | 111, 000, or 100 — bits resolve independently |
| t2 | 100 (4) | 100 (4) — settles eventually |
The fix: encode the value so that only one bit ever changes between consecutive values — a Gray code. With Gray coding, even if a bit is caught mid-transition, the destination domain can only ever read either the old value or the new one, never a nonsense intermediate.
# Binary <-> Gray code conversion, and a demo of the # single-bit-change property that makes Gray-coded counters # safe to cross clock domains through 2-FF synchronizers.-> def bin_to_gray(b: int) -> int: return b ^ (b >> 1) def gray_to_bin(g: int) -> int: b = 0 while g: b ^= g g >>= 1 return b def hamming_distance(a: int, b: int) -> int: return bin(a ^ b).count("1") WIDTH = 4 print(f"{'dec':>3} {'binary':>8} {'gray':>8} {'bits changed vs prev':>22}") prev_gray = None for n in range(2 ** WIDTH): g = bin_to_gray(n) changed = hamming_distance(g, prev_gray) if prev_gray is not None else 0 print(f"{n:>3} {n:0{WIDTH}b} {g:0{WIDTH}b} {changed:>22}") prev_gray = g # Every row after the first changes exactly 1 bit -> safe for CDC
04 CDC pattern 2 — req/ack handshake
When a full multi-bit payload must cross domains but doesn't toggle every cycle (e.g. a command word), a 4-phase handshake is common: the source asserts req, the data is held stable, the destination synchronizes req, consumes the data, and pulses back a synchronized ack. The source only changes the data after it sees ack.
# Simplified req/ack handshake FSM model between two domains. # Both req and ack pass through their own 2-FF synchronizer # before being acted on in the receiving domain. from enum import Enum class State(Enum): IDLE = 0 WAIT_ACK = 1 class Sender: def __init__(self): self.state = State.IDLE self.req = 0 self.data = None def send(self, payload, ack_seen): if self.state == State.IDLE and payload is not None: self.data = payload self.req = 1 self.state = State.WAIT_ACK elif self.state == State.WAIT_ACK and ack_seen: self.req = 0 self.state = State.IDLE return self.req, self.data class Receiver: def __init__(self): self.ack = 0 self.captured = None def receive(self, synced_req, data): if synced_req and self.captured is None: self.captured = data # data is stable because req only asserts once data is stable self.ack = 1 elif not synced_req: self.ack = 0 self.captured = None return self.ack, self.captured
The handshake trades throughput (one transfer takes several destination-clock cycles to complete) for correctness on wide, infrequently-changing payloads.
05 CDC pattern 3 — asynchronous FIFO
For continuous, high-throughput data (a DMA stream, a bus bridge), the standard structure is a dual-clock asynchronous FIFO: the write side runs on clk_a, the read side on clk_b, and only the pointers — not the data — cross domains, always Gray-coded and always through 2-FF synchronizers.
Fig. 3 — Data lives in a dual-port RAM (not shown); only Gray-coded pointers cross domains, each through its own 2-FF synchronizer, to derive full/empty status safely.
# Minimal behavioral model of async-FIFO pointer management: # binary pointers for addressing, gray pointers for crossing, # and the classic full/empty comparison logic. def bin_to_gray(b): return b ^ (b >> 1) class AsyncFifo: def __init__(self, depth_bits=4): self.depth_bits = depth_bits # pointers are depth_bits+1 wide self.wptr_bin = self.rptr_bin = 0 self.wptr_gray_sync = self.rptr_gray_sync = 0 # synced into the other domain def write(self): full = self.is_full() if not full: self.wptr_bin += 1 return not full def read(self): empty = self.is_empty() if not empty: self.rptr_bin += 1 return not empty def sync_pointers(self): # models the 2-FF-delayed, gray-coded pointer arriving # in the opposite domain -- in real RTL this lags by # 2 destination-clock cycles self.wptr_gray_sync = bin_to_gray(self.wptr_bin) self.rptr_gray_sync = bin_to_gray(self.rptr_bin) def is_full(self): # full: write ptr has wrapped exactly one lap ahead of read ptr wrap = self.depth_bits + 1 return (self.wptr_bin - self.rptr_gray_sync) == (1 << self.depth_bits) def is_empty(self): return self.wptr_gray_sync == self.rptr_bin fifo = AsyncFifo(depth_bits=3) for i in range(5): ok = fifo.write() fifo.sync_pointers() print(f"write {i}: accepted={ok} wptr_bin={fifo.wptr_bin}")
06 Reset Domain Crossing (RDC)
Resets are asynchronous by nature — they need to assert immediately, regardless of clock, to force a device into a known state during power-up or error recovery. That immediacy is exactly what makes them dangerous: an asynchronous reset release can hit a flop's recovery/removal window just like data hits setup/hold, causing the same kind of unpredictable capture.
The industry-standard fix is asynchronous assert, synchronous de-assert: reset can slam on instantly, but its release is re-timed through a small synchronizer so every flop in the domain comes out of reset on the same clock edge.
Fig. 4 — Reset synchronizer: asserts async_rst_n immediately clears both flops; release ripples through 2 clocked stages, so de-assertion is clean and aligned to clk.
# Behavioral model: async assert, sync de-assert reset synchronizer. # reset_in: 0 = asserted (active-low), 1 = released class ResetSynchronizer: def __init__(self): self.ff1 = 0 self.ff2 = 0 # sync_rst_n -- what the domain's logic actually sees def step(self, async_rst_n): if async_rst_n == 0: # asynchronous, immediate: no clock needed to assert self.ff1 = 0 self.ff2 = 0 else: # synchronous release: ripples through on clock edges only self.ff2 = self.ff1 self.ff1 = 1 return self.ff2 sync = ResetSynchronizer() sequence = [0, 0, 1, 1, 1, 1] # reset held, then released for cyc, rst_in in enumerate(sequence): out = sync.step(rst_in) print(f"cycle {cyc}: async_rst_n={rst_in} -> sync_rst_n={out}") # sync_rst_n only rises 2 clean clock edges after async_rst_n rises
RDC-specific hazards to check for
- Reset ordering: if domain A's reset releases before domain B's, but B feeds combinational logic into A, A can briefly sample garbage from a still-reset B.
- Reset/CDC interaction: a synchronizer flop that is itself reset by a different, asynchronous reset than its source flop can be forced out of the valid CDC sequence — this is the most common RDC bug flagged by static tools.
- Glitches on combinationally-generated resets: resets built from gated logic (rather than a dedicated reset controller output) can glitch and re-assert unpredictably; treat any derived reset as a new async source needing its own synchronizer.
- Mixed synchronous/asynchronous reset styles across IP from different vendors integrated into one SoC.
07 Verifying CDC/RDC: beyond simulation
RTL simulation with ideal, zero-jitter clocks will not catch most CDC/RDC bugs — the failure mode depends on real, variable clock-edge relationships that a functional testbench doesn't model. Signoff instead relies on:
| Technique | What it catches |
|---|---|
| Static structural CDC (e.g. Synopsys SpyGlass CDC, Siemens Questa CDC) | Un-synchronized crossings, missing 2-FF chains, non-Gray multi-bit buses, glitch-prone reconvergence |
| Formal RDC analysis | Reset ordering violations, reset/CDC interaction, unreachable reset states |
SDC timing constraints (set_false_path, set_max_delay -datapath_only) | Tells STA to stop enforcing a meaningless single-clock timing path across the synchronizer, without hiding the crossing entirely |
| Metastability-aware gate-level simulation | Injects X-propagation or random delay at synchronizer inputs to catch downstream logic that isn't tolerant of a 1-cycle uncertainty |
08 Checklist
- Every single-bit signal crossing clock domains passes through a 2-FF (or deeper, for very high-speed domains) synchronizer.
- Every multi-bit value crossing domains is either Gray-coded, held stable through a req/ack handshake, or passed through an async FIFO with Gray-coded pointers.
- No combinational logic sits between the first synchronizer flop and the source signal.
- Every reset is asynchronously asserted, synchronously de-asserted, per destination clock domain.
- Derived/gated resets are treated as new asynchronous sources and re-synchronized.
- Reset release ordering across domains is deliberately sequenced, not left to physical layout skew.
- SDC false-path/max-delay constraints exist only where a real synchronizer structure backs them.
- Static CDC/RDC tool run is clean, or every waiver has a documented, reviewed justification.

0 Comments