Advanced Memory Architectures & FIFO Engineering
Volume 05 ended with a promise: for sustained high-throughput data across clock domains, you need an asynchronous FIFO. This volume delivers it - and the async FIFO is genuinely the most elegant circuit in mainstream digital design, because it takes three ideas you already have (Gray counters, two-flop synchronizers, dual-port memory) and combines them so that every possible error lands on the safe side.
6.1 SRAM array architecture
A flip-flop costs roughly 20-30 transistors. An SRAM bit costs six. That factor is why no real design stores kilobytes in registers, and why memory is the one part of a chip that is almost never synthesized from your RTL.
Inferring memory in Verilog
You do not describe transistors. You describe an array and a specific access pattern, and the tool recognises it. The rules for getting dedicated block RAM instead of a mountain of flip-flops are narrow and worth memorising.
module ram_sync #(
parameter DW = 8, AW = 10
) (
input wire clk,
input wire we,
input wire [AW-1:0] waddr, raddr,
input wire [DW-1:0] wdata,
output reg [DW-1:0] rdata
);
reg [DW-1:0] mem [0:(1<<AW)-1];
always @(posedge clk) begin
if (we) mem[waddr] <= wdata;
rdata <= mem[raddr]; // REGISTERED read
end
// -> dedicated block RAM.
// 1 cycle of read latency.
endmodule
module ram_async #(
parameter DW = 8, AW = 10
) (
input wire clk,
input wire we,
input wire [AW-1:0] waddr, raddr,
input wire [DW-1:0] wdata,
output wire [DW-1:0] rdata
);
reg [DW-1:0] mem [0:(1<<AW)-1];
always @(posedge clk)
if (we) mem[waddr] <= wdata;
assign rdata = mem[raddr]; // COMBINATIONAL read
// -> LUT-based distributed RAM.
// 0 latency, but hugely expensive
// at this depth.
endmodule
if (!rst_n) rdata <= 0; else rdata <= mem[raddr];
- prevents block RAM inference on several tool/architecture combinations, because the
hardware primitive has no such reset on that path. Your 1 K × 8 memory quietly becomes
8192 flip-flops. Always read the synthesis report's RAM inference section
rather than assuming.
| Read-during-write mode | What rdata shows when reading the address being written |
Note |
|---|---|---|
| Read-first (old data) | The value that was there before this write | The default for most inference styles |
| Write-first (write-through) | The value being written this cycle | Needs explicit bypass logic or a vendor attribute |
| No-change | Output holds its previous value | Lowest power; not all architectures support it |
6.2 The synchronous single-clock FIFO
A FIFO is a memory plus two pointers. The only real design question is how to tell full apart from empty - because in both cases the two pointers address the same location.
module fifo_sync #(
parameter DW = 8,
parameter AW = 4 // depth = 2**AW
) (
input wire clk,
input wire rst_n,
input wire wr_en,
input wire [DW-1:0] wdata,
input wire rd_en,
output wire [DW-1:0] rdata,
output wire full,
output wire empty,
output wire [AW:0] count
);
localparam DEPTH = 1 << AW;
reg [DW-1:0] mem [0:DEPTH-1];
// ONE BIT WIDER than the address - the extra bit is the lap counter.
reg [AW:0] wptr, rptr;
// Qualify the enables so a write when full (or read when empty) is a
// harmless no-op rather than silent corruption.
wire do_wr = wr_en && !full;
wire do_rd = rd_en && !empty;
always @(posedge clk)
if (do_wr) mem[wptr[AW-1:0]] <= wdata;
// Combinational read: data is available the moment it is written
// ("first-word fall-through"). Register this for block RAM instead,
// at the cost of one cycle of read latency.
assign rdata = mem[rptr[AW-1:0]];
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr <= {(AW+1){1'b0}};
rptr <= {(AW+1){1'b0}};
end else begin
if (do_wr) wptr <= wptr + 1'b1;
if (do_rd) rptr <= rptr + 1'b1;
end
end
// Completely equal -> nothing in flight.
assign empty = (wptr == rptr);
// Same address, different lap -> writer has lapped the reader.
assign full = (wptr[AW] != rptr[AW]) &&
(wptr[AW-1:0] == rptr[AW-1:0]);
// Subtraction wraps correctly in two's complement, so this is exact.
assign count = wptr - rptr;
endmodule
6.3 The asynchronous dual-clock FIFO
Now the hard version. Writes arrive on wclk, reads happen on
rclk, and the two have no phase relationship whatsoever. The memory itself is
fine - writer and reader never touch the same cell at the same time, because the flags
prevent it. The difficulty is entirely in the flags, and each side needs to
know something that lives in the other clock domain.
Why Gray code makes the crossing legal
Volume 5.3 established the rule: a multi-bit value cannot cross through per-bit synchronizers, because bits resolve independently and you can latch a value that never existed. Gray code dissolves that problem - only one bit changes per increment, so a mistimed sample can only ever return the old value or the new one. Both are real pointer values. There is no third possibility.
The full condition: why the top two bits invert
Empty is easy: the read pointer has caught up exactly, so the two Gray values are equal. Full is the famous one. It means the write pointer is exactly one full lap ahead - a difference of 2AW in binary, which is just the top bit. But Gray code is a reflected code: the second half of the sequence mirrors the first. Advancing by half the range therefore flips the top two bits and leaves the rest alone.
001→111, 011→101, 010→100,
110→000. Top two inverted, remainder unchanged, every time.
module fifo_async #(
parameter DW = 8,
parameter AW = 4 // depth = 2**AW (MUST be a power of 2)
) (
// ---- write domain ----
input wire wclk,
input wire wrst_n,
input wire wr_en,
input wire [DW-1:0] wdata,
output reg wfull,
// ---- read domain ----
input wire rclk,
input wire rrst_n,
input wire rd_en,
output wire [DW-1:0] rdata,
output reg rempty
);
localparam DEPTH = 1 << AW;
reg [DW-1:0] mem [0:DEPTH-1];
// ================= WRITE DOMAIN ======================================
reg [AW:0] wbin, wgray;
wire [AW:0] wbin_next = wbin + (wr_en & ~wfull);
wire [AW:0] wgray_next = wbin_next ^ (wbin_next >> 1); // binary -> Gray
always @(posedge wclk or negedge wrst_n)
if (!wrst_n) begin
wbin <= {(AW+1){1'b0}};
wgray <= {(AW+1){1'b0}};
end else begin
wbin <= wbin_next;
wgray <= wgray_next;
end
// BINARY pointer addresses the memory; only the GRAY one ever crosses.
always @(posedge wclk)
if (wr_en && !wfull) mem[wbin[AW-1:0]] <= wdata;
// ================= READ DOMAIN =======================================
reg [AW:0] rbin, rgray;
wire [AW:0] rbin_next = rbin + (rd_en & ~rempty);
wire [AW:0] rgray_next = rbin_next ^ (rbin_next >> 1);
always @(posedge rclk or negedge rrst_n)
if (!rrst_n) begin
rbin <= {(AW+1){1'b0}};
rgray <= {(AW+1){1'b0}};
end else begin
rbin <= rbin_next;
rgray <= rgray_next;
end
assign rdata = mem[rbin[AW-1:0]];
// ================= POINTER SYNCHRONIZERS =============================
(* ASYNC_REG = "TRUE" *) reg [AW:0] wgray_s1, wgray_s2; // wgray into rclk
always @(posedge rclk or negedge rrst_n)
if (!rrst_n) begin wgray_s1 <= {(AW+1){1'b0}}; wgray_s2 <= {(AW+1){1'b0}}; end
else begin wgray_s1 <= wgray; wgray_s2 <= wgray_s1; end
(* ASYNC_REG = "TRUE" *) reg [AW:0] rgray_s1, rgray_s2; // rgray into wclk
always @(posedge wclk or negedge wrst_n)
if (!wrst_n) begin rgray_s1 <= {(AW+1){1'b0}}; rgray_s2 <= {(AW+1){1'b0}}; end
else begin rgray_s1 <= rgray; rgray_s2 <= rgray_s1; end
// ================= FLAGS (registered) ================================
// EMPTY: the read pointer has caught the write pointer exactly.
wire rempty_next = (rgray_next == wgray_s2);
always @(posedge rclk or negedge rrst_n)
if (!rrst_n) rempty <= 1'b1; // a FIFO powers up empty
else rempty <= rempty_next;
// FULL: the write pointer is one whole lap ahead. In Gray, that is the
// read pointer with its TOP TWO bits inverted.
wire wfull_next = (wgray_next ==
{~rgray_s2[AW:AW-1], rgray_s2[AW-2:0]});
always @(posedge wclk or negedge wrst_n)
if (!wrst_n) wfull <= 1'b0;
else wfull <= wfull_next;
endmodule
- The read side sees an old, smaller write pointer → it believes there is less data than there really is → it may say empty when data has in fact arrived. It just waits. Safe.
- The write side sees an old, smaller read pointer → it believes the FIFO is fuller than it really is → it may say full when space has been freed. It just waits. Safe.
6.4 FIFO depth calculation for bursts
"How deep should the FIFO be?" is asked in almost every interview that touches CDC, and the method is always the same: work out how much data piles up while the writer is ahead of the reader.
Both rates are effective rates - items per second, not clock frequency. If the writer only writes on one clock in three, its effective rate is one third of its clock. Getting that distinction right is most of the difficulty.
| Step | Case A - writer and reader both every clock | Case B - writer 1-in-2, reader 1-in-4 |
|---|---|---|
| Clocks | write 80 MHz, read 50 MHz | write 80 MHz, read 50 MHz |
| Effective rates | Fw = 80 M/s, Fr = 50 M/s | Fw = 40 M/s, Fr = 12.5 M/s |
| Burst duration | 120 / 80 M = 1.5 µs | 120 / 40 M = 3.0 µs |
| Items read meanwhile | 50 M × 1.5 µs = 75 | 12.5 M × 3.0 µs = 37.5 → 37 |
| Required depth | 120 - 75 = 45 | 120 - 37 = 83 |
| Round up (power of two) | 64 | 128 |
Interview grilling - "What if the writer never stops? What depth then?"
A trap question, and the answer is not a number. If the average write rate exceeds the average read rate indefinitely, then no finite FIFO is large enough - occupancy grows without bound and it overflows eventually, whatever depth you pick.
"FIFO depth only solves bursts - short-term rate mismatch with a long-term
average the reader can sustain. If the sustained write rate is higher than the sustained
read rate, the answer is not a bigger FIFO, it is back-pressure: the
writer must be able to see full and stall. If the source physically cannot
be stalled - an ADC, an incoming serial link - then the system needs either a faster
reader or a defined drop policy."
The follow-up: "What if it can't be stalled and can't drop?" Then the FIFO is the wrong structure and you need a rate-matched design - a wider read port, a faster read clock, or elastic buffering further downstream.
6.5 ROM inference vs instantiation
A ROM is a memory you never write. Small ones become logic; large ones become initialised block RAM. Which you get depends entirely on how you write it.
// ---- Small table -> combinational logic (a LUT tree) --------------------
// Perfectly fine up to a few dozen entries. Above that it explodes.
module rom_case (
input wire [3:0] addr,
output reg [6:0] seg // 7-segment decoder
);
always @(*) begin
case (addr)
4'h0: seg = 7'h3F; 4'h1: seg = 7'h06;
4'h2: seg = 7'h5B; 4'h3: seg = 7'h4F;
4'h4: seg = 7'h66; 4'h5: seg = 7'h6D;
4'h6: seg = 7'h7D; 4'h7: seg = 7'h07;
4'h8: seg = 7'h7F; 4'h9: seg = 7'h6F;
default: seg = 7'h00; // every path assigned -> no latch
endcase
end
endmodule
// ---- Large table -> initialised block RAM ------------------------------
module rom_bram #(
parameter AW = 10,
parameter DW = 16,
parameter INIT = "coeffs.hex"
) (
input wire clk,
input wire [AW-1:0] addr,
output reg [DW-1:0] data
);
reg [DW-1:0] mem [0:(1<<AW)-1];
// On an FPGA the bitstream carries these values, so the memory powers up
// already loaded. On an ASIC this initial block is NOT synthesizable --
// there you instantiate a compiled ROM macro or synthesize the table
// into logic instead.
initial $readmemh(INIT, mem);
always @(posedge clk)
data <= mem[addr]; // registered read -> block RAM
endmodule
| Situation | Infer or instantiate? | Why |
|---|---|---|
| Small lookup table, any target | Infer with case |
Portable, readable, optimises well |
| Simple single- or dual-port RAM on FPGA | Infer | Tools recognise the pattern reliably; stays portable |
| Byte enables, ECC, true dual-port, odd widths | Instantiate a vendor macro | Inference cannot express these features |
| Any ASIC memory | Instantiate compiler output | A memory compiler generates the macro, timing and test collateral |
| Initialised contents on ASIC | Instantiate, or synthesize as logic | initial / $readmemh do not synthesize for ASIC |
Volume 06 recap
| Concept | The one thing to remember |
|---|---|
| SRAM | 6 transistors per bit vs ~24 for a flop. Sense amps read a small differential. |
| Block RAM inference | Registered read. A reset on the read output can silently cost you the BRAM. |
| FIFO pointers | One bit wider than the address. That bit counts laps. |
| Sync FIFO flags | Empty = pointers equal. Full = MSBs differ, addresses match. |
| Async FIFO | Gray pointers cross; binary pointers address the memory. Keep both. |
| Async full condition | wgray == {~rgray_s2[AW:AW-1], rgray_s2[AW-2:0]} - top two inverted. |
| Why it is safe | Stale pointers make both flags pessimistic. Neither side can be optimistic. |
| Depth | B × (1 - F_r/F_w) using effective rates. Round reads down. |
| Sustained overflow | No depth fixes it. The answer is back-pressure, not a bigger FIFO. |
| ROM | $readmemh initialises FPGA block RAM; it does not synthesize for ASIC. |