Module 7: Advanced RTL Design Techniques ยท 7.5 Clock Domain Crossing

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.

Four-phase handshake: data held while req and ack go up and down
Figure 1: Four-phase request/acknowledge handshake timing

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.

โ† The Two-Flop Synchronizer as a Module CDC Lint and the Mistakes It Catches โ†’