Hardware Engineering · Digital Logic

Clock Domain Crossing: Synchronizers, Handshakes, and Asynchronous FIFOs

Why a signal crossing between unrelated clocks cannot be checked by static timing, and the three structures that carry data across a boundary safely.

A synchronous design rests on one assumption: every register is clocked by a clock whose edge positions are known relative to every other edge. Skew and jitter degrade that relationship but do not destroy it, so timing analysis can bound the degradation. When two parts of a chip run on clocks from different oscillators, or from one oscillator through an independent divider, no such relationship exists. There is no fixed phase, no bounded frequency ratio, and no edge of one clock that can be said to occur at a particular time relative to the other.

Why the shared-edge reasoning fails

Static timing analysis checks that data launched by one register arrives at the capturing register within a window defined by the clock period. The check assumes the launching and capturing edges are related by a known number of periods. Across domains that assumption is false: the capturing edge may occur anywhere inside the launching cycle, including inside the capture register’s setup and hold aperture.

If a transition at the asynchronous input happens to fall inside the aperture, the capturing flip-flop’s internal node is left near the mid-point and must regenerate from a tiny imbalance. As metastability describes, the resolution is exponential in the time allowed and the resulting failure rate falls exponentially with that time. A crossing is therefore never deterministic. It is a probabilistic structure with a mean time between failures, and designing a crossing means choosing a structure whose failure rate is acceptable for the product’s life.

Every crossing also requires that the design tell the tools the truth: a path between domains must be declared asynchronous, or the tool will report it as a violation of a relationship that does not exist and the resulting reports become noise that hides real problems.

Crossing one bit

A single bit — a level, a request pulse, a flag — crosses with two flip-flops on the destination clock. The first may go metastable; the second samples it a full period later, when the first has had a whole destination clock period to resolve. The second flip-flop’s output is the only consumer of the first’s output, which is what keeps the metastable value from being fanned out into logic that would interpret different bits at different times.

The structure is deliberately not general. It works for one bit because a one-bit value has no intermediate states: whatever the level is, and whenever it is read, the reader sees the old value or the new one. For two or more bits that argument collapses, because the reader can see some bits of the old word and some of the new one — a value that was never written.

There is also a timing rule that is easy to miss. A pulse that is only one source-clock cycle wide can be missed entirely when it crosses into a slower domain, because the chain samples at discrete instants and the pulse may fall between two of them. The usual remedy is to hold the level until the other side acknowledges, or to use a toggle: the source flips a registered bit on each event, and the destination detects edges of the synchronized result, which converts an arbitrarily short event into a level change that persists for at least a full destination cycle.

Crossing a counter with Gray code

The most common multi-bit crossing is a pointer or a counter, and it has a specific solution. Encode the counter in Gray code, where consecutive values differ in exactly one bit. A reader sampling during a transition then sees at most one bit in an ambiguous state, and after synchronization each bit settles to either its old or its new value. Because only one bit differs, there is no combination of old and new bits that is not one of the two endpoints. The sampled value is the old counter value or the new one, never a spurious third value.

The conversion is cheap. Incrementing a Gray code means converting to binary, incrementing, and re-encoding: a carry chain through the binary value followed by one level of exclusive-OR to produce the new Gray word. Converting Gray to binary is a prefix exclusive-OR over the bits. Only the carry chain grows with the pointer width, and for the pointer widths a FIFO uses it stays short.

def bin_to_gray(n):
    """Gray code of an integer: highest bit unchanged, each bit the XOR of adjacent bits."""
    return n ^ (n >> 1)


def gray_to_bin(g):
    """Inverse: each bit is the XOR of itself with all higher bits (a prefix XOR)."""
    n, shift = g, 1
    while shift < g.bit_length():
        n ^= n >> shift
        shift <<= 1
    return n


for i in range(8):
    g = bin_to_gray(i)
    assert gray_to_bin(g) == i
    # Adjacent Gray codes differ in exactly one bit: verify the popcount of the XOR is 1.
    if i > 0:
        assert bin(bin_to_gray(i) ^ bin_to_gray(i - 1)).count("1") == 1

The assertion in the loop is the property that makes the encoding safe across a boundary. It is not the only requirement, though: the counter must be incremented by a register in its own domain, and the Gray-encoded value must be registered before it is synchronized, so that the bits driven onto the crossing paths all change within one cycle of each other.

Handshakes for arbitrary payloads

When the payload is a multi-bit word with no Gray-codable structure, the crossing is controlled by a protocol rather than by an encoding. The standard shape is a four-phase handshake: the source places the data on a bus and asserts a request; the destination synchronizes the request, captures the data, and asserts an acknowledge; the source synchronizes the acknowledge, sees it, and deasserts the request; the destination sees the request deassert and deasserts the acknowledge. The data bus is not synchronized bit by bit at all. It is guaranteed stable for the entire interval during which the request is high, which is long enough — several destination clock cycles at minimum — for the destination to sample it as ordinary synchronous data.

The protocol’s cost is latency: two round trips through synchronizers, which is at least four destination clock cycles plus the synchronizer latency, and more when the domains differ in speed. Its advantage is that it works for any payload width, needs no special encoding, and can carry backpressure implicitly, because the source will not start the next transfer until the previous one is acknowledged.

A two-phase, or toggle-based, handshake removes half the transitions by using edges rather than levels, at the cost of control logic that must track edge parity on both sides. The trade is latency and logic against noise tolerance, and both forms appear in real designs.

The asynchronous FIFO and its flags

For streaming data with throughput requirements, a handshake per word is too slow. The asynchronous FIFO decouples the two clocks with a memory and two pointers, and both domains access the memory without a shared clock.

The memory is dual-ported: the write side drives the write port with the write clock, the read side drives the read port with the read clock, and no address is ever presented to both ports with a requirement that they agree. The write pointer increments in the write domain only, and the read pointer in the read domain only.

Each pointer is then Gray coded and synchronized into the other domain through a two-flop chain. The flags are computed locally from the local pointer and the synchronized remote pointer. Full is computed in the write domain; empty in the read domain.

The comparison needs one additional trick. With an $n$-bit address space, pointers are stored in $n+1$ bits. Empty is the condition that the two pointers are exactly equal. Full is the condition that the write pointer has completed one extra lap. In binary that lap is visible as a single differing bit at the top; in the Gray code that actually crosses the boundary, one lap shows up as the two most significant bits inverted with the rest equal. The extra bit is what distinguishes “the write pointer caught up with the read pointer” from “the read pointer caught up with the write pointer”, which are the same value in $n$ bits.

The synchronized pointer lags the true one by the synchronizer’s latency — two destination clock cycles, plus the comparator — so the flags are conservative. A FIFO reported non-full may in fact be full for another cycle or two, and a FIFO reported non-empty may in fact be empty. Conservatism is safe: the producer simply waits, and the consumer does not read, and no data is lost. The cost is usable depth. The FIFO must be deep enough that the conservative flags plus the synchronizer latency plus the burst in flight do not make the producer stall in the middle of a burst it cannot suspend.

// Write-side pointer synchronization and full detection. Both pointers are Gray coded and
// AW+1 bits wide, addressing 2**AW entries; the read pointer arrives from the read domain.
module fifo_flags #(parameter AW = 4) (
  input  wire         wr_clk,
  input  wire [AW:0]  wr_ptr_gray,
  input  wire [AW:0]  rd_ptr_gray,
  output reg          full
);
  reg [AW:0] rd_ptr_sync1, rd_ptr_sync2;

  always @(posedge wr_clk) begin
    rd_ptr_sync1 <= rd_ptr_gray;     // first stage: may be metastable
    rd_ptr_sync2 <= rd_ptr_sync1;    // second stage: settled, still Gray coded
    // One lap ahead in Gray code: the two top bits are inverted, the rest match.
    full <= (wr_ptr_gray[AW:AW-1] == ~rd_ptr_sync2[AW:AW-1]) &&
            (wr_ptr_gray[AW-2:0]  ==  rd_ptr_sync2[AW-2:0]);
  end
endmodule

The Gray coding is what makes the synchronized value meaningful; the extra bit is what makes the comparison a full/empty decision rather than an ambiguity; the two-stage chain is what bounds the metastability failure rate; and running the comparator on the write clock is what makes it a local decision.

What still goes wrong

Three failures recur in designs that have all the structures above.

Signals that cross without being marked. A debug counter, a status bit, or a signal added late in the project can cross a boundary without a synchronizer because nothing in the source file makes the boundary obvious. This is the argument for checking the netlist automatically, comparing declared synchronizers against actual crossings.

Reconvergence after independent synchronization. Two signals synchronized separately may be captured in different cycles, so logic that combines them sees a mixture of old and new. Gray coding and handshakes avoid this by construction; unrelated signals combined downstream do not. The rule is that independently synchronized signals must not resolve a decision together.

Resets. An asynchronous reset asserted to both domains at once is safe to assert and unsafe to release, because the release reaches the two domains on different cycles. Each domain needs its own synchronizer on the deassert path, and the ordering between the two releases must be part of the design rather than left to skew. The same clock-network concerns that make skew matter also make reset release a timing problem, and a reset that arrives while a domain is still initializing produces a state the specification never described.

References

  1. Metastability and Synchronizers: A Tutorial — Ran Ginosar, IEEE Design & Test of Computers, 2011
  2. Clock Domain Crossing (CDC) Design & Verification Techniques Using SystemVerilog — Clifford E. Cummings, SNUG 2008
  3. Digital Design and Computer Architecture — Harris and Harris, Morgan Kaufmann