Volume 06 Advanced 5 sub-modules ~65 min read

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.

A six transistor SRAM cell alongside the block organisation of an SRAM array THE 6T SRAM CELL BLB BL Q QB WL 2 cross-coupled inverters hold the bit (4 transistors) + 2 access transistors gated by the word line = 6T ARRAY ORGANISATION addr ROW DECODER CELL ARRAY SENSE AMPLIFIERS COLUMN MUX data Sense amps detect a tiny differential - that is why SRAM is fast and dense.
Figure 6.1 - Reading does not swing the bit lines fully. Both are precharged high, the cell tips one of them by a few tens of millivolts, and a sense amplifier resolves that difference. Getting a full logic swing on every read would be far too slow and burn far too much power.

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
The reset that silently costs you 1024 block RAM bits Adding a reset to the read output - 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.

The extra pointer bit Make each pointer one bit wider than the address. That top bit counts how many laps the pointer has done. Then: empty = the pointers are completely equal; full = the top bits differ while every address bit matches. One extra flip-flop per pointer solves the whole problem.
Three states of an eight deep FIFO showing how the extra pointer bit distinguishes empty from full EMPTY w r w = 0_000 r = 0_000 identical 3 ITEMS w r w = 0_011 r = 0_000 count = 3 FULL w r w = 1_000 r = 0_000 MSB differs Rows 1 and 3 address the SAME cell. Only the extra bit tells them apart.
Figure 6.2 - Empty and full both put the pointers on cell 0. The lap counter in the top bit is the entire difference between "nothing to read" and "no room to write".

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.

Block diagram of an asynchronous FIFO with Gray coded pointers crossing through two flop synchronizers in each direction WRITE DOMAIN - wclk READ DOMAIN - rclk wbin counter → wgray full logic top 2 bits inverted rbin counter → rgray empty logic exact equality DUAL-PORT MEMORY no sync needed waddr+data rdata 2-FF sync into rclk wgray 2-FF sync into wclk rgray Only Gray-coded POINTERS cross. The data never does.
Figure 6.3 - Two crossings, in opposite directions. The write side needs the read pointer to know if it is full; the read side needs the write pointer to know if it is empty. Both cross as Gray code through ordinary two-flop synchronizers.

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.

A three bit Gray code ring showing that a pointer four positions ahead has its top two bits inverted 3-BIT GRAY RING (depth 4, pointers 3 bits) 000 rptr 001 011 010 110 wptr - 4 ahead = FULL 111 101 100 THE COMPARISON rgray 0 0 0 wgray 1 1 0 inverted same full = wgray == {~rgray[AW:AW-1], rgray[AW-2:0]}
Figure 6.4 - Check it at every position and it holds: 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 property that makes this circuit correct A synchronized pointer is always stale - two clocks old at least. That sounds dangerous. It is actually what makes the design safe, because both errors point the same way:
  • 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.
Neither side can ever be optimistic. That is why the FIFO cannot overflow or underflow - the worst staleness can do is cost you a little throughput.
Three ways people break this design 1. Synchronizing the binary pointer instead of the Gray one - this is exactly the multi-bit failure from Volume 5.3. 2. Using a depth that is not a power of two - the Gray sequence no longer wraps with a single bit change, and the full comparison stops being valid. 3. Addressing the memory with the Gray pointer. Gray is for crossing; the binary pointer addresses the array. Keep both.

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.

Required depth for a burst of B items Depth = B × ( 1 - Fread / Fwrite )

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.

FIFO occupancy over time during a burst, rising while the writer outpaces the reader and draining afterwards time occupancy 45 burst: 120 items written at 80 MHz (1.5 µs) fills at 80 - 50 = 30 M items/s drains at 50 M/s reader continues at 50 MHz 120 written - (50 MHz x 1.5 µs = 75 read) = 45 must be stored
Figure 6.5 - Occupancy is the integral of (write rate - read rate). It peaks the instant the burst ends, and that peak is the depth you must provide.
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
Three things to say after you give the number Round down the reads, never up - 37.5 items read becomes 37, because assuming the reader kept up better than it did would under-size the FIFO. Add margin for synchronizer latency - the full flag is computed from a pointer that is two clocks stale, so a few slots of usable depth are lost (safely, but they are lost). And round up to a power of two, because the Gray-pointer scheme in §6.3 requires it.
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
On an FPGA, you would not build this by hand Everything above is the theory you need in order to trust a FIFO - and on a real FPGA the vendor macro already implements it, mapped onto hardened control logic inside the block RAM tile. Which coding styles actually infer a block RAM, what the three write modes cost, and why first-word-fall-through makes a handshake fall out for free are covered in FPGA Mastery Volume 04.

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.