· 12 min read
Async FIFO in SystemVerilog: Why Binary Pointers Break and How Gray Code Fixes Them
- systemverilog
- fifo
- async-fifo
- cdc
- gray-code
- fpga
- rtl-design
The synthesis numbers tell you the wrong story. I ran both designs through sky130 and the naive version — the one that directly synchronizes raw binary pointers across clock domains — came back at 505 MHz with 818 cells and 5918 square microns. The gray-coded version, the one that actually works, measured 325 MHz, 958 cells, 6278.5 square microns. If you stopped at those numbers, you'd ship the broken design and call it an optimization.
That's the trap. The binary pointer FIFO synthesizes beautifully precisely because it does less — no XOR tree to compute the gray conversion, no extra lap bit, fewer cells. The timing analyzer sees a short critical path because it doesn't know the path is catastrophically wrong. No timing violation. No DRC. The thing just silently corrupts your data at runtime, and usually only under specific clock phase relationships you didn't happen to hit in simulation.
I've seen this happen in silicon. The FIFO "works" in board bringup because both clocks came from the same PLL and the phase relationship was benign. Then someone changed a clock config, or moved to production silicon with slightly different PLL behavior, and suddenly the FIFO starts producing garbage at random intervals. That failure comes directly from a multi-bit metastability hazard that no static timing tool will flag.
An async FIFO differs from a synchronous one in one fundamental way: there is no shared timing reference. In a single-clock FIFO, you compare write and read pointers on the same clock edge, which is trivially safe. In an async FIFO, the write domain increments its pointer on wr_clk, the read domain increments on rd_clk, and both domains need to make decisions — is it full? is it empty? — using a pointer that originated in the other domain. The moment you carry a signal across clock domains without proper synchronization, you risk metastability: the receiving flip-flop samples a signal mid-transition, enters a metastable state, and resolves to an arbitrary logic level after an unpredictable delay. A 2-FF synchronizer chain handles this for a single-bit signal by giving metastability time to resolve before the value is used. The MTBF math makes single-bit crossings extremely reliable. Multi-bit crossings through a 2-FF chain are a different matter.
Here's the binary pointer hazard worked out concretely. Consider a 4-bit write pointer incrementing from 7 to 8: 0111 → 1000. Four bits change simultaneously. When the read domain's synchronizer samples the write pointer, each of those four bits can independently be old or new depending on exact routing delays and setup/hold margins. A completely plausible capture: bit 3 (the MSB) resolves to new (1), bits 2:0 resolve to old (1, 1, 1). The synchronizer outputs 1111 — decimal 15 — a value the counter never held. It jumped from 7 to 15 as far as the read domain is concerned. If the read domain sees a write pointer of 15 when the actual write pointer is 8, it thinks there are 8 unread words in the FIFO. Those memory locations contain garbage — maybe uninitialized data, maybe stale data from a previous transaction. The FIFO hands that garbage to your downstream logic.
The failure isn't subtle. It produces real wrong data, real protocol violations, real system crashes, just intermittently — which is the worst kind of bug.
Gray code exists specifically to eliminate multi-bit transitions. The binary-to-gray conversion is one line: gray = bin ^ (bin >> 1). Here's what consecutive values look like for a 4-bit counter:
| Decimal | Binary | Gray |
|---|---|---|
| 0 | 0000 | 0000 |
| 1 | 0001 | 0001 |
| 2 | 0010 | 0011 |
| 3 | 0011 | 0010 |
| 4 | 0100 | 0110 |
| 5 | 0101 | 0111 |
| 6 | 0110 | 0101 |
| 7 | 0111 | 0100 |
| 8 | 1000 | 1100 |
Binary 7 to 8 is 0111 → 1000: four bits flip. Gray 7 to 8 is 0100 → 1100: exactly one bit flips. This holds for every consecutive pair. When a 2-FF synchronizer samples a gray-coded pointer mid-transition, only one bit is changing. The synchronizer either captures the old value or the new value — no phantom intermediate. The 0111 → 1000 catastrophe simply cannot happen in gray space.
Now the extra-bit problem. With an ADDR_W-bit pointer, equal write and read pointers are ambiguous: they could mean the FIFO is empty (pointers started at zero and haven't diverged) or that the write pointer has wrapped all the way around and caught up again — full. You can't tell the difference. The standard fix, from Cliff Cummings' 2002 SNUG paper, is to use ADDR_W+1 bits for both pointers. The extra MSB acts as a lap indicator. You only use the lower ADDR_W bits to index into memory; the full ADDR_W+1 bits are used for full/empty detection.
With 5-bit pointers for a 16-deep FIFO: if wr_ptr == rd_ptr with all bits identical including the MSB, the FIFO is empty — both pointers are on the same lap at the same position. If the write pointer has lapped the read pointer by exactly 16 positions, the FIFO is full. What does "lapped by 16" look like in gray space? In binary, it means wr_ptr_bin == rd_ptr_bin + 16. The lower 4 bits are identical; the upper bit differs. But in gray code, a lap of 16 in 5-bit gray space doesn't just flip one bit — it flips the top two bits. This is the part most explanations skip.
Here's the intuition. A 5-bit gray code cycles through 32 values. Full condition is when wr is 16 positions ahead of rd — halfway around the 32-position cycle. At the halfway point, the gray sequence inverts the top two MSBs while the lower bits remain equal. It's not arbitrary; it falls out of the structure of the gray sequence itself. The full condition in gray space is therefore: wr_ptr_gray == {~rd_ptr_gray_sync[ADDR_W:ADDR_W-1], rd_ptr_gray_sync[ADDR_W-2:0]}. Two MSBs inverted, all other bits the same. If you only inverted one bit, you'd be comparing against the wrong position in the cycle and you'd get false full assertions or missed full assertions — either data corruption or a FIFO that appears perpetually full.
Here's the complete correct implementation:
// fifo_async_gray.sv
//
// CORRECT async FIFO: (ADDR_W+1)-bit gray-coded pointers synchronized
// across clock domains with 2-FF synchronizers.
`default_nettype none
module fifo_async_gray #(
parameter int DATA_W = 8,
parameter int DEPTH = 16
) (
// Write port
input wire wr_clk,
input wire wr_rst_n, // async active-low reset
input wire wr_en,
input wire [DATA_W-1:0] din,
output logic full,
// Read port
input wire rd_clk,
input wire rd_rst_n, // async active-low reset
input wire rd_en,
output logic [DATA_W-1:0] dout,
output logic empty
);
localparam int ADDR_W = $clog2(DEPTH); // 4 for DEPTH=16
// binary -> gray: one XOR, one shift. No LUT overhead worth measuring.
function automatic logic [ADDR_W:0] bin2gray(input logic [ADDR_W:0] bin);
bin2gray = bin ^ (bin >> 1);
endfunction
logic [DATA_W-1:0] mem [0:DEPTH-1];
// --- Write domain ---
logic [ADDR_W:0] wr_ptr_bin;
logic [ADDR_W:0] wr_ptr_gray;
// ASYNC_REG tells synthesis to place these FFs adjacent, improving MTBF
// and excluding the crossing path from multi-cycle timing checks.
(* ASYNC_REG = "TRUE" *) logic [ADDR_W:0] rd_ptr_gray_sync1;
(* ASYNC_REG = "TRUE" *) logic [ADDR_W:0] rd_ptr_gray_sync2;
always_ff @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) begin
wr_ptr_bin <= '0;
wr_ptr_gray <= '0;
end else if (wr_en && !full) begin
wr_ptr_bin <= wr_ptr_bin + 1'b1;
// Register the *next* gray value directly — no combinatorial
// path from gray back to binary on the way out.
wr_ptr_gray <= bin2gray(wr_ptr_bin + 1'b1);
end
end
always_ff @(posedge wr_clk) begin
if (wr_en && !full)
mem[wr_ptr_bin[ADDR_W-1:0]] <= din; // lower bits index memory
end
// 2-FF synchronizer: rd_ptr_gray into write domain
always_ff @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) begin
rd_ptr_gray_sync1 <= '0;
rd_ptr_gray_sync2 <= '0;
end else begin
rd_ptr_gray_sync1 <= rd_ptr_gray;
rd_ptr_gray_sync2 <= rd_ptr_gray_sync1;
end
end
// Full: wr has lapped rd by exactly DEPTH. In 5-bit gray space, that
// means top two MSBs inverted, lower bits identical.
assign full = (wr_ptr_gray == {~rd_ptr_gray_sync2[ADDR_W:ADDR_W-1],
rd_ptr_gray_sync2[ADDR_W-2:0]});
// --- Read domain ---
logic [ADDR_W:0] rd_ptr_bin;
logic [ADDR_W:0] rd_ptr_gray;
(* ASYNC_REG = "TRUE" *) logic [ADDR_W:0] wr_ptr_gray_sync1;
(* ASYNC_REG = "TRUE" *) logic [ADDR_W:0] wr_ptr_gray_sync2;
always_ff @(posedge rd_clk or negedge rd_rst_n) begin
if (!rd_rst_n) begin
rd_ptr_bin <= '0;
rd_ptr_gray <= '0;
end else if (rd_en && !empty) begin
rd_ptr_bin <= rd_ptr_bin + 1'b1;
rd_ptr_gray <= bin2gray(rd_ptr_bin + 1'b1);
end
end
// Combinatorial read — zero extra latency. Add a register here if you
// need to close timing on a fast rd_clk.
assign dout = mem[rd_ptr_bin[ADDR_W-1:0]];
// 2-FF synchronizer: wr_ptr_gray into read domain
always_ff @(posedge rd_clk or negedge rd_rst_n) begin
if (!rd_rst_n) begin
wr_ptr_gray_sync1 <= '0;
wr_ptr_gray_sync2 <= '0;
end else begin
wr_ptr_gray_sync1 <= wr_ptr_gray;
wr_ptr_gray_sync2 <= wr_ptr_gray_sync1;
end
end
// Empty: all bits equal including MSB lap bit. No phantom values possible.
assign empty = (rd_ptr_gray == wr_ptr_gray_sync2);
endmodule
`default_nettype wire
A few things worth calling out. The bin2gray function is one line — bin ^ (bin >> 1) — and synthesizes to a handful of XOR gates. The critical decision is registering bin2gray(wr_ptr_bin + 1'b1) directly rather than computing gray from the registered binary pointer; this avoids a combinatorial path from gray back to binary and means the gray pointer registers update cleanly on the same clock edge as the binary counter. The (* ASYNC_REG = "TRUE" *) attribute on both stages of each synchronizer chain isn't optional decoration — it tells Vivado to place those flip-flops adjacent to each other, which reduces routing delay between stages and improves MTBF. It also signals to the static timing analysis engine that the crossing path should not be analyzed for setup/hold violations in the normal way. Put it on both stages, not just the first.
For contrast, here's the unsafe binary version — specifically the synchronizer and full-detection logic that breaks things:
// 2-FF synchronizer: bring binary read pointer into write clock domain
always_ff @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) begin
rd_ptr_bin_sync1 <= '0;
rd_ptr_bin_sync2 <= '0;
end else begin
rd_ptr_bin_sync1 <= rd_ptr_bin; // direct binary sync -- UNSAFE
rd_ptr_bin_sync2 <= rd_ptr_bin_sync1;
end
end
// Full detection: rd_ptr_bin_sync2 may be a phantom value.
// This comparison can assert full erroneously or miss a true-full condition.
// Note: this logic also has a separate wrap-around bug (it fails when both
// pointers are zero after a full lap), deliberately left as-is to keep
// the focus on the synchronizer hazard — not the full logic itself.
assign full = (wr_ptr_bin == rd_ptr_bin_sync2) &&
(wr_ptr_bin != '0 || rd_ptr_bin_sync2 != '0);
The single line rd_ptr_bin_sync1 <= rd_ptr_bin is where the disaster begins. Every time rd_ptr_bin increments through a transition that flips more than one bit, this synchronizer can capture a phantom value and propagate it to the full/empty logic. The synthesis tool sees a valid 2-FF structure and has no objection. The timing analyzer sees a path that meets setup and hold. Nothing flags it.
Now the timing constraint that most articles skip entirely. Gray coding guarantees only one bit of the pointer changes per increment, but it does not guarantee all bits of the pointer bus land in the destination domain within the same clock cycle if routed with different delays. If bit 3 of the gray pointer arrives 0.5 ns later than bits 0:2 due to place-and-route routing skew, the synchronizer can briefly sample an inconsistent combination — the same multi-bit hazard already described. The way to prevent this in Vivado is an explicit constraint:
set_max_delay -datapath_only -from [get_cells wr_ptr_gray_reg*] \
-to [get_cells rd_ptr_gray_sync1_reg*] \
[get_property PERIOD [get_clocks wr_clk]]
The -datapath_only flag tells Vivado to constrain the combinatorial path delay without applying clock skew or uncertainty — it enforces that all bits of the gray pointer arrive within one source-clock period, which is tight enough to prevent cross-bit skew from corrupting the captured value. Without this constraint, the router is free to route individual bits with arbitrary delay, and you're relying on physical luck. Apply the equivalent set_multicycle_path or set_max_delay on the return path for rd_ptr_gray crossing into the write domain as well. In Quartus, the same thing is achieved through the Timing Closure Advisor's synchronizer identification, or manually via set_max_delay in a .sdc file.
Three mistakes show up on almost every first implementation. Reset handling is non-obvious: both wr_rst_n and rd_rst_n should be held asserted long enough that both domains fully initialize, and each reset should be released synchronously within its own domain. Releasing reset asynchronously across two different domains risks the FIFO seeing a partially-reset state when it first comes out of reset. DEPTH must be a power of two — the extra-bit trick and the top-two-MSB inversion derivation both assume the gray code cycles through exactly 2^(ADDR_W+1) values with the correct symmetry properties. A non-power-of-two depth breaks the math. And (* ASYNC_REG = "TRUE" *) must appear on both synchronizer stages, not just the first. Some implementations put it only on sync1 and leave sync2 unconstrained; the synthesis tool can then freely move sync2 away from sync1, degrading the MTBF benefit and potentially reintroducing routing skew between stages.
The measured results, from sky130 standard-cell synthesis:
| Design | Area (sq-um) | Cells | Fmax | Critical Path |
|---|---|---|---|---|
fifo_async_naive (binary sync, broken) |
5918.2 | 818 | 505 MHz | 1.98 ns |
fifo_async_gray (gray-coded, correct) |
6278.5 | 958 | 325 MHz | 3.08 ns |
The gray-coded design is 6% larger and 56% slower by static timing. The longer critical path comes from the full-detection logic — comparing a 5-bit gray pointer against a bit-inverted 5-bit value involves more logic levels than a straight binary equality compare. None of that matters, because the binary version produces wrong outputs. The correct design is the one that takes 3.08 ns.
If you want to run either of these through synthesis yourself and see the timing reports directly, you can do it on Logicode.