Clock Domain Crossing (CDC) and Reset Domain Crossing (RDC)

Domain Crossing

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.

SoC / ASIC verification ~14 min read Python simulations included
CLK_A (source domain) DATA (async, changes off clk_a edges) CLK_B (destination domain) setup/hold violated Q output hovers mid-rail before resolving

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.

Why it matters Unmanaged CDC/RDC is the single largest source of silent, hard-to-reproduce silicon bugs — the kind that pass simulation (which uses zero-delay, deterministic clocks) but fail in the lab under real, drifting clock relationships.

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:

metastability_mtbf.py
# 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.

async_in FF1 may go metastable FF2 clean, synced sync_out clk_b

Fig. 2 — Two flops clocked by clk_b. FF1 absorbs the metastability risk; FF2 only ever samples an already-resolved value.

two_ff_synchronizer.py
# 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}")
Common mistake The 2-FF synchronizer only works for signals that are guaranteed to hold their value long enough to be sampled and for logic that can tolerate a 1–2 cycle latency. It does not work for multi-bit buses (see next section) or for pulses shorter than one destination clock period.

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.

cyclesource bus (binary)what dest domain might sample
t0011 (3)011 (3)
t1100 (4)111, 000, or 100 — bits resolve independently
t2100 (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.

gray_code.py
# 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.

handshake_sync.py
# 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.

WRITE DOMAIN (clk_a) write ptr (bin) -> gray-encode wptr_gray READ DOMAIN (clk_b) read ptr (bin) -> gray-encode rptr_gray 2-FF sync rptr_gray into clk_a -> full flag 2-FF sync wptr_gray into clk_b -> empty flag

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.

async_fifo_pointers.py
# 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.

async_rst_n FF1 async clear, sync set FF2 async clear, sync set sync_rst_n to domain's flops clk (destination domain)

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.

reset_synchronizer.py
# 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:

TechniqueWhat 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 analysisReset 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 simulationInjects X-propagation or random delay at synchronizer inputs to catch downstream logic that isn't tolerant of a 1-cycle uncertainty
Practical note A false path in SDC silences the timing tool, not the bug. Every waived crossing should map to an actual synchronizer structure in RTL that the CDC tool has verified — waivers without structural justification are the most common way real bugs slip through signoff.

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.

Post a Comment

0 Comments