Clock-domain crossings: choose the transfer and check its assumptions

Guide Last tested 24 October 2026

Choose a clock-domain crossing by what must survive: a stable level, every event, or a coherent word. Two receiving registers address one part of a single-bit crossing; they do not guarantee pulse capture or make independently sampled bus bits agree. The example below separates those obligations so each has a visible result.

Synchronizer assumptions

Start with the receiving contract. In this example, level is a status that becomes one and stays there. The destination uses level_meta followed by level_sync, both clocked by dst_clk; downstream logic reads the second register. A change first reaches the receiving stage and becomes visible at the second stage on a later destination edge. The printed trace shows that latency. It does not show an analog metastable voltage resolving.

Real synchronizer depth is a reliability and implementation decision. AMD’s versioned guidance connects the number of synchronization registers to MTBF, resources and latency, and calls for implementation-based analysis where applicable1. Treat a two-register drawing as the start of that assessment. Record source activity, destination frequency, the target implementation and the required reliability before deciding that a particular depth is adequate.

A useful crossing record names the source register, destination stages, consuming logic, clock relationship and reset assumption. Include the implementation constraints and the evidence that the intended stages survive synthesis and placement. A waveform with ideal edges cannot supply that physical evidence. In particular, the first destination stage is not a general-purpose replacement for the asynchronous input; exposing it to several consumers would defeat the intended containment boundary.

The following complete example uses explicit initial state to keep its logical scenarios deterministic. It does not implement a production reset protocol. Its clocks have different periods, and stimulus changes avoid the relevant sampling edge. That separation lets the assertions test the intended transfer behavior without relying on simulator scheduling races. Save the block as example.sv and use the commands below it.

// SPDX-License-Identifier: MIT
`timescale 1ns/1ps
module crossing_demo(
  input logic src_clk, dst_clk, level, short_pulse, event_toggle,
  input logic glitch, request,
  input logic [1:0] raw_bus, held_bus,
  output logic level_sync=0, registered_glitch=0, ack_sync=0,
  output logic [1:0] naive_bus=0, coherent_bus=0,
  output integer raw_events=0, toggle_events=0
);
  logic level_meta=0, event_meta=0, event_sync=0, event_seen=0;
  logic request_meta=0, request_sync=0, ack=0, ack_meta=0;
  always @(posedge src_clk) begin
    registered_glitch <= glitch;
    ack_meta <= ack;
    ack_sync <= ack_meta;
  end
  always @(posedge dst_clk) begin
    level_meta <= level;
    level_sync <= level_meta;
    event_meta <= event_toggle;
    event_sync <= event_meta;
    event_seen <= event_sync;
    if (short_pulse) raw_events <= raw_events+1;
    if (event_sync != event_seen) toggle_events <= toggle_events+1;
    naive_bus <= raw_bus;
    request_meta <= request;
    request_sync <= request_meta;
    if (request_sync != ack) begin
      coherent_bus <= held_bus;
      ack <= request_sync;
    end
  end
endmodule
module tb;
  logic src_clk=0, dst_clk=0, level=0, short_pulse=0, event_toggle=0;
  logic glitch=0, request=0, level_sync, registered_glitch, ack_sync;
  logic [1:0] raw_bus=0, held_bus=0, naive_bus, coherent_bus;
  integer raw_events, toggle_events, glitch_edges=0, registered_edges=0;
  crossing_demo dut(.*);
  always #4 src_clk=~src_clk;
  always #7 dst_clk=~dst_clk;
  always @(posedge glitch) glitch_edges++;
  always @(posedge registered_glitch) registered_edges++;
  initial begin
    #2; level=1;
    #6; short_pulse=1; event_toggle=~event_toggle;
    $display("TRACE t=%0t first_sample=%b",$time,level_sync);
    #2; short_pulse=0;
    #12;
    assert(level_sync===1) else $fatal(1,"level transfer failed");
    $display("TRACE t=%0t second_sample=%b",$time,level_sync);
    #29;
    assert(raw_events==0 && toggle_events==1) else $fatal(1,"pulse trace");
    $display("TRACE raw_events=%0d toggle_events=%0d",raw_events,toggle_events);
    #3; glitch=1;
    #2; glitch=0;
    #7;
    assert(glitch_edges==1 && registered_edges==0) else $fatal(1,"glitch trace");
    $display("TRACE raw_glitch_edges=%0d registered_edges=%0d",glitch_edges,registered_edges);
    #13; raw_bus[0]=1;
    #2; raw_bus[1]=1;
    #2;
    assert(naive_bus===2'b01) else $fatal(1,"torn bus trace");
    $display("TRACE naive_bus=%b intended_old=00 intended_new=11",naive_bus);
    held_bus=raw_bus; request=~request;
    wait(ack_sync==request); #1;
    assert(coherent_bus===2'b11) else $fatal(1,"coherent transfer failed");
    $display("TRACE coherent_bus=%b ack=%b",coherent_bus,ack_sync);
    $display("PASS logical CDC scenarios; no analog metastability model");
    $finish;
  end
  initial begin #300; $fatal(1,"timeout"); end
endmodule

The Icarus library binding below is installation-specific; use the ivl directory belonging to the selected installation. The command references document language selection and assertion controls23.

Icarus invocation:

iverilog -B "${ICARUS_LIB}" -g2012 -gstrict-expr-width -Wall -s tb -o run.vvp example.sv
vvp -M "${ICARUS_LIB}" run.vvp

Verilator invocation:

verilator --binary --timing --assert --language 1800-2012 --x-assign 0 --x-initial 0 -Wno-fatal --quiet-build --quiet-stats --quiet-exit --top-module tb --Mdir obj example.sv
./obj/Vtb +verilator+quiet

Both executions produced these application trace lines. The timestamps use the testbench’s precision: 8000 is 8 ns and 22000 is 22 ns. Tool-specific finish banners are retained with the full execution records; they do not change these observations.

TRACE t=8000 first_sample=0
TRACE t=22000 second_sample=1
TRACE raw_events=0 toggle_events=1
TRACE raw_glitch_edges=1 registered_edges=0
TRACE naive_bus=01 intended_old=00 intended_new=11
TRACE coherent_bus=11 ack=1
PASS logical CDC scenarios; no analog metastability model

The first status print remains zero because the second receiving stage has not yet observed the update. At the second print it is one. This is a latency check, not a comparison between a metastable and a safe circuit. The test also intentionally leaves the raw short pulse and raw bus connected to separate demonstrators, so their failures can be observed beside the persistent event and held-data alternatives.

For a real reset sequence, add an explicit policy for both domains. Decide whether an event already in flight is discarded, replayed or completed when one domain resets. The toggle and acknowledgement states must agree with that policy after release. Initializing every bit to zero here makes the starting condition readable; it does not prove reset-domain crossing behavior or recovery from independent resets.

Pulse capture

A destination can miss an entire pulse even when the pulse is a valid event in the source’s timeline. Here, short_pulse is high from 8 ns to 10 ns. The destination samples at 7 ns and 21 ns, so neither sample sees it. The raw counter stays zero. Adding more receiving stages to that sampled zero would not reconstruct the event; the information never entered the chain.

The same stimulus changes event_toggle once and leaves its new state in place. That state passes through two destination registers. Comparing the synchronized value with event_seen produces one destination event, so toggle_events becomes one. The durable state change gives the destination an observation opportunity that the two-nanosecond pulse did not provide. These two counters are the concrete failure and correction demonstrated by the run.

A toggle is not an unlimited event queue. If the source toggles twice before the destination observes the intermediate state, the source can return to its previous value and hide both events. This example sends only one event; it has no busy output or backpressure for the event path. Before adopting that path, specify a minimum event separation and verify it against the complete synchronization and consumption latency. If the producer cannot obey that separation, select a protocol that acknowledges each accepted event or provides buffering.

For a handshake, acceptance should have a precise meaning: the receiver captured the event or data, not merely that the sender asserted a request. The source must wait until the agreed completion condition before reusing the transfer state. AMD’s full-handshake macro documentation makes completion before another transfer an explicit usage requirement; this example uses an original two-phase toggle protocol rather than that macro4. The common engineering obligation is to preserve each transfer until it is acknowledged.

Write checks around both ends of that obligation. Count accepted source requests and completed destination events, detect an attempted new request while busy, and bound completion only while the destination clock is running. A stopped clock is an important case to distinguish from lost data: a persistent request can remain pending, but this example does not promise a completion deadline if sampling stops.

Glitch registration

The glitch scenario is a different problem from the intentional short pulse. The testbench raises glitch at 54 ns and lowers it at 56 ns, between source-clock sampling edges. An edge counter on the raw signal records one transition. The source-registered output records none because neither source edge captures the transient. This demonstrates the selected pulse being rejected by that particular sampling schedule.

Do not turn that result into a universal glitch-filter claim. Move a transient onto a source sampling edge and the registered value can change. A source register establishes a clocked launch point; it is not a device that recognizes which events the designer intended. If the input represents an actual event that must survive regardless of width, first convert it into a suitable persistent or acknowledged representation instead of relying on this transient-rejection example.

In a design review, distinguish the logic that creates the signal from the logic that crosses it. Identify whether a combinational decode can produce extra transitions, which source register defines the permitted launch point, and whether any bypass path reaches the receiving chain. The original example isolates that distinction by measuring raw and registered edges separately. It does not model gate delays, reconvergent physical hazards or the probability that a transient reaches a metastable aperture.

The receiving contract still matters after registration. A clean, source-clocked pulse can be too short for a slower destination, exactly as the earlier missed-pulse result shows. Source registration and event preservation therefore solve different obligations. Use both when the signal’s origin and the transfer protocol require them, and keep their checks separate so a passing registration test cannot conceal a lost destination event.

Coherent multibit transfers

A word must retain its relationship between bits. The raw demonstration changes the bus from 00 toward 11, with bit zero changing at 76 ns and bit one at 78 ns. The destination samples between them and records 01. That is a literal intermediate value in this controlled schedule, rather than a claim about metastability. It shows why independently seeing plausible bits does not establish that the destination received one intended update.

The coherent path copies the completed word into held_bus, toggles request, and keeps the data unchanged until the synchronized acknowledgement returns. The destination captures that held value only when its synchronized request differs from ack. In the measured scenario the result is 11, and the source eventually sees acknowledgement one. The acknowledgement carries completion information back across its own receiving chain; it is not an instantaneous cross-domain permission.

This is a bundled-data demonstration. Its bus is not independently synchronized bit by bit. Correct physical use requires a timing argument that the held bus reaches the destination before the qualified capture, together with a protocol guarantee that it stays stable long enough. The delay through the control chain gives the intended opportunity in this RTL schedule; actual routing and constraints must establish the corresponding implementation requirement. The reference macro likewise specifies sampling and handshake conditions rather than making an arbitrary bus safe by declaration4.

Choose the representation according to the data. For a sparse configuration update, a held word with acknowledgement can make the ownership transfer clear. For a stream that cannot wait for a round trip, an appropriately designed asynchronous FIFO may be a better architectural choice. Gray-coded state is another specialized option, but this page neither implements nor tests it. Encoding a general payload as Gray code is not a substitute for defining which transitions and values the receiver may consume.

Finish by recording the failure that each check is meant to expose. This run distinguishes a missed pulse, a sampled intermediate word, a transient rejected between source edges and the expected two-stage level latency. Keep those observations separate from implementation signoff. A justified waiver needs its own topology, protocol, reset and constraint evidence; it cannot be earned merely by reaching the final PASS line in this small testbench.

Not covered on this page

Sources

  1. AMD, UG949: selecting DEST_SYNC_FF, version 2021.1, opened 24 October 2026. ↩

  2. Icarus Verilog command-line flags, current documentation opened 24 October 2026; executed Icarus version 13.0. ↩

  3. Verilator executable arguments, guide 5.052, opened 24 October 2026. ↩

  4. AMD, UG953: XPM_CDC_HANDSHAKE, version 2022.2, published 19 October 2022, opened 24 October 2026. ↩ ↩2

How we test: methodology. Found a mistake? Corrections.