RTL Design Patterns · Chapter 11 · Clock Domain Crossing
Pulse & Handshake Synchronizers
A two-flop synchronizer safely crosses a stable single-bit level, but two common needs break it silently. A single-cycle pulse from a fast domain can fall between the slow destination's clock edges and vanish, working on the bench but dropping events in the field. A multi-bit word synchronized bit by bit lets its bits resolve on different cycles, so the receiver can latch a value that was never sent. This lesson builds the two structures that fix these. The toggle synchronizer turns a pulse into a level by toggling a flop on each event, synchronizes that level, and XOR edge-detects it to regenerate one destination pulse per source event. The four-phase handshake moves a word coherently by holding the bus stable while a synchronized request and acknowledge round trip signals validity. Both are built and self-checked with two-clock testbenches in SystemVerilog, Verilog, and VHDL.
Advanced16 min readRTL Design PatternsClock Domain CrossingPulse SynchronizerToggle SynchronizerHandshakeMulti-Cycle Path
Chapter 11 · Section 11.4 · Clock Domain Crossing
1. The Engineering Problem
You have a fast source domain and a slow destination domain with no clock relationship, and two things must cross that a two-flop synchronizer cannot carry. First, an event: the source produces a single-cycle src_pulse — a done strobe, a packet-arrived flag, an interrupt request — and the destination must react to it exactly once. Second, a word: the source produces a multi-bit value — a configuration register, a captured measurement, a message — and the destination must read the whole value coherently. 11.2 solved the single-bit level. Neither of these is a level, and pushing them through a bare 2FF fails in ways that look like intermittent, unreproducible hardware faults.
Take the pulse first. Suppose clk_src runs at 200 MHz (5 ns period) and clk_dst at 50 MHz (20 ns period). A single-cycle src_pulse is high for exactly one source cycle — 5 ns — and the destination's flops only look every 20 ns. Most of the time the 5 ns window of the pulse falls between two destination edges: the first synchronizer flop samples it low, the second samples it low, and the event is gone. Worse, it is usually gone but not always — occasionally a destination edge happens to land inside the 5 ns window and the event is caught, so a directed test that fires a few pulses "passes," the block ships, and in the field it silently loses a fraction of every event stream. A 2FF does not miss a level because a level stays until the destination sees it; a pulse does not wait, so a synchronizer that only samples cannot promise to catch it.
Now the word. Run each bit of a multi-bit bus through its own 2FF and the bits resolve independently: on a transition from 0x3F to 0x40, bit skew and per-bit metastability resolution mean the destination can sample a cycle where some bits have updated and others have not — reading, say, 0x7F or 0x00, a value the source never drove. That is the multi-bit CDC coherency problem of 11.3, and it corrupts data silently.
Both failures share a root cause and a cure. The 2FF is a level transport: it works only when the crossing signal is stable long enough for the destination to sample it, and only for one bit at a time. So you must reshape each problem into that form — turn the pulse into a level the 2FF can carry and then recover the pulse, and hold the whole word stable while a single control bit crosses to say 'the bus is valid, read it now.'
// clk_src (fast) and clk_dst (slow) are UNRELATED.
// PROBLEM 1 — a single-cycle event straight into a 2FF:
// always @(posedge clk_dst) begin s1 <= src_pulse; s2 <= s1; end
// // src_pulse is high for ONE fast cycle; if that window falls between two
// // clk_dst edges, s1 and s2 both sample 0 => the event is LOST. Sometimes
// // it lands on an edge and is caught => "works on the bench," drops in field.
// PROBLEM 2 — a multi-bit word, one 2FF per bit:
// // bits resolve on DIFFERENT clk_dst cycles => the receiver can latch a word
// // that was NEVER sourced (incoherent). This is the 11.3 coherency failure.
// The need: reshape each into what a 2FF CAN carry — a stable LEVEL, one bit.
// Pulse -> toggle a level per event, 2FF it, XOR edge-detect it back (toggle sync).
// Word -> hold the bus stable while a req/ack level handshake crosses (handshake).2. Mental Model
3. Pattern Anatomy
Two structures, one idea. Each reshapes its problem into a single-bit level a 2FF can carry, then recovers the original meaning on the far side.
(A) The toggle synchronizer — datapath and rate limit. Four blocks in a line.
- Source toggle flop. In
clk_src, a floptoggleinverts itself on every cyclesrc_pulseis high:if (src_pulse) toggle <= ~toggle. One source event flips the level exactly once; the level then holds until the next event. This is the reshaping step — an event stream has become a level that transitions once per event, which is precisely the clean single-bit level a 2FF is built for. - Destination 2FF. The
togglelevel crosses intoclk_dstthrough a standard two-flop synchronizer (sync[0]sacrificial,sync[1]resolved) — the 11.2 structure, unchanged, becausetoggleis a proper level. - A third destination flop. One more flop delays the synchronized level by a cycle (
sync[2] <= sync[1]), so the destination holds this cycle's and last cycle's synchronized value side by side. - XOR edge detector.
dst_pulse = sync[1] ^ sync[2]is high for exactly one destination cycle whenever the synchronized level changed — i.e. each time a source toggle arrives. Every source event, whatever its pulse width, produces exactly one destination pulse.
The load-bearing constraint is the rate limit: the destination must observe every toggle transition, which takes on the order of two destination cycles per event (2FF latency plus the edge detect). If source events arrive faster than the destination can register each toggle, two toggles can cancel or merge before the XOR sees them, and events are lost or miscounted. So the toggle synchronizer is one-pulse-per-event only if source events are spaced by at least a couple of destination cycles — a minimum-separation requirement the source (or a busy/back-pressure flag) must honour.
Toggle (pulse/event) synchronizer — event to level, across, back to one event
data flow(B) The four-phase handshake synchronizer — sequence and the data-stable window. The data bus itself never crosses a synchronizer; only REQ and ACK do, and the bus is held still while they round-trip. The four phases:
- Phase 1 — source asserts. The source drives the multi-bit
databus with the word and raisesreq. It must not changedatawhilereqis high. - Phase 2 — destination captures.
reqcrosses toclk_dstthrough a 2FF. When the destination sees the synchronizedreqrise, the bus has been stable for at least the 2FF latency, so it samplesdata(now guaranteed settled — a multi-cycle path) and raisesack. - Phase 3 — source releases.
ackcrosses back toclk_srcthrough a 2FF. When the source sees synchronizedack, it knows the word was captured, so it dropsreq— and only now may it changedatafor the next word. - Phase 4 — return to idle. The dropped
reqcrosses to the destination, which dropsack; that crosses back, and both sides are idle, ready for the next word.
The data-stable window is the whole point: from the moment req rises to the moment the source sees ack and drops req, the bus is frozen. Because req took two-plus destination cycles to cross, the bus is guaranteed to have settled long before the destination samples it — this is why the crossing is called a multi-cycle path: the tool need not (and cannot) close single-cycle timing on the data bus, because the protocol holds it stable across many cycles. No bit-skew, no per-bit resolution race, no incoherent word. The cost is latency: one word per full four-phase round trip. A two-phase variant (validity carried by a toggling req/ack rather than a return-to-idle level, exactly the toggle trick of pattern A) removes the phase-3/4 return-to-idle and roughly doubles throughput, at the cost of slightly more edge-detect logic on each side.
Four-phase handshake — hold the bus, cross req, capture, ack back, release
data flowWhen to use each. Reach for a toggle synchronizer to cross an event — a strobe, a done flag, an interrupt, a per-event increment — where you need exactly one destination pulse per source event and events are spaced apart. Reach for a handshake to cross an occasional multi-bit control or config word — a register write, a captured sample, a mode word — where coherence matters more than throughput. For a stream of words at rate, neither is enough: use an async FIFO (11.6), which is built from exactly these pieces — gray-coded pointers (11.5) each carried across by a 2FF, with the FIFO providing the buffering and flow control a single handshake cannot.
4. Real RTL Implementation
This is the core of the page. We build two structures — (a) a toggle-based pulse/event synchronizer (source toggle flop, destination 2FF, third flop, XOR edge-detect, one destination pulse per source event) and (b) a four-phase handshake data synchronizer for a multi-bit word (REQ/ACK each 2FF-synced, data held stable until ACK) — each in SystemVerilog, Verilog, and VHDL, with an RTL block and a self-checking two-clock testbench. The reasoning is identical across the three; only the syntax differs.
Two disciplines run through every block. First, every synchronizer is the 11.2 two-flop structure, unchanged — its input is always a clean level (the toggle, or REQ/ACK), never a pulse and never a bus. Second, the pulse and data are reshaped into levels before crossing and recovered after — the toggle turns an event into a level and the XOR turns it back; the handshake holds the bus while a req/ack level round-trips.
4a. The toggle (pulse/event) synchronizer — three ways
The source toggles a level on every src_pulse; the destination 2FF-synchronizes that level, delays it one more cycle, and XORs the two synchronized samples to regenerate one dst_pulse per source event. Note that src_pulse may be any width — the toggle flips once per cycle it is high, so the testbench must present each event as a one-cycle strobe; a multi-cycle source assertion is a distinct event per cycle, which is the correct and intended semantics.
module pulse_sync (
input logic clk_src, // fast source clock
input logic clk_dst, // slow destination clock (async to clk_src)
input logic rst, // async, active-high -> defined power-up
input logic src_pulse, // single-cycle event in the SOURCE domain
output logic dst_pulse // one-cycle event in the DESTINATION domain
);
// SOURCE: turn an EVENT into a LEVEL. toggle flips once per src_pulse cycle, so a
// stream of events becomes a level that changes once per event and HOLDS between them
// — exactly the clean single-bit level a 2FF is built to carry.
logic toggle;
always_ff @(posedge clk_src or posedge rst)
if (rst) toggle <= 1'b0;
else if (src_pulse) toggle <= ~toggle; // one flip == one event
// DESTINATION: 2FF-synchronize the LEVEL (11.2), plus one extra delay flop so we hold
// the last two synchronized samples. sync[1] is the resolved value; sync[2] is it
// delayed one cycle. NOTHING but this chain reads the toggle level.
(* ASYNC_REG = "TRUE" *)
logic [2:0] sync; // sync[0] sacrificial, sync[1] resolved, sync[2] delayed
always_ff @(posedge clk_dst or posedge rst)
if (rst) sync <= 3'b000;
else sync <= {sync[1:0], toggle}; // shift the level down the chain
// EDGE DETECT: the level changed <=> an event crossed. XOR fires for exactly one
// dst cycle per toggle, regenerating one pulse per source event, width-independent.
assign dst_pulse = sync[2] ^ sync[1];
endmoduleThe self-checking testbench runs two unrelated clocks (fast source, slow destination), fires source pulses of varied widths and spacing, and asserts that the number of destination pulses equals the number of source events exactly — none missed, none doubled. It also instantiates the naive DUT (a bare 2FF straight on src_pulse) side by side to demonstrate it loses events.
module pulse_sync_tb;
logic clk_src = 1'b0, clk_dst = 1'b0, rst, src_pulse = 1'b0;
logic dst_pulse, naive_pulse;
int src_events = 0, dst_events = 0, naive_events = 0, errors = 0;
// Toggle synchronizer under test.
pulse_sync dut (.clk_src(clk_src), .clk_dst(clk_dst), .rst(rst),
.src_pulse(src_pulse), .dst_pulse(dst_pulse));
// NAIVE reference: a bare 2FF straight on the pulse (the WRONG way) — for contrast.
logic n0, n1;
always_ff @(posedge clk_dst or posedge rst)
if (rst) {n1, n0} <= 2'b00;
else {n1, n0} <= {n0, src_pulse};
assign naive_pulse = n1; // just the synchronized level, no edge detect
// Two UNRELATED clocks: fast source, slow destination (edges drift, as in a real cross).
always #5 clk_dst = ~clk_dst; // ~100 MHz destination (slow side)
always #2 clk_src = ~clk_src; // ~250 MHz source (fast side)
// Count destination pulses on each side.
always_ff @(posedge clk_dst) if (!rst && dst_pulse) dst_events++;
always_ff @(posedge clk_dst) if (!rst && naive_pulse) naive_events++;
// Fire ONE source-domain event of a given width, spaced by 'gap' source cycles.
task automatic fire(input int width, input int gap);
repeat (width) begin @(posedge clk_src); src_pulse = 1'b1; end
@(posedge clk_src); src_pulse = 1'b0;
src_events++; // one EVENT (each 1-cycle-high span counts once)
repeat (gap) @(posedge clk_src); // spacing honours the rate limit
endtask
initial begin
rst = 1'b1; repeat (4) @(posedge clk_dst); rst = 1'b0;
// Varied widths and spacing; gap >= a couple of dst cycles so every toggle is seen.
fire(1, 12); fire(1, 20); fire(3, 16); fire(1, 24); fire(2, 18); fire(1, 14);
repeat (10) @(posedge clk_dst); // drain the pipe
// Note: a wide source assertion held for N cycles IS N events by this DUT's
// semantics; here every fire() presents ONE contiguous high span == one event
// for counting, so dst_events must equal src_events.
assert (dst_events == src_events)
else begin $error("toggle sync miscount: src=%0d dst=%0d", src_events, dst_events); errors++; end
if (errors == 0)
$display("PASS: toggle sync delivered exactly one dst pulse per event (src=%0d dst=%0d); naive 2FF caught only %0d",
src_events, dst_events, naive_events);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog form is the same four blocks with reg/wire typing and $display self-checks. The toggle, the 3-bit sync chain, and the XOR are identical.
module pulse_sync (
input wire clk_src,
input wire clk_dst,
input wire rst, // async, active-high
input wire src_pulse, // single-cycle source event
output wire dst_pulse
);
// SOURCE: event -> level. Flip once per src_pulse cycle.
reg toggle;
always @(posedge clk_src or posedge rst)
if (rst) toggle <= 1'b0;
else if (src_pulse) toggle <= ~toggle;
// DESTINATION: 2FF-sync the level + one delay flop for the edge detect.
(* ASYNC_REG = "TRUE" *)
reg [2:0] sync;
always @(posedge clk_dst or posedge rst)
if (rst) sync <= 3'b000;
else sync <= {sync[1:0], toggle};
// EDGE DETECT: XOR of the last two synchronized samples -> one pulse per event.
assign dst_pulse = sync[2] ^ sync[1];
endmodule module pulse_sync_tb;
reg clk_src, clk_dst, rst, src_pulse;
wire dst_pulse, naive_pulse;
integer src_events, dst_events, naive_events, errors, w, g;
pulse_sync dut (.clk_src(clk_src), .clk_dst(clk_dst), .rst(rst),
.src_pulse(src_pulse), .dst_pulse(dst_pulse));
// NAIVE 2FF straight on the pulse (the WRONG way), for contrast.
reg n0, n1;
always @(posedge clk_dst or posedge rst)
if (rst) begin n1 <= 1'b0; n0 <= 1'b0; end
else begin n1 <= n0; n0 <= src_pulse; end
assign naive_pulse = n1;
always #5 clk_dst = ~clk_dst; // slow destination
always #2 clk_src = ~clk_src; // fast source, async
always @(posedge clk_dst) if (!rst && dst_pulse) dst_events = dst_events + 1;
always @(posedge clk_dst) if (!rst && naive_pulse) naive_events = naive_events + 1;
// Fire one event of 'width' source cycles, then wait 'gap' source cycles.
task fire;
input integer width;
input integer gap;
integer i;
begin
for (i = 0; i < width; i = i + 1) begin @(posedge clk_src); src_pulse = 1'b1; end
@(posedge clk_src); src_pulse = 1'b0;
src_events = src_events + 1;
for (i = 0; i < gap; i = i + 1) @(posedge clk_src);
end
endtask
initial begin
clk_src = 1'b0; clk_dst = 1'b0; src_pulse = 1'b0;
src_events = 0; dst_events = 0; naive_events = 0; errors = 0;
rst = 1'b1; repeat (4) @(posedge clk_dst); rst = 1'b0;
fire(1, 12); fire(1, 20); fire(3, 16); fire(1, 24); fire(2, 18); fire(1, 14);
repeat (10) @(posedge clk_dst);
if (dst_events !== src_events) begin
$display("FAIL: toggle miscount src=%0d dst=%0d", src_events, dst_events);
errors = errors + 1;
end
if (errors == 0)
$display("PASS: one dst pulse per event (src=%0d dst=%0d); naive 2FF caught only %0d",
src_events, dst_events, naive_events);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleIn VHDL the toggle is a std_logic inverted on each event, the sync chain is a std_logic_vector shifted on clk_dst, and dst_pulse is a concurrent xor of the two synchronized samples.
library ieee;
use ieee.std_logic_1164.all;
entity pulse_sync is
port (
clk_src : in std_logic; -- fast source clock
clk_dst : in std_logic; -- slow destination clock (async)
rst : in std_logic; -- async, active-high
src_pulse : in std_logic; -- single-cycle source event
dst_pulse : out std_logic
);
end entity;
architecture rtl of pulse_sync is
signal toggle : std_logic; -- event -> level
signal sync : std_logic_vector(2 downto 0); -- 2FF + one delay flop
attribute ASYNC_REG : string;
attribute ASYNC_REG of sync : signal is "TRUE"; -- keep sync flops adjacent
begin
-- SOURCE: flip the level once per src_pulse cycle.
src_proc : process (clk_src, rst) begin
if rst = '1' then
toggle <= '0';
elsif rising_edge(clk_src) then
if src_pulse = '1' then toggle <= not toggle; end if;
end if;
end process;
-- DESTINATION: 2FF-sync the level, plus one delay flop for the edge detect.
dst_proc : process (clk_dst, rst) begin
if rst = '1' then
sync <= (others => '0');
elsif rising_edge(clk_dst) then
sync <= sync(1 downto 0) & toggle;
end if;
end process;
-- EDGE DETECT: XOR of the two synchronized samples -> one pulse per event.
dst_pulse <= sync(2) xor sync(1);
end architecture; library ieee;
use ieee.std_logic_1164.all;
entity pulse_sync_tb is
end entity;
architecture sim of pulse_sync_tb is
signal clk_src : std_logic := '0';
signal clk_dst : std_logic := '0';
signal rst : std_logic := '1';
signal src_pulse : std_logic := '0';
signal dst_pulse : std_logic;
signal naive_pulse : std_logic;
signal n0, n1 : std_logic := '0';
signal src_events, dst_events, naive_events : integer := 0;
begin
dut : entity work.pulse_sync
port map (clk_src => clk_src, clk_dst => clk_dst, rst => rst,
src_pulse => src_pulse, dst_pulse => dst_pulse);
-- Two UNRELATED clocks: fast source, slow destination.
clk_dst <= not clk_dst after 5 ns; -- slow side
clk_src <= not clk_src after 2 ns; -- fast side, async
-- NAIVE 2FF straight on the pulse (the WRONG way), for contrast.
naive : process (clk_dst, rst) begin
if rst = '1' then n1 <= '0'; n0 <= '0';
elsif rising_edge(clk_dst) then n1 <= n0; n0 <= src_pulse; end if;
end process;
naive_pulse <= n1;
-- Count destination pulses on each side.
cnt : process (clk_dst) begin
if rising_edge(clk_dst) and rst = '0' then
if dst_pulse = '1' then dst_events <= dst_events + 1; end if;
if naive_pulse = '1' then naive_events <= naive_events + 1; end if;
end if;
end process;
stim : process
-- Fire one event of 'width' source cycles, then wait 'gap' source cycles.
procedure fire(width : in integer; gap : in integer) is
begin
for i in 1 to width loop
wait until rising_edge(clk_src); src_pulse <= '1';
end loop;
wait until rising_edge(clk_src); src_pulse <= '0';
src_events <= src_events + 1;
for i in 1 to gap loop wait until rising_edge(clk_src); end loop;
end procedure;
begin
for i in 1 to 4 loop wait until rising_edge(clk_dst); end loop;
rst <= '0';
fire(1, 12); fire(1, 20); fire(3, 16); fire(1, 24); fire(2, 18); fire(1, 14);
for i in 1 to 10 loop wait until rising_edge(clk_dst); end loop;
assert dst_events = src_events
report "toggle sync miscount: dst_events /= src_events" severity error;
report "pulse_sync self-check complete (dst pulse per event; naive 2FF loses events)" severity note;
wait;
end process;
end architecture;4b. The four-phase handshake (MCP) data synchronizer — three ways
The handshake module carries a whole WIDTH-bit word coherently: the source latches the word and raises REQ, the destination samples the held-stable bus and raises ACK, REQ and ACK each cross through their own 2FF, and only after the source sees ACK does it release the bus. The data bus is a multi-cycle path — never synchronized, only held stable.
module handshake_sync #(
parameter int WIDTH = 16 // width of the word being crossed
)(
input logic clk_src,
input logic clk_dst,
input logic rst, // async, active-high
// source side
input logic src_valid, // request to send src_data
input logic [WIDTH-1:0] src_data, // the word to cross
output logic src_ready, // high when a new word may be presented
// destination side
output logic dst_valid, // one-cycle strobe: dst_data is a fresh word
output logic [WIDTH-1:0] dst_data // the coherently-captured word
);
// ---- SOURCE DOMAIN: drive REQ + the held-stable bus; wait for synchronized ACK ----
logic req; // level, crosses to dst
logic [WIDTH-1:0] data_hold; // the bus, HELD STABLE while req is high
logic [1:0] ack_sync; // 2FF: ack level, dst -> src
logic ack_s; // resolved ack in the source domain
assign ack_s = ack_sync[1];
assign src_ready = ~req; // may present a new word only when idle
always_ff @(posedge clk_src or posedge rst)
if (rst) begin req <= 1'b0; data_hold <= '0; end
else begin
if (!req && src_valid) begin
data_hold <= src_data; // latch the word...
req <= 1'b1; // ...and assert REQ; bus now HELD until ack
end else if (req && ack_s) begin
req <= 1'b0; // saw ACK: release. Only NOW may data change.
end
end
// ---- DESTINATION DOMAIN: 2FF-sync REQ; capture the STABLE bus; assert ACK ----
(* ASYNC_REG = "TRUE" *) logic [1:0] req_sync; // 2FF: req level, src -> dst
logic req_d; // delayed, for edge detect
logic ack; // level, crosses back to src
always_ff @(posedge clk_dst or posedge rst)
if (rst) begin req_sync <= 2'b00; req_d <= 1'b0; ack <= 1'b0; dst_data <= '0; dst_valid <= 1'b0; end
else begin
req_sync <= {req_sync[0], req};
req_d <= req_sync[1];
dst_valid <= 1'b0; // default: pulse only on capture
if (req_sync[1] && !req_d) begin // rising synchronized REQ -> capture
dst_data <= data_hold; // bus settled long ago (MCP): coherent
dst_valid <= 1'b1; // one-cycle 'new word' strobe
ack <= 1'b1; // acknowledge
end else if (!req_sync[1]) begin
ack <= 1'b0; // req withdrawn -> drop ack (phase 4)
end
end
// ACK level crosses back into the source domain (2FF), read as ack_s above.
always_ff @(posedge clk_src or posedge rst)
if (rst) ack_sync <= 2'b00;
else ack_sync <= {ack_sync[0], ack};
endmoduleThe testbench runs two unrelated clocks, sends a stream of random words through the handshake, and asserts two properties: every word the destination reports equals the word the source sent (coherent), and the source never changes data_hold while req is high (data-stable-until-ack).
module handshake_sync_tb;
localparam int WIDTH = 16;
logic clk_src = 1'b0, clk_dst = 1'b0, rst;
logic src_valid, src_ready, dst_valid;
logic [WIDTH-1:0] src_data, dst_data;
int errors = 0, sent = 0, got = 0;
logic [WIDTH-1:0] expected_q [$]; // model: words in flight, checked in order
handshake_sync #(.WIDTH(WIDTH)) dut (
.clk_src(clk_src), .clk_dst(clk_dst), .rst(rst),
.src_valid(src_valid), .src_data(src_data), .src_ready(src_ready),
.dst_valid(dst_valid), .dst_data(dst_data));
always #3 clk_src = ~clk_src; // source
always #5 clk_dst = ~clk_dst; // destination, async
// DATA-STABLE-UNTIL-ACK: while req is high inside the DUT, data_hold must not change.
logic [WIDTH-1:0] hold_prev;
always_ff @(posedge clk_src)
if (!rst && dut.req) begin
assert (dut.data_hold === hold_prev)
else begin $error("data changed while REQ high"); errors++; end
end
always_ff @(posedge clk_src) hold_prev <= dut.data_hold;
// Destination collects words; each must match the next sourced word (coherent, in order).
always_ff @(posedge clk_dst)
if (!rst && dst_valid) begin
got++;
assert (expected_q.size() > 0 && dst_data === expected_q[0])
else begin $error("incoherent word: got=%h exp=%h", dst_data, expected_q[0]); errors++; end
if (expected_q.size() > 0) void'(expected_q.pop_front());
end
// Source sends a word when the handshake is ready.
task automatic send(input logic [WIDTH-1:0] w);
@(posedge clk_src); while (!src_ready) @(posedge clk_src);
src_data = w; src_valid = 1'b1; expected_q.push_back(w); sent++;
@(posedge clk_src); src_valid = 1'b0;
endtask
initial begin
rst = 1'b1; src_valid = 1'b0; src_data = '0;
repeat (4) @(posedge clk_dst); rst = 1'b0;
for (int i = 0; i < 8; i++) send($urandom); // random multi-bit words
repeat (40) @(posedge clk_dst); // let the last words drain
assert (got == sent)
else begin $error("word count mismatch: sent=%0d got=%0d", sent, got); errors++; end
if (errors == 0) $display("PASS: %0d words crossed coherently, data stable until ack", got);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog handshake is the same protocol with reg/wire typing; the source FSM latches the word on REQ and releases on synchronized ACK, and the destination captures on the rising synchronized REQ. The testbench checks coherence with a small in-order model array.
module handshake_sync #(
parameter WIDTH = 16
)(
input wire clk_src,
input wire clk_dst,
input wire rst,
input wire src_valid,
input wire [WIDTH-1:0] src_data,
output wire src_ready,
output reg dst_valid,
output reg [WIDTH-1:0] dst_data
);
// SOURCE: drive REQ + held bus; release on synchronized ACK.
reg req;
reg [WIDTH-1:0] data_hold;
reg [1:0] ack_sync;
wire ack_s = ack_sync[1];
assign src_ready = ~req;
always @(posedge clk_src or posedge rst)
if (rst) begin req <= 1'b0; data_hold <= {WIDTH{1'b0}}; end
else begin
if (!req && src_valid) begin
data_hold <= src_data; // latch word, hold bus stable
req <= 1'b1;
end else if (req && ack_s) begin
req <= 1'b0; // saw ACK: release; data may now change
end
end
// DESTINATION: 2FF-sync REQ, capture stable bus, assert ACK.
(* ASYNC_REG = "TRUE" *) reg [1:0] req_sync;
reg req_d, ack;
always @(posedge clk_dst or posedge rst)
if (rst) begin
req_sync <= 2'b00; req_d <= 1'b0; ack <= 1'b0;
dst_data <= {WIDTH{1'b0}}; dst_valid <= 1'b0;
end else begin
req_sync <= {req_sync[0], req};
req_d <= req_sync[1];
dst_valid <= 1'b0;
if (req_sync[1] && !req_d) begin // rising synchronized REQ -> capture
dst_data <= data_hold; // coherent: bus long settled (MCP)
dst_valid <= 1'b1;
ack <= 1'b1;
end else if (!req_sync[1]) begin
ack <= 1'b0;
end
end
// ACK crosses back to source through a 2FF.
always @(posedge clk_src or posedge rst)
if (rst) ack_sync <= 2'b00;
else ack_sync <= {ack_sync[0], ack};
endmodule module handshake_sync_tb;
parameter WIDTH = 16;
reg clk_src, clk_dst, rst, src_valid;
reg [WIDTH-1:0] src_data;
wire src_ready, dst_valid;
wire [WIDTH-1:0] dst_data;
integer errors, sent, got, wr, rd, i;
reg [WIDTH-1:0] model [0:63]; // in-order model of words in flight
reg [WIDTH-1:0] hold_prev;
handshake_sync #(.WIDTH(WIDTH)) dut (
.clk_src(clk_src), .clk_dst(clk_dst), .rst(rst),
.src_valid(src_valid), .src_data(src_data), .src_ready(src_ready),
.dst_valid(dst_valid), .dst_data(dst_data));
always #3 clk_src = ~clk_src;
always #5 clk_dst = ~clk_dst;
// DATA-STABLE-UNTIL-ACK: data_hold must not change while req is high.
always @(posedge clk_src) begin
if (!rst && dut.req && dut.data_hold !== hold_prev) begin
$display("FAIL: data changed while REQ high"); errors = errors + 1;
end
hold_prev <= dut.data_hold;
end
// Destination collects words; each must match the next model word (coherent, in order).
always @(posedge clk_dst)
if (!rst && dst_valid) begin
if (dst_data !== model[rd]) begin
$display("FAIL: incoherent got=%h exp=%h", dst_data, model[rd]);
errors = errors + 1;
end
rd = rd + 1;
got = got + 1;
end
task send;
input [WIDTH-1:0] w;
begin
@(posedge clk_src); while (!src_ready) @(posedge clk_src);
src_data = w; src_valid = 1'b1; model[wr] = w; wr = wr + 1; sent = sent + 1;
@(posedge clk_src); src_valid = 1'b0;
end
endtask
initial begin
clk_src = 1'b0; clk_dst = 1'b0; rst = 1'b1; src_valid = 1'b0; src_data = 0;
errors = 0; sent = 0; got = 0; wr = 0; rd = 0;
repeat (4) @(posedge clk_dst); rst = 1'b0;
for (i = 0; i < 8; i = i + 1) send($random);
repeat (40) @(posedge clk_dst);
if (got !== sent) begin
$display("FAIL: word count sent=%0d got=%0d", sent, got); errors = errors + 1;
end
if (errors == 0) $display("PASS: %0d words crossed coherently, data stable until ack", got);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleIn VHDL the handshake uses two clocked processes for the source (REQ + held bus, release on synchronized ACK) and destination (2FF REQ, capture, ACK), with the two 2FF chains as std_logic_vector shifts. numeric_std carries the data as a plain std_logic_vector since it is only moved, not computed on.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity handshake_sync is
generic ( WIDTH : positive := 16 );
port (
clk_src : in std_logic;
clk_dst : in std_logic;
rst : in std_logic; -- async, active-high
src_valid : in std_logic;
src_data : in std_logic_vector(WIDTH-1 downto 0);
src_ready : out std_logic;
dst_valid : out std_logic;
dst_data : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of handshake_sync is
signal req : std_logic := '0';
signal data_hold : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal ack_sync : std_logic_vector(1 downto 0) := "00"; -- 2FF ack: dst -> src
signal req_sync : std_logic_vector(1 downto 0) := "00"; -- 2FF req: src -> dst
signal req_d, ack : std_logic := '0';
attribute ASYNC_REG : string;
attribute ASYNC_REG of req_sync : signal is "TRUE";
attribute ASYNC_REG of ack_sync : signal is "TRUE";
begin
src_ready <= not req; -- new word only when idle
-- SOURCE: drive REQ + held bus; release on synchronized ACK.
src_proc : process (clk_src, rst) begin
if rst = '1' then
req <= '0'; data_hold <= (others => '0'); ack_sync <= "00";
elsif rising_edge(clk_src) then
ack_sync <= ack_sync(0) & ack; -- 2FF ack back into source
if req = '0' and src_valid = '1' then
data_hold <= src_data; -- latch word, hold bus stable
req <= '1';
elsif req = '1' and ack_sync(1) = '1' then
req <= '0'; -- saw ACK: release; data may change
end if;
end if;
end process;
-- DESTINATION: 2FF-sync REQ, capture stable bus, assert ACK.
dst_proc : process (clk_dst, rst) begin
if rst = '1' then
req_sync <= "00"; req_d <= '0'; ack <= '0';
dst_data <= (others => '0'); dst_valid <= '0';
elsif rising_edge(clk_dst) then
req_sync <= req_sync(0) & req;
req_d <= req_sync(1);
dst_valid <= '0'; -- default: pulse on capture only
if req_sync(1) = '1' and req_d = '0' then -- rising synchronized REQ
dst_data <= data_hold; -- coherent: bus long settled (MCP)
dst_valid <= '1';
ack <= '1';
elsif req_sync(1) = '0' then
ack <= '0';
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity handshake_sync_tb is
end entity;
architecture sim of handshake_sync_tb is
constant WIDTH : positive := 16;
signal clk_src, clk_dst : std_logic := '0';
signal rst : std_logic := '1';
signal src_valid : std_logic := '0';
signal src_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal src_ready : std_logic;
signal dst_valid : std_logic;
signal dst_data : std_logic_vector(WIDTH-1 downto 0);
type word_arr is array (0 to 63) of std_logic_vector(WIDTH-1 downto 0);
signal model : word_arr;
signal wr, rd, sent, got : integer := 0;
signal lfsr : std_logic_vector(WIDTH-1 downto 0) := x"ACE1"; -- pseudo-random words
begin
dut : entity work.handshake_sync
generic map (WIDTH => WIDTH)
port map (clk_src => clk_src, clk_dst => clk_dst, rst => rst,
src_valid => src_valid, src_data => src_data, src_ready => src_ready,
dst_valid => dst_valid, dst_data => dst_data);
clk_src <= not clk_src after 3 ns;
clk_dst <= not clk_dst after 5 ns;
-- Destination collects words; each must match the next model word (coherent, in order).
chk : process (clk_dst) begin
if rising_edge(clk_dst) and rst = '0' and dst_valid = '1' then
assert dst_data = model(rd)
report "incoherent word: dst_data /= sourced word" severity error;
rd <= rd + 1;
got <= got + 1;
end if;
end process;
stim : process
-- advance a simple LFSR to get the next pseudo-random word
procedure next_word(signal v : inout std_logic_vector(WIDTH-1 downto 0)) is
variable fb : std_logic;
begin
fb := v(15) xor v(13) xor v(12) xor v(10);
v <= v(WIDTH-2 downto 0) & fb;
end procedure;
procedure send is
begin
wait until rising_edge(clk_src);
while src_ready = '0' loop wait until rising_edge(clk_src); end loop;
src_data <= lfsr;
model(wr) <= lfsr;
wr <= wr + 1;
sent <= sent + 1;
src_valid <= '1';
wait until rising_edge(clk_src);
src_valid <= '0';
next_word(lfsr);
end procedure;
begin
for i in 1 to 4 loop wait until rising_edge(clk_dst); end loop;
rst <= '0';
for i in 1 to 8 loop send; end loop;
for i in 1 to 40 loop wait until rising_edge(clk_dst); end loop;
assert got = sent
report "word count mismatch: got /= sent" severity error;
report "handshake_sync self-check complete (coherent transfer, data stable until ack)" severity note;
wait;
end process;
end architecture;Across all three languages the two lessons are one sentence each: a synchronizer carries a level, so turn an event into a toggling level and XOR it back into exactly one destination pulse; and move a multi-bit word by holding the bus stable while a req/ack level round-trips, so the destination always samples a coherent word.
5. Verification Strategy
Both structures are timing-and-coherence properties, not truth tables, so verification runs two unrelated clocks and checks the exact contract of each. Two sentences capture correctness:
The toggle synchronizer delivers exactly one destination-domain pulse per source event — none missed, none doubled — regardless of source pulse width, as long as events honour the minimum separation. The handshake synchronizer delivers to the destination exactly the word the source sent (coherent), and the source never changes the data bus before it sees ACK (data-stable-until-ack).
Everything below makes those checkable and maps onto the §4 testbenches.
- Two-async-clock, self-checking testbenches. Generate
clk_srcandclk_dstwith unrelated periods so their edges drift, exactly as a real crossing. For the pulse sync, drive the fast clock as the source and the slow clock as the destination — the case where a naive 2FF loses events — and for the handshake, either direction works since the bus is held. - Pulse: count events, both ways. Fire source pulses of varied widths and spacings, count source events, count destination
dst_pulseassertions, and assert they are equal. The §4a testbenches do this in all three HDLs (SVassert (dst_events == src_events), Verilogif (dst_events !== src_events) $display("FAIL…"), VHDLassert dst_events = src_events … severity error). Instantiating the naive 2FF-on-the-pulse alongside and showing its count is lower is the direct demonstration of the §7 bug. - Handshake: coherence and data-stability. Send a stream of random words and check two properties: every word the destination reports (
dst_valid) equals the next word the source sent — an in-order model queue/array proves coherence — and, sampled in the source domain,data_holdnever changes whilereqis high — proving data-stable-until-ack. The §4b testbenches assert both. - Invariants, stated conceptually. Toggle: one XOR edge per source toggle;
sync[0]is the only flop exposed to the async toggle and nothing but the chain reads it. Handshake:reqandackare each a single-bit level carried by a proper 2FF (never the data); the data bus is a multi-cycle path held stable from REQ-assert to REQ-release;src_readyis low while a transfer is in flight so the source cannot overrun. - Corner cases to exercise. Toggle: the fastest source spacing that still honours the rate limit (confirm no merge), and a violation of the rate limit (fire two events one source cycle apart into a much slower destination — confirm events are lost, which is the expected failure the rate limit exists to prevent). Handshake: back-to-back words (confirm the source waits for
src_ready), reset mid-transfer (both sides return to idle cleanly), and a word all-ones then all-zeros (confirm no bit-skew corruption, which a bare per-bit 2FF would show). - Lint / CDC-tool intent. A structural CDC checker is the primary sign-off: it must confirm every crossing signal (
toggle,req,ack) is a registered synchronizer, that the data bus is declared a multi-cycle / quasi-static path gated by the handshake (not a single-cycle timed crossing), and that no synchronized level is combinationally re-crossed. Treat a CDC-clean report as gating. - Expected waveform. Toggle:
toggleflips once per source event;sync[1]follows two destination edges later;dst_pulseis a clean one-cycle spike per flip. The bug signature is fewerdst_pulsespikes than source events (loss) or a doubled spike (rate-limit merge). Handshake:data_holdsteps and freezes;reqrises, then two edges laterdst_validpulses andackrises;reqthen falls and the bus is free. The bug signature isdst_datamomentarily showing a value that was never ondata_hold— which cannot happen here because the bus is held, and does happen with a naive per-bit bus synchronizer.
6. Common Mistakes
Naive pulse 'synchronization' — a bare 2FF straight on the pulse. The signature failure. A single-cycle src_pulse from a fast domain fed directly into a destination 2FF is missed entirely when its high time falls between destination edges (both flops sample low), and a wide source pulse held for several destination cycles is stretched into a multi-cycle level or doubled by the edge logic — either way the one-event-to-one-event promise is broken. A synchronizer carries a level; it does not carry a pulse. The fix is always toggle-then-edge-detect: convert the event to a toggling level in the source domain, 2FF the level, and XOR the last two synchronized samples to regenerate one destination pulse. Never 2FF a raw pulse.
Firing source events faster than the destination can capture toggles (rate-limit violation). The toggle synchronizer promises one pulse per event only if the destination observes every toggle transition, which needs roughly two destination cycles per event. If the source fires two events one source cycle apart into a much slower destination, the toggle flips twice before the destination samples it once — the two flips cancel (back to the same level) and the destination sees no change, silently losing both events; or two flips merge into one observed transition and it counts one where there were two. The source must space events (a busy/back-pressure flag, or a known-slow event rate) so every toggle is seen; if events genuinely arrive at rate, you need an async FIFO, not a toggle.
Changing handshake data before ACK returns. The entire coherence guarantee rests on the bus being frozen from the moment req rises until the source sees synchronized ack. If the source updates data early — because it treated req as latency-free, or drove new data on the next source cycle — the destination, which samples the bus two-plus destination cycles later, reads a mix of the old and new word: exactly the incoherent value the handshake exists to prevent. The rule is absolute: hold data stable while req is high, and change it only after the synchronized ack releases req.
Not synchronizing REQ or ACK (or synchronizing the data instead). REQ and ACK cross clock domains and are therefore async single-bit levels — each must go through its own 2FF, or it can arrive metastable and the handshake state machine takes an illegal step (a spurious capture, a stuck req). The complementary error is synchronizing the data bus (per-bit 2FFs) instead of holding it: that reintroduces the 11.3 coherency bug the multi-cycle-path handshake was built to avoid. Synchronize the control bits; hold the data.
Forgetting the ACK path (source overruns the destination). A handshake needs both directions: REQ so the destination knows a word is ready, and ACK so the source knows it was taken. Omit the ACK 2FF (or ignore ACK) and the source has no way to know the destination captured the word — it drops req and presents the next word on its own schedule, overrunning a slower destination and dropping words. The src_ready = ~req (only-idle-may-send) interlock plus the ACK round trip is what paces the source to the destination; both halves are mandatory.
7. DebugLab
The interrupt that mostly arrived — a single-cycle pulse 2FF-synchronized straight across, and the events that vanished
The engineering lesson: a synchronizer crosses levels, not pulses — so convert an event to a toggling level before it crosses and XOR it back into exactly one destination pulse on the far side, and move a multi-bit word with a req/ack handshake that holds the bus stable across the crossing; never 2FF a raw pulse, and never change handshake data before ACK. The tell for the pulse bug is unmistakable: a count that runs low, never reproduces on the bench, drifts with temperature and voltage, and differs board to board — the fingerprint of events falling between destination edges rather than an ordinary counter fault. Two habits make both failures impossible, identical across SystemVerilog, Verilog, and VHDL: for an event, toggle a level per pulse, 2FF the level, and XOR-edge-detect it (respecting the minimum event separation); for a word, hold the bus stable while a 2FF-synced req/ack round-trips, changing the data only after ACK — then mark every synchronizer ASYNC_REG and run a CDC checker so no raw pulse or unheld bus ever crosses a clock boundary.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses 'a synchronizer crosses levels; reshape the problem into a level.'
Exercise 1 — Trace the toggle synchronizer
A source domain fires a single-cycle src_pulse. Draw toggle (source domain), sync[0], sync[1], sync[2], and dst_pulse across the next several clk_dst edges. State exactly how many destination pulses one source event produces, why that is independent of how wide the source pulse was, and in one line why dst_pulse = sync[1] ^ sync[2] fires for exactly one destination cycle per event.
Exercise 2 — Break the toggle synchronizer on its rate limit
A designer uses a toggle synchronizer to cross a data_ready strobe from a 300 MHz domain into a 60 MHz domain, and the strobe can fire on back-to-back source cycles. Explain (i) the minimum event separation the destination requires and why, (ii) precisely what the destination sees when two events arrive one source cycle apart (why both can be lost), and (iii) what structure you would use instead if data_ready genuinely fires at rate, and why it succeeds where the toggle fails.
Exercise 3 — Sequence the handshake
For the four-phase handshake, list the four phases in order and, for each, state which domain acts, what it drives, and what it is waiting for. Then identify the exact window during which the source must not change the data bus, and explain in one line why the destination is guaranteed to sample a coherent word even though the two clocks are unrelated (name the multi-cycle-path property that makes it safe).
Exercise 4 — Reject the wrong structure
For each crossing, say whether a toggle synchronizer, a four-phase handshake, or an async FIFO is correct, and why in one line: (a) a single-cycle frame_done interrupt that fires at most once per millisecond; (b) an 8-bit threshold config register written occasionally by software; (c) a continuous stream of 32-bit samples arriving every source cycle; (d) a packet_start strobe that can fire on consecutive fast-domain cycles during a burst. (Hint: event vs occasional-word vs stream, and mind the rate limit.)
10. Related Tutorials
Continue in Chapter 11 — Clock Domain Crossing (forward links unlock as they ship):
- The Multi-Bit CDC Problem — Chapter 11.3; the coherency failure (independent per-bit resolution) that the four-phase handshake on this page solves by holding the whole bus stable while a single control bit crosses.
- Gray-Coded Pointer Synchronization — Chapter 11.5; the other way to move a multi-bit value safely — make only one bit change per step so a 2FF can carry each bit — used for FIFO pointers where a handshake would be too slow.
- Asynchronous FIFO — Chapter 11.6; the streaming crossing that composes exactly these pieces — a level synchronizer per gray-coded pointer plus a RAM buffer — for the at-rate case a single handshake cannot keep up with.
- CDC Design Rules — the checklist that makes 'never 2FF a raw pulse; move a word with a held-bus handshake or gray code' an enforced sign-off rule.
Backward / in-track dependencies:
- The Two-Flop Synchronizer — Chapter 11.2; the direct prerequisite — the level synchronizer that carries the TOGGLE, the REQ, and the ACK on this page unchanged, and whose single-bit-level contract this page works around for pulses and words.
- Metastability — Chapter 11.1; why every one of these crossings (toggle, REQ, ACK) still needs a 2FF, and the
exp(-t / tau)resolution the synchronizer buys before any of this logic reads the crossed bit. - Pulse & Strobe Generators — Chapter 3.6; the single-cycle strobe this page must carry across domains, and the edge-detect (
sync[1] ^ sync[2]) technique reused to regenerate one destination pulse per event. - valid/ready Handshake — Chapter 9.1; the same-clock handshake grammar (a REQ/ACK exchange gating a data transfer) that this page carries across clock domains with a 2FF on each control bit.
- Ready/Valid Pipelining — Chapter 9.2; how a validated data transfer is paced and buffered within a domain, the intra-domain counterpart to the cross-domain handshake here.
- Synchronous FIFO Architecture — Chapter 7.1; the single-clock FIFO whose pointers, once gray-coded and synchronized (via 11.2 + 11.5), build the async FIFO that this handshake is the occasional-word alternative to.
Verilog v1 prerequisites (the grammar these patterns transcribe into):
- Blocking and Non-Blocking Assignments — why the toggle flop, the sync chains, and the handshake registers all update with non-blocking (
<=) so each stage advances one edge at a time. - Initial and Always Blocks — the clocked
always @(posedge clk_src)/@(posedge clk_dst)processes the source and destination logic are built in, and the reset branches they open with. - If-Else Statements — the reset-then-update structure of each clocked block and the phase logic of the handshake (
if (!req && src_valid) … else if (req && ack_s) …). - Timing Checks — the setup/hold checks whose violation is the metastability every crossing bit (toggle, req, ack) is 2FF-synchronized against.
11. Summary
- A synchronizer crosses a level, not a pulse and not a bus — so reshape the problem into a level. The two-flop synchronizer of 11.2 carries a stable single-bit level. A single-cycle pulse can fall between the slower destination's edges and be lost (or a wide one stretched/doubled); a multi-bit word synchronized bit-by-bit resolves incoherently (11.3). Both are fixed by turning what you have into a level the 2FF can carry and recovering it on the far side.
- Toggle synchronizer — for an event. Toggle a source-domain flop on each
src_pulse(event to level), 2FF-synchronize the toggling level, and XOR the last two synchronized samples (dst_pulse = sync[1] ^ sync[2]) to regenerate exactly one destination pulse per source event — width-independent. The one cost is a rate limit: source events must be spaced about two destination cycles apart so every toggle is observed, or events merge and are lost. Use it for strobes, done flags, interrupts, per-event increments. - Handshake (MCP) synchronizer — for a multi-bit word. The source drives the bus and raises REQ (holding the bus stable); a 2FF carries REQ across; the destination samples the held-stable bus and raises ACK; a 2FF carries ACK back; the source sees ACK, drops REQ, and only then changes the data. The bus is a multi-cycle path frozen across the crossing, so the destination always reads a coherent word — no bit-skew — at the cost of round-trip latency (a two-phase toggling variant nearly doubles throughput). Use it for occasional multi-bit control/config words.
- The signature failures are 2FF-on-a-raw-pulse, rate-limit violation, and changing handshake data before ACK. Straight-2FF loses events that fall between edges (a count that runs low, unreproducibly, drifting with temperature — the CDC fingerprint); over-fast events merge toggles; early data change corrupts the captured word. Never 2FF a raw pulse; honour the event separation; hold the data until ACK; synchronize REQ/ACK but never the data.
- Match the structure to what crosses: toggle for events, handshake for occasional words, async FIFO for streams. A toggle carries no data; a handshake is one word per round trip; a stream at rate needs the buffering of an async FIFO (11.6), built from these same pieces — gray-coded pointers (11.5) each carried by a 2FF. All of it built and self-check-verified here in SystemVerilog, Verilog, and VHDL.