The 4-phase handshake buys certainty with a round trip. When the source can wait long enough, a cheaper scheme works.
The source parks the word in a holding register and sends a pulse through a toggle synchroniser. The destination captures the held word when the pulse arrives.
The destination register is an ordinary enabled flop. It keeps its value unless the pulse arrives, which is the mux recirculation the lesson is named for.
Read the top row as the data path and the bottom row as the control path; they meet only at the mux in front of dout.
The word never passes through a synchroniser. It is safe because hold_reg does not change while the destination may sample it.
Only the toggle crosses through flops. pulse_d is one clk_d cycle wide, so dout loads the held word exactly once.
When pulse_d is low the mux feeds dout back to itself. That loop is the recirculation; in RTL it is simply if (pulse_d) dout <= hold_reg;.
The testbench moves 3,000 + 3,000 words (HOLD = 6 and HOLD = 20). Derive earliest capture and latest permitted overwrite separately before using this open-loop structure.
Check | Result |
|---|---|
Source clock 10 ns, destination 17.06 ns (slower) | 3,000 16-bit words, none corrupted or lost |
Source input bus changes right after each load | Yes, the holding register protects the data |
Source reuse interval | HOLD=6 permits overwrite after 7 source periods = 70 ns; see the edge derivation below |
Mutation: hold time of 1 cycle | 2,740 of 3,000 words corrupted |
Read the timeline as one axis of time after E0. The top bar is how long the data holds still. The lower bar is when the destination can capture it.
The capture window (44.12 to 61.18 ns) lies inside the hold window (0 to 70 ns), so the data is still when it is sampled.
The earliest capture is Ts + 2*Td (D2) and the latest is Ts + 3*Td (D3). Each extra destination period a control deferral adds moves the capture later by 17.06 ns.
Only 8.82 ns remain after D3, for late control terms, hold and margin. A single extra destination period would push the capture past the overwrite at 70 ns.
Event | Edge / bound in this listing |
|---|---|
E0: accept load | hold_reg and pulse_s update after source edge 0 |
E1: launch toggle | pulse_sync consumes the registered pulse_s one source period later |
D0 / D1 / D2 | First stage can sample at D0, second updates at D1, destination consumes pulse_d at D2; earliest capture is approximately Ts + 2*Td after E0 |
Ideal latest capture | Less than Ts + 3*Td + control clock-to-Q/route terms when D0 is the next sampling edge; extra model deferrals add destination periods |
Next overwrite | Counter loads HOLD at E0, becomes zero after E_HOLD, next acceptance is E_(HOLD+1) |
Two distinct inequalities must hold. First, source data clock-to-Q + maximum data route + destination setup + margin must be less than the earliest control-driven capture interval. Second, the earliest next data change must occur after the latest permitted capture plus hold/margin. The output consumer observes registered out_valid on the following destination edge; that adds consumer latency, not an extra hold_reg capture edge. State bounded control deferrals explicitly; physical metastability has no deterministic finite ceiling.
For Ts=10 ns and Td=17.06 ns, the ideal capture envelope is approximately 44.12 to 61.18 ns after E0, plus implementation terms. The next overwrite for HOLD=6 is at 70 ns, leaving less than 8.82 ns for late control terms, hold and margin; an extra destination-cycle deferral can exhaust it. HOLD=20 checks counter range, not an all-corners guarantee. A worked illustrative data budget of 30 ns including clock-to-Q/setup/margin fits before 44.12 ns, but must be constrained and measured per bit.
# Illustrative only: adapt endpoints and tool delay accounting.
# The complete data path, including clock-to-Q/setup/margin, must fit 44.12 ns.
# Reserve 14.12 ns here; constrain the tool-counted datapath to 30 ns.
set_max_delay 30.0 -datapath_only -from [get_cells dut/hold_reg*] -to [get_cells dut/dout_reg*]
# Check all W data bits in effective reports; no broad clock-group/false-path override.
# Derive a separate latest capture/overwrite bound or use acknowledgement.The hold time is a timing assumption between clocks, not something the protocol enforces. If the destination clock is slowed (for example by a power mode), the assumption can break.
When the clock ratio is not fixed, use the 4-phase handshake. It adapts automatically.
HOLD is a parameter of at least 1 source cycle; the counter is sized from it with $clog2(HOLD+1), so a hold of 16 or more (as the slow-clock exercise below needs) works, and an elaboration check rejects HOLD below 1. The testbench runs the default HOLD = 6 and a second instance with HOLD = 20, above 15, so a counter truncated to 4 bits would fail it.
No. Ideal latest capture approaches Ts + 3 × Td = 10 + 3 × 50 = 160 ns (Td is the 50 ns destination period) plus control delays, while overwrite is permitted at 70 ns. Select HOLD from (HOLD+1) × Ts exceeding the justified latest capture plus hold/margin, and also verify earliest data arrival. Use a handshake if clock/resolution bounds cannot be guaranteed.
The holding register, toggle pulse and load logic with an order-checking testbench.
`timescale 1ns/1ps
module meta_ff #(parameter real TCQ = 0.25, TSU = 0.3, THD = 0.15) (input logic clk, d, output logic q);
realtime t_d = -100.0, t_clk = -100.0;
initial q = 1'b0;
always @(d) begin
t_d = $realtime;
if ($realtime - t_clk < THD) q <= #TCQ $urandom_range(0, 1);
end
always @(posedge clk) begin
t_clk = $realtime;
if ($realtime - t_d < TSU) q <= #TCQ $urandom_range(0, 1);
else q <= #TCQ d;
end
endmodule
// Pulse crossing by toggling (see the previous lesson)
module pulse_sync (
input logic clk_s, rst_s_n, pulse_s,
input logic clk_d, rst_d_n,
output logic pulse_d
);
logic toggle_s, q1, q2, q2_prev;
always_ff @(posedge clk_s or negedge rst_s_n)
if (!rst_s_n) toggle_s <= 1'b0; else if (pulse_s) toggle_s <= ~toggle_s;
meta_ff f1 (.clk(clk_d), .d(toggle_s), .q(q1));
meta_ff f2 (.clk(clk_d), .d(q1), .q(q2));
always_ff @(posedge clk_d or negedge rst_d_n)
if (!rst_d_n) q2_prev <= 1'b0; else q2_prev <= q2;
assign pulse_d = rst_d_n && (q2 ^ q2_prev);
endmodule
// Multi-bit transfer by "recirculating" data: the source holds the word still, a synchronised
// load pulse tells the destination when to capture it. The data bus itself has no synchroniser.
module mux_recirc #(parameter int W = 16, parameter int HOLD = 6) (
input logic clk_s, rst_s_n, clk_d, rst_d_n,
input logic load, input logic [W-1:0] din, output logic ready,
output logic out_valid, output logic [W-1:0] dout
);
logic [W-1:0] hold_reg;
logic [$clog2(HOLD+1)-1:0] hold_cnt; // wide enough to count down from HOLD itself
logic pulse_s, pulse_d;
if (HOLD < 1) begin : g_hold_check // legal range: HOLD >= 1 source cycle
$error("mux_recirc: HOLD must be at least 1 source cycle");
end
assign ready = (hold_cnt == 0);
always_ff @(posedge clk_s or negedge rst_s_n)
if (!rst_s_n) begin hold_reg <= '0; hold_cnt <= '0; pulse_s <= 1'b0; end
else begin
pulse_s <= 1'b0;
if (hold_cnt != 0) hold_cnt <= hold_cnt - 1'b1; // keep the data still for HOLD source cycles
else if (load) begin hold_reg <= din; pulse_s <= 1'b1; hold_cnt <= HOLD; end
end
pulse_sync u_p (.clk_s, .rst_s_n, .pulse_s, .clk_d, .rst_d_n, .pulse_d);
always_ff @(posedge clk_d or negedge rst_d_n)
if (!rst_d_n) begin dout <= '0; out_valid <= 1'b0; end
else begin
out_valid <= pulse_d;
if (pulse_d) dout <= hold_reg; // capture the stable word on the synchronised pulse
end
endmodule
// One complete check of mux_recirc for a given HOLD: its own clocks, scoreboard and result.
module recirc_check #(parameter int W = 16, parameter int HOLD = 6, parameter int WORDS = 3000)
(output logic done, output int errors, output int got);
logic clk_s = 0, clk_d = 0, rst_s_n = 0, rst_d_n = 0, load = 0, ready, out_valid;
logic [W-1:0] din = 0, dout;
mux_recirc #(W, HOLD) dut (.*);
always #5 clk_s = ~clk_s; // 10 ns
always #8.53 clk_d = ~clk_d; // 17.06 ns: the destination is the SLOWER clock
logic [W-1:0] expected [$];
int sent = 0;
initial begin done = 0; errors = 0; got = 0; end
initial begin #100 rst_s_n = 1; rst_d_n = 1; end
initial begin
@(posedge rst_s_n);
repeat (WORDS) begin
wait (ready);
@(negedge clk_s);
load = 1; din = $urandom; expected.push_back(din); sent++;
@(negedge clk_s);
load = 0; din = $urandom; // the input bus changes right after the transfer
repeat ($urandom_range(0, 3)) @(negedge clk_s);
end
repeat (20 + HOLD) @(posedge clk_d);
if (got != sent) errors++;
done = 1;
end
always @(posedge clk_d) if (out_valid) begin
got++;
if (expected.size() == 0) errors++;
else if (dout !== expected.pop_front()) errors++;
end
endmodule
// Two settings: the default HOLD = 6, and HOLD = 20, above the 15 that a 4-bit counter could hold.
module tb_recirc;
logic done_a, done_b;
int err_a, err_b, got_a, got_b;
recirc_check #(.HOLD(6)) u_a (.done(done_a), .errors(err_a), .got(got_a));
recirc_check #(.HOLD(20)) u_b (.done(done_b), .errors(err_b), .got(got_b));
initial begin
wait (done_a && done_b);
if (err_a == 0 && err_b == 0)
$display("PASS: HOLD = 6 and HOLD = 20 (above 15): %0d + %0d 16-bit words crossed to a slower clock with a synchronised load pulse; none corrupted or lost", got_a, got_b);
else
$display("FAIL: HOLD = 6 errors %0d (got %0d); HOLD = 20 errors %0d (got %0d)", err_a, got_a, err_b, got_b);
$finish;
end
endmoduleTestbench, run with Icarus Verilog 13.0 (2026-10-16): prints PASS with 3,000 + 3,000 words (HOLD = 6 and HOLD = 20). This digital-model result does not establish worst-case analog resolution or all clock phases.