RTL Design Patterns · Chapter 11 · Clock Domain Crossing
Why Multi-Bit Buses Can't Use It
A two-flop synchronizer safely crosses one asynchronous bit, so the obvious move is to put one on every bit of a bus. That is wrong, and it is one of the most damaging bugs in clock-domain-crossing design. Each bit's synchronizer resolves old-or-new independently, so when several source bits change together the destination captures an arbitrary mix of old and new bits, a word that was never driven. Crossing a count from 0111 to 1000 where all bits flip at once, the destination can latch 1111 or 0000 or 0110. Every bit is metastability-safe, but the bus has lost coherency, the guarantee that all captured bits belong to the same sample. You learn why per-bit synchronizers fail, and the three correct fixes, Gray coding, a stable-bus handshake, and an async FIFO, in SystemVerilog, Verilog, and VHDL.
Advanced14 min readRTL Design PatternsClock Domain CrossingMulti-Bit CDCData CoherencyGray CodeMetastability
Chapter 11 · Section 11.3 · Clock Domain Crossing
1. The Engineering Problem
In 11.2 you built a two-flop synchronizer and it worked: an asynchronous control bit — a single flag, an enable, a level from another clock — arrives at the destination domain, spends one flop absorbing metastability, and emerges the next cycle as a clean, resolved value. One bit, safely crossed. The design closed timing, the CDC checker went green, and the pattern is now in your toolbox.
Now the real chip hands you a wider job. A capture engine in clock domain A produces an 8-bit sample count that domain B must read. A configuration block in one clock feeds a 16-bit control word to a datapath in another. A FIFO in domain A maintains a write pointer that domain B needs to compute occupancy. In every case the thing to cross is not one bit — it is a bus, several bits that together form one number. And the two-flop synchronizer worked so cleanly on one bit that the generalization writes itself: instantiate the synchronizer WIDTH times, one two-flop chain per bit of the bus, and wire them in parallel. It compiles. Each bit is now metastability-safe. Static timing is clean. The CDC tool, checking that every crossing has a synchronizer, reports zero violations.
And it is wrong. Not stylistically wrong — functionally, data-corrupting wrong, in a way that passes most simulations and then fails intermittently in the lab. The failure is not metastability; the synchronizers handle that. The failure is that the WIDTH independent synchronizers have no idea they belong to the same word. When several source bits change on nearly the same source edge and the destination clock samples during that change, each bit's chain independently resolves to the OLD value or the NEW value — and the destination assembles those independent decisions into a word that was never driven on the source. A binary count stepping from 0111 to 1000 flips all four bits at once; the destination can capture 1111, or 0000, or 0110 — none of which the counter ever held. Downstream, a comparator or an address decode consumes that phantom value and does the wrong thing.
// Domain A produces an 8-bit count; domain B must read it.
// clk_a, clk_b are ASYNCHRONOUS (unrelated frequency and phase).
// THE TEMPTING WRONG ANSWER: one 2FF synchronizer PER BIT of the bus.
// reg [7:0] bus_meta, bus_sync;
// always @(posedge clk_b) begin
// bus_meta <= bus_in; // WIDTH parallel 2FF chains
// bus_sync <= bus_meta; // each bit resolves INDEPENDENTLY
// end
// -> every bit is metastability-safe, and the WORD can still be garbage.
// The bus needs COHERENCY (all bits from one sample), which per-bit 2FF
// does NOT provide. The rest of this page is why, and the three real fixes.2. Mental Model
3. Pattern Anatomy
The anatomy here is a failure and its three cures. Start with the failure, because seeing exactly where coherency dies is what makes the cures obvious.
The incoherent-capture mechanism, worked in full. Take a 4-bit binary counter in domain A stepping 0110, 0111, 1000, 1001. The dangerous step is 0111 to 1000: all four bits change. The source flops do not all change at literally the same instant — each has its own clock-to-Q, plus routing skew, so for a sub-nanosecond window the bus is in flight, some bits already at their new value and some still at their old. If the destination clk_b edge falls in that window, each of the four per-bit synchronizers samples its bit somewhere in its own transition and independently resolves OLD or NEW. The captured word is any of the 16 combinations of old-bit-or-new-bit across the four positions: 0111, 1000, 1111, 0000, 0110, 1001, and so on. Exactly two of those (0111 and 1000) are the words the counter actually held; the other fourteen are phantom values the source never drove. That is the bit-skew / data-coherency failure.
Why more synchronizer flops do not help. Adding a third flop makes each bit more metastability-safe — it does nothing for coherency, because the problem was never metastability. Each bit still resolves to a clean OLD or NEW; you have simply made each independent-but-clean decision even cleaner. Coherency is a property of the relationship between bits, and no per-bit circuit can create it.
The crucial distinction, stated once. Metastability is per-bit and the 2FF synchronizer fixes it. Coherency is per-word and the 2FF synchronizer cannot touch it. A single-bit crossing needs only metastability-safety, which is why 11.2 works. A multi-bit crossing needs metastability-safety AND coherency, which is why 11.2 does not generalize.
The three cures, each restoring coherency a different way.
- Cure 1 — change only one bit at a time (Gray code, 11.5 / 3.3). If successive source values differ in exactly one bit, then during any transition at most one bit is in flight. The destination either catches that bit old or new — so the captured word is either the previous value or the next value, both real, adjacent, valid counts. There is no third possibility to be garbage. This is the answer for monotonic sequences: FIFO pointers, counters.
- Cure 2 — hold the bus stable and cross a synchronized valid (handshake / MCP, 11.4). Drive the wide bus, then leave it unchanging. Cross a single data-valid bit through a 2FF synchronizer (a one-bit crossing, which is safe). The destination samples the bus only after that valid flag has synchronized — and by then the bus has been stable for cycles, so every bit is settled and the capture is trivially coherent. This is the answer for arbitrary values that change occasionally: a control word, a configuration register.
- Cure 3 — an async FIFO (11.6). For a stream of words, a dual-clock FIFO writes with clk_a and reads with clk_b; only the Gray-coded pointers cross (cure 1 applied to the pointers), and the data itself lives in a dual-port RAM read coherently on the destination side.
The multi-bit CDC problem and its three cures
data flowThe rest of the page makes the mechanism concrete: §4 builds the wrong per-bit crossing and a right Gray-coded crossing in all three HDLs, §5 gives the two-clock testbench that detects an incoherent word, and §7 dramatizes the lab failure it causes.
4. Real RTL Implementation
Two crossings, each in SystemVerilog, Verilog, and VHDL, each with a two-asynchronous-clock self-checking testbench:
- (a) the WRONG per-bit 2FF bus synchronizer — WIDTH parallel two-flop chains over a binary bus, with a testbench that drives a value changing many bits at once and detects an incoherent captured word (a value the source never drove);
- (b) a CORRECT crossing — cross the value as Gray code so only one bit changes per step, decode back to binary after synchronizing, with a testbench showing every captured word is a value that was actually driven (coherent).
The RTL is deliberately parallel between (a) and (b) so the only structural difference — binary bus versus Gray-coded bus — is the whole lesson. In every language the source runs a binary counter (BIN); the wrong version crosses BIN directly, the right version crosses its Gray encoding (GRAY) and decodes on the far side.
4a. The WRONG crossing — a two-flop synchronizer per bus bit
Here is the tempting generalization: WIDTH independent two-flop chains, one per bit of BUS_IN. It is metastability-safe per bit and incoherent per word.
module bus_sync_bad #(
parameter int WIDTH = 4
)(
input logic clk_b, // destination clock (async to source)
input logic rst,
input logic [WIDTH-1:0] bus_in, // free-running BINARY value from clk_a domain
output logic [WIDTH-1:0] bus_sync // "synchronized" word used in clk_b domain
);
// WIDTH parallel two-flop chains. Each BIT is metastability-safe. But the
// chains are INDEPENDENT: on a multi-bit source transition, bit i can resolve
// to the OLD value while bit i+1 resolves to the NEW value, so bus_sync is a
// blend of two source words -> a value that was NEVER driven (incoherent).
logic [WIDTH-1:0] bus_meta;
always_ff @(posedge clk_b or posedge rst) begin
if (rst) begin
bus_meta <= '0;
bus_sync <= '0;
end else begin
bus_meta <= bus_in; // flop 1: absorbs metastability per bit
bus_sync <= bus_meta; // flop 2: clean per bit, still incoherent
end
end
endmoduleThe self-checking testbench runs two asynchronous clocks, drives BUS_IN from a free-running binary counter (so it steps through multi-bit transitions like 0111 to 1000), records every value the source actually drives, and after each destination sample asserts that BUS_SYNC is a value that was genuinely driven. Because the bits resolve independently across a transition, this assertion fires: BUS_SYNC takes a phantom value.
module bus_sync_bad_tb;
localparam int WIDTH = 4;
logic clk_a = 0, clk_b = 0, rst = 1;
logic [WIDTH-1:0] bin = '0; // source binary counter
logic [WIDTH-1:0] bus_sync;
int errors = 0, samples = 0;
// Asynchronous clocks: deliberately non-harmonic periods so the clk_b edge
// eventually lands inside a clk_a multi-bit transition.
always #5 clk_a = ~clk_a; // 100 MHz
always #3.5 clk_b = ~clk_b; // ~143 MHz, unrelated phase
// Source: free-running binary count in the clk_a domain (many bits flip at once).
always_ff @(posedge clk_a) if (!rst) bin <= bin + 1'b1;
bus_sync_bad #(.WIDTH(WIDTH)) dut (.clk_b(clk_b), .rst(rst),
.bus_in(bin), .bus_sync(bus_sync));
// "was driven" oracle: a binary up-counter drives EVERY value 0..2^W-1, so the
// set of legal words is all of them EXCEPT that a captured word must equal the
// source's current or immediately-previous value. We model coherency directly:
// record the last two source values and require bus_sync to be one of them.
logic [WIDTH-1:0] src_now, src_prev;
always_ff @(posedge clk_b) begin
src_prev <= src_now;
src_now <= bin; // sampled reference of the source word
end
initial begin
#20 rst = 0;
repeat (400) @(posedge clk_b) begin
samples++;
// COHERENCY CHECK: a coherent crossing can only ever present the source
// value at (or just before) this edge. A per-bit blend violates this.
if (bus_sync !== src_now && bus_sync !== src_prev) begin
errors++;
$display("INCOHERENT: bus_sync=%b was never driven (src_now=%b src_prev=%b)",
bus_sync, src_now, src_prev);
end
end
if (errors == 0) $display("PASS: every captured word was coherent");
else $display("FAIL: %0d of %0d samples incoherent (per-bit 2FF corrupts the bus)",
errors, samples);
$finish;
end
endmoduleThe bus_sync !== src_now && bus_sync !== src_prev check is the coherency oracle: a correct crossing can only ever hand the destination the source word at this edge or the one just before it. A per-bit blend produces a third, phantom word — and that is what makes the test FAIL. (In simulation this manifests when the destination edge samples a source flop mid-transition; a gate-level or X-pessimistic sim shows it readily, and silicon shows it as intermittent corruption.)
The Verilog version is the same two-flop-per-bit structure with reg typing, and its testbench uses if (bus_sync !== src_now && bus_sync !== src_prev) $display("FAIL...") as the self-check instead of an assert.
module bus_sync_bad #(
parameter WIDTH = 4
)(
input wire clk_b,
input wire rst,
input wire [WIDTH-1:0] bus_in, // free-running BINARY bus from clk_a domain
output reg [WIDTH-1:0] bus_sync
);
reg [WIDTH-1:0] bus_meta;
// WIDTH independent 2FF chains: metastability-safe per bit, incoherent per word.
always @(posedge clk_b or posedge rst) begin
if (rst) begin
bus_meta <= {WIDTH{1'b0}};
bus_sync <= {WIDTH{1'b0}};
end else begin
bus_meta <= bus_in; // each bit resolves OLD/NEW on its own
bus_sync <= bus_meta;
end
end
endmodule module bus_sync_bad_tb;
parameter WIDTH = 4;
reg clk_a, clk_b, rst;
reg [WIDTH-1:0] bin;
wire [WIDTH-1:0] bus_sync;
reg [WIDTH-1:0] src_now, src_prev;
integer errors, samples, i;
always #5 clk_a = ~clk_a; // async clocks, non-harmonic periods
always #3.5 clk_b = ~clk_b;
always @(posedge clk_a) if (!rst) bin <= bin + 1'b1; // binary source count
bus_sync_bad #(.WIDTH(WIDTH)) dut (.clk_b(clk_b), .rst(rst),
.bus_in(bin), .bus_sync(bus_sync));
// reference: source word at this clk_b edge and the previous one
always @(posedge clk_b) begin
src_prev <= src_now;
src_now <= bin;
end
initial begin
clk_a = 0; clk_b = 0; rst = 1; bin = 0;
errors = 0; samples = 0;
#20 rst = 0;
for (i = 0; i < 400; i = i + 1) begin
@(posedge clk_b);
samples = samples + 1;
// was_driven == (bus_sync equals current or previous source word)
if (bus_sync !== src_now && bus_sync !== src_prev) begin
errors = errors + 1;
$display("FAIL: bus_sync=%b never driven (now=%b prev=%b)",
bus_sync, src_now, src_prev);
end
end
if (errors == 0) $display("PASS: all captures coherent");
else $display("FAIL: %0d/%0d incoherent (per-bit 2FF on a binary bus)",
errors, samples);
$finish;
end
endmoduleVHDL expresses the same structure with numeric_std: the source count is an unsigned, the per-bit crossing is two registered std_logic_vector stages, and the self-check is assert ... severity error.
library ieee;
use ieee.std_logic_1164.all;
entity bus_sync_bad is
generic ( WIDTH : positive := 4 );
port (
clk_b : in std_logic;
rst : in std_logic;
bus_in : in std_logic_vector(WIDTH-1 downto 0); -- binary bus, clk_a domain
bus_sync : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of bus_sync_bad is
signal bus_meta : std_logic_vector(WIDTH-1 downto 0);
begin
-- WIDTH independent 2FF chains: each bit metastability-safe, the WORD incoherent.
process (clk_b, rst)
begin
if rst = '1' then
bus_meta <= (others => '0');
bus_sync <= (others => '0');
elsif rising_edge(clk_b) then
bus_meta <= bus_in; -- flop 1 per bit
bus_sync <= bus_meta; -- flop 2 per bit
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity bus_sync_bad_tb is
end entity;
architecture sim of bus_sync_bad_tb is
constant WIDTH : positive := 4;
signal clk_a, clk_b, rst : std_logic := '0';
signal bin : unsigned(WIDTH-1 downto 0) := (others => '0');
signal bus_sync : std_logic_vector(WIDTH-1 downto 0);
signal src_now, src_prev : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal errors : natural := 0;
begin
clk_a <= not clk_a after 5 ns; -- async clocks (non-harmonic)
clk_b <= not clk_b after 3500 ps;
dut : entity work.bus_sync_bad
generic map (WIDTH => WIDTH)
port map (clk_b => clk_b, rst => rst,
bus_in => std_logic_vector(bin), bus_sync => bus_sync);
-- source binary counter in the clk_a domain
src : process (clk_a)
begin
if rising_edge(clk_a) and rst = '0' then
bin <= bin + 1;
end if;
end process;
-- reference: source word at this clk_b edge and the previous one
ref : process (clk_b)
begin
if rising_edge(clk_b) then
src_prev <= src_now;
src_now <= std_logic_vector(bin);
end if;
end process;
chk : process
begin
rst <= '1'; wait for 20 ns; rst <= '0';
for i in 0 to 399 loop
wait until rising_edge(clk_b);
-- COHERENCY: bus_sync must be the current or previous source word.
if bus_sync /= src_now and bus_sync /= src_prev then
errors <= errors + 1;
assert false
report "INCOHERENT: bus_sync was never driven on the source"
severity error;
end if;
end loop;
if errors = 0 then
report "PASS: all captures coherent" severity note;
else
report "FAIL: per-bit 2FF corrupted the binary bus" severity error;
end if;
wait;
end process;
end architecture;4b. The CORRECT crossing — cross the value as Gray code (one bit per step)
The fix keeps the same two-flop synchronizer structure but changes what crosses: instead of the binary count, cross its Gray code, where consecutive values differ in exactly one bit. Now a mid-transition sample can only catch that one bit old or new — so the synchronized Gray word is always either the previous Gray value or the next one, both real. Decode back to binary on the destination side. The synchronizer is unchanged; coherency comes from the encoding, not from the flops.
module bus_sync_gray #(
parameter int WIDTH = 4
)(
input logic clk_a, // source clock
input logic clk_b, // destination clock (async)
input logic rst,
output logic [WIDTH-1:0] bin_dst // coherent binary value in the clk_b domain
);
// Source: binary counter + its registered Gray encoding. Gray guarantees that
// consecutive words differ in EXACTLY ONE bit, so any mid-transition sample
// catches at most one bit in flight -> the result is a real adjacent value.
logic [WIDTH-1:0] bin, gray;
always_ff @(posedge clk_a or posedge rst) begin
if (rst) begin
bin <= '0;
gray <= '0;
end else begin
bin <= bin + 1'b1;
gray <= (bin + 1'b1) ^ ((bin + 1'b1) >> 1); // bin2gray of the NEXT count
end
end
// Cross the GRAY word with the SAME per-bit 2FF structure as before...
logic [WIDTH-1:0] gray_meta, gray_sync;
always_ff @(posedge clk_b or posedge rst) begin
if (rst) begin
gray_meta <= '0;
gray_sync <= '0;
end else begin
gray_meta <= gray;
gray_sync <= gray_meta;
end
end
// ...then decode Gray back to binary on the destination side (combinational).
always_comb begin
bin_dst[WIDTH-1] = gray_sync[WIDTH-1];
for (int i = WIDTH-2; i >= 0; i--)
bin_dst[i] = bin_dst[i+1] ^ gray_sync[i]; // gray2bin
end
endmoduleThe testbench again runs two asynchronous clocks and applies the same coherency oracle — but this time it passes, because every synchronized value is an adjacent, valid count.
module bus_sync_gray_tb;
localparam int WIDTH = 4;
logic clk_a = 0, clk_b = 0, rst = 1;
logic [WIDTH-1:0] bin_dst;
int errors = 0, samples = 0;
always #5 clk_a = ~clk_a;
always #3.5 clk_b = ~clk_b;
bus_sync_gray #(.WIDTH(WIDTH)) dut (.clk_a(clk_a), .clk_b(clk_b),
.rst(rst), .bin_dst(bin_dst));
// Independent reference model of the source count, sampled at each clk_b edge.
logic [WIDTH-1:0] ref_cnt = '0, src_now, src_prev;
always_ff @(posedge clk_a) if (!rst) ref_cnt <= ref_cnt + 1'b1;
always_ff @(posedge clk_b) begin src_prev <= src_now; src_now <= ref_cnt; end
initial begin
#20 rst = 0;
repeat (400) @(posedge clk_b) begin
samples++;
// Coherency holds: a one-bit-per-step Gray crossing can only present
// the current or the immediately-previous source count.
assert (bin_dst === src_now || bin_dst === src_prev)
else begin
errors++;
$error("bin_dst=%b not adjacent to source (now=%b prev=%b)",
bin_dst, src_now, src_prev);
end
end
if (errors == 0) $display("PASS: Gray crossing coherent on all %0d samples", samples);
else $display("FAIL: %0d incoherent", errors);
$finish;
end
endmoduleThe Verilog version keeps the same three stages — binary counter plus registered Gray, per-bit 2FF on the Gray word, combinational gray2bin — with reg typing and a for loop for the decode.
module bus_sync_gray #(
parameter WIDTH = 4
)(
input wire clk_a,
input wire clk_b,
input wire rst,
output reg [WIDTH-1:0] bin_dst
);
reg [WIDTH-1:0] bin, gray, gray_meta, gray_sync;
integer i;
// source: binary counter + registered bin2gray (one bit changes per step)
always @(posedge clk_a or posedge rst) begin
if (rst) begin
bin <= {WIDTH{1'b0}};
gray <= {WIDTH{1'b0}};
end else begin
bin <= bin + 1'b1;
gray <= (bin + 1'b1) ^ ((bin + 1'b1) >> 1);
end
end
// per-bit 2FF on the GRAY word (safe: only one bit ever in flight)
always @(posedge clk_b or posedge rst) begin
if (rst) begin
gray_meta <= {WIDTH{1'b0}};
gray_sync <= {WIDTH{1'b0}};
end else begin
gray_meta <= gray;
gray_sync <= gray_meta;
end
end
// gray2bin on the destination side
always @(*) begin
bin_dst[WIDTH-1] = gray_sync[WIDTH-1];
for (i = WIDTH-2; i >= 0; i = i - 1)
bin_dst[i] = bin_dst[i+1] ^ gray_sync[i];
end
endmodule module bus_sync_gray_tb;
parameter WIDTH = 4;
reg clk_a, clk_b, rst;
wire [WIDTH-1:0] bin_dst;
reg [WIDTH-1:0] ref_cnt, src_now, src_prev;
integer errors, samples, i;
always #5 clk_a = ~clk_a;
always #3.5 clk_b = ~clk_b;
bus_sync_gray #(.WIDTH(WIDTH)) dut (.clk_a(clk_a), .clk_b(clk_b),
.rst(rst), .bin_dst(bin_dst));
always @(posedge clk_a) if (!rst) ref_cnt <= ref_cnt + 1'b1;
always @(posedge clk_b) begin src_prev <= src_now; src_now <= ref_cnt; end
initial begin
clk_a = 0; clk_b = 0; rst = 1;
ref_cnt = 0; errors = 0; samples = 0;
#20 rst = 0;
for (i = 0; i < 400; i = i + 1) begin
@(posedge clk_b);
samples = samples + 1;
// was_driven: current or previous source count (coherent)
if (bin_dst !== src_now && bin_dst !== src_prev) begin
errors = errors + 1;
$display("FAIL: bin_dst=%b never driven (now=%b prev=%b)",
bin_dst, src_now, src_prev);
end
end
if (errors == 0) $display("PASS: Gray crossing coherent on all %0d samples", samples);
else $display("FAIL: %0d incoherent", errors);
$finish;
end
endmoduleVHDL uses numeric_std throughout: the Gray encode is bin xor ('0' & bin(WIDTH-1 downto 1)) shifted via a slice, and the decode is an iterative XOR. The self-check is assert ... severity error.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity bus_sync_gray is
generic ( WIDTH : positive := 4 );
port (
clk_a : in std_logic;
clk_b : in std_logic;
rst : in std_logic;
bin_dst : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of bus_sync_gray is
signal bin : unsigned(WIDTH-1 downto 0) := (others => '0');
signal gray : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal gray_meta : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal gray_sync : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
-- bin2gray: g = b xor (b >> 1)
function bin2gray (b : unsigned) return std_logic_vector is
begin
return std_logic_vector(b xor ('0' & b(b'high downto 1)));
end function;
begin
-- source: binary counter + registered Gray (one bit changes per step)
src : process (clk_a, rst)
begin
if rst = '1' then
bin <= (others => '0');
gray <= (others => '0');
elsif rising_edge(clk_a) then
bin <= bin + 1;
gray <= bin2gray(bin + 1);
end if;
end process;
-- per-bit 2FF on the GRAY word (only one bit ever in flight -> coherent)
syn : process (clk_b, rst)
begin
if rst = '1' then
gray_meta <= (others => '0');
gray_sync <= (others => '0');
elsif rising_edge(clk_b) then
gray_meta <= gray;
gray_sync <= gray_meta;
end if;
end process;
-- gray2bin on the destination side (combinational)
dec : process (gray_sync)
variable b : std_logic_vector(WIDTH-1 downto 0);
begin
b(WIDTH-1) := gray_sync(WIDTH-1);
for i in WIDTH-2 downto 0 loop
b(i) := b(i+1) xor gray_sync(i);
end loop;
bin_dst <= b;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity bus_sync_gray_tb is
end entity;
architecture sim of bus_sync_gray_tb is
constant WIDTH : positive := 4;
signal clk_a, clk_b, rst : std_logic := '0';
signal bin_dst : std_logic_vector(WIDTH-1 downto 0);
signal ref_cnt : unsigned(WIDTH-1 downto 0) := (others => '0');
signal src_now, src_prev : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal errors : natural := 0;
begin
clk_a <= not clk_a after 5 ns;
clk_b <= not clk_b after 3500 ps;
dut : entity work.bus_sync_gray
generic map (WIDTH => WIDTH)
port map (clk_a => clk_a, clk_b => clk_b, rst => rst, bin_dst => bin_dst);
-- independent reference count, sampled on each clk_b edge
refc : process (clk_a)
begin
if rising_edge(clk_a) and rst = '0' then
ref_cnt <= ref_cnt + 1;
end if;
end process;
refs : process (clk_b)
begin
if rising_edge(clk_b) then
src_prev <= src_now;
src_now <= std_logic_vector(ref_cnt);
end if;
end process;
chk : process
begin
rst <= '1'; wait for 20 ns; rst <= '0';
for i in 0 to 399 loop
wait until rising_edge(clk_b);
-- coherent: bin_dst is the current or previous source count
assert (bin_dst = src_now or bin_dst = src_prev)
report "INCOHERENT: captured a value never driven"
severity error;
if bin_dst /= src_now and bin_dst /= src_prev then
errors <= errors + 1;
end if;
end loop;
if errors = 0 then
report "PASS: Gray crossing coherent" severity note;
else
report "FAIL: incoherent captures" severity error;
end if;
wait;
end process;
end architecture;Across all three languages the contrast is the whole point: the synchronizer flops are identical in the bad and good versions. Coherency did not come from the crossing logic — it came from ensuring only one bit changes per step. That is why Gray coding (11.5), a stable-bus handshake (11.4), or an async FIFO (11.6) fixes the bug and adding synchronizer flops never can.
5. Verification Strategy
The correctness question for a multi-bit crossing is not metastability — it is coherency, and the single invariant that captures it is:
Every word the destination captures must be a word the source actually drove — the value present at this destination edge, or the one immediately before it. A captured word that the source never held is an incoherency failure.
That invariant is exactly the oracle the §4 testbenches implement, and it is what makes the failure detectable in simulation rather than only in silicon.
- Two asynchronous clocks, deliberately non-harmonic. Drive clk_a and clk_b at unrelated periods (for example 5 ns and 3.5 ns half-periods) so the clk_b edge sweeps through every phase relative to clk_a and eventually lands inside a multi-bit source transition. Harmonic or same-frequency clocks can hide the bug by never sampling in flight — a classic false PASS.
- A source that changes many bits at once. A free-running binary counter is the ideal stimulus: every carry chain (0111 to 1000, 1111 to 0000) flips several bits on one edge, maximizing the incoherency window. This is the deliberate opposite of a Gray source, and it is what the per-bit testbench uses to provoke the failure.
- The coherency oracle, self-checking. Sample an independent reference of the source word on each destination edge, keep the current and previous values, and assert the captured word equals one of them. SystemVerilog
assert (captured === src_now || captured === src_prev); Verilogif (captured !== src_now && captured !== src_prev) $display("FAIL..."); VHDLassert (captured = src_now or captured = src_prev) severity error. Any other value is a word that was never driven — the signature of per-bit incoherency. - The contrast test — same oracle, coherent source. Re-run the identical oracle against the Gray-coded crossing (§4b). It passes, because a one-bit-per-step source can only ever present an adjacent value. The same oracle applied to a stable-bus-plus-handshake crossing (11.4) also passes, because the bus is settled before it is sampled. Running the oracle against both the binary and the Gray crossing is what proves the encoding, not the synchronizer, is what fixed it.
- What a plain single-domain sim misses. A testbench with one clock, or synchronous clocks, or that only checks the value eventually settles, will report PASS on the broken per-bit design — the incoherent capture is a transient on a specific edge alignment. Coherency verification requires the two-clock, edge-by-edge, was-it-ever-driven check; a functional-only or clock-locked bench is the false-PASS trap.
- Expected waveform. On the broken design, most clk_b edges capture a clean source value, but on edges aligned to a carry (the 0111-to-1000 style transition) BUS_SYNC shows a value that appears in neither the source-now nor source-prev trace — a glitch word between two valid counts. On the Gray design, the destination value only ever steps to an adjacent count, and the phantom words never appear.
6. Common Mistakes
Per-bit 2FF on a binary bus. The signature mistake, and the whole subject of this page: instantiating a two-flop synchronizer on each bit of a multi-bit binary bus. Every bit is metastability-safe and the assembled word is incoherent — on any multi-bit transition the destination can capture a value that was never driven. A binary bus is precisely the worst case because ordinary increments flip several bits at once. The fix is never more flops; it is Gray coding, a stable-bus handshake, or an async FIFO.
Conflating metastability-safety with coherency. The reasoning error behind the bug: each bit is synchronized, so the bus must be fine. Metastability-safety is per-bit and coherency is per-word, and they are independent properties. A crossing can be 100% metastability-safe and 0% coherent — that is exactly what per-bit 2FF is. Whenever you cross more than one bit, ask the coherency question separately: do all these bits belong to the same source sample?
Crossing a free-running binary counter without Gray coding. A common concrete form: a FIFO write pointer or an event counter in domain A read directly by domain B through per-bit synchronizers. Because the counter free-runs and increments flip multiple bits, the destination periodically reads a pointer value that never existed, and full/empty or occupancy logic built on it is wrong. Any counter that crosses a clock boundary should cross as Gray (3.3, 11.5), not binary.
Sampling a bus while it is still changing (no stable or valid gating). Even for an arbitrary control word (not a counter), crossing the raw bus while the source is free to change it means the destination has no idea whether the bits it grabbed are all from one update. The fix is to make the bus quiet before sampling: hold it stable, cross a single synchronized valid, and read the bus only after valid resolves (11.4). Sampling an in-flight bus is the general form of the coherency bug.
Trusting a single-clock or synchronous-clock testbench. A bench that does not run two genuinely asynchronous, non-harmonic clocks can pass the broken design forever, because it never samples the source mid-transition. The absence of a failing test is not evidence of coherency; it is often evidence the bench cannot see the failure. Coherency needs the two-clock, edge-aligned, was-it-driven oracle of §5.
7. DebugLab
The address that pointed nowhere — a per-bit synchronizer captured a value the counter never held
The engineering lesson: a two-flop synchronizer fixes metastability for one bit; a multi-bit bus additionally needs coherency — the guarantee that all captured bits belong to the same source sample — and per-bit synchronizers destroy it. The tell is a bug that is rare, load-dependent, and phase-sensitive, and that survives every directed test: those are the fingerprints of an incoherent capture that only happens when a destination edge lands inside a multi-bit transition. The cure is never more synchronizer flops. Make only one bit change per step (Gray code, 11.5 / 3.3), hold the bus stable and cross a synchronized valid (handshake, 11.4), or use an async FIFO (11.6) — three ways to give the word the coherency the synchronizer cannot.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the metastability-versus-coherency distinction.
Exercise 1 — Enumerate the phantom words
A 3-bit binary counter crosses a clock boundary through per-bit two-flop synchronizers. It steps from 011 to 100 (both low bits and the high bit change). List every value the destination could capture if the clk_b edge lands in the in-flight window, mark which are real counts and which are phantom, and state how many of the possible captures are corrupt. Then redo the exercise for the Gray encoding of the same step and explain why the phantom set is now empty.
Exercise 2 — Metastability-safe but wrong
An engineer, worried about metastability, upgrades a broken per-bit binary bus crossing from two flops per bit to four flops per bit and re-runs the two-clock coherency test. Predict the result and justify it in one sentence. Then state, precisely, which property the extra flops improved and which property they left untouched — and why no number of per-bit flops can fix the second.
Exercise 3 — Pick the crossing for the value
For each value crossing from clk_a to clk_b, choose Gray code, a stable-bus handshake, or an async FIFO, and justify in one line: (a) a free-running FIFO write pointer; (b) a 32-bit configuration register that software updates occasionally; (c) a continuous stream of 16-bit samples that must not be lost; (d) an event counter read for a status display. (Hint: does the value change one step at a time, change rarely, or stream?)
Exercise 4 — Design the coherency oracle
You are handed a multi-bit CDC block and told it is coherent. Describe the testbench that would prove or disprove it: what clock relationship you would impose and why, what source stimulus maximizes the chance of catching the bug, and the exact self-check you would write so that a single incoherent capture fails the test. Then explain why a single-clock testbench of the same block is worthless for this purpose, and what false conclusion it would produce.
10. Related Tutorials
Continue in RTL Design Patterns (forward links unlock as they ship):
- Pulse & Handshake Synchronization — Chapter 11.4; cure 2 in full — hold the bus stable, cross a synchronized valid, sample only when settled (the MCP formulation).
- Gray-Code Pointer Synchronization — Chapter 11.5; cure 1 applied to FIFO pointers — cross the pointer as Gray so only one bit changes per step.
- Asynchronous FIFO — Chapter 11.6; cure 3 — Gray-coded pointers plus a dual-port RAM read coherently, for streaming across clocks.
- CDC Design Rules — the checklist this failure sits on: never cross a multi-bit binary bus with per-bit synchronizers.
Prerequisites and foundations (direct prereqs; unlock as they ship):
- Two-Flop Synchronizer — Chapter 11.2; the pattern that safely crosses ONE bit and the exact thing this page shows you cannot replicate per bus bit.
- Metastability — Chapter 11.1; the per-bit failure mode the synchronizer fixes — distinct from the coherency failure of this page.
- Gray Counters — Chapter 3.3; the one-bit-per-step counter that makes cure 1 possible, with its own binary-pointer-across-a-clock DebugLab.
- Up/Down Counters — Chapter 3.2; the binary counters whose multi-bit increments are exactly what corrupts a naive crossing.
Adjacent patterns:
- valid/ready Handshake — Chapter 9.1; the in-domain handshake whose valid-bit discipline the CDC handshake (cure 2) borrows to gate a stable bus.
- Synchronous FIFO Architecture — Chapter 7.1; the single-clock FIFO whose pointers become the Gray-coded crossings inside the async FIFO of cure 3.
Backward / method:
- The RTL Design Mindset — Chapter 0.2; the Interface / State / Datapath / Control / Verification lenses — here the verification lens (a coherency oracle) is what exposes a bug the structural view hides.
- What Is an RTL Design Pattern? — Chapter 0.1; why a recurring failure like incoherent multi-bit CDC earns a named pattern and a standard set of cures.
11. Summary
- A two-flop synchronizer crosses ONE bit; it does not generalize to a bus. Putting a 2FF on every bit makes each bit metastability-safe but leaves the word incoherent — the signature multi-bit CDC bug.
- Metastability is per-bit; coherency is per-word — and they are independent. The synchronizer fixes metastability. It cannot supply coherency, the guarantee that all captured bits belong to the same source sample. A crossing can be fully metastability-safe and fully incoherent.
- Independent old/new resolution mixes two source words. When many source bits change together (a binary count 0111 to 1000) and the destination edge lands mid-transition, each bit resolves old or new on its own, and the captured word can be a value the source NEVER drove (1111, 0000, 0110) — a phantom between two valid counts.
- The three cures all restore coherency, and none of them is more flops. Change one bit at a time (Gray code, 11.5 / 3.3) so any blend is an adjacent valid value; hold the bus stable and cross a synchronized valid (handshake / MCP, 11.4) so it is sampled only when settled; or push words through an async FIFO (11.6). In §4 the synchronizer flops are identical between the broken binary crossing and the correct Gray crossing — the fix is the encoding, not the synchronizer.
- Verify coherency, not just metastability. Run two genuinely asynchronous, non-harmonic clocks, drive a source that flips many bits at once, and assert every captured word is a value the source actually drove (
captured == src_now || captured == src_prev) — the self-checking two-clock oracle shown here in SystemVerilog, Verilog, and VHDL. A single-clock or clock-locked bench, or a green structural CDC lint, is a false PASS.