Volume 04 Intermediate 5 sub-modules ~55 min read

Block RAM, FIFOs & Memory Inference

Memory inference is pattern matching. Vivado holds a small set of RTL templates and checks whether your code is one of them. Match the template and a hard memory tile appears, costing nothing in the fabric. Miss it by a single line - one asynchronous read, one stray reset - and you get thirty thousand flip-flops and a synthesis run that takes forty minutes to tell you so.

This volume assumes the FIFO theory Pointer arithmetic, Gray-code pointers, the almost-full/almost-empty margin and the depth formula for burst traffic are all developed in Volume 06 of the Verilog course. This volume is about the FPGA-specific half: which templates map to which silicon, what the write modes cost, and when to stop inferring and instantiate.

4.1 The templates that infer block RAM

There is one canonical single-port template. Everything else is a variation on it, and every failure to infer is a deviation from it.


module sp_bram #(
  parameter int AW = 10,          // 1024 words
  parameter int DW = 32
) (
  input  logic          clk,
  input  logic          en,
  input  logic          we,
  input  logic [AW-1:0] addr,
  input  logic [DW-1:0] din,
  output logic [DW-1:0] dout
);

  // The array. No initial value needed - an FPGA's memory contents come
  // from the bitstream, so an uninitialised array powers up as zeros.
  logic [DW-1:0] mem [0:(1<<AW)-1];

  always_ff @(posedge clk) begin
    if (en) begin
      if (we) mem[addr] <= din;
      dout <= mem[addr];          // registered read - THE critical line
    end
  end

endmodule

Four properties of that template are load-bearing. Break any one and inference fails:

Property Why the primitive needs it What you get if you break it
Read is registered BRAM has a clocked output latch, period Flip-flops + a giant mux
Array has no reset The array is SRAM; there is no clear input Flip-flops, always
Single write port Each BRAM port has one write path Duplicated memories, or fabric
Address is a plain index Address decode is inside the tile Fabric, if the "address" is really a decoded one-hot

Byte enables and initialisation


module bram_byte_en (
  input  logic        clk,
  input  logic [3:0]  wstrb,       // one bit per byte lane
  input  logic [9:0]  addr,
  input  logic [31:0] din,
  output logic [31:0] dout
);

  logic [31:0] mem [0:1023];

  // Load contents at configuration time. The file is read at SYNTHESIS
  // time and baked into the bitstream - it is not a runtime operation.
  initial $readmemh("boot_rom.hex", mem);

  // A generate loop over byte lanes maps onto the BRAM's native write
  // enable pins. Writing this as a single masked assignment
  //   mem[addr] <= (din & mask) | (mem[addr] & ~mask);
  // creates a read-modify-write and destroys inference.
  always_ff @(posedge clk) begin
    for (int i = 0; i < 4; i++)
      if (wstrb[i]) mem[addr][i*8 +: 8] <= din[i*8 +: 8];
    dout <= mem[addr];
  end

endmodule
$readmemh in synthesis is real, and it is not simulation-only Vivado reads the hex file during synthesis and writes its contents into the BRAM's INIT strings, which end up in the bitstream. This is how you ship a boot ROM, a sine table or a coefficient set with zero runtime cost. Two caveats: the path is relative to the working directory, so add the file to the project as a design source rather than hoping; and UltraRAM cannot be initialised at all - it always powers up as zeros, so anything needing initial contents must be BRAM.

4.2 Write modes: read-first, write-first, no-change

When a read and a write hit the same address in the same cycle, the primitive has to do something. Which something is a hardware setting, and your RTL selects it by the shape of the always block. Most engineers have never chosen deliberately.

Three block RAM write modes compared: write-first shows new data, read-first shows old data, and no-change holds the previous output SAME ADDRESS, SAME CYCLE: WRITE 0xBB OVER 0xAA clk we write 0xBB W-FIRST prev 0xBB (the NEW data) transparent; most logic, most power R-FIRST prev 0xAA (the OLD data) needed by some FIFO structures NO-CHG prev (output frozen through the write) least power, best clock-to-out capture edge the mode is chosen by the SHAPE of your always block, not by a parameter
Figure 4.2 - The three modes differ only in what appears on dout during a same-address collision. If your design never collides, choose NO_CHANGE and take the free power and timing.

// ---- WRITE_FIRST -----------------------------------------------------
// The write is described BEFORE the read, and dout takes din directly.
always_ff @(posedge clk)
  if (en) begin
    if (we) begin
      mem[addr] <= din;
      dout      <= din;        // new data forwarded to the output
    end else
      dout <= mem[addr];
  end

// ---- READ_FIRST ------------------------------------------------------
// The read is unconditional; it sees the array as it was before the write.
always_ff @(posedge clk)
  if (en) begin
    if (we) mem[addr] <= din;
    dout <= mem[addr];         // old contents
  end

// ---- NO_CHANGE -------------------------------------------------------
// The read happens ONLY when not writing, so dout simply holds.
always_ff @(posedge clk)
  if (en) begin
    if (we) mem[addr] <= din;
    else    dout <= mem[addr];
  end
Mode Dynamic power Clock-to-out Use when
NO_CHANGE Lowest - output does not toggle during writes Best Default choice. Any memory with separate read and write phases
READ_FIRST Medium Good You need the previous value; some FIFO pointer schemes rely on it
WRITE_FIRST Highest Worst - extra forwarding mux in the path You genuinely need same-cycle write-to-read forwarding
The mode also decides whether simulation matches hardware If your RTL implies WRITE_FIRST but the primitive ends up in READ_FIRST - which happens when a mismatched template forces Vivado to pick a default - then simulation and hardware disagree on exactly one cycle, only when a collision occurs. That is the worst class of bug there is: rare, data-dependent, and invisible in a short testbench. Write the template exactly, and check the synthesis log, which prints the inferred mode for every RAM.

4.3 True and simple dual-port memories

Every block RAM has two physical ports. What differs is how much of each port you use, and the distinction has a real cost.

Simple dual-port (SDP) True dual-port (TDP)
Port A Write only Read and write
Port B Read only Read and write
Maximum width 72 bits on a 36 Kb tile 36 bits per port
Clocks Independent Independent
Collision risk Only read-during-write on one address Write-write collisions corrupt data
Typical use FIFOs, line buffers, stream reorder Shared register files, CPU-accessible scratchpads

The doubled width on SDP is the headline. A 512-deep by 64-bit buffer fits in one BRAM36 as SDP, and needs two as TDP. If you only need one writer and one reader - which is the overwhelming majority of buffers - say so in the RTL and halve the memory cost.


// Simple dual-port, independent clocks. This is the workhorse: a stream
// crossing from one clock domain into another through a shared buffer.
module sdp_bram #(
  parameter int AW = 9,           // 512 words
  parameter int DW = 64           // 64 bits - fits ONE tile as SDP
) (
  input  logic          wr_clk,
  input  logic          wr_en,
  input  logic [AW-1:0] wr_addr,
  input  logic [DW-1:0] wr_data,

  input  logic          rd_clk,
  input  logic          rd_en,
  input  logic [AW-1:0] rd_addr,
  output logic [DW-1:0] rd_data
);

  (* ram_style = "block" *)
  logic [DW-1:0] mem [0:(1<<AW)-1];

  // Write port - one clock
  always_ff @(posedge wr_clk)
    if (wr_en) mem[wr_addr] <= wr_data;

  // Read port - a DIFFERENT clock. The BRAM handles this natively;
  // the array itself needs no synchroniser. What DOES need one is the
  // control logic that decides rd_addr is safe to use.
  always_ff @(posedge rd_clk)
    if (rd_en) rd_data <= mem[rd_addr];

endmodule
A dual-clock BRAM is not a CDC solution The memory array tolerates two unrelated clocks - the silicon is built for it. What it does not do is tell the reader when an entry is valid. If the write pointer crosses into the read domain without Gray coding and a synchroniser, the reader will occasionally see a pointer value that never existed and read a half-written entry. The array is safe; the protocol around it is what you have to build. That protocol is exactly what an async FIFO is, which is why you should almost always use one instead of rolling your own - see §4.4.

True dual-port collisions

With TDP, two independent ports can target the same address in the same cycle. The outcomes are defined but unpleasant:

There is no hardware arbiter. If two masters can address the same location, you must build the arbitration yourself, or partition the address space so a collision is structurally impossible.

4.4 XPM FIFOs and the FIFO primitives

You should almost never write a FIFO by hand on an FPGA. Not because the logic is hard - the Verilog course builds one from first principles - but because the vendor macro is verified, maps onto the hardened FIFO control logic inside the BRAM tile, and gets the CDC constraints right without you writing them.

XPM (Xilinx Parameterized Macros) are the modern answer: plain SystemVerilog modules you instantiate directly in RTL, with no IP catalogue, no generated wrapper files and no regeneration step when you change a parameter. They live in version control like any other source.


// Asynchronous FIFO, 512 x 32, first-word-fall-through.
// No IP catalogue, no .xci file - this is the whole instantiation.
xpm_fifo_async #(
  .FIFO_MEMORY_TYPE   ("block"),   // block | distributed | ultra | auto
  .FIFO_WRITE_DEPTH   (512),
  .WRITE_DATA_WIDTH   (32),
  .READ_DATA_WIDTH    (32),
  .READ_MODE          ("fwft"),    // "fwft" or "std"
  .FIFO_READ_LATENCY  (0),         // must be 0 when READ_MODE is fwft
  .CDC_SYNC_STAGES    (3),         // 2 is the minimum; 3 for high MTBF
  .PROG_FULL_THRESH   (500),
  .PROG_EMPTY_THRESH  (10),
  .USE_ADV_FEATURES   ("0707"),    // enables prog_full/prog_empty/counts
  .ECC_MODE           ("no_ecc")
) u_fifo (
  .rst           (wr_rst),         // async assert, sync deassert to wr_clk

  .wr_clk        (wr_clk),
  .wr_en         (wr_en),
  .din           (wr_data),
  .full          (full),
  .prog_full     (almost_full),
  .wr_data_count (wr_count),

  .rd_clk        (rd_clk),
  .rd_en         (rd_en),
  .dout          (rd_data),
  .empty         (empty),
  .prog_empty    (almost_empty),
  .rd_data_count (rd_count),

  // Tie off what you do not use - leaving them unconnected is legal
  // but makes the synthesis log noisy.
  .injectsbiterr (1'b0), .injectdbiterr (1'b0),
  .sleep         (1'b0),
  .sbiterr       (), .dbiterr        (),
  .overflow      (), .underflow      (),
  .wr_ack        (), .data_valid     (),
  .almost_full   (), .almost_empty   (),
  .wr_rst_busy   (), .rd_rst_busy    ()
);

First-word-fall-through, and why it matters

Timing comparison of standard FIFO read mode requiring a read request before data appears, versus first-word-fall-through where data is already present STANDARD vs FIRST-WORD-FALL-THROUGH clk STANDARD rd_en dout stale stale D0 - one cycle AFTER rd_en FWFT dout D0 already there D1 D2 latency 1 latency 0 FWFT: !empty IS tvalid, and rd_en IS tready. AXI-Stream falls out for free.
Figure 4.4 - In FWFT mode the head of the queue is always on dout. Reading is a "pop what you already see" operation, which maps directly onto a valid/ready handshake.
Use FWFT for anything that talks a handshake With FWFT, !empty is your tvalid and rd_en is your tready, with no adaptation logic and no extra cycle. Standard mode forces you to build a small skid buffer to hold the popped word while the consumer decides - which is exactly the state machine that goes wrong under back-pressure. The AXI-Stream discussion in Volume 05 assumes FWFT throughout.
prog_full is late by design On an asynchronous FIFO, prog_full and the data counts are computed from a pointer that had to cross clock domains, so they lag reality by the synchroniser depth - typically two to three cycles of the destination clock, plus more if the clocks are far apart in frequency. Set PROG_FULL_THRESH with enough headroom to absorb every write that can still be in flight during that lag. Sizing it as "depth minus one" produces a FIFO that overflows under burst traffic and works perfectly in every short test.

4.5 When to instantiate instead of infer

Inference is the default and should stay the default: it is portable, readable and simulates without vendor libraries. But four features exist only in the primitive, and no RTL template can reach them.

Feature Why inference cannot get there What to use
ECC (single-bit correct, double-bit detect) There is no RTL that means "add Hamming protection" xpm_memory_sdpram with ECC_MODE, or RAMB36E1 directly
Cascade across tiles without fabric mux The cascade path is a dedicated wire between vertically adjacent tiles CASCADE_HEIGHT attribute, or explicit instantiation
Hardened FIFO control logic The pointer and flag logic lives inside the tile xpm_fifo_* or FIFO36E1
Dynamic power gating The SLEEP pin has no RTL equivalent XPM macro's sleep port

The middle ground - and the right answer nine times out of ten - is an XPM macro. It is source code you instantiate, so it stays readable and version-controlled, but it reaches every feature of the primitive:


// A 4096 x 64 simple dual-port RAM with single-error-correct,
// double-error-detect. Nothing you can write in plain RTL infers this.
xpm_memory_sdpram #(
  .MEMORY_SIZE        (262144),      // bits, not words
  .MEMORY_PRIMITIVE   ("block"),
  .WRITE_DATA_WIDTH_A (64),
  .READ_DATA_WIDTH_B  (64),
  .ADDR_WIDTH_A       (12),
  .ADDR_WIDTH_B       (12),
  .READ_LATENCY_B     (2),           // 2 = use the output register
  .ECC_MODE           ("both_encode_and_decode"),
  .CLOCKING_MODE      ("independent_clock")
) u_ram (
  .clka   (wr_clk), .ena  (wr_en), .wea (1'b1),
  .addra  (wr_addr), .dina (wr_data),

  .clkb   (rd_clk), .enb  (rd_en),
  .addrb  (rd_addr), .doutb (rd_data),

  // Report corrected and uncorrectable errors up to your status logic.
  .sbiterrb (ecc_corrected), .dbiterrb (ecc_fatal),

  .injectsbiterra (1'b0), .injectdbiterra (1'b0),
  .regceb (1'b1), .rstb (1'b0), .sleep (1'b0)
);
Interview grilling - "How would you build a 2048 x 128 buffer, and what does it cost?"

Start with the arithmetic out loud, because that is the actual skill being tested:

  • 2048 × 128 = 262,144 bits = 256 Kb.
  • A BRAM36 holds 36 Kb, of which 32 Kb is usable data in the wide aspect ratios. So the floor is 256/32 = 8 tiles.
  • Width matters as much as total bits: as SDP each tile gives 72 bits, so 128 bits needs 2 tiles side by side, and 2048 deep needs 4 tiles stacked - 2 × 4 = 8. The two calculations agree, which is the check that you did it right.

Then the engineering judgement:

  • SDP, not TDP - one writer, one reader, so take the 72-bit width.
  • Enable the output register and pay two cycles of latency; at 128 bits wide the clock-to-out matters.
  • On UltraScale+, consider UltraRAM instead - one URAM288 is 288 Kb and would hold the whole thing in a single tile, freeing eight BRAMs. The catch is that URAM cannot be initialised and both ports share one clock.
  • Check the cascade. Four tiles stacked should use the dedicated cascade path; if the tool built a fabric multiplexer instead, you have added a logic level to every read.

The sentence that closes it: "And I would confirm the result in report_utilization rather than trusting the arithmetic - an off-by-one on width is the difference between 8 tiles and 16."

Volume 04 recap

Concept The one thing to remember
Inference Pattern matching. Registered read, no reset on the array.
$readmemh Runs at synthesis and lands in the bitstream. Not simulation-only.
Write modes NO_CHANGE by default - least power, best timing.
SDP vs TDP SDP gives 72-bit width. One writer + one reader? Say so.
Dual-clock BRAM The array is safe. The pointer protocol is your job.
FWFT !empty = valid, rd_en = ready. Free handshake.
prog_full Lags by the synchroniser depth. Leave headroom.
XPM macros Source you can read, features you cannot infer.