RTL Design Patterns · Chapter 11 · Clock Domain Crossing
Gray-Code Pointer Synchronization
You cannot cross a multi-bit binary value into another clock domain by putting a two-flop synchronizer on each bit. When several source bits change on the same edge, the per-bit synchronizers resolve old-or-new independently, and the destination assembles a word the source never held, a phantom. Yet an async FIFO has to cross exactly such a value: its read and write pointers are multi-bit counters each domain must see in the other. The fix is not a better synchronizer but a better encoding. Gray-code the pointer, and consecutive values differ in one bit, so any mid-transition sample lands on a real, adjacent count. The recipe is to keep the pointer as a gray counter, synchronize the gray value bit by bit, then convert back to binary after the synchronizer, never before. This lesson builds that crossing with two-clock testbenches in SystemVerilog, Verilog, and VHDL.
Advanced14 min readRTL Design PatternsClock Domain CrossingGray CodeFIFO PointerData CoherencyAsync FIFO
Chapter 11 · Section 11.5 · Clock Domain Crossing
1. The Engineering Problem
You are building the dual-clock FIFO that sits between two unrelated clocks — a clk_wr producer and a clk_rd consumer. The structure is the synchronous FIFO of 7.1 split across a boundary: a dual-port RAM, a write pointer that advances on clk_wr, a read pointer that advances on clk_rd. But full and empty cannot be computed from a local occupancy count anymore, because no single clock sees both pointers. To decide empty, the read side must compare its own read pointer against the write pointer — which lives in the other clock domain. To decide full, the write side must compare its write pointer against the read pointer — same problem, other direction. Each side has to see the other domain's pointer, and that means crossing a multi-bit counter across an asynchronous boundary.
And 11.3 already proved that is a trap. The write pointer is a multi-bit binary counter. Put a two-flop synchronizer on each of its bits — the obvious move — and the moment the pointer steps through a transition where several bits change at once, the per-bit synchronizers resolve independently: some bits catch the old value, some the new, and the read side assembles a pointer word that was never a real write-pointer value. A binary write pointer stepping 0111 -> 1000 flips all four bits; the read side can momentarily latch 1111, 0000, or 0110 — none of which the write pointer ever held. It then compares that phantom pointer against its read pointer, computes empty or full from garbage, and either reads a slot that was never written (stale data) or refuses a read that should have succeeded (a dropped word). The corruption is intermittent, clock-ratio-dependent, and invisible in a single-clock simulation.
So the requirement is sharp. You cannot avoid crossing the pointer — the flag logic needs it. You cannot per-bit-2FF it — that is 11.3's phantom. And you cannot handshake it the way 11.4 handshakes an arbitrary bus, because the pointer advances every time a word moves and a per-transfer handshake would throttle the FIFO to a crawl. What you can exploit is a property the pointer has that an arbitrary bus does not: a FIFO pointer only ever moves by one, monotonically. A value that changes by exactly one step at a time can be encoded so that one step changes exactly one bit — and a one-bit change is the one case 11.3 says is safe to cross. That encoding is Gray code, and building the crossing around it is this page.
// clk_wr and clk_rd are UNRELATED. wr_ptr is a multi-bit counter in clk_wr.
// The read side needs wr_ptr to compute EMPTY (rd_ptr vs synchronized wr_ptr).
// WRONG — per-bit 2FF a BINARY pointer (this is 11.3's phantom):
// always @(posedge clk_rd) begin
// wr_ptr_meta <= wr_ptr; // bit-by-bit, each resolves old-or-new alone
// wr_ptr_sync <= wr_ptr_meta; // on 0111 -> 1000 all bits flip => phantom word
// end
// A step 0111 -> 1000 can be captured as 1111 / 0000 / 0110 => EMPTY computed on garbage.
// The need: cross a value that only moves by +/-1 so that ONE bit changes per step,
// making any old/new sample a REAL, ADJACENT pointer value. That is Gray coding, and
// it is this page: increment as binary, cross as GRAY, convert back AFTER the 2FF.2. Mental Model
3. Pattern Anatomy
The crossing is three pieces in a fixed order: a Gray-valued pointer in the source domain, a per-bit two-flop synchronizer on the Gray value, and a Gray-to-binary conversion in the destination domain. The order is the whole correctness.
The two conversions, and the exact formulas. Binary-to-Gray is a single row of XORs: gray = bin ^ (bin >> 1) — each Gray bit is a source binary bit XORed with the bit above it. It is purely combinational and cheap, and it has the defining consequence that incrementing the binary count by one flips exactly one Gray bit. Gray-to-binary is the inverse, a prefix-XOR scan from the top bit down: the most-significant bits are equal (bin[msb] = gray[msb]), and each lower binary bit is the Gray bit XORed with the binary bit just above it (bin[i] = bin[i+1] ^ gray[i]). This is not an XOR of adjacent Gray bits — that shortcut is right for two-bit values and silently wrong for three bits and up (a signature decode bug, §6). The round-trip is exact: convert binary to Gray and back and you recover the original count.
Where each conversion sits — the load-bearing placement. You may increment the pointer either as a binary counter that you convert to Gray each cycle, or as a native Gray counter (3.3) — both produce the same crossing value, and the binary-plus-convert form is the common one because you usually also want the binary pointer locally for RAM addressing. But the crossing value is always Gray. The per-bit synchronizer carries the Gray bits, unchanged, into the destination domain. The Gray-to-binary conversion happens only after the second synchronizer flop, in the destination domain, purely for the comparison the flag logic needs. Increment as binary or Gray; cross as Gray; convert back after the 2FF — that sentence is the pattern.
The Gray-pointer crossing — increment, convert to Gray, cross the GRAY, convert back after the synchronizer
data flowWhy 'at most one behind' is exactly the property the async FIFO needs. The read side compares its read pointer against the synchronized write pointer to decide empty. Because the synchronized write pointer is always real and never ahead of the true write pointer, the read side's view of "how much data is available" is always less than or equal to the truth. If it thinks the FIFO is empty, it genuinely is (or has just become non-empty and the read side will see it a cycle or two later) — so the read side never reads a slot that was not written. Symmetrically, the write side sees a read pointer that is real and never ahead, so its view of "how much room is available" is conservative, and it never overwrites unread data. The synchronizer latency shows up only as the flags being slightly slow to clear — the FIFO occasionally reports full or empty a cycle or two longer than strictly necessary — which costs a hair of throughput and is completely safe. That is why 11.6 is built on this crossing and not on a cleverer one.
The strict requirement — this works only for a single-Gray-step value. The entire guarantee rests on one bit changing per transition, and that holds only because the pointer moves by exactly one, monotonically. Feed this structure a value that can jump by more than one step in a cycle — an arbitrary counter that skips, a bus that loads a new value, any non-adjacent change — and several Gray bits change at once, the mid-transition sample is again an old/new blend of multiple bits, and the phantom is back. Gray-code crossing is not a general multi-bit CDC solution; it is specifically the monotonic pointer solution. An arbitrary multi-bit bus still needs the stable-bus handshake of 11.4 (hold the bus steady, cross a single synchronized valid bit). One value, moving one step at a time: that is this pattern's exact contract, and every failure in §6 and §7 is a violation of it.
4. Real RTL Implementation
This is the core of the page. We build two things — (a) a full parameterized-WIDTH Gray-pointer crossing (a source-domain binary counter, gray = bin ^ (bin >> 1), a per-bit two-flop synchronizer into the destination, and a prefix-XOR gray -> binary decode), with a two-async-clock testbench that asserts every synchronized value is a real pointer value at most a bounded amount behind the source (coherent and monotonic); and (b) the binary-pointer-crossing bug beside the Gray-pointer fix — the binary version captures a never-held phantom, the Gray version never does — each shown in SystemVerilog, Verilog, and VHDL with an RTL block and a self-checking clocked testbench. The reasoning is identical in all three; only the syntax differs.
One discipline runs through every RTL block: the pointer increments with non-blocking assignments on clk_wr; the crossing value is always the Gray code; the per-bit synchronizer is a plain shift of the Gray vector on clk_rd (no logic between its two flops — the 11.2 rule, applied per bit); and the gray -> binary conversion happens only in the destination domain, after the second flop. Reset is asynchronous, active-high, so the pointers come up at zero in a defined state.
4a. The parameterized Gray-pointer crossing — three ways
The module increments a binary pointer in the source domain, converts it to Gray, and exposes the Gray value for crossing; the destination-domain half runs a per-bit two-flop synchronizer on that Gray value and decodes it back to binary. Making WIDTH a parameter lets a FIFO of any depth reuse it unchanged.
module gray_ptr_sync #(
parameter int WIDTH = 4 // pointer width (FIFO addr + 1 wrap bit)
)(
input logic clk_wr, // SOURCE domain: pointer increments here
input logic clk_rd, // DESTINATION domain: pointer is observed here
input logic rst, // async, active-high -> defined power-up
input logic inc, // advance the pointer by ONE (monotonic)
output logic [WIDTH-1:0] bin_ptr, // local binary pointer (RAM address, src side)
output logic [WIDTH-1:0] bin_sync // write pointer as seen in the DEST domain
);
// --- SOURCE DOMAIN: increment in binary, convert to GRAY for crossing ------------
logic [WIDTH-1:0] gray_ptr;
always_ff @(posedge clk_wr or posedge rst) begin
if (rst) bin_ptr <= '0;
else if (inc) bin_ptr <= bin_ptr + 1'b1; // +1 only: monotonic pointer
end
// bin -> gray: one Gray bit flips per increment. This is the value that CROSSES.
assign gray_ptr = bin_ptr ^ (bin_ptr >> 1);
// --- DESTINATION DOMAIN: per-bit 2FF on the GRAY value ---------------------------
(* ASYNC_REG = "TRUE" *) // keep the sync flops adjacent
logic [WIDTH-1:0] gray_meta, gray_sync;
always_ff @(posedge clk_rd or posedge rst) begin
if (rst) begin gray_meta <= '0; gray_sync <= '0; end
else begin gray_meta <= gray_ptr; gray_sync <= gray_meta; end // wire only
end
// --- gray -> binary: prefix-XOR scan, ONLY here (after the synchronizer) ---------
// bin[MSB] = gray[MSB]; bin[i] = bin[i+1] ^ gray[i]. NOT an adjacent-gray XOR.
always_comb begin
bin_sync[WIDTH-1] = gray_sync[WIDTH-1];
for (int i = WIDTH-2; i >= 0; i--)
bin_sync[i] = bin_sync[i+1] ^ gray_sync[i];
end
endmoduleThe self-checking testbench runs two unrelated clocks — a source clock that advances the pointer and a faster, asynchronous destination clock that samples it — and, on every destination edge, asserts two things: the synchronized value is a value the source pointer actually held at some point (coherence — never a phantom), and it lags the current source pointer by at most a small bounded amount (monotonic, never ahead). A history array of every source-pointer value the source ever held is the reference model.
module gray_ptr_sync_tb;
localparam int WIDTH = 4;
localparam int MAXLAG = 3; // 2 sync stages + 1 in-flight margin
logic clk_wr = 1'b0, clk_rd = 1'b0, rst, inc;
logic [WIDTH-1:0] bin_ptr, bin_sync;
int errors = 0;
gray_ptr_sync #(.WIDTH(WIDTH)) dut (
.clk_wr(clk_wr), .clk_rd(clk_rd), .rst(rst),
.inc(inc), .bin_ptr(bin_ptr), .bin_sync(bin_sync));
// Two UNRELATED clocks (co-prime half-periods -> edges drift, as in a real crossing).
always #5 clk_wr = ~clk_wr; // ~100 MHz source
always #7 clk_rd = ~clk_rd; // ~71 MHz destination (async)
// Reference: mark every binary value the SOURCE pointer has ever held as "real".
bit held [2**WIDTH];
always @(posedge clk_wr) if (!rst) held[bin_ptr] = 1'b1;
// On every DEST edge, the decoded synced pointer must be a REAL value the source held,
// and at most MAXLAG steps behind the current source pointer (never ahead).
always @(posedge clk_rd) if (!rst) begin
#1;
if (!held[bin_sync]) begin
$error("PHANTOM: bin_sync=%0d was NEVER a source pointer value", bin_sync);
errors++;
end
// circular distance the sync pointer lags the live source pointer
if (((bin_ptr - bin_sync) & (2**WIDTH-1)) > MAXLAG) begin
$error("LAG: bin_sync=%0d more than %0d behind bin_ptr=%0d", bin_sync, MAXLAG, bin_ptr);
errors++;
end
end
initial begin
rst = 1'b1; inc = 1'b0;
repeat (2) @(posedge clk_wr); #1; rst = 1'b0;
// Free-run the pointer for many source cycles across the async boundary.
repeat (60) @(posedge clk_wr) inc = 1'b1;
inc = 1'b0;
repeat (6) @(posedge clk_rd);
if (errors == 0) $display("PASS: synced pointer always real and at most %0d behind (coherent, monotonic)", MAXLAG);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog form is the same crossing with reg/wire typing. The gray -> binary scan is written with a for loop inside an always @(*), and the self-check uses if (!held[bin_sync]) $display("FAIL") in place of SystemVerilog's assert.
module gray_ptr_sync #(
parameter WIDTH = 4
)(
input wire clk_wr,
input wire clk_rd,
input wire rst,
input wire inc,
output reg [WIDTH-1:0] bin_ptr,
output reg [WIDTH-1:0] bin_sync
);
wire [WIDTH-1:0] gray_ptr;
(* ASYNC_REG = "TRUE" *)
reg [WIDTH-1:0] gray_meta, gray_sync;
integer i;
// SOURCE: +1 binary counter (monotonic)
always @(posedge clk_wr or posedge rst)
if (rst) bin_ptr <= {WIDTH{1'b0}};
else if (inc) bin_ptr <= bin_ptr + 1'b1;
// bin -> gray: the value that CROSSES (one bit flips per step)
assign gray_ptr = bin_ptr ^ (bin_ptr >> 1);
// DEST: per-bit 2FF on the GRAY vector (wire only between the two flops)
always @(posedge clk_rd or posedge rst)
if (rst) begin gray_meta <= {WIDTH{1'b0}}; gray_sync <= {WIDTH{1'b0}}; end
else begin gray_meta <= gray_ptr; gray_sync <= gray_meta; end
// gray -> binary: prefix-XOR scan, ONLY here (after the synchronizer)
always @(*) begin
bin_sync[WIDTH-1] = gray_sync[WIDTH-1];
for (i = WIDTH-2; i >= 0; i = i - 1)
bin_sync[i] = bin_sync[i+1] ^ gray_sync[i];
end
endmodule module gray_ptr_sync_tb;
parameter WIDTH = 4;
parameter MAXLAG = 3;
reg clk_wr, clk_rd, rst, inc;
wire [WIDTH-1:0] bin_ptr, bin_sync;
reg held [0:(1<<WIDTH)-1];
integer errors, k, lag;
gray_ptr_sync #(.WIDTH(WIDTH)) dut (
.clk_wr(clk_wr), .clk_rd(clk_rd), .rst(rst),
.inc(inc), .bin_ptr(bin_ptr), .bin_sync(bin_sync));
always #5 clk_wr = ~clk_wr; // ~100 MHz
always #7 clk_rd = ~clk_rd; // ~71 MHz, async
// Reference: record every value the SOURCE pointer holds.
always @(posedge clk_wr) if (!rst) held[bin_ptr] = 1'b1;
// was_held(bin_sync): every decoded synced value must be a REAL source value.
always @(posedge clk_rd) if (!rst) begin
#1;
if (held[bin_sync] !== 1'b1) begin
$display("FAIL PHANTOM: bin_sync=%0d never a source value", bin_sync);
errors = errors + 1;
end
lag = (bin_ptr - bin_sync) & ((1<<WIDTH)-1);
if (lag > MAXLAG) begin
$display("FAIL LAG: bin_sync=%0d more than %0d behind bin_ptr=%0d", bin_sync, MAXLAG, bin_ptr);
errors = errors + 1;
end
end
initial begin
errors = 0; clk_wr = 1'b0; clk_rd = 1'b0; rst = 1'b1; inc = 1'b0;
for (k = 0; k < (1<<WIDTH); k = k + 1) held[k] = 1'b0;
repeat (2) @(posedge clk_wr); #1; rst = 1'b0;
inc = 1'b1;
repeat (60) @(posedge clk_wr);
inc = 1'b0;
repeat (6) @(posedge clk_rd);
if (errors == 0) $display("PASS: synced pointer always real and at most %0d behind", MAXLAG);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleIn VHDL the crossing is the same three stages. Binary-to-Gray is bin xor ('0' & bin(WIDTH-1 downto 1)) (a shift-right by one), the per-bit synchronizer is a pair of std_logic_vector registers on clk_rd, and Gray-to-binary is the prefix-XOR scan in a loop. numeric_std carries the arithmetic.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity gray_ptr_sync is
generic ( WIDTH : positive := 4 );
port (
clk_wr : in std_logic; -- source domain
clk_rd : in std_logic; -- destination domain
rst : in std_logic; -- async, active-high
inc : in std_logic; -- advance pointer by one
bin_ptr : out std_logic_vector(WIDTH-1 downto 0); -- local binary (RAM addr)
bin_sync : out std_logic_vector(WIDTH-1 downto 0) -- write ptr seen in dest
);
end entity;
architecture rtl of gray_ptr_sync is
signal bin_q : unsigned(WIDTH-1 downto 0);
signal gray_ptr : std_logic_vector(WIDTH-1 downto 0);
signal gray_meta : std_logic_vector(WIDTH-1 downto 0);
signal gray_sync : std_logic_vector(WIDTH-1 downto 0);
attribute ASYNC_REG : string;
attribute ASYNC_REG of gray_meta : signal is "TRUE";
attribute ASYNC_REG of gray_sync : signal is "TRUE";
begin
-- SOURCE: monotonic +1 binary counter
process (clk_wr, rst) begin
if rst = '1' then bin_q <= (others => '0');
elsif rising_edge(clk_wr) then
if inc = '1' then bin_q <= bin_q + 1; end if;
end if;
end process;
bin_ptr <= std_logic_vector(bin_q);
-- bin -> gray: xor with a right-shift-by-one. This value CROSSES.
gray_ptr <= std_logic_vector(bin_q) xor
std_logic_vector('0' & bin_q(WIDTH-1 downto 1));
-- DEST: per-bit 2FF on the GRAY vector (wire only between the two flops)
process (clk_rd, rst) begin
if rst = '1' then
gray_meta <= (others => '0'); gray_sync <= (others => '0');
elsif rising_edge(clk_rd) then
gray_meta <= gray_ptr; gray_sync <= gray_meta;
end if;
end process;
-- gray -> binary: prefix-XOR scan, ONLY here (after the synchronizer)
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_sync <= b;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity gray_ptr_sync_tb is
end entity;
architecture sim of gray_ptr_sync_tb is
constant WIDTH : positive := 4;
constant MAXLAG : natural := 3;
signal clk_wr : std_logic := '0';
signal clk_rd : std_logic := '0';
signal rst : std_logic := '1';
signal inc : std_logic := '0';
signal bin_ptr : std_logic_vector(WIDTH-1 downto 0);
signal bin_sync : std_logic_vector(WIDTH-1 downto 0);
-- reference: which binary values the SOURCE pointer has held
type held_arr is array (0 to 2**WIDTH-1) of boolean;
signal held : held_arr := (others => false);
begin
dut : entity work.gray_ptr_sync
generic map (WIDTH => WIDTH)
port map (clk_wr => clk_wr, clk_rd => clk_rd, rst => rst,
inc => inc, bin_ptr => bin_ptr, bin_sync => bin_sync);
-- Two UNRELATED clocks: 5 ns and 7 ns half-periods drift against each other.
clk_wr <= not clk_wr after 5 ns;
clk_rd <= not clk_rd after 7 ns;
-- record every source-pointer value
rec : process (clk_wr) begin
if rising_edge(clk_wr) and rst = '0' then
held(to_integer(unsigned(bin_ptr))) <= true;
end if;
end process;
-- on every dest edge: synced value must be REAL and at most MAXLAG behind
chk : process (clk_rd)
variable lag : integer;
begin
if rising_edge(clk_rd) and rst = '0' then
assert held(to_integer(unsigned(bin_sync)))
report "PHANTOM: bin_sync was never a source pointer value" severity error;
lag := (to_integer(unsigned(bin_ptr)) - to_integer(unsigned(bin_sync)))
mod (2**WIDTH);
assert lag <= MAXLAG
report "LAG: bin_sync more than MAXLAG behind bin_ptr" severity error;
end if;
end process;
stim : process begin
wait until rising_edge(clk_wr); wait until rising_edge(clk_wr);
wait for 1 ns; rst <= '0';
inc <= '1';
for k in 1 to 60 loop wait until rising_edge(clk_wr); end loop;
inc <= '0';
for k in 1 to 6 loop wait until rising_edge(clk_rd); end loop;
report "gray_ptr_sync self-check complete" severity note;
wait;
end process;
end architecture;4b. The binary-pointer bug beside the Gray-pointer fix — with a bit-skew model
Pattern (b) is the signature failure shown as buggy vs fixed RTL, then dramatized in §7. The buggy version crosses the pointer as binary, per-bit 2FF; the fixed version crosses it as Gray and decodes after. To make the difference visible in simulation we model bit skew: on the crossing edge the buggy binary path is allowed to sample a multi-bit transition with its bits resolving independently, so the destination captures a word the source never held; the Gray path, sampling the same transition, can only capture the old or the new adjacent value.
// BUGGY: cross the pointer as BINARY, per-bit 2FF. On a multi-bit step (0111 -> 1000)
// the bits resolve independently -> the destination can capture a PHANTOM.
module ptr_cross_bad #(parameter int WIDTH = 4)(
input logic clk_rd, rst,
input logic [WIDTH-1:0] bin_ptr, // source binary pointer, crossing raw
output logic [WIDTH-1:0] bin_seen // what the dest domain believes it is
);
logic [WIDTH-1:0] meta, sync;
always_ff @(posedge clk_rd or posedge rst)
if (rst) begin meta <= '0; sync <= '0; end
else begin meta <= bin_ptr; sync <= meta; end // BINARY crosses -> bit skew
assign bin_seen = sync; // may be a value the source never held
endmodule
// FIXED: cross the pointer as GRAY (one bit per step), decode to binary AFTER the 2FF.
module ptr_cross_good #(parameter int WIDTH = 4)(
input logic clk_rd, rst,
input logic [WIDTH-1:0] bin_ptr,
output logic [WIDTH-1:0] bin_seen
);
logic [WIDTH-1:0] gray_ptr, meta, sync;
assign gray_ptr = bin_ptr ^ (bin_ptr >> 1); // cross as GRAY
always_ff @(posedge clk_rd or posedge rst)
if (rst) begin meta <= '0; sync <= '0; end
else begin meta <= gray_ptr; sync <= meta; end // one bit in flight -> safe
always_comb begin // gray -> bin AFTER 2FF
bin_seen[WIDTH-1] = sync[WIDTH-1];
for (int i = WIDTH-2; i >= 0; i--)
bin_seen[i] = bin_seen[i+1] ^ sync[i];
end
endmodule module ptr_cross_bug_tb;
localparam int WIDTH = 4;
logic clk_rd = 1'b0, rst;
logic [WIDTH-1:0] bin_ptr;
logic [WIDTH-1:0] bad_seen, good_seen;
int errors = 0;
ptr_cross_bad #(.WIDTH(WIDTH)) ubad (.clk_rd(clk_rd), .rst(rst), .bin_ptr(bin_ptr), .bin_seen(bad_seen));
ptr_cross_good #(.WIDTH(WIDTH)) ugood (.clk_rd(clk_rd), .rst(rst), .bin_ptr(bin_ptr), .bin_seen(good_seen));
always #5 clk_rd = ~clk_rd;
initial begin
rst = 1'b1; bin_ptr = 4'b0111; // just before the all-bits step
@(posedge clk_rd); #1; rst = 1'b0;
@(posedge clk_rd); // first sync stage latches 0111
// BIT-SKEW MODEL: mid-flight 0111 -> 1000, some bits already new, some still old.
// Force the BAD path's first stage to a blended, never-held word (e.g. 1111).
force ubad.meta = 4'b1111; // phantom: source held 0111 then 1000
// The GOOD path crosses GRAY; the same transition flips ONE gray bit, so its
// first stage can only hold gray(0111)=0100 or gray(1000)=1100 -> both decode
// to a REAL adjacent pointer (7 or 8). Model the old gray value here.
force ugood.meta = 4'b0100; // gray(0111): decodes to 7 (real)
bin_ptr = 4'b1000; // source completes the step
repeat (2) @(posedge clk_rd); #1;
release ubad.meta; release ugood.meta;
// BUGGY: the destination believed the pointer was 1111 = 15, a value the source
// NEVER held (it went 7 -> 8). That corrupts any full/empty compare.
if (bad_seen === 4'd15)
$display("BUG OBSERVED: binary crossing captured phantom pointer 15 (never held)");
// FIXED: decoded value must be a REAL, adjacent pointer (7 or 8), never a phantom.
assert (good_seen === 4'd7 || good_seen === 4'd8)
else begin $error("gray crossing produced non-adjacent %0d", good_seen); errors++; end
if (errors == 0) $display("PASS: gray crossing stays on real adjacent pointers; binary crossing can phantom");
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same story with reg/wire; the bit-skew model uses force/release identically, and the self-check prints PASS/FAIL on whether the Gray-crossed value stays on a real adjacent pointer.
// BUGGY: cross BINARY, per-bit 2FF -> multi-bit step can capture a phantom.
module ptr_cross_bad #(parameter WIDTH = 4)(
input wire clk_rd, rst,
input wire [WIDTH-1:0] bin_ptr,
output wire [WIDTH-1:0] bin_seen
);
reg [WIDTH-1:0] meta, sync;
always @(posedge clk_rd or posedge rst)
if (rst) begin meta <= {WIDTH{1'b0}}; sync <= {WIDTH{1'b0}}; end
else begin meta <= bin_ptr; sync <= meta; end
assign bin_seen = sync;
endmodule
// FIXED: cross GRAY (one bit per step), decode after the 2FF.
module ptr_cross_good #(parameter WIDTH = 4)(
input wire clk_rd, rst,
input wire [WIDTH-1:0] bin_ptr,
output reg [WIDTH-1:0] bin_seen
);
wire [WIDTH-1:0] gray_ptr = bin_ptr ^ (bin_ptr >> 1);
reg [WIDTH-1:0] meta, sync;
integer i;
always @(posedge clk_rd or posedge rst)
if (rst) begin meta <= {WIDTH{1'b0}}; sync <= {WIDTH{1'b0}}; end
else begin meta <= gray_ptr; sync <= meta; end
always @(*) begin
bin_seen[WIDTH-1] = sync[WIDTH-1];
for (i = WIDTH-2; i >= 0; i = i - 1)
bin_seen[i] = bin_seen[i+1] ^ sync[i];
end
endmodule module ptr_cross_bug_tb;
parameter WIDTH = 4;
reg clk_rd, rst;
reg [WIDTH-1:0] bin_ptr;
wire [WIDTH-1:0] bad_seen, good_seen;
integer errors;
ptr_cross_bad #(.WIDTH(WIDTH)) ubad (.clk_rd(clk_rd), .rst(rst), .bin_ptr(bin_ptr), .bin_seen(bad_seen));
ptr_cross_good #(.WIDTH(WIDTH)) ugood (.clk_rd(clk_rd), .rst(rst), .bin_ptr(bin_ptr), .bin_seen(good_seen));
always #5 clk_rd = ~clk_rd;
initial begin
errors = 0; clk_rd = 1'b0; rst = 1'b1; bin_ptr = 4'b0111;
@(posedge clk_rd); #1; rst = 1'b0;
@(posedge clk_rd);
// BIT-SKEW MODEL for the 0111 -> 1000 step.
force ubad.meta = 4'b1111; // phantom word (source never held 15)
force ugood.meta = 4'b0100; // gray(0111) -> decodes to 7 (real)
bin_ptr = 4'b1000;
repeat (2) @(posedge clk_rd); #1;
release ubad.meta; release ugood.meta;
if (bad_seen === 4'd15)
$display("BUG OBSERVED: binary crossing captured phantom pointer 15");
// was_real(good_seen): must be an adjacent, real pointer (7 or 8).
if (!(good_seen === 4'd7 || good_seen === 4'd8)) begin
$display("FAIL: gray crossing produced non-adjacent %0d", good_seen);
errors = errors + 1;
end
if (errors == 0) $display("PASS: gray crossing stays on real adjacent pointers; binary can phantom");
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleIn VHDL the same pair uses numeric_std; the bit-skew model forces the buggy path's first stage to a never-held word while the Gray path's first stage holds a real Gray value that decodes to an adjacent pointer.
library ieee;
use ieee.std_logic_1164.all;
-- BUGGY: cross the pointer as BINARY, per-bit 2FF.
entity ptr_cross_bad is
generic ( WIDTH : positive := 4 );
port ( clk_rd, rst : in std_logic;
bin_ptr : in std_logic_vector(WIDTH-1 downto 0);
bin_seen : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of ptr_cross_bad is
signal meta, sync : std_logic_vector(WIDTH-1 downto 0);
begin
process (clk_rd, rst) begin
if rst = '1' then meta <= (others => '0'); sync <= (others => '0');
elsif rising_edge(clk_rd) then meta <= bin_ptr; sync <= meta; end if;
end process;
bin_seen <= sync;
end architecture;
-- FIXED: cross GRAY (one bit per step), decode to binary AFTER the 2FF.
library ieee;
use ieee.std_logic_1164.all;
entity ptr_cross_good is
generic ( WIDTH : positive := 4 );
port ( clk_rd, rst : in std_logic;
bin_ptr : in std_logic_vector(WIDTH-1 downto 0);
bin_seen : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of ptr_cross_good is
signal gray_ptr, meta, sync : std_logic_vector(WIDTH-1 downto 0);
begin
gray_ptr <= bin_ptr xor ('0' & bin_ptr(WIDTH-1 downto 1)); -- cross as GRAY
process (clk_rd, rst) begin
if rst = '1' then meta <= (others => '0'); sync <= (others => '0');
elsif rising_edge(clk_rd) then meta <= gray_ptr; sync <= meta; end if;
end process;
process (sync) -- gray -> bin AFTER 2FF
variable b : std_logic_vector(WIDTH-1 downto 0);
begin
b(WIDTH-1) := sync(WIDTH-1);
for i in WIDTH-2 downto 0 loop
b(i) := b(i+1) xor sync(i);
end loop;
bin_seen <= b;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ptr_cross_bug_tb is
end entity;
architecture sim of ptr_cross_bug_tb is
constant WIDTH : positive := 4;
signal clk_rd : std_logic := '0';
signal rst : std_logic := '1';
signal bin_ptr : std_logic_vector(WIDTH-1 downto 0) := "0111";
signal good_seen : std_logic_vector(WIDTH-1 downto 0);
begin
-- The FIXED design is instantiated; its "always a real adjacent pointer" is the property.
ugood : entity work.ptr_cross_good
generic map (WIDTH => WIDTH)
port map (clk_rd => clk_rd, rst => rst, bin_ptr => bin_ptr, bin_seen => good_seen);
clk_rd <= not clk_rd after 5 ns;
stim : process begin
wait until rising_edge(clk_rd); wait for 1 ns; rst <= '0';
wait until rising_edge(clk_rd);
-- Model the 0111 -> 1000 step; gray crossing captures 7 or 8, never a phantom.
bin_ptr <= "1000";
for k in 1 to 3 loop wait until rising_edge(clk_rd); end loop;
wait for 1 ns;
assert to_integer(unsigned(good_seen)) = 7 or to_integer(unsigned(good_seen)) = 8
report "gray crossing produced a non-adjacent (phantom) pointer" severity error;
report "ptr_cross self-check complete (gray stays on real adjacent pointers)" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: cross the pointer as Gray so only one bit is ever in flight — then any old/new sample is a real, adjacent count — and convert back to binary only after the synchronizer, never before.
Synthesis & Trade-offs
The Gray-pointer crossing is cheap in gates but its correctness lives in the placement of the conversions and the properties of the encoding, so the synthesis story is part of the pattern.
- What it costs and what it infers. Binary-to-Gray is one row of XOR gates (
WIDTH-1two-input XORs), effectively free. The synchronizer isWIDTHtwo-flop chains (2 * WIDTHflops) on the destination clock — the same per-bit structure as 11.2, and it must carry the sameASYNC_REG/dont_touchintent so the tool keeps each pair adjacent and does not retime, duplicate, or fan out the first stage. Gray-to-binary is a prefix-XOR scan: a chain ofWIDTH-1XORs whose delay grows withWIDTHbut which sits comfortably inside a clock period for realistic pointer widths (and can be pipelined or restructured if it ever dominates). - Why the conversion placement is not optional. The single most important synthesis fact is that the crossing value must be Gray. If a tool or a careless refactor moves the Gray-to-binary decode before the synchronizer — or lets the binary pointer itself cross — you are per-bit-2FF-ing a multi-bit binary value again and the phantom returns. Keep the decode combinational logic strictly in the destination domain, downstream of the second flop; a CDC checker verifies that the only thing crossing the boundary is the registered Gray vector.
- Latency and the conservative-flag consequence. The synchronized pointer is valid two destination cycles after it changed (three for a 3-flop chain), exactly like 11.2. In FIFO terms that latency makes the flags lag: full clears a cycle or two after a read actually frees a slot, empty clears a cycle or two after a write actually adds data. This is always in the safe direction — the flags are pessimistic, never optimistic — so the FIFO never overflows or under-reads; it only occasionally leaves a slot idle a hair longer than strictly necessary. Sizing the FIFO with a couple of slots of margin absorbs the latency entirely.
- The single-step requirement is a design constraint, not a suggestion. Gray crossing is valid only while the pointer moves by one per step. That is guaranteed by construction for a FIFO pointer (increment-by-one, monotonic), but if you ever reuse this crossing for a value that can jump — a counter that loads, a pointer that skips on flush — the guarantee evaporates and you need the 11.4 handshake instead. Treat "one Gray step per transition" as an invariant the surrounding logic must preserve.
5. Verification Strategy
A pointer crossing's correctness is a coherency-and-monotonicity property, not a truth table, so verification must run two unrelated clocks and check that the destination's view of the source pointer is always a real value and never ahead of the true value. The single sentence that captures a correct crossing is:
Every value the destination decodes for the source pointer is a value the source pointer actually held at some point (never a phantom), and it lags the source's current pointer by at most the synchronizer's bounded latency (never ahead) — so a flag computed from it is always conservative.
Everything below makes that checkable and maps onto the testbenches in §4.
- Two-async-clock, self-checking testbench. Generate
clk_wrandclk_rdwith co-prime (unrelated) periods so their edges drift, exactly as a real crossing does. Free-run the source pointer for many cycles and, on every destination edge, decode the synchronized Gray value back to binary and check it against a reference. The §4a testbenches do this in all three HDLs (SVassert (held[bin_sync]), Verilogif (held[bin_sync] !== 1) $display("FAIL"), VHDLassert held(...) severity error), proving both coherence and bounded lag across a live drifting crossing. - The reference model is a 'held' history. The cleanest reference is a one-bit-per-value array marking every binary value the source pointer has ever held. The invariant is simply
held[decoded_sync_pointer] == 1on every destination edge — a phantom is precisely a decoded value that was never held. This is far more powerful than checking a single expected value, because it catches any never-held word, not just the ones you thought to predict. - Model bit skew on the buggy binary path. You cannot simulate analog bit skew directly, but you can model its consequence: on the crossing edge,
forcethe buggy binary synchronizer's first stage to a blended word the source never held (e.g.1111between0111and1000) and confirm the destination believes a phantom pointer; then show the Gray path, sampling the same transition, can only holdgray(0111)orgray(1000), both decoding to a real adjacent count. The §4b testbenches inject exactly this and assert the Gray crossing never leaves the set of real adjacent pointers — the property the binary crossing violates. - Bounded-lag (never-ahead) check. Beyond "is it real," assert the direction and magnitude of the lag: the circular distance
(source_ptr - sync_ptr)must be small and non-negative (the sync pointer is behind, never ahead). This is what proves the flag logic is conservative — a sync pointer that ever ran ahead of the source would make the FIFO think it had more data or more room than it does, the dangerous direction. The §4a testbenches bound this distance byMAXLAG(two sync stages plus an in-flight margin). - Lint / CDC-tool intent. A structural CDC checker (Spyglass-CDC, Questa-CDC, or equivalent) is the primary verification for this pattern, above any testbench: it identifies every multi-bit crossing and flags any that is not Gray-coded (or handshake-protected), any binary bus crossed bit-by-bit, and any Gray-to-binary decode that sits on the source side of the synchronizer. The rule it encodes is 'a multi-bit pointer crosses only as a registered Gray vector, decoded on the destination side.' Treat a CDC-clean report as a gating sign-off, not an optional nicety.
- Corner cases to exercise. The wrap transition (all-ones Gray value back to the origin, still a single-bit change in Gray); the pointer changing right at a destination edge (the skew-inducing case, modelled by the
force); a fast source relative to the destination (confirm the sync pointer still never runs ahead, only lags more);WIDTH = 3, 4, 8(confirm the prefix-XOR decode is correct for every width — the adjacent-XOR shortcut fails fromWIDTH = 3); and reset asserted mid-run (both pointers return to a defined zero cleanly).
6. Common Mistakes
Crossing the pointer as binary (the 11.3 phantom, the signature mistake). The defining error: putting a per-bit two-flop synchronizer directly on the binary pointer. On any step where several bits change at once — 0111 -> 1000 flips all four — the per-bit synchronizers resolve old-or-new independently and the destination captures a word the source never held. The full/empty compare then runs on garbage, and the FIFO reads a slot that was never written or drops a word. The whole point of the pattern is that the crossing value must be Gray; crossing binary is not a smaller version of this pattern, it is the bug the pattern exists to prevent.
Converting Gray to binary before the synchronizer. A subtler version of the same mistake. You correctly build a Gray pointer, but then you decode it to binary in the source domain (or on the source side of the crossing) and synchronize the binary result. Now the thing crossing the boundary is again a multi-bit binary value, several of whose bits change per step — the phantom is back, just one conversion earlier than you think. The decode must live in the destination domain, strictly after the second synchronizer flop. Cross the Gray; decode after.
Using Gray crossing for a value that jumps by more than one step. The guarantee rests entirely on one bit changing per transition, which holds only for a value that moves one step at a time. Apply this crossing to a counter that can load, skip, or jump — or to any non-adjacent multi-bit change — and several Gray bits change at once, the mid-transition sample is once more an old/new blend of multiple bits, and the phantom returns. Gray crossing is the monotonic pointer solution, not a general multi-bit CDC tool; a value that can jump needs the stable-bus handshake of 11.4.
An incorrect Gray-to-binary decode (the adjacent-XOR off-by-one). The decode must be the prefix-XOR scan — bin[i] = bin[i+1] ^ gray[i], chaining from the top bit down — not a local XOR of adjacent Gray bits (bin[i] = gray[i] ^ gray[i+1]). The adjacent-XOR shortcut happens to be correct for WIDTH <= 2, so it passes a small unit test, and then decodes a real 4-bit pointer to the wrong count — corrupting full/empty from the other direction while the crossing itself was fine. Always use the prefix-XOR scan, and verify the round-trip for WIDTH >= 3.
Forgetting the synchronizer flops still need ASYNC_REG (per bit). Each of the WIDTH Gray bits crosses through its own two-flop synchronizer, and every one of those is subject to 11.2's rules: no fan-out on the first stage, no logic between the two flops, both flops in the destination domain, and the ASYNC_REG/dont_touch attribute so the tool cannot retime, duplicate, or absorb them. Gray coding solves coherency; it does not exempt the individual bits from metastability handling. Both properties are required together.
Treating the synchronized pointer as up-to-date. The destination's copy of the pointer lags by two cycles (three for a 3-flop chain) plus in-flight time. Flag logic that assumes the other domain's pointer is current — a full check that expects an immediate update after a read, a data-available check that expects the write "now" — will be off by the synchronizer's latency. The lag is always safe (the flags are pessimistic), but you must budget it: size the FIFO with a slot or two of margin so the conservative flags never throttle real traffic.
7. DebugLab
The FIFO that read a word that was never written — a write pointer crossed as binary
The engineering lesson: a monotonic pointer can cross an asynchronous boundary safely only if it is Gray-coded first — because Gray adjacency guarantees that any old/new sampling ambiguity is still a real, adjacent count, never a phantom the source never held. The tell is a bug that bites once in millions of crossings, only while the pointer is stepping through a multi-bit transition, never reproducibly, and gets worse as the clock ratio drives more transitions into the sampling window — the fingerprint of a data-coherency CDC failure, not an ordinary logic bug. One habit makes it impossible, and it is identical across SystemVerilog, Verilog, and VHDL: cross the pointer as Gray so only one bit is ever in flight, convert back to binary only after the synchronizer, and keep the value strictly monotonic (one step per cycle) — then mark the per-bit sync flops ASYNC_REG and run a CDC checker. This is the exact mechanism that makes the asynchronous FIFO of 11.6 safe.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "cross as Gray, decode after, one step at a time" discipline.
Exercise 1 — Trace the safe crossing
A 4-bit write pointer steps 0111 -> 1000 (all four binary bits flip). Give the Gray codes of both values and confirm they differ in exactly one bit. Then, for a destination clock that samples right at that transition, list every value the Gray synchronizer's first stage could capture, decode each back to binary, and show that every possibility is one of the two real, adjacent pointers — never a phantom. Contrast with the set of words a binary per-bit crossing could capture on the same step.
Exercise 2 — Find the conversion-placement bug
Two engineers build a Gray pointer crossing. Engineer A converts binary to Gray in the source domain, synchronizes the Gray value, and decodes to binary after the second flop. Engineer B builds the same Gray pointer but decodes it to binary in the source domain and synchronizes the binary result. Both simulate identically in a single-clock RTL run. Explain (i) which design has a CDC hole and precisely where, (ii) what the failure looks like in silicon and why it is intermittent and clock-ratio-dependent, and (iii) the one-line change that fixes B.
Exercise 3 — Justify the conservative flag
Explain, in terms of "the synchronized pointer is real and at most one step behind," why an async FIFO's empty flag (computed from the synchronized write pointer) can be late to clear but can never falsely clear — i.e. why the FIFO may briefly under-report available data but will never let the read side read an unwritten slot. Then state the symmetric argument for the full flag and the write side, and name the small cost the flag latency imposes and how a designer absorbs it.
Exercise 4 — Reject the wrong structures
For each value crossing an asynchronous boundary, say whether a Gray-coded per-bit synchronizer is correct, and if not, what structure you would use instead and why: (a) a monotonic FIFO write pointer; (b) an 8-bit configuration bus that occasionally loads a brand-new value; (c) a single-bit async flush level; (d) an up/down occupancy counter that can change by more than one per cycle. (Hint: single-step-monotonic vs arbitrary-jump vs single-bit-level each has a different CDC contract.)
10. Related Tutorials
Continue in Chapter 11 — Clock Domain Crossing (sibling and forward links unlock as they ship):
- The Multi-Bit CDC Problem — Chapter 11.3; the incoherency this page solves for the monotonic case — why per-bit 2FF on a binary counter captures a phantom, and why Gray code is the first of its three cures.
- The Two-Flop Synchronizer — Chapter 11.2; the per-bit structure each Gray bit crosses through, and the adjacency / no-fan-out /
ASYNC_REGrules that still apply to every bit here. - Metastability — Chapter 11.1; the reason each Gray bit still needs a synchronizer at all — Gray coding gives coherency, not metastability immunity.
- Pulse / Handshake Synchronization — Chapter 11.4; the structure for the values this pattern cannot cross — an arbitrary multi-bit bus held stable while a single synchronized valid bit crosses.
- Asynchronous FIFO — Chapter 11.6; the dual-clock FIFO built directly on this crossing — its Gray-coded pointers are carried across the boundary exactly this way to make full/empty conservative.
- CDC Design Rules — the checklist that makes "every multi-bit pointer crosses only as a registered Gray vector, decoded on the destination side" an enforced sign-off rule.
Backward / in-track dependencies:
- Gray Counters — Chapter 3.3; the encoding at the heart of this page — the single-bit-change property, the
gray = bin ^ (bin >> 1)conversion, and the prefix-XOR decode this crossing relies on. - Up/Down Counters — the monotonic counter a FIFO pointer is, and why its one-step-at-a-time behaviour is exactly the invariant Gray crossing requires.
- Synchronous FIFO Architecture — Chapter 7.1; the single-clock FIFO whose pointers become Gray-coded and synchronized (via this pattern) to build the async FIFO.
- FIFO Full / Empty Generation — the pointer comparison that consumes the synchronized pointer, and why the at-most-one-behind guarantee keeps it conservative.
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Bitwise Operators — the XOR and shift that build
gray = bin ^ (bin >> 1)and the prefix-XOR Gray-to-binary scan. - Blocking and Non-Blocking Assignments — why the pointer and synchronizer flops update with non-blocking (
<=) while the combinational Gray decode uses blocking (=). - Case Statements — the combinational-block discipline the Gray-to-binary decode shares with any complete combinational logic.
- If-Else Statements — the reset-then-update structure of the clocked pointer and synchronizer blocks.
11. Summary
- A monotonic pointer cannot be per-bit-2FF-crossed as binary, but can be crossed as Gray. 11.3 proved a binary counter crossed bit-by-bit captures a phantom when several bits change at once. Gray code removes that condition: consecutive Gray values differ in exactly one bit, so during any transition only one bit is in flight, and any mid-transition sample is either the old or the new value — both real, adjacent pointers. The phantom is gone not because the ambiguity was resolved but because the encoding makes every possible sample valid.
- Increment as binary or Gray; cross as Gray; convert back to binary only after the synchronizer. The crossing value is always the Gray code, carried by a per-bit two-flop synchronizer (which still obeys every 11.2 rule — adjacency, no first-stage fan-out,
ASYNC_REG). The Gray-to-binary decode is the prefix-XOR scan (bin[i] = bin[i+1] ^ gray[i], not an adjacent-Gray XOR) and lives strictly in the destination domain, downstream of the second flop. Decode before the crossing and the binary phantom returns. - The synchronized pointer is always real and at most one step behind — never ahead. Because every capture is a valid adjacent value, the destination's copy of the pointer is a value the source genuinely held and lags the true pointer only by the synchronizer's bounded latency. That "real and never ahead" property is exactly what makes an async FIFO's full/empty conservative: the flags may clear a cycle late (pessimistic, safe) but never claim space or data that is not there, so the FIFO never overflows and never reads an unwritten slot.
- It works only for a single-Gray-step value. The whole guarantee rests on one bit changing per transition, which holds only for a value that moves one step at a time — a monotonic pointer or counter. A value that can jump (a loading counter, an arbitrary bus) changes several Gray bits at once and brings the phantom back; that needs the stable-bus handshake of 11.4, not this crossing.
- The signature failure is crossing the pointer as binary (or decoding before the synchronizer). Both reintroduce the multi-bit phantom; both bite once in millions of crossings, only while the pointer steps through a multi-bit transition, unreproducibly, and worse as the clock ratio drives more transitions into the sampling window — the fingerprint of a data-coherency CDC bug. Cross as Gray, decode after, keep it monotonic, mark the sync flops
ASYNC_REG, and verify with a two-async-clock self-checking testbench plus a CDC checker. Built and self-check-verified here in SystemVerilog, Verilog, and VHDL — and it is the exact mechanism the asynchronous FIFO of 11.6 is built on.