The Request/Acknowledge Handshake
The previous article limited the two-flop synchronizer to single bits and Gray-coded counters. This one covers the remaining case, an arbitrary word.
Gray code’s trick depends on one property: the value only ever moves to an adjacent value in a fixed sequence. That is true for a counter, which is why it works for FIFO pointers. It is not true for a configuration register, a command word or any multi-bit value that can jump from anything to anything. For that you need a different tool, the request/acknowledge handshake.
Hold the data still until it is safe
If a multi-bit value cannot be trusted to cross while changing every cycle, the fix is not to let it change. Hold it stable for long enough that the destination can synchronize a single control bit, sample the now-stable data with an ordinary register (no synchronizer on the data path, since it is not changing during the sampling window), and signal back that it is done.
This is a four-phase handshake: request rises, acknowledge rises in response, request falls, acknowledge falls in response. Each transition is synchronized independently with the two-flop synchronizer from the previous article. Only single control bits, req and ack, ever cross the clock boundary. The data word never needs a synchronizer, because it is guaranteed stable for the whole handshake.
Source side
`timescale 1ns / 1ps
`default_nettype none
module cdc_handshake_src #(parameter WIDTH = 16) (
input wire src_clk, src_rst_n,
input wire [WIDTH-1:0] data_in,
input wire send, // pulse: send this data across
output reg [WIDTH-1:0] data_held, // stable output, feeds the destination side directly
output reg req,
input wire ack_sync, // already synchronized into src_clk domain
output reg busy
);
localparam IDLE = 2'b00, WAIT_ACK = 2'b01, WAIT_ACK_LOW = 2'b10;
reg [1:0] state;
always @(posedge src_clk or negedge src_rst_n) begin
if (!src_rst_n) begin
state <= IDLE; req <= 1'b0; busy <= 1'b0; data_held <= {WIDTH{1'b0}};
end else begin
case (state)
IDLE: begin
busy <= 1'b0;
if (send) begin
data_held <= data_in; // latch and HOLD: must not change until ack_sync falls
req <= 1'b1;
busy <= 1'b1;
state <= WAIT_ACK;
end
end
WAIT_ACK: begin
if (ack_sync) begin
req <= 1'b0; // saw the ack, start lowering the request
state <= WAIT_ACK_LOW;
end
end
WAIT_ACK_LOW: begin
if (!ack_sync) begin // wait for the destination to see req fall and drop ack
busy <= 1'b0;
state <= IDLE;
end
end
default: state <= IDLE;
endcase
end
end
endmodule
The discipline this FSM enforces: data_held is only updated in IDLE, right before req rises, and stays untouched through WAIT_ACK and WAIT_ACK_LOW, so the destination has a guaranteed-stable window in which to sample it.
Destination side
module cdc_handshake_dst #(parameter WIDTH = 16) (
input wire dst_clk, dst_rst_n,
input wire req_sync, // already synchronized into dst_clk domain
input wire [WIDTH-1:0] data_held, // stable: safe to sample directly, no synchronizer needed
output reg ack,
output reg [WIDTH-1:0] data_out,
output reg data_valid
);
reg req_sync_d; // edge detector
always @(posedge dst_clk or negedge dst_rst_n) begin
if (!dst_rst_n) begin
ack <= 1'b0; data_out <= {WIDTH{1'b0}}; data_valid <= 1'b0; req_sync_d <= 1'b0;
end else begin
req_sync_d <= req_sync;
data_valid <= 1'b0; // default: pulse for exactly one cycle
if (req_sync && !req_sync_d) begin // rising edge of req_sync: data is stable NOW
data_out <= data_held;
data_valid <= 1'b1;
ack <= 1'b1;
end else if (!req_sync && ack) begin // source has seen our ack and lowered req
ack <= 1'b0;
end
end
end
endmodule
ack is a plain register here. It gets synchronized by feeding it through a sync_2ff instance back into the source domain, arriving as ack_sync in the source module. Same building block, one more signal.
The cost: throughput
A full four-phase handshake takes several cycles on each side per transfer, because every transition crosses a two-flop synchronizer before the other side can react. That makes it much lower throughput than the async FIFO in Section 7.4. It is the right tool for configuration registers, mode changes and occasional command words, and the wrong tool for a continuous data stream, which is what the FIFO is for.
| Approach | Throughput | Best for |
|---|---|---|
| Two-flop synchronizer alone | Very high (one signal, resolves in 2 cycles) | Single control bits |
| Gray-code pointers (async FIFO) | High, streaming | Continuous multi-bit data streams |
| Request/acknowledge handshake | Low, one transfer per several cycles on each side | Infrequent configuration or command words |
Configuration and status register interfaces that bridge a fast core clock to a slower peripheral bus clock are the textbook use. Nobody streams high-bandwidth data through a handshake, but reading or writing an occasional 32-bit control register across a clock boundary is exactly the low-frequency, correctness-first situation it is built for.
Practice Exercise
Explain why it would be unsafe to skip the WAIT_ACK_LOW state in the source FSM, going straight back to IDLE (and possibly starting a new transfer) the moment ack_sync is first seen high.
Solution
If the source returned to IDLE and started a new transfer as soon as it saw ack_sync rise, it could lower req and raise it again for the next word before the destination has finished with the current one, specifically before the destination has seen req fall and dropped its ack. The destination’s edge detector (req_sync && !req_sync_d) relies on seeing a clean falling edge before the next rising one. If req bounces low and back high faster than the synchronizer and edge detector can follow, the destination can miss the boundary between two transfers and treat two words as one, or drop one. Waiting for ack_sync to return low guarantees the handshake for word N has completed on both sides before word N+1 begins.
Gray code handles multi-bit crossing for counters. For an arbitrary word the four-phase handshake is the standard tool: hold the data stable, synchronize only the control bits, and let the data cross untouched because it never changes while it is being sampled. The cost is throughput, which is why it suits configuration and command transfers and not data streams. In the next article, the last in this section, we look at the mistakes CDC lint tools exist to catch, and why passing simulation has never meant a design is CDC-clean.