Everything so far has lived inside one clock, where every flip-flop samples on the same edge and the timing question is well posed. Real systems have several clocks that bear no fixed relationship to one another, and a signal that crosses from one to another cannot simply be wired across. Doing so is one of the most common causes of a design that works in simulation, works on the bench most of the time, and fails rarely and unrepeatably in the field. This page explains why, and gives the two standard structures that make a crossing safe, one for a single bit and one for a bus.
Metastability, the reason a wire is not enough
When a signal from one clock feeds a flip-flop clocked by another, nothing keeps it away from that flip-flop's setup and hold window, because the two clocks drift freely against each other. Sooner or later an edge catches the signal mid-transition, and the flip-flop enters a metastable state, its output hovering between high and low for an unpredictable time before it settles randomly to one or the other. If downstream logic samples during that hover it sees a value that is neither clean nor consistent, and the design misbehaves. The hover cannot be prevented, only tolerated, by giving the flip-flop time to resolve before anything trusts its output.
The two-flop synchronizer for one bit
For a single-bit signal the answer is a chain of flip-flops in the receiving clock, usually two. The first flop may go metastable, but it is given a full clock period alone to settle, and only the second flop, sampling the now-resolved first, drives the rest of the design. The chance a flop is still metastable a whole period later is vanishingly small, and a third stage makes it smaller still for the fastest clocks.
synchronizer.svmodule synchronizer #(
parameter int STAGES = 2
)(
input logic clk,
input logic rst,
input logic async_in,
output logic sync_out
);
logic [STAGES-1:0] chain;
always_ff @(posedge clk)
if (rst) chain <= '0;
else chain <= {chain[STAGES-2:0], async_in};
assign sync_out = chain[STAGES-1];
endmoduleThe crucial limitation is in the name. This works for one bit. It does not work for a bus, because the several bits of a multi-bit value take slightly different routes and settle on slightly different edges, so the receiver can catch a value that is part old and part new, a number that never actually existed. A counter crossing this way could momentarily read anything. Buses need a different structure.
The asynchronous FIFO for a bus
To move a stream of data words across clocks the standard answer is an asynchronous FIFO, a first-in first-out buffer with the write side in one clock and the read side in the other. The data itself never gets synchronized, it simply sits in a memory that both clocks can reach. What crosses are the read and write pointers, needed by each side to compute full and empty, and those are made safe with a single idea. The pointers are kept in Gray code, a counting scheme where exactly one bit changes from each value to the next. Because only one bit ever changes, when a pointer is sampled through a two-flop synchronizer the worst case is that the one changing bit is caught mid-flight, and the receiver then reads either the old pointer or the new one, never a corrupt mixture. Either is safe, because a pointer that is momentarily one step stale only makes the FIFO look slightly more full or slightly more empty than it is, which is conservative and never wrong in the dangerous direction.
async_fifo.sv// write and read pointers are Gray-coded, so only one bit changes per step
// and a pointer sampled by the other clock is always the old or new value.
// full and empty are registered, which also breaks the combinational loop
// that would otherwise form between the increment and the flags.
assign wbin_next = wbin + (winc && !wfull);
assign wgray_next = (wbin_next >> 1) ^ wbin_next; // binary to Gray
always_ff @(posedge wclk) begin
if (wrst) begin wbin <= '0; wgray <= '0; wfull <= 1'b0; end
else begin
wbin <= wbin_next;
wgray <= wgray_next;
// full when the next write pointer catches the synchronized read
// pointer with the top two Gray bits inverted (the wrap-detect trick)
wfull <= (wgray_next == {~rgray_s2[ADDR_W:ADDR_W-1], rgray_s2[ADDR_W-2:0]});
end
endThat is the heart of it, the write side of the pointer logic. The conversion from a binary count to Gray code is a single exclusive-or with the shifted count, and the full flag compares the next write pointer against the read pointer after it has been brought across through its own two-flop synchronizer. Registering full and empty is not only about timing, it also breaks the combinational loop that would otherwise form, since the increment depends on full and full would otherwise depend on the increment. The complete module with both domains and a testbench that runs two asymmetric clocks and checks a thousand words cross in order is in the repository. Getting an asynchronous FIFO exactly right is famously fiddly, which is why the standard design is worth learning rather than reinventing.
With crossings handled, the remaining pieces are about connecting blocks together and building something real. The next page is the handshake that ties modern FPGA designs together.