RTL Design Patterns · Chapter 6 · Memories & Register Files
Inferring RAM
Everything you have built so far stores state in flip-flops, which is fine for tens or hundreds of bits. But the moment a design needs a real buffer, such as a line buffer, a large lookup table, or a FIFO's data store, flip-flops stop being an option: a few kilobytes as individual flops would burn tens of thousands of registers and a read mux no clock can close timing on. Real chips use dedicated storage: block RAM tiles on an FPGA, a compiled SRAM macro on an ASIC. You cannot instantiate them directly; you get one only by writing RTL in the exact shape the synthesis tool recognizes as a RAM, a two-dimensional array written on the clock edge and read through a register. The costly mistake is a read that looks right, simulates fine, and quietly fails to infer memory, collapsing into a giant flop-and-mux array. This lesson teaches memory as a synthesis-inference pattern, in SystemVerilog, Verilog, and VHDL.
Intermediate16 min readRTL Design PatternsInferring RAMBlock RAMSynchronous ReadWrite EnableSynthesis Inference
Chapter 6 · Section 6.1 · Memories & Register Files
1. The Engineering Problem
You are building a line buffer for an image pipeline: it must hold one full scan line — say 2048 pixels of 16 bits each, 32 kilobits — so the next processing stage can read any pixel while the current line streams in. You reach for what you know: a register. But a register stores one word. You need 2048 of them, addressable, and you can write one and read another every cycle. So you consider a bank of 2048 sixteen-bit registers. Then you do the arithmetic. That is 32,768 flip-flops for the storage alone, plus a 2048-to-1 read mux 16 bits wide to select which word comes out, plus the address decode that drives it. On an FPGA that is thousands of slices and a mux tree so deep it will not close timing; on an ASIC it is an area and power figure your block budget cannot absorb. And this is a small buffer.
This is not a corner case — it is the shape of nearly every real memory need in a chip: a lookup table, a FIFO's data store, a cache line array, a coefficient bank, a scratchpad. In every one, the requirement is identical: store many words, address them, write one and read one per cycle — and the requirement is fundamentally different in kind from a register. A register is a handful of bits you name individually; a memory is kilobits to megabits you address. Flip-flops are the wrong primitive for it the way a spreadsheet is the wrong tool for a terabyte database: it works at ten rows and collapses at ten million.
Silicon already solved this. An FPGA is peppered with dedicated block RAM tiles — dense, dual-ported storage arrays sitting right in the fabric, thousands of bits each. An ASIC flow drops in a compiled SRAM macro — a hand-optimized memory array orders of magnitude denser than the same bits in flops. These resources exist precisely so you never build a kilobyte buffer out of registers. But there is no block_ram keyword. You do not instantiate the memory; you describe it in RTL, and the synthesis tool infers it — if, and only if, your RTL matches the template the tool recognizes as a RAM. Get the template right and 32 kilobits collapses into one block RAM tile. Get it subtly wrong — one detail in how you read — and the tool cannot map it, so it falls back to exactly the flop-and-mux array you were trying to escape.
// WRONG at scale — a bank of registers plus a giant read mux:
// reg [15:0] pix [0:2047]; // 32,768 flip-flops...
// assign dout = pix[raddr]; // ...plus a 2048:1, 16-bit read mux tree
// // thousands of slices, deep mux, will not close timing / will not fit.
// RIGHT — describe a RAM the tool INFERS into ONE block-RAM tile:
// reg [15:0] mem [0:2047]; // a 2D array = the storage
// always @(posedge clk) begin
// if (we) mem[waddr] <= din; // CLOCKED write with a write-enable
// dout <= mem[raddr]; // REGISTERED (synchronous) read <-- the key
// end
// // 32 kbit -> one dedicated memory. The registered read is what makes it a RAM.2. Mental Model
3. Pattern Anatomy
The RAM is built from one 2D array and a small set of load-bearing decisions about how it is written and — the part that matters most — how it is read.
The 2D array — the storage itself. The memory is declared as an array of vectors: reg [WIDTH-1:0] mem [0:DEPTH-1] in Verilog/SystemVerilog, a type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0) in VHDL. WIDTH is the word size; DEPTH is the number of words; the address is AW = clog2(DEPTH) bits. This array is the thing the tool maps onto a block RAM — but only if everything you do to it matches the template.
The clocked write with a write-enable. Writes happen inside a posedge clk process, gated by a write-enable: if (we) mem[waddr] <= din. The write-enable is not optional decoration — it is what lets the address hold while the memory is not written, and it is part of the block RAM's native port. Omit it (write every cycle unconditionally) and you have a memory you can never not write, which is rarely what a buffer wants and can also block inference of the enable-having block-RAM port. The write is always synchronous; there is no such thing as an asynchronous write into a block RAM.
The registered (synchronous) read — the crux of the whole pattern. This is the one decision that decides whether you get a block RAM at all. A block RAM reads through an output register: you present raddr this cycle, and the data appears on dout next cycle. In RTL that is a read inside the clocked process — dout <= mem[raddr] — so dout is a registered signal with one cycle of latency. The alternative, a combinational read assign dout = mem[raddr], has zero latency and is asynchronous — and no block RAM tile can do an asynchronous read. So a combinational read cannot infer a block RAM; the tool falls back to distributed RAM (LUT-based storage, far smaller capacity) or, past that, to a literal flop array with a read mux. The read latency is not a cost to minimize away; it is the admission ticket to the dedicated memory. (You can register the output or register the address — both give one cycle of latency and both infer a block RAM; the styles differ subtly in read-during-write behaviour, which is 6.2's subject.)
Single-port vs simple-dual-port. A single-port RAM has one address shared by read and write — one thing happens per cycle at one address. A simple-dual-port RAM has one write port (waddr, we, din) and one separate read port (raddr, dout): you can write one address and read a different one in the same cycle. Simple-dual-port is what a FIFO's data store and most streaming buffers need (producer writes while consumer reads), and block RAM tiles natively provide two ports, so it infers just as cleanly as single-port — you simply give the read its own address.
Byte / word write-enables. A wide word often must be written a byte at a time (a CPU storing a byte into a 32-bit memory, a packed structure updating one field). The pattern generalizes with a per-lane write-enable: a we bit (or a wstrb vector) per byte lane, each gating the write of its own slice of the word. Block RAMs support byte-enables natively, so a per-byte write-enable loop infers the block RAM's byte-write feature rather than a read-modify-write.
The RAM inference template — a 2D array, a clocked write, and (the crux) a registered read
data flowTwo things are deferred on purpose, and naming them marks the boundary of this pattern. Initial contents — loading a table into the memory at power-up (a ROM, a coefficient bank) — is Section 6.4; here the memory starts undefined and is written by the design. Read-during-write — what dout shows when you read and write the same address in the same cycle (the "write-first" vs "read-first" vs "no-change" behaviours, and why registering the output vs the address changes the answer) — is Section 6.2; here we always read a different address than we write, so the question does not arise. This page teaches the one thing that makes a memory a memory at all: the array-plus-clocked-write-plus-registered-read template that infers the dedicated block.
4. Real RTL Implementation
This is the core of the page. We build three code families — (a) the single-port synchronous RAM inference template (the block-RAM template), (b) a simple-dual-port + byte-write-enable variant, and (c) the combinational-read bug vs the registered-read fix — and each is shown in SystemVerilog, Verilog, and VHDL, with both an RTL block and a self-checking, clocked testbench. The idea is identical in all three; the syntax differs, and seeing them side by side is what makes the pattern language-independent in your head.
One discipline runs through every RTL block on this page: the write is clocked and gated by a write-enable, and the read is registered (one cycle of latency). That single rule is what makes each block infer a real block RAM, and it is exactly what family (c) violates.
4a. The single-port synchronous RAM — WIDTH- and DEPTH-generic, three ways
Start with the template. One address serves both read and write; the write is gated by we; the read is registered so dout carries the addressed word one cycle later. WIDTH and DEPTH are generic, so the same source is a 512×8 table or a 2048×16 buffer. The SystemVerilog form uses an unpacked array for mem and puts both the write and the read inside one always_ff, which is the canonical single-port block-RAM template every FPGA synthesis tool recognizes.
module ram_sp #(
parameter int WIDTH = 16, // word width
parameter int DEPTH = 2048, // number of words
localparam int AW = $clog2(DEPTH) // address width = clog2(DEPTH)
)(
input logic clk,
input logic we, // write-enable: write only when high
input logic [AW-1:0] addr, // shared read/write address (single port)
input logic [WIDTH-1:0] din, // data to write
output logic [WIDTH-1:0] dout // registered read data — valid NEXT cycle
);
// The 2D array IS the storage the tool maps onto a block RAM.
logic [WIDTH-1:0] mem [DEPTH];
// Single clocked process. The write is gated by `we`; the read is REGISTERED
// (dout <= mem[addr]) — that one-cycle-latency read is exactly what a block RAM
// does, and is what makes this infer a dedicated memory instead of flops+mux.
always_ff @(posedge clk) begin
if (we)
mem[addr] <= din; // synchronous, write-enabled write
dout <= mem[addr]; // REGISTERED read → 1-cycle read latency
end
endmoduleThe SystemVerilog testbench is clocked and its shape is the template for all six §4 testbenches: write a known pattern into every address, then read every address back and check dout against the pattern — offsetting the check by the one-cycle read latency of the synchronous read. It also verifies the write-enable gates writes (a write with we low must not change memory).
module ram_sp_tb;
localparam int WIDTH = 16;
localparam int DEPTH = 64; // small DEPTH so the sweep is quick
localparam int AW = $clog2(DEPTH);
logic clk = 0;
logic we;
logic [AW-1:0] addr;
logic [WIDTH-1:0] din, dout;
int errors = 0;
ram_sp #(.WIDTH(WIDTH), .DEPTH(DEPTH)) dut
(.clk(clk), .we(we), .addr(addr), .din(din), .dout(dout));
always #5 clk = ~clk; // rising edge every 10 time units
// Reference model: a golden copy of what the memory SHOULD contain.
logic [WIDTH-1:0] golden [DEPTH];
function automatic logic [WIDTH-1:0] pat(int a); // deterministic per-address pattern
return WIDTH'(a * 3 + 7);
endfunction
initial begin
we = 0; addr = 0; din = 0;
@(negedge clk);
// PHASE 1 — write the pattern into every address (we high).
for (int a = 0; a < DEPTH; a++) begin
we = 1; addr = a[AW-1:0]; din = pat(a); golden[a] = pat(a);
@(negedge clk);
end
we = 0;
// PHASE 2 — read every address back. dout is valid ONE cycle after addr is
// presented (registered read), so we present addr, take an edge,
// then compare on the FOLLOWING sample.
for (int a = 0; a < DEPTH; a++) begin
addr = a[AW-1:0];
@(posedge clk); #1; // addr latched; dout updates on THIS edge
// dout now holds mem[addr] presented at the PREVIOUS negedge → account for latency
assert (dout === golden[a])
else begin $error("read a=%0d dout=%h exp=%h", a, dout, golden[a]); errors++; end
@(negedge clk);
end
// PHASE 3 — write-enable gate: attempt a write with we=0; memory must NOT change.
addr = 0; din = 16'hDEAD; we = 0; @(negedge clk);
addr = 0; @(posedge clk); #1;
assert (dout === golden[0])
else begin $error("we=0 wrote memory! dout=%h exp=%h", dout, golden[0]); errors++; end
if (errors == 0) $display("PASS: ram_sp stores, reads back (1-cycle latency), gates on we");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog form is identical in intent; the differences are reg/wire typing and the classic memory-array declaration. The read is just as registered, so it infers the same block RAM and carries the same one-cycle latency.
module ram_sp #(
parameter WIDTH = 16,
parameter DEPTH = 2048,
parameter AW = 11 // = clog2(DEPTH); set by the instantiator
)(
input wire clk,
input wire we,
input wire [AW-1:0] addr,
input wire [WIDTH-1:0] din,
output reg [WIDTH-1:0] dout // `reg` — driven in the clocked block, registered read
);
// Classic Verilog memory array — the 2D storage the tool maps to a block RAM.
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we)
mem[addr] <= din; // synchronous write-enabled write
dout <= mem[addr]; // REGISTERED read → 1-cycle latency → block RAM
end
endmodule module ram_sp_tb;
parameter WIDTH = 16;
parameter DEPTH = 64;
parameter AW = 6; // = clog2(64)
reg clk;
reg we;
reg [AW-1:0] addr;
reg [WIDTH-1:0] din;
wire [WIDTH-1:0] dout;
reg [WIDTH-1:0] golden [0:DEPTH-1];
integer a, errors;
ram_sp #(.WIDTH(WIDTH), .DEPTH(DEPTH), .AW(AW)) dut
(.clk(clk), .we(we), .addr(addr), .din(din), .dout(dout));
initial clk = 1'b0;
always #5 clk = ~clk;
initial begin
errors = 0; we = 0; addr = 0; din = 0;
@(negedge clk);
// PHASE 1 — write a deterministic pattern into every address.
for (a = 0; a < DEPTH; a = a + 1) begin
we = 1; addr = a[AW-1:0]; din = (a*3 + 7); golden[a] = (a*3 + 7);
@(negedge clk);
end
we = 0;
// PHASE 2 — read every address back, one cycle after presenting it.
for (a = 0; a < DEPTH; a = a + 1) begin
addr = a[AW-1:0];
@(posedge clk); #1; // registered read updates dout here
if (dout !== golden[a]) begin
$display("FAIL: read a=%0d dout=%h exp=%h", a, dout, golden[a]);
errors = errors + 1;
end
@(negedge clk);
end
// PHASE 3 — write-enable gate: we=0 must not modify memory.
addr = 0; din = 16'hDEAD; we = 0; @(negedge clk);
addr = 0; @(posedge clk); #1;
if (dout !== golden[0]) begin
$display("FAIL: we=0 wrote memory! dout=%h exp=%h", dout, golden[0]);
errors = errors + 1;
end
if (errors == 0) $display("PASS: ram_sp stores, reads back with 1-cycle latency, gates on we");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the array is a named type (type ram_t is array (0 to DEPTH-1) of std_logic_vector), and the read and write live in one rising_edge(clk) process. The signal assignment <= to dout inside the process makes the read registered, exactly as in SV/Verilog — so it infers the same block RAM with the same one-cycle latency. The generic plays the role of the Verilog parameter.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_sp is
generic (
WIDTH : positive := 16; -- word width
DEPTH : positive := 2048 -- number of words
);
port (
clk : in std_logic;
we : in std_logic; -- write-enable
addr : in std_logic_vector(natural(ceil(log2(real(DEPTH))))-1 downto 0);
din : in std_logic_vector(WIDTH-1 downto 0);
dout : out std_logic_vector(WIDTH-1 downto 0) -- registered read, valid next cycle
);
end entity;
architecture rtl of ram_sp is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t; -- the 2D storage → block RAM
begin
-- One clocked process. `if we = '1'` gates the write; the read assigns dout with
-- a signal <= inside the edge, so dout is REGISTERED — one cycle of latency — which
-- is what makes this infer a dedicated block RAM rather than distributed logic.
process (clk)
begin
if rising_edge(clk) then
if we = '1' then
mem(to_integer(unsigned(addr))) <= din; -- synchronous write-enabled write
end if;
dout <= mem(to_integer(unsigned(addr))); -- REGISTERED read → block RAM
end if;
end process;
end architecture;The VHDL testbench generates a clock, writes the pattern into every address, then reads each back one cycle later, self-checking with assert ... report ... severity error — the VHDL idiom that plays the role SystemVerilog's assert/$error and Verilog's if (...) $display("FAIL") play.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_sp_tb is
end entity;
architecture sim of ram_sp_tb is
constant WIDTH : positive := 16;
constant DEPTH : positive := 64;
constant AW : positive := 6; -- = clog2(64)
signal clk : std_logic := '0';
signal we : std_logic := '0';
signal addr : std_logic_vector(AW-1 downto 0) := (others => '0');
signal din : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal dout : std_logic_vector(WIDTH-1 downto 0);
type gold_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal golden : gold_t;
begin
dut : entity work.ram_sp
generic map (WIDTH => WIDTH, DEPTH => DEPTH)
port map (clk => clk, we => we, addr => addr, din => din, dout => dout);
clk <= not clk after 5 ns;
stim : process
variable p : std_logic_vector(WIDTH-1 downto 0);
begin
wait until falling_edge(clk);
-- PHASE 1 — write a deterministic pattern into every address.
for a in 0 to DEPTH-1 loop
p := std_logic_vector(to_unsigned((a*3 + 7) mod 2**WIDTH, WIDTH));
we <= '1'; addr <= std_logic_vector(to_unsigned(a, AW)); din <= p;
golden(a) <= p;
wait until falling_edge(clk);
end loop;
we <= '0';
-- PHASE 2 — read every address back, one cycle after presenting it.
for a in 0 to DEPTH-1 loop
addr <= std_logic_vector(to_unsigned(a, AW));
wait until rising_edge(clk); -- registered read updates dout here
wait for 1 ns;
assert dout = golden(a)
report "ram_sp read mismatch (latency accounted?)" severity error;
wait until falling_edge(clk);
end loop;
-- PHASE 3 — write-enable gate: we='0' must not change memory.
addr <= (others => '0'); din <= x"DEAD"; we <= '0';
wait until falling_edge(clk);
addr <= (others => '0');
wait until rising_edge(clk); wait for 1 ns;
assert dout = golden(0)
report "ram_sp: we=0 wrote memory!" severity error;
report "ram_sp self-check complete" severity note;
wait;
end process;
end architecture;The testbenches above use a small DEPTH = 64 so the exhaustive write-then-read sweep runs fast, but the RAM is genuinely WIDTH/DEPTH-generic: re-instantiate it at 512×8 or 2048×16 and the same write-pattern-then-read-back loop proves each — a parameterized memory must be verified generic, not assumed generic. The reference model does not change; only the address range and word width grow.
4b. Simple-dual-port + byte-write-enable — one write port, one read port
A single-port RAM does one thing per cycle at one address. A simple-dual-port RAM gives the write its own address (waddr, we, din) and the read its own (raddr, dout), so a producer can write one location while a consumer reads another in the same cycle — exactly what a FIFO data store or a streaming buffer needs. Because block RAM tiles are natively two-ported, this infers just as cleanly; the read is still registered. We also add a byte write-enable on the SystemVerilog version to show the wide-word case: a per-byte we bit gates the write of its own lane, inferring the block RAM's native byte-write feature.
module ram_sdp_be #(
parameter int NBYTES = 4, // word = NBYTES bytes (32-bit here)
parameter int DEPTH = 1024,
localparam int WIDTH = NBYTES*8,
localparam int AW = $clog2(DEPTH)
)(
input logic clk,
input logic [NBYTES-1:0] we, // ONE write-enable per byte lane
input logic [AW-1:0] waddr, // write port address
input logic [WIDTH-1:0] din,
input logic [AW-1:0] raddr, // SEPARATE read port address
output logic [WIDTH-1:0] dout // registered read
);
logic [WIDTH-1:0] mem [DEPTH];
always_ff @(posedge clk) begin
// Byte-write: each we[b] gates its own 8-bit lane → infers block RAM byte-enables,
// not a read-modify-write. Unwritten lanes keep their stored value.
for (int b = 0; b < NBYTES; b++)
if (we[b])
mem[waddr][b*8 +: 8] <= din[b*8 +: 8];
// Registered read on the SEPARATE read port → write & read different addresses
// in the same cycle; still one-cycle latency; still a block RAM.
dout <= mem[raddr];
end
endmoduleThe testbench writes different bytes on different cycles (exercising the per-lane enables), reads a different address than it writes to prove the two ports are independent, and accounts for the one-cycle read latency throughout.
module ram_sdp_be_tb;
localparam int NBYTES = 4;
localparam int DEPTH = 32;
localparam int WIDTH = NBYTES*8;
localparam int AW = $clog2(DEPTH);
logic clk = 0;
logic [NBYTES-1:0] we;
logic [AW-1:0] waddr, raddr;
logic [WIDTH-1:0] din, dout;
logic [WIDTH-1:0] golden [DEPTH];
int errors = 0;
ram_sdp_be #(.NBYTES(NBYTES), .DEPTH(DEPTH)) dut
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout));
always #5 clk = ~clk;
initial begin
we = '0; waddr = 0; raddr = 0; din = 0;
foreach (golden[i]) golden[i] = '0;
@(negedge clk);
// PHASE 1 — write full words to every address (all byte lanes enabled).
for (int a = 0; a < DEPTH; a++) begin
we = '1; waddr = a[AW-1:0]; din = WIDTH'(a * 32'h01010101 + 32'h0F0F0F0F);
golden[a] = din;
@(negedge clk);
end
// PHASE 2 — write ONLY byte lane 0 at address 5; the other 3 lanes must hold.
we = 4'b0001; waddr = 5; din = 32'hFFFFFF_AB;
golden[5][7:0] = 8'hAB; // only lane 0 changes in the model
@(negedge clk);
we = '0;
// PHASE 3 — read back on the SEPARATE read port (independent of waddr), 1-cycle late.
for (int a = 0; a < DEPTH; a++) begin
raddr = a[AW-1:0];
@(posedge clk); #1;
assert (dout === golden[a])
else begin $error("a=%0d dout=%h exp=%h", a, dout, golden[a]); errors++; end
@(negedge clk);
end
if (errors == 0) $display("PASS: ram_sdp_be — byte-enables + independent read port work");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog simple-dual-port RAM is the same structure with a single (word-level) write-enable to keep the port list compact; the load-bearing point is the separate read address and the registered read. Writing waddr and reading raddr in one cycle is what makes it dual-port.
module ram_sdp #(
parameter WIDTH = 16,
parameter DEPTH = 1024,
parameter AW = 10
)(
input wire clk,
input wire we, // word-level write-enable
input wire [AW-1:0] waddr, // write address
input wire [WIDTH-1:0] din,
input wire [AW-1:0] raddr, // independent read address
output reg [WIDTH-1:0] dout // registered read
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we)
mem[waddr] <= din; // write one address...
dout <= mem[raddr]; // ...read a DIFFERENT address, same cycle (dual-port)
end
endmodule module ram_sdp_tb;
parameter WIDTH = 16;
parameter DEPTH = 32;
parameter AW = 5;
reg clk;
reg we;
reg [AW-1:0] waddr, raddr;
reg [WIDTH-1:0] din;
wire [WIDTH-1:0] dout;
reg [WIDTH-1:0] golden [0:DEPTH-1];
integer a, errors;
ram_sdp #(.WIDTH(WIDTH), .DEPTH(DEPTH), .AW(AW)) dut
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout));
initial clk = 1'b0;
always #5 clk = ~clk;
initial begin
errors = 0; we = 0; waddr = 0; raddr = 0; din = 0;
@(negedge clk);
// PHASE 1 — fill every address with a pattern.
for (a = 0; a < DEPTH; a = a + 1) begin
we = 1; waddr = a[AW-1:0]; din = (a*5 + 1); golden[a] = (a*5 + 1);
@(negedge clk);
end
we = 0;
// PHASE 2 — read each address on the independent read port, 1-cycle latency.
for (a = 0; a < DEPTH; a = a + 1) begin
raddr = a[AW-1:0];
@(posedge clk); #1;
if (dout !== golden[a]) begin
$display("FAIL: a=%0d dout=%h exp=%h", a, dout, golden[a]);
errors = errors + 1;
end
@(negedge clk);
end
if (errors == 0) $display("PASS: ram_sdp reads back on the independent port (1-cycle latency)");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the simple-dual-port RAM is the same array type with two integer indices — one for the write, one for the read — both inside the clocked process, and the registered read on the separate raddr. Writing mem(w) and reading mem(r) in the same edge is the dual-port behaviour; the <= to dout keeps the read registered.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_sdp is
generic ( WIDTH : positive := 16; DEPTH : positive := 1024 );
port (
clk : in std_logic;
we : in std_logic; -- write-enable
waddr : in std_logic_vector(natural(ceil(log2(real(DEPTH))))-1 downto 0);
din : in std_logic_vector(WIDTH-1 downto 0);
raddr : in std_logic_vector(natural(ceil(log2(real(DEPTH))))-1 downto 0);
dout : out std_logic_vector(WIDTH-1 downto 0) -- registered read
);
end entity;
architecture rtl of ram_sdp is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t;
begin
process (clk)
begin
if rising_edge(clk) then
if we = '1' then
mem(to_integer(unsigned(waddr))) <= din; -- write one address...
end if;
dout <= mem(to_integer(unsigned(raddr))); -- ...read another, same cycle
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_sdp_tb is
end entity;
architecture sim of ram_sdp_tb is
constant WIDTH : positive := 16;
constant DEPTH : positive := 32;
constant AW : positive := 5;
signal clk : std_logic := '0';
signal we : std_logic := '0';
signal waddr : std_logic_vector(AW-1 downto 0) := (others => '0');
signal raddr : std_logic_vector(AW-1 downto 0) := (others => '0');
signal din : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal dout : std_logic_vector(WIDTH-1 downto 0);
type gold_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal golden : gold_t;
begin
dut : entity work.ram_sdp
generic map (WIDTH => WIDTH, DEPTH => DEPTH)
port map (clk => clk, we => we, waddr => waddr, din => din,
raddr => raddr, dout => dout);
clk <= not clk after 5 ns;
stim : process
variable p : std_logic_vector(WIDTH-1 downto 0);
begin
wait until falling_edge(clk);
for a in 0 to DEPTH-1 loop
p := std_logic_vector(to_unsigned((a*5 + 1) mod 2**WIDTH, WIDTH));
we <= '1'; waddr <= std_logic_vector(to_unsigned(a, AW)); din <= p;
golden(a) <= p;
wait until falling_edge(clk);
end loop;
we <= '0';
for a in 0 to DEPTH-1 loop
raddr <= std_logic_vector(to_unsigned(a, AW));
wait until rising_edge(clk);
wait for 1 ns;
assert dout = golden(a)
report "ram_sdp read mismatch on independent port" severity error;
wait until falling_edge(clk);
end loop;
report "ram_sdp self-check complete" severity note;
wait;
end process;
end architecture;4c. The combinational-read bug — buggy (async read) vs fixed (registered read), in all three HDLs
Family (c) is the signature failure of this chapter, shown here as buggy vs fixed RTL in each language, then dramatized narratively in §7. The bug is the same everywhere: the memory array and the clocked write are correct, but the read is written combinationally — assign dout = mem[addr] (SV/Verilog) or a concurrent dout <= mem(addr) outside the clocked process (VHDL). That read is asynchronous (zero latency), and no block RAM tile can do an asynchronous read, so the tool cannot map the array to a block RAM. It falls back to distributed RAM (small LUT-based storage) or, past distributed-RAM capacity, to a literal flop array with a giant read mux — enormous area, failed timing, or a design that will not fit. It simulates perfectly because a combinational read is functionally fine; it fails at synthesis / place-and-route. The fix is to register the read (move it inside the clocked process), accepting the one-cycle latency that is the block RAM's price.
// BUGGY: the read is COMBINATIONAL (assign) → asynchronous, zero-latency. No block
// RAM can do an async read, so this array degrades to distributed RAM or a
// giant flop+mux structure. It SIMULATES fine; it blows up at synthesis/P&R.
module ram_read_bad #(
parameter int WIDTH = 16, parameter int DEPTH = 2048,
localparam int AW = $clog2(DEPTH)
)(
input logic clk, we,
input logic [AW-1:0] waddr, raddr,
input logic [WIDTH-1:0] din,
output logic [WIDTH-1:0] dout
);
logic [WIDTH-1:0] mem [DEPTH];
always_ff @(posedge clk)
if (we) mem[waddr] <= din; // write is fine (synchronous)
assign dout = mem[raddr]; // BUG: combinational read → will NOT infer block RAM
endmodule
// FIXED: register the read (move it into the clocked block). One cycle of latency,
// but now it MATCHES the block RAM's synchronous port → infers a dense memory.
module ram_read_good #(
parameter int WIDTH = 16, parameter int DEPTH = 2048,
localparam int AW = $clog2(DEPTH)
)(
input logic clk, we,
input logic [AW-1:0] waddr, raddr,
input logic [WIDTH-1:0] din,
output logic [WIDTH-1:0] dout
);
logic [WIDTH-1:0] mem [DEPTH];
always_ff @(posedge clk) begin
if (we) mem[waddr] <= din;
dout <= mem[raddr]; // REGISTERED read → 1-cycle latency → block RAM
end
endmoduleThe testbench is what actually distinguishes the two — but not by functional value, because both are functionally correct. It distinguishes them by latency: the fixed RAM returns the data one cycle after raddr is presented, so a testbench written for the correct one-cycle-latency read passes on the good module and would mis-time the bad one. (The area/timing blow-up is invisible in RTL sim; it shows up only after synthesis — which is exactly why this bug survives to place-and-route.)
module ram_read_bug_tb;
localparam int WIDTH = 16, DEPTH = 32, AW = $clog2(DEPTH);
logic clk = 0, we;
logic [AW-1:0] waddr, raddr;
logic [WIDTH-1:0] din, dout;
logic [WIDTH-1:0] golden [DEPTH];
int errors = 0;
// Bind to ram_read_good → PASS. ram_read_bad simulates too, but only the
// registered read infers a block RAM (checked at synthesis, not here).
ram_read_good #(.WIDTH(WIDTH), .DEPTH(DEPTH)) dut
(.clk(clk), .we(we), .waddr(waddr), .raddr(raddr), .din(din), .dout(dout));
always #5 clk = ~clk;
initial begin
we = 0; waddr = 0; raddr = 0; din = 0;
@(negedge clk);
for (int a = 0; a < DEPTH; a++) begin
we = 1; waddr = a[AW-1:0]; din = WIDTH'(a*9 + 4); golden[a] = WIDTH'(a*9 + 4);
@(negedge clk);
end
we = 0;
for (int a = 0; a < DEPTH; a++) begin
raddr = a[AW-1:0];
@(posedge clk); #1; // registered read: valid one cycle later
assert (dout === golden[a])
else begin $error("a=%0d dout=%h exp=%h", a, dout, golden[a]); errors++; end
@(negedge clk);
end
if (errors == 0) $display("PASS: registered read returns stored data with 1-cycle latency");
else $display("FAIL: %0d mismatches (read-latency accounting?)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same story: a wire/assign combinational read versus a reg read assigned in the clocked block. The assign looks harmless and simulates fine; it is what stops the array from becoming a block RAM.
// BUGGY: combinational read via assign → asynchronous → NOT a block RAM.
module ram_read_bad #(parameter WIDTH=16, DEPTH=2048, AW=11)(
input wire clk, we,
input wire [AW-1:0] waddr, raddr,
input wire [WIDTH-1:0] din,
output wire [WIDTH-1:0] dout // `wire`: driven combinationally
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) if (we) mem[waddr] <= din;
assign dout = mem[raddr]; // BUG: async read → distributed RAM / flop array
endmodule
// FIXED: registered read → 1-cycle latency → infers a block RAM.
module ram_read_good #(parameter WIDTH=16, DEPTH=2048, AW=11)(
input wire clk, we,
input wire [AW-1:0] waddr, raddr,
input wire [WIDTH-1:0] din,
output reg [WIDTH-1:0] dout // `reg`: registered read
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we) mem[waddr] <= din;
dout <= mem[raddr]; // REGISTERED read → block RAM
end
endmodule module ram_read_bug_tb;
parameter WIDTH = 16, DEPTH = 32, AW = 5;
reg clk, we;
reg [AW-1:0] waddr, raddr;
reg [WIDTH-1:0] din;
wire [WIDTH-1:0] dout;
reg [WIDTH-1:0] golden [0:DEPTH-1];
integer a, errors;
// Bind to ram_read_good to PASS with correct latency accounting.
ram_read_good #(.WIDTH(WIDTH), .DEPTH(DEPTH), .AW(AW)) dut
(.clk(clk), .we(we), .waddr(waddr), .raddr(raddr), .din(din), .dout(dout));
initial clk = 1'b0;
always #5 clk = ~clk;
initial begin
errors = 0; we = 0; waddr = 0; raddr = 0; din = 0;
@(negedge clk);
for (a = 0; a < DEPTH; a = a + 1) begin
we = 1; waddr = a[AW-1:0]; din = (a*9 + 4); golden[a] = (a*9 + 4);
@(negedge clk);
end
we = 0;
for (a = 0; a < DEPTH; a = a + 1) begin
raddr = a[AW-1:0];
@(posedge clk); #1; // registered read valid one cycle later
if (dout !== golden[a]) begin
$display("FAIL: a=%0d dout=%h exp=%h", a, dout, golden[a]);
errors = errors + 1;
end
@(negedge clk);
end
if (errors == 0) $display("PASS: registered read returns stored data, 1-cycle latency");
else $display("FAIL: %0d mismatches (latency accounting?)", errors);
$finish;
end
endmoduleIn VHDL the same bug appears when dout is driven by a concurrent signal assignment (outside the process) instead of inside the rising_edge(clk) process. The concurrent form is combinational and asynchronous; the in-process form is registered. Same array, same write — only where the read lives changes whether it becomes a block RAM.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: dout is driven by a CONCURRENT assignment (outside the process) → async
-- read → cannot infer a block RAM; degrades to distributed logic / flops.
entity ram_read_bad is
generic ( WIDTH : positive := 16; DEPTH : positive := 2048 );
port ( clk, we : in std_logic;
waddr, raddr : in std_logic_vector(10 downto 0);
din : in std_logic_vector(WIDTH-1 downto 0);
dout : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of ram_read_bad is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t;
begin
process (clk) begin
if rising_edge(clk) then
if we = '1' then mem(to_integer(unsigned(waddr))) <= din; end if;
end if;
end process;
dout <= mem(to_integer(unsigned(raddr))); -- BUG: concurrent → async read → no block RAM
end architecture;
-- FIXED: move the read INSIDE the clocked process → registered → 1-cycle latency → block RAM.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_read_good is
generic ( WIDTH : positive := 16; DEPTH : positive := 2048 );
port ( clk, we : in std_logic;
waddr, raddr : in std_logic_vector(10 downto 0);
din : in std_logic_vector(WIDTH-1 downto 0);
dout : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of ram_read_good is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t;
begin
process (clk) begin
if rising_edge(clk) then
if we = '1' then mem(to_integer(unsigned(waddr))) <= din; end if;
dout <= mem(to_integer(unsigned(raddr))); -- REGISTERED read → block RAM
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_read_bug_tb is
end entity;
architecture sim of ram_read_bug_tb is
constant WIDTH : positive := 16;
constant DEPTH : positive := 2048; -- AW=11 to match the port
constant AW : positive := 11;
signal clk : std_logic := '0';
signal we : std_logic := '0';
signal waddr : std_logic_vector(AW-1 downto 0) := (others => '0');
signal raddr : std_logic_vector(AW-1 downto 0) := (others => '0');
signal din : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal dout : std_logic_vector(WIDTH-1 downto 0);
type gold_t is array (0 to 31) of std_logic_vector(WIDTH-1 downto 0);
signal golden : gold_t;
begin
-- Bind to ram_read_good to PASS; ram_read_bad simulates but will not infer a block RAM.
dut : entity work.ram_read_good
generic map (WIDTH => WIDTH, DEPTH => DEPTH)
port map (clk => clk, we => we, waddr => waddr, din => din,
raddr => raddr, dout => dout);
clk <= not clk after 5 ns;
stim : process
variable p : std_logic_vector(WIDTH-1 downto 0);
begin
wait until falling_edge(clk);
for a in 0 to 31 loop -- exercise the first 32 addresses
p := std_logic_vector(to_unsigned((a*9 + 4) mod 2**WIDTH, WIDTH));
we <= '1'; waddr <= std_logic_vector(to_unsigned(a, AW)); din <= p;
golden(a) <= p;
wait until falling_edge(clk);
end loop;
we <= '0';
for a in 0 to 31 loop
raddr <= std_logic_vector(to_unsigned(a, AW));
wait until rising_edge(clk); -- registered read valid one cycle later
wait for 1 ns;
assert dout = golden(a)
report "read mismatch — latency accounted?" severity error;
wait until falling_edge(clk);
end loop;
report "ram_read self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: the read makes the memory. A registered (synchronous) read — the read written inside the clocked process, one cycle of latency — infers a dense block RAM; a combinational/asynchronous read (an assign, or a concurrent VHDL assignment) simulates identically but cannot map to a block RAM and degrades into a giant flop-and-mux array.
5. Verification Strategy
A RAM is sequential storage, so its correctness is not a truth table — it is a behaviour over addresses and time, and the single invariant that captures a correct synchronous RAM is:
Every address, once written, reads back exactly the value written — accounting for the one-cycle latency of the synchronous read — and a write is applied only when the write-enable is asserted.
Everything below is that one invariant, made checkable, and it maps directly onto the clocked testbenches in §4.
- Write-a-pattern, read-it-back (self-checking). The core self-check: generate a clock, write a deterministic per-address pattern into every address (a golden reference array records what was written), then read every address back and assert
doutequals the golden value. Because the pattern is a known function of the address, the reference model is one line and the check is automatic. The threeram_sptestbenches in §4a do exactly this — SVassert (dout === golden[a]), Verilogif (dout !== golden[a]) $display("FAIL…"), VHDLassert dout = golden(a) report … severity error. - Account for the one-cycle read latency — the check most people get wrong. Because the read is registered, the data for
raddrpresented this cycle is valid ondoutnext cycle. A testbench that presents an address and checksdoutin the same cycle sees stale data and reports a false failure — or, worse, is written against a zero-latency (buggy) read and then breaks when the RAM is fixed. The §4 testbenches present the address, take a clock edge, and compare on the following sample; getting this offset right is a first-class part of verifying a synchronous memory (and the exact off-by-one §6 warns about; the deeper read-during-write timing is 6.2). - Verify the write-enable gates writes. Drive a write with
wede-asserted and assert the addressed word did not change. A RAM that writes regardless ofwepasses the read-back test but fails this one — and it is a real bug (data corrupted on cycles that were supposed to hold). The §4aram_sptestbenches include this explicitwe = 0check. - Every address, and the boundaries. Exercise all
DEPTHaddresses (a smallDEPTHin the TB makes the full sweep cheap), including address 0 andDEPTH-1, so an off-by-one in the address decode or an accidental truncation of the address is caught. For the simple-dual-port RAM, additionally write one address while reading a different one in the same cycle to prove the two ports are independent; for byte-write-enables, write a single lane and assert the other lanes held their previous value. - Failure modes the testbench catches. The read-back mismatch catches a broken write or a broken address decode; the latency-offset check catches wrong read-latency accounting; the
we = 0check catches a missing write-enable. The one failure a functional testbench cannot catch is the §7 bug — a combinational read simulates perfectly and only fails at synthesis (won't infer block RAM, blows up area/timing). That one is caught not by simulation but by reading the synthesis report (utilization: block RAM count = 0 when you expected N) — verification of a memory includes checking the synthesis inference, not just the waveform. - Parameter-generic verification (512×8, 2048×16). A
WIDTH/DEPTH-generic memory must be verified generic, not assumed generic. Re-instantiate the DUT at a couple of sizes and re-run the write-then-read sweep; the reference model is unchanged, only the address range and word width grow. A memory that passes at 64×16 but was written with an accidental fixed address width will fail the moment you re-instantiate it deeper. - Expected waveform. A correct synchronous RAM shows
doutupdating only at rising edges, one cycle after the address that produced it — never instantly. Presentraddr = 5anddoutshowsmem[5]on the next edge, holding until the edge after that. The visual signature of the §7 bug is adoutthat tracksraddrcombinationally (changes the instantraddrchanges, zero latency) — which simulates fine but is the tell-tale of an asynchronous read that will not become a block RAM.
6. Common Mistakes
Combinational / asynchronous read — the signature memory bug. Writing the read as assign dout = mem[addr] (SV/Verilog) or a concurrent dout <= mem(addr) outside the clocked process (VHDL) makes the read asynchronous, and no block RAM tile can do an asynchronous read. So the tool cannot map the array to a block RAM; it falls back to distributed RAM (small, LUT-based) or, past that capacity, a literal flop array with a wide read mux — huge area, failed timing, or a design that will not fit. The insidious part is that it simulates perfectly: functionally a combinational read is correct, so RTL sim is green while synthesis / place-and-route quietly fails. The fix is to register the read (move it inside the clocked process), accepting the one-cycle latency that is the block RAM's price. (Full post-mortem in §7; buggy-vs-fixed RTL for all three HDLs in §4c.)
Wrong read-latency accounting (the off-by-one). A synchronous read has one cycle of latency: the data for raddr presented this cycle is on dout next cycle. Reading dout in the same cycle you presented the address gets last address's data (or stale/undefined data on the first read) — the classic off-by-one that shows up as "the memory returns the wrong word by one access." It is not a memory bug; it is a latency bug in the surrounding logic (or the testbench), and it is exactly why the §5 read-back check offsets the comparison by one cycle. (The subtler same-address read-during-write timing — write-first vs read-first — is Section 6.2.)
Using a giant flop array instead of the RAM template. Declaring reg [W-1:0] mem [0:D-1] and reading it combinationally, or worse hand-building a bank of named registers plus a read mux, "works" in sim but is precisely the flop-and-mux structure the block RAM exists to replace. For anything beyond a few dozen small words it is an area, routing, and timing disaster. If the data is genuinely a memory (addressed, many words), write the inference template and let the tool give you the dedicated array — do not build storage out of flip-flops at scale.
Missing the write-enable. Writing mem[addr] <= din every cycle unconditionally (no if (we)) means the memory can never hold — every address it points at is overwritten each cycle. A buffer almost always needs to write selectively (write when data arrives, hold otherwise), and the block RAM port has a native write-enable; omitting it both breaks the intended behaviour and can prevent inference of the enable-having port. Always gate the write with a write-enable (and per-byte enables for wide words written in lanes).
Too many read/write ports for the target block RAM. A block RAM tile has a fixed number of ports — typically two (true dual-port), sometimes effectively one write + one read (simple dual-port). RTL that reads the same memory at three or four independent addresses in one cycle asks for more ports than the tile has, so the tool either replicates the whole memory (N copies for N read ports — N times the block RAM) or gives up and builds registers. If you need many concurrent read ports, that is a design decision (banking, replication, time-multiplexing), not something to leave to the tool by accident — and it is exactly what motivates the register-file patterns of Section 6.3.
7. DebugLab
The line buffer that would not fit — a combinational read that never became a block RAM
The engineering lesson: a memory is a synthesis-inference pattern, not just a data structure — the read must be clocked (registered) to get a block RAM, and "it simulates" is not "it maps to the memory you wanted." The tell is a design that is functionally perfect in RTL sim but explodes in the utilization report — block RAM count zero, LUTs/flops through the roof, place-and-route failing to fit or close timing — because a combinational read asked the tool for a zero-latency behaviour no block RAM tile can provide, so it built the very flop-and-mux array the RAM was meant to replace. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: write the read inside the clocked process (registered, one-cycle latency) so it matches the block RAM's synchronous port, and verify inference by reading the synthesis report — confirm the block RAM tiles you expected actually appeared — not by trusting the waveform alone.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "a RAM is an array with a clocked write and a registered read" habit.
Exercise 1 — Registered vs combinational read: predict the hardware
Two engineers implement the same 1024×32 buffer. Engineer A writes the read as dout <= mem[raddr] inside the clocked process; Engineer B writes assign dout = mem[raddr]. Both simulate identically. State (i) how the synthesized hardware differs (which infers a block RAM and which does not, and why), (ii) the read latency each has, and (iii) exactly what shows up in the synthesis utilization report for B that does not for A. Then explain in one line why RTL simulation alone cannot distinguish them.
Exercise 2 — Account for the latency
A synchronous RAM has one cycle of read latency. A consumer needs mem[k] available in the cycle it processes item k. Describe two ways to make the addressed word arrive on time (one that changes when the address is presented, one that adds structure around the read), and state the cost of each. Then explain the off-by-one a naive consumer suffers if it presents raddr = k and samples dout in the same cycle, and which access's data it actually gets.
Exercise 3 — Choose the port structure
For each, say whether you'd use a single-port or a simple-dual-port RAM, and why in one line: (a) a scratchpad a state machine touches one access at a time; (b) a FIFO's data store, written by the producer while the consumer reads; (c) a lookup table read every cycle but only ever loaded once at startup; (d) a line buffer written by the incoming stream while the processing stage reads the previous line. (Hint: ask whether a read and a write must happen in the same cycle at different addresses.)
Exercise 4 — Size and inference sanity-check
You describe a 4096×64 memory (256 kbit) and expect it to infer block RAM on your target FPGA, whose block RAM tile is 18 kbit. (i) Roughly how many tiles must the tool use, and why can't it be one? (ii) You accidentally add a third independent read address to the same memory — describe what the tool is forced to do and how the block RAM count changes. (iii) Name the one report you would read to confirm the memory inferred as expected, and the single number in it that tells you whether the read was written correctly.
10. Related Tutorials
Continue in RTL Design Patterns (this-batch siblings go live together; forward links unlock as they ship):
- Memory Read Timing — Chapter 6.2; the same registered read, taken deeper: read-during-write behaviour (write-first vs read-first vs no-change) and why registering the output vs the address changes what
doutshows on a same-address read — the timing question this page deliberately deferred. - Register Files — Chapter 6.3; when you need many concurrent read ports (a CPU reading two source registers while writing one), the multi-port memory that block RAM alone cannot give you — the answer to §6's "too many ports" mistake.
- ROM & Initialization — Chapter 6.4; loading the memory's contents at power-up (a coefficient table, a microcode ROM) — the initialization this page left as undefined-until-written.
- Dual-Port RAM — Chapter 6; the true two-port memory (two full read/write ports), generalizing the simple-dual-port variant of §4b.
- Synchronous FIFO Architecture — Chapter 7.1; the FIFO's data store is a simple-dual-port RAM from this page, wrapped in read/write pointers and full/empty logic.
Backward / method:
- The Register Pattern (D-FF) — Chapter 2.1; the flip-flop this page contrasts memory against — a register is a handful of bits you name; a RAM is kilobits you address, and it is not a pile of these.
- Register with Enable / Load — Chapter 2.2; the write-enable that gates a register's load is the same idea as the RAM's write-enable that gates a word's write.
- Multiplexers — Chapter 1.1; the giant read mux a combinational read builds — exactly the flop-and-mux structure a block RAM exists to replace.
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this page applied to storage at scale.
- What Is an RTL Design Pattern? — Chapter 0.1; why "inferring RAM" is the archetypal synthesis-inference pattern — the RTL you write must be recognized, not just correct.
Related combinational context:
- Decoders — Chapter 1.2; the address decode a memory hides inside — turning an address into the one word it selects.
- Synchronous Reset — Chapter 2.3; block RAM contents are typically not reset (they initialize by loading, not by reset) — a place where the reset discipline deliberately does not apply, and why.
- FSM + Datapath — Chapter; the controller that drives a memory's address, write-enable, and read timing — the memory is the datapath, the FSM is the control.
Verilog v1 prerequisites (the grammar this pattern relies on):
- Blocking and Non-Blocking Assignments — why the clocked write and registered read use
<=, and why a combinationalassignread is a different (asynchronous) thing entirely. - Physical Data Types — the
reg/wireand vector/array typing behindreg [W-1:0] mem [0:D-1], and whydoutis areg(registered) in the good version and awirein the buggy one. - Generate Blocks — the elaboration-time replication used to parameterize per-byte write-enable lanes and banked memories by
WIDTH/DEPTH.
11. Summary
- A RAM is a 2D array with a clocked write and a registered read — not a pile of flip-flops. Storing kilobits in individual flops is impossibly expensive (thousands of registers plus a giant read mux); real memory maps to a dedicated block RAM tile (FPGA) or SRAM macro (ASIC), and you get one by writing RTL the synthesis tool recognizes as a RAM:
reg [W-1:0] mem [0:D-1],if (we) mem[waddr] <= din;,dout <= mem[raddr];— in one clocked process.always_ff @(posedge clk)/always @(posedge clk)/rising_edge(clk)are three spellings of the same synchronous memory. - The registered read is the crux. A block RAM is a synchronous array — read data comes out through an output register, one cycle after the address. A registered read (inside the clocked process, one-cycle latency) matches that and infers the dedicated memory; a combinational read (
assign dout = mem[addr], or a concurrent VHDL read) is asynchronous, which no block RAM can do, so it degrades into distributed RAM or a giant flop-and-mux array — the §7 DebugLab. The one-cycle read latency is the price of density, not a flaw to remove. - Write-enable, DEPTH/WIDTH, and port structure. Gate the write with a write-enable so the memory can hold (per-byte enables for wide words written in lanes); parameterize
DEPTH(address =clog2(DEPTH)bits) andWIDTHso one template is a table or a buffer. A single-port RAM shares one address; a simple-dual-port RAM writes one address and reads another in the same cycle — what a FIFO or streaming buffer needs, and what block RAM's two ports provide natively. - Verify over addresses and time — and check inference. A clocked, self-checking testbench writes a per-address pattern, reads every address back accounting for the one-cycle read latency, and confirms the write-enable gates writes — at a couple of
WIDTH/DEPTHsizes. The one bug simulation cannot catch is the combinational read: it passes the waveform and fails at synthesis, so verifying a memory includes reading the utilization report and confirming the block RAM tiles you expected actually appeared. The self-checking testbenches are shown here in SystemVerilog, Verilog, and VHDL. - Deferred on purpose. Initial contents (loading a table at power-up) is Section 6.4; read-during-write timing (same-address read and write in one cycle) is Section 6.2. This page teaches the one thing that makes a memory a memory: the array-plus-clocked-write-plus-registered-read template that infers the dedicated block.