RTL Design Patterns · Chapter 6 · Memories & Register Files
Read Timing & Read-During-Write
A block RAM is not a magic, instant lookup; it is a small pipeline with a timing contract, and two clauses in that contract quietly break real designs. First, a synchronous read is registered: the data arrives one cycle after you present the address, so a reader that samples too early gets the previous location, an off-by-one that hides until something pipelines around it. Second, when you read and write the same address in the same cycle, the read-during-write collision returns the old value, the newly written value, or something undefined, and which one depends on the memory technology rather than on what your RTL assumes. Model the wrong one and simulation and synthesis diverge, or a FIFO returns stale data at the full or empty boundary. This lesson treats read latency and read-during-write as a timing contract you model explicitly, covers the three collision modes, and adds a write-forwarding bypass, in SystemVerilog, Verilog, and VHDL.
Intermediate16 min readRTL Design PatternsBlock RAMRead LatencyRead-During-WriteWrite ForwardingMemory Timing
Chapter 6 · Section 6.2 · Memories & Register Files
1. The Engineering Problem
You have inferred a clean block RAM — the 6.1 template, a clocked write and an addressed read — and dropped it into a small design: a data buffer that a producer writes and a consumer reads, sometimes at the same address on the same cycle. In your block-level simulation the numbers are right. On the FPGA, and later in gate-level sim, a fault appears that no one can reproduce on demand: every so often the consumer reads a location and gets the value that used to be there, not the value just written. It is intermittent. It depends on the exact sequence of accesses. Slowing the clock does not help. It never shows up in the directed tests that read one cycle and write another.
Two independent facts about the RAM are hiding behind that symptom, and both are timing, not logic. The first is read latency. A synchronous read registers its output: you present the address on cycle N, and the data for that address appears on the dout bus on cycle N+1, not N. If any part of your logic assumes the data is available the same cycle as the address — if it samples the read data a cycle too early — it is reading the previous address's data. That is a silent off-by-one that only surfaces once something downstream pipelines around the read.
The second fact is read-during-write (RDW): what the read returns when the same address is read and written on the same clock edge. Physically, inside the memory, the write is landing and the read is being launched at the same instant, so the memory has to decide what the read sees. Real block RAMs implement one of three policies — return the old value that was there before the write (read-first), return the new value being written this cycle (write-first, also called transparent or read-new), or return something undefined (no-change / "don't care"). Crucially, this is a per-technology behaviour baked into the silicon macro. Your RTL contains a model of that behaviour, and if the model says read-new while the target block RAM does read-old, the same-address collision returns stale data — exactly the intermittent bug above — and if the consumer needs the new value (a forwarding path, a FIFO at its boundary), the design is simply missing a bypass.
// A buffer: producer writes, consumer reads. Both can hit the SAME address
// on the SAME clock edge.
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we) mem[waddr] <= din; // write lands on this edge
dout <= mem[raddr]; // READ is REGISTERED → dout valid NEXT cycle
end
// Two questions the RTL above does NOT answer, but the hardware DOES:
// 1) LATENCY: dout holds mem[raddr] one cycle LATE. Did the reader pipeline for that?
// 2) RDW: when raddr == waddr && we, is dout the OLD mem value or the NEW din?
// The block RAM picks ONE policy in silicon. Your model must match it —
// or add a bypass — or same-address collisions read stale data.2. Mental Model
The counter model is not a metaphor you drop after §2 — it is the frame for the rest of the page. Read latency is "the data comes back next tick." Read-during-write is "the house rule at the counter." The bypass is "you personally hand the customer the item you just shelved, because the house rule would have given them the old one." Hold that and the RTL is mechanical.
3. Pattern Anatomy
The pattern is a synchronous memory plus two timing decisions layered on top of the 6.1 inference: how many cycles late the read data is, and what a same-address collision returns — with a bypass as the escape hatch when those two decisions do not give you what you need.
Read latency — one cycle, or two. The read is registered, so the anatomy is: address in on cycle N → memory array lookup → read data register → dout valid on cycle N+1. That is one cycle of read latency, and it is the default block-RAM read. Add an optional output register (a second flop on dout, often available inside the same block-RAM primitive to improve timing) and the latency becomes two cycles — dout valid on N+2. The number is a contract: whoever consumes dout must delay everything it correlates with the read (the address, a tag, a valid bit) by the same N, or it aligns read data with the wrong request. This is why 6.1's dout <= mem[raddr] was already a registered read — the <= on a clocked edge is the read register.
The three read-during-write modes — and how the RTL template selects each. When raddr == waddr and we fire on the same edge, the collision resolves one of three ways, and the ordering of the write and the read inside the clocked block is what selects it:
- Read-first (read-old). The read is captured before the write updates the array, so
doutreturns the value that was there before this cycle's write. In RTL: read the array into the output register, then write — the read sees the pre-write contents. - Write-first (write-through / read-new / transparent). The write updates the array first, so the read on the same address returns the value just written. In RTL: write the array, then read — the read sees the post-write contents. Equivalently, forward
dintodouton a collision. - No-change (undefined on collision). On a same-address collision the output register holds its previous value (or is explicitly undefined) — the read is simply not defined during a write. This is the lowest-power block-RAM mode; you must never depend on the collision result.
The write-forwarding bypass mux. When your logic needs read-new but the target block RAM only offers read-old (a very common FPGA/ASIC situation — many macros are read-first by default), you add a write-forwarding bypass: compare the registered read and write addresses, and on a collision drive din (also registered to match the read latency) onto dout in place of the memory output. It is exactly the 1.1 multiplexer, with the select computed by the 5.x comparator raddr == waddr && we. The bypass makes a read-first RAM behave read-new to the reader, at the cost of a comparator and a mux on the read path.
Read timing and read-during-write — the memory's timing contract
data flowWhy this matters concretely: any structure that reads an address back-to-back with a write to that same address lives or dies on the RDW mode. A synchronous FIFO (→ 7.1) at its empty boundary can have the write pointer land data at exactly the location the read pointer is about to fetch; a pipeline forwarding path reads a register the same cycle it is written. If those need the freshly-written value and the RAM is read-first, the bypass is not optional — it is the difference between a correct design and one that corrupts intermittently, only at the collision.
4. Real RTL Implementation
This is the core of the page. We build three things — (a) a sync RAM with an explicit RDW mode (a read-first and a write-first variant, WIDTH/DEPTH generic), (b) a write-forwarding bypass that gives read-new on a read-old RAM, and (c) the RDW-mismatch bug (buggy vs fixed) — each in SystemVerilog, Verilog, and VHDL, with an RTL block and a self-checking clocked testbench that reads and writes the same address the same cycle and checks old-vs-new per mode. The idea is identical across all three; the syntax differs.
Two disciplines run through every block. First, the read is registered (<= on a clocked edge), so every testbench accounts for the one-cycle read latency — it checks dout on the cycle after the address. Second, the RDW mode is selected by the order of the write and the read inside the clocked block: read-then-write gives read-first (old); write-then-read gives write-first (new). This is a design intent you state explicitly, not something to leave to the tool.
4a. Sync RAM with an explicit RDW mode — read-first and write-first
The template is 6.1's single-port sync RAM, parameterized on WIDTH/DEPTH, with the read/write ordering made explicit so the read-during-write mode is chosen on purpose. Reading the array before the write statement gives read-first (dout is the old value on a collision); writing before the read gives write-first (dout is the new value). A MODE parameter selects between them so one module documents both contracts.
module sync_ram_rdw #(
parameter int WIDTH = 32,
parameter int DEPTH = 256,
parameter bit WRITE_FIRST = 1'b0, // 0 = read-first (old), 1 = write-first (new)
localparam int AW = $clog2(DEPTH)
)(
input logic clk,
input logic we, // synchronous write enable
input logic [AW-1:0] waddr,
input logic [WIDTH-1:0] din,
input logic [AW-1:0] raddr,
output logic [WIDTH-1:0] dout // REGISTERED read → valid one cycle later
);
logic [WIDTH-1:0] mem [DEPTH];
// The RDW mode is chosen by the ORDER of the read and the write in the
// clocked block. This is a deliberate contract, not an accident.
always_ff @(posedge clk) begin
if (WRITE_FIRST) begin
// WRITE-FIRST: update the array FIRST, then read → same-addr read
// returns the value just written (read-NEW / transparent).
if (we) mem[waddr] <= din;
dout <= (we && (raddr == waddr)) ? din : mem[raddr];
end else begin
// READ-FIRST: capture the read BEFORE the write → same-addr read
// returns the value that was there before this cycle (read-OLD).
dout <= mem[raddr];
if (we) mem[waddr] <= din;
end
end
endmoduleThe read latency is one cycle in both modes — dout is a flop. The only difference between the two branches is what a same-address collision returns, and that is the whole point: the RTL now states its RDW contract. The self-checking testbench is clocked, and it exercises the collision deliberately: it drives raddr == waddr with we high on the same edge, then samples dout on the next edge (respecting the one-cycle latency) and asserts the old value for read-first and the new value for write-first.
module sync_ram_rdw_tb;
localparam int WIDTH = 32, DEPTH = 256, AW = $clog2(DEPTH);
logic clk = 0; always #5 clk = ~clk;
logic we;
logic [AW-1:0] waddr, raddr;
logic [WIDTH-1:0] din, dout_rf, dout_wf;
int errors = 0;
// Two DUTs: one read-first, one write-first, same stimulus.
sync_ram_rdw #(.WIDTH(WIDTH), .DEPTH(DEPTH), .WRITE_FIRST(1'b0)) rf
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout_rf));
sync_ram_rdw #(.WIDTH(WIDTH), .DEPTH(DEPTH), .WRITE_FIRST(1'b1)) wf
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout_wf));
task automatic wr(input [AW-1:0] a, input [WIDTH-1:0] d);
@(negedge clk); we=1; waddr=a; din=d; raddr=a; // drive off the falling edge
endtask
initial begin
we=0; waddr=0; raddr=0; din=0;
// Seed address 7 with an OLD value, no read collision yet.
wr(7, 32'hDEAD_0000); @(posedge clk);
we=0; @(negedge clk); raddr=7; @(posedge clk); #1; // read-back settles (latency 1)
// COLLISION: read and write address 7 on the SAME edge with a NEW value.
@(negedge clk); we=1; waddr=7; raddr=7; din=32'hBEEF_0000;
@(posedge clk); #1; // dout valid this edge (latency 1)
// read-first must return the OLD value; write-first the NEW value.
assert (dout_rf === 32'hDEAD_0000)
else begin $error("read-first collision: dout=%h exp OLD", dout_rf); errors++; end
assert (dout_wf === 32'hBEEF_0000)
else begin $error("write-first collision: dout=%h exp NEW", dout_wf); errors++; end
if (errors == 0) $display("PASS: RDW modes return old (read-first) and new (write-first)");
else $display("FAIL: %0d RDW mismatches", errors);
$finish;
end
endmoduleThe Verilog form is the same story with reg/wire typing and always @(posedge clk). The WRITE_FIRST selection and the read/write ordering are identical; the collision bypass in the write-first branch ((we && raddr==waddr) ? din : mem[raddr]) is the load-bearing line.
module sync_ram_rdw #(
parameter WIDTH = 32,
parameter DEPTH = 256,
parameter WRITE_FIRST = 0, // 0 = read-first (old), 1 = write-first (new)
parameter AW = 8 // = clog2(DEPTH), set by instantiator
)(
input wire clk,
input wire we,
input wire [AW-1:0] waddr,
input wire [WIDTH-1:0] din,
input wire [AW-1:0] raddr,
output reg [WIDTH-1:0] dout // registered read → one-cycle latency
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (WRITE_FIRST) begin
if (we) mem[waddr] <= din; // write first
dout <= (we && (raddr == waddr)) ? din : mem[raddr]; // read-NEW on collision
end else begin
dout <= mem[raddr]; // read first (read-OLD)
if (we) mem[waddr] <= din;
end
end
endmodule module sync_ram_rdw_tb;
parameter WIDTH = 32, DEPTH = 256, AW = 8;
reg clk = 0; always #5 clk = ~clk;
reg we;
reg [AW-1:0] waddr, raddr;
reg [WIDTH-1:0] din;
wire [WIDTH-1:0] dout_rf, dout_wf;
integer errors;
sync_ram_rdw #(.WIDTH(WIDTH), .DEPTH(DEPTH), .WRITE_FIRST(0), .AW(AW)) rf
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout_rf));
sync_ram_rdw #(.WIDTH(WIDTH), .DEPTH(DEPTH), .WRITE_FIRST(1), .AW(AW)) wf
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout_wf));
initial begin
errors = 0; we = 0; waddr = 0; raddr = 0; din = 0;
// Seed address 7 with an OLD value.
@(negedge clk); we = 1; waddr = 7; din = 32'hDEAD0000; raddr = 7;
@(negedge clk); we = 0;
@(posedge clk); #1; // let read register settle
// COLLISION on address 7 with a NEW value, same edge.
@(negedge clk); we = 1; waddr = 7; raddr = 7; din = 32'hBEEF0000;
@(posedge clk); #1; // dout valid (latency 1)
if (dout_rf !== 32'hDEAD0000) begin
$display("FAIL: read-first collision dout=%h exp OLD", dout_rf); errors = errors + 1;
end
if (dout_wf !== 32'hBEEF0000) begin
$display("FAIL: write-first collision dout=%h exp NEW", dout_wf); errors = errors + 1;
end
if (errors == 0) $display("PASS: RDW old (read-first) and new (write-first) correct");
else $display("FAIL: %0d RDW mismatches", errors);
$finish;
end
endmoduleIn VHDL the memory is an array type, the write is guarded by we on rising_edge(clk), and the RDW mode is again the read/write ordering. generic replaces parameter; dout <= mem(to_integer(unsigned(raddr))) is the registered read. The write-first branch forwards din on a collision.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity sync_ram_rdw is
generic (
WIDTH : positive := 32;
DEPTH : positive := 256;
WRITE_FIRST : boolean := false -- false = read-first, true = write-first
);
port (
clk : in std_logic;
we : in std_logic;
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 (one-cycle latency)
);
end entity;
architecture rtl of sync_ram_rdw is
type mem_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t;
begin
process (clk)
variable wi, ri : integer;
begin
if rising_edge(clk) then
wi := to_integer(unsigned(waddr));
ri := to_integer(unsigned(raddr));
if WRITE_FIRST then
-- write first, then read → read-NEW on a same-address collision
if we = '1' then mem(wi) <= din; end if;
if we = '1' and ri = wi then dout <= din; -- forward new value
else dout <= mem(ri); end if;
else
-- read first, then write → read-OLD on a same-address collision
dout <= mem(ri);
if we = '1' then mem(wi) <= din; end if;
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity sync_ram_rdw_tb is
end entity;
architecture sim of sync_ram_rdw_tb is
constant WIDTH : positive := 32;
constant DEPTH : positive := 256;
constant AW : positive := 8;
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_rf, dout_wf : std_logic_vector(WIDTH-1 downto 0);
begin
clk <= not clk after 5 ns;
rf : entity work.sync_ram_rdw
generic map (WIDTH => WIDTH, DEPTH => DEPTH, WRITE_FIRST => false)
port map (clk => clk, we => we, waddr => waddr, din => din, raddr => raddr, dout => dout_rf);
wf : entity work.sync_ram_rdw
generic map (WIDTH => WIDTH, DEPTH => DEPTH, WRITE_FIRST => true)
port map (clk => clk, we => we, waddr => waddr, din => din, raddr => raddr, dout => dout_wf);
stim : process
begin
-- Seed address 7 with an OLD value.
wait until falling_edge(clk);
we <= '1'; waddr <= std_logic_vector(to_unsigned(7, AW)); din <= x"DEAD0000";
raddr <= std_logic_vector(to_unsigned(7, AW));
wait until falling_edge(clk); we <= '0';
wait until rising_edge(clk); wait for 1 ns; -- read register settles
-- COLLISION on address 7 with a NEW value, same edge.
wait until falling_edge(clk);
we <= '1'; waddr <= std_logic_vector(to_unsigned(7, AW));
raddr <= std_logic_vector(to_unsigned(7, AW)); din <= x"BEEF0000";
wait until rising_edge(clk); wait for 1 ns; -- dout valid (latency 1)
assert dout_rf = x"DEAD0000"
report "read-first collision: dout /= OLD" severity error;
assert dout_wf = x"BEEF0000"
report "write-first collision: dout /= NEW" severity error;
report "sync_ram_rdw self-check complete" severity note;
wait;
end process;
end architecture;4b. Write-forwarding bypass — read-new on a read-old RAM
Now the situation the DebugLab dramatizes: your design needs read-new, but the target block RAM is read-first and you cannot change that (it is the macro's fixed behaviour). The fix is a write-forwarding bypass wrapped around the read-first RAM: register the write address and din to match the one-cycle read latency, compare the registered read and write addresses, and on a collision mux the forwarded din onto dout in place of the (stale) memory output. The comparator is 5.x; the mux is 1.1. The result behaves read-new to the reader without touching the RAM primitive.
module ram_bypass #(
parameter int WIDTH = 32,
parameter int DEPTH = 256,
localparam int AW = $clog2(DEPTH)
)(
input logic clk,
input logic we,
input logic [AW-1:0] waddr,
input logic [WIDTH-1:0] din,
input logic [AW-1:0] raddr,
output logic [WIDTH-1:0] dout
);
logic [WIDTH-1:0] mem [DEPTH];
logic [WIDTH-1:0] mem_q; // read-first RAM output (may be STALE on collision)
logic collide_q; // registered "this read hit the write addr"
logic [WIDTH-1:0] din_q; // registered din, aligned to the read latency
always_ff @(posedge clk) begin
mem_q <= mem[raddr]; // READ-FIRST: captured before the write
if (we) mem[waddr] <= din;
// Register the collision detect + din so they line up with mem_q one cycle later.
collide_q <= we && (raddr == waddr); // comparator: read hit the written address
din_q <= din;
end
// Bypass MUX on the read path: on a collision, forward the new data instead
// of the read-first RAM's old value → the reader sees read-NEW.
assign dout = collide_q ? din_q : mem_q;
endmoduleThe self-checking testbench proves the bypass: it collides read and write on the same address, and asserts dout returns the new value even though the underlying RAM is read-first. It also checks a non-colliding read (different addresses) still returns memory, so the bypass does not corrupt the normal path.
module ram_bypass_tb;
localparam int WIDTH = 32, DEPTH = 256, AW = $clog2(DEPTH);
logic clk = 0; always #5 clk = ~clk;
logic we; logic [AW-1:0] waddr, raddr; logic [WIDTH-1:0] din, dout;
int errors = 0;
ram_bypass #(.WIDTH(WIDTH), .DEPTH(DEPTH)) dut
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout));
initial begin
we=0; waddr=0; raddr=0; din=0;
// Seed address 9 = OLD.
@(negedge clk); we=1; waddr=9; din=32'h0000_0AAA; raddr=9;
@(negedge clk); we=0; @(posedge clk); #1;
// COLLISION: write NEW to 9 while reading 9 → bypass must forward NEW.
@(negedge clk); we=1; waddr=9; raddr=9; din=32'h0000_0BBB;
@(posedge clk); #1;
assert (dout === 32'h0000_0BBB)
else begin $error("bypass collision: dout=%h exp NEW", dout); errors++; end
// NON-COLLISION: read a different address → must return memory, not din.
@(negedge clk); we=1; waddr=5; raddr=9; din=32'h0000_0CCC;
@(posedge clk); #1;
assert (dout === 32'h0000_0BBB) // addr 9 now holds the prior NEW value
else begin $error("no-collision read: dout=%h exp mem[9]", dout); errors++; end
if (errors == 0) $display("PASS: bypass forwards NEW on collision, mem otherwise");
else $display("FAIL: %0d bypass mismatches", errors);
$finish;
end
endmoduleThe Verilog bypass is identical in structure — a read-first RAM, a registered comparator, a registered din, and a continuous mux on dout.
module ram_bypass #(
parameter WIDTH = 32,
parameter DEPTH = 256,
parameter AW = 8
)(
input wire clk,
input wire we,
input wire [AW-1:0] waddr,
input wire [WIDTH-1:0] din,
input wire [AW-1:0] raddr,
output wire [WIDTH-1:0] dout
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
reg [WIDTH-1:0] mem_q; // read-first output (stale on collision)
reg collide_q;
reg [WIDTH-1:0] din_q;
always @(posedge clk) begin
mem_q <= mem[raddr]; // read first
if (we) mem[waddr] <= din;
collide_q <= we && (raddr == waddr); // registered collision detect
din_q <= din; // registered forward data
end
assign dout = collide_q ? din_q : mem_q; // bypass mux → read-NEW on collision
endmodule module ram_bypass_tb;
parameter WIDTH = 32, DEPTH = 256, AW = 8;
reg clk = 0; always #5 clk = ~clk;
reg we; reg [AW-1:0] waddr, raddr; reg [WIDTH-1:0] din;
wire [WIDTH-1:0] dout; integer errors;
ram_bypass #(.WIDTH(WIDTH), .DEPTH(DEPTH), .AW(AW)) dut
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout));
initial begin
errors = 0; we = 0; waddr = 0; raddr = 0; din = 0;
@(negedge clk); we = 1; waddr = 9; din = 32'h00000AAA; raddr = 9; // seed OLD
@(negedge clk); we = 0; @(posedge clk); #1;
@(negedge clk); we = 1; waddr = 9; raddr = 9; din = 32'h00000BBB; // collision NEW
@(posedge clk); #1;
if (dout !== 32'h00000BBB) begin
$display("FAIL: bypass collision dout=%h exp NEW", dout); errors = errors + 1;
end
@(negedge clk); we = 1; waddr = 5; raddr = 9; din = 32'h00000CCC; // no collision
@(posedge clk); #1;
if (dout !== 32'h00000BBB) begin
$display("FAIL: no-collision dout=%h exp mem[9]", dout); errors = errors + 1;
end
if (errors == 0) $display("PASS: bypass forwards NEW on collision, mem otherwise");
else $display("FAIL: %0d bypass mismatches", errors);
$finish;
end
endmoduleIn VHDL the bypass is the same: a read-first process, a registered comparator and din, and a concurrent mux on dout.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_bypass is
generic ( WIDTH : positive := 32; DEPTH : positive := 256 );
port (
clk : in std_logic;
we : in std_logic;
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)
);
end entity;
architecture rtl of ram_bypass is
type mem_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t;
signal mem_q : std_logic_vector(WIDTH-1 downto 0); -- read-first output (stale on collide)
signal din_q : std_logic_vector(WIDTH-1 downto 0);
signal collide : std_logic;
begin
process (clk)
variable wi, ri : integer;
begin
if rising_edge(clk) then
wi := to_integer(unsigned(waddr));
ri := to_integer(unsigned(raddr));
mem_q <= mem(ri); -- read FIRST (read-old)
if we = '1' then mem(wi) <= din; end if;
if we = '1' and ri = wi then collide <= '1'; -- registered collision detect
else collide <= '0'; end if;
din_q <= din; -- registered forward data
end if;
end process;
dout <= din_q when collide = '1' else mem_q; -- bypass mux → read-NEW
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_bypass_tb is
end entity;
architecture sim of ram_bypass_tb is
constant WIDTH : positive := 32;
constant DEPTH : positive := 256;
constant AW : positive := 8;
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);
begin
clk <= not clk after 5 ns;
dut : entity work.ram_bypass
generic map (WIDTH => WIDTH, DEPTH => DEPTH)
port map (clk => clk, we => we, waddr => waddr, din => din, raddr => raddr, dout => dout);
stim : process
begin
wait until falling_edge(clk); -- seed OLD at addr 9
we <= '1'; waddr <= std_logic_vector(to_unsigned(9, AW));
din <= x"00000AAA"; raddr <= std_logic_vector(to_unsigned(9, AW));
wait until falling_edge(clk); we <= '0';
wait until rising_edge(clk); wait for 1 ns;
wait until falling_edge(clk); -- collision: NEW to 9, read 9
we <= '1'; waddr <= std_logic_vector(to_unsigned(9, AW));
raddr <= std_logic_vector(to_unsigned(9, AW)); din <= x"00000BBB";
wait until rising_edge(clk); wait for 1 ns;
assert dout = x"00000BBB" report "bypass collision: dout /= NEW" severity error;
wait until falling_edge(clk); -- no collision: read 9, write 5
we <= '1'; waddr <= std_logic_vector(to_unsigned(5, AW));
raddr <= std_logic_vector(to_unsigned(9, AW)); din <= x"00000CCC";
wait until rising_edge(clk); wait for 1 ns;
assert dout = x"00000BBB" report "no-collision read: dout /= mem(9)" severity error;
report "ram_bypass self-check complete" severity note;
wait;
end process;
end architecture;4c. The RDW-mismatch bug — buggy vs fixed, in all three HDLs
Pattern (c) is the signature failure, shown as buggy vs fixed RTL, then dramatized in §7. The bug is the same everywhere: a design that needs read-new instantiates (or infers) a read-first RAM and omits the bypass. On any cycle that does not collide, it is correct. On the exact cycle a location is read the same instant it is written, the reader gets the old value — stale data — and only there. The fix is either to select the write-first RDW mode (if the target supports it) or to add the write-forwarding bypass of 4b.
// BUGGY: a forwarding buffer that ASSUMES a same-address read returns the value
// just written (read-new). But this RAM is READ-FIRST: on a collision it
// returns the OLD value → the forwarding path silently reads stale data.
module fwd_buf_bad #(parameter int WIDTH=32, DEPTH=64, 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
dout <= mem[raddr]; // READ-FIRST: dout is the PRE-write value...
if (we) mem[waddr] <= din; // ...but the design expects read-NEW. MISMATCH.
end
endmodule
// FIXED: keep the read-first RAM, add a write-forwarding bypass so a collision
// returns the freshly-written data (read-new), as the design requires.
module fwd_buf_good #(parameter int WIDTH=32, DEPTH=64, 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];
logic [WIDTH-1:0] mem_q, din_q; logic collide_q;
always_ff @(posedge clk) begin
mem_q <= mem[raddr];
if (we) mem[waddr] <= din;
collide_q <= we && (raddr == waddr); // detect the same-address collision
din_q <= din;
end
assign dout = collide_q ? din_q : mem_q; // forward NEW on collision → read-new
endmodule module rdw_bug_tb;
localparam int WIDTH=32, DEPTH=64, AW=$clog2(DEPTH);
logic clk=0; always #5 clk=~clk;
logic we; logic [AW-1:0] waddr, raddr; logic [WIDTH-1:0] din, dout;
int errors=0;
// Point at fwd_buf_good to PASS; at fwd_buf_bad to see the stale read fail.
fwd_buf_good dut (.clk(clk), .we(we), .waddr(waddr), .raddr(raddr), .din(din), .dout(dout));
initial begin
we=0; waddr=0; raddr=0; din=0;
@(negedge clk); we=1; waddr=3; din=32'h1111_0000; raddr=3; // seed OLD at 3
@(negedge clk); we=0; @(posedge clk); #1;
// The COLLISION case: read addr 3 the SAME cycle a NEW value is written to 3.
// A forwarding path needs the NEW value. read-first (bad) returns OLD.
@(negedge clk); we=1; waddr=3; raddr=3; din=32'h2222_0000;
@(posedge clk); #1;
assert (dout === 32'h2222_0000) // read-new expected
else begin $error("collision: dout=%h exp NEW (stale-read bug)", dout); errors++; end
if (errors == 0) $display("PASS: same-address collision returns the freshly-written value");
else $display("FAIL: %0d stale reads (RDW mismatch — missing bypass)", errors);
$finish;
end
endmoduleThe Verilog pair is the same story with reg outputs. The bug is the missing bypass; sweeping the collision case — read and write the same address the same cycle — is what exposes it. Non-colliding tests pass either way.
// BUGGY: read-first RAM where the design assumes read-new → stale on collision.
module fwd_buf_bad #(parameter WIDTH=32, DEPTH=64, AW=6)(
input wire clk, we, input wire [AW-1:0] waddr, raddr,
input wire [WIDTH-1:0] din, output reg [WIDTH-1:0] dout
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
dout <= mem[raddr]; // read-first: PRE-write value
if (we) mem[waddr] <= din; // design wanted read-new → MISMATCH
end
endmodule
// FIXED: add the write-forwarding bypass → read-new on collision.
module fwd_buf_good #(parameter WIDTH=32, DEPTH=64, AW=6)(
input wire clk, we, input wire [AW-1:0] waddr, raddr,
input wire [WIDTH-1:0] din, output wire [WIDTH-1:0] dout
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
reg [WIDTH-1:0] mem_q, din_q; reg collide_q;
always @(posedge clk) begin
mem_q <= mem[raddr];
if (we) mem[waddr] <= din;
collide_q <= we && (raddr == waddr);
din_q <= din;
end
assign dout = collide_q ? din_q : mem_q; // forward NEW on collision
endmodule module rdw_bug_tb;
parameter WIDTH=32, DEPTH=64, AW=6;
reg clk=0; always #5 clk=~clk;
reg we; reg [AW-1:0] waddr, raddr; reg [WIDTH-1:0] din;
wire [WIDTH-1:0] dout; integer errors;
// Bind to fwd_buf_good to PASS; to fwd_buf_bad to observe the stale read.
fwd_buf_good #(.WIDTH(WIDTH), .DEPTH(DEPTH), .AW(AW)) dut
(.clk(clk), .we(we), .waddr(waddr), .raddr(raddr), .din(din), .dout(dout));
initial begin
errors=0; we=0; waddr=0; raddr=0; din=0;
@(negedge clk); we=1; waddr=3; din=32'h11110000; raddr=3; // seed OLD
@(negedge clk); we=0; @(posedge clk); #1;
@(negedge clk); we=1; waddr=3; raddr=3; din=32'h22220000; // COLLISION
@(posedge clk); #1;
if (dout !== 32'h22220000) begin // read-new expected
$display("FAIL: collision dout=%h exp NEW (stale-read bug)", dout);
errors = errors + 1;
end
if (errors == 0) $display("PASS: collision returns freshly-written value (read-new)");
else $display("FAIL: %0d stale reads (missing bypass)", errors);
$finish;
end
endmoduleIn VHDL the same mismatch appears when a read-first process feeds a design that needs read-new and no bypass forwards din. The fix mirrors 4b: a registered comparator and a concurrent mux on dout.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: read-first RAM, design assumes read-new → stale data on collision.
entity fwd_buf_bad is
generic ( WIDTH : positive := 32; AW : positive := 6 );
port ( clk, we : in std_logic;
waddr, raddr : in std_logic_vector(AW-1 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 fwd_buf_bad is
type mem_t is array (0 to 2**AW-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t;
begin
process (clk) begin
if rising_edge(clk) then
dout <= mem(to_integer(unsigned(raddr))); -- read-first (OLD)
if we = '1' then mem(to_integer(unsigned(waddr))) <= din; end if;
end if;
end process;
end architecture;
-- FIXED: read-first RAM + write-forwarding bypass → read-new on collision.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity fwd_buf_good is
generic ( WIDTH : positive := 32; AW : positive := 6 );
port ( clk, we : in std_logic;
waddr, raddr : in std_logic_vector(AW-1 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 fwd_buf_good is
type mem_t is array (0 to 2**AW-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t;
signal mem_q, din_q : std_logic_vector(WIDTH-1 downto 0);
signal collide : std_logic;
begin
process (clk)
variable wi, ri : integer;
begin
if rising_edge(clk) then
wi := to_integer(unsigned(waddr));
ri := to_integer(unsigned(raddr));
mem_q <= mem(ri);
if we = '1' then mem(wi) <= din; end if;
if we = '1' and ri = wi then collide <= '1'; else collide <= '0'; end if;
din_q <= din;
end if;
end process;
dout <= din_q when collide = '1' else mem_q; -- forward NEW on collision
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity rdw_bug_tb is
end entity;
architecture sim of rdw_bug_tb is
constant WIDTH : positive := 32;
constant AW : positive := 6;
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);
begin
clk <= not clk after 5 ns;
-- Bind to fwd_buf_good to PASS; to fwd_buf_bad to observe the stale read.
dut : entity work.fwd_buf_good
generic map (WIDTH => WIDTH, AW => AW)
port map (clk => clk, we => we, waddr => waddr, raddr => raddr, din => din, dout => dout);
stim : process
begin
wait until falling_edge(clk); -- seed OLD at addr 3
we <= '1'; waddr <= std_logic_vector(to_unsigned(3, AW));
din <= x"11110000"; raddr <= std_logic_vector(to_unsigned(3, AW));
wait until falling_edge(clk); we <= '0';
wait until rising_edge(clk); wait for 1 ns;
wait until falling_edge(clk); -- COLLISION: NEW to 3, read 3
we <= '1'; waddr <= std_logic_vector(to_unsigned(3, AW));
raddr <= std_logic_vector(to_unsigned(3, AW)); din <= x"22220000";
wait until rising_edge(clk); wait for 1 ns;
assert dout = x"22220000" -- read-new expected
report "collision: dout /= NEW (stale-read bug — missing bypass)" severity error;
report "rdw_bug self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: read latency and read-during-write are the memory's timing contract — model the read as registered (pipeline for its latency), pin down the RDW mode to match the target block RAM, and add a write-forwarding bypass when you need read-new on a read-old RAM.
5. Verification Strategy
A synchronous memory's correctness is no longer a pure truth table — it is a timing contract, so verification must be clocked and must check when data appears and what a collision returns, not just the steady-state mapping. The invariants that capture a correct read-timed memory are:
Read data appears exactly N cycles after its address (N=1 registered, N=2 with the output register). A same-address read-during-write returns the value dictated by the modelled RDW mode — old for read-first, new for write-first — and if the design needs read-new on a read-old RAM, the bypass forwards
dinon exactly the colliding cycle and nowhere else.
Everything below is that contract made checkable, and it maps onto the clocked testbenches in §4.
- Verify the read latency (data appears N cycles after the address). Present a known address on cycle N, then sample
douton cycle N+1 (one-cycle latency) and assert it equals the seeded contents. Deliberately sample on cycle N too and confirm it is not yet the requested data — proving the read is registered. This catches the read-latency off-by-one directly: a reader that pipelines by the wrong number of cycles linesdoutup with the wrong address. For a two-cycle configuration (output register), the check moves to N+2. - Verify each RDW mode by reading and writing the SAME address the same cycle. Drive
raddr == waddrwithwehigh on one edge, then sampledouton the next (latency-aware) edge and assert old for read-first and new for write-first — exactly what the §4a dual-DUT testbench does (assert dout_rf === OLD,assert dout_wf === NEW). This is the single most important corner and the one naive suites skip. Also drive the non-colliding case (differentraddr/waddr) and confirmdoutis the memory value, so the collision path does not leak into normal reads. - Verify the bypass path forwards correctly. For the write-forwarding wrapper, assert
doutis the freshly-writtendinon a collision (read-new) and is the memory contents on a non-collision — the §4b testbench checks both, so the bypass mux is proven to fire only on the collision and to leave the normal read path untouched. - Parameter-generic verification (WIDTH, DEPTH). A parameterized memory must be verified generic, not assumed generic. Re-run the latency and collision checks across
WIDTH = 8, 32andDEPTH = 16, 256(a non-power-of-two depth stresses the address-width computation), and both RDW modes. A wrapper that passes at one width can still mis-aligndin_qor the comparator at another. - Failure modes → the DebugLab. The break this section guards against is the RDW mismatch of §7: a design that needs read-new on a read-first RAM with no bypass reads stale data only on same-address collisions. The collision test above is precisely the test that fails on the buggy DUT and passes on the fixed one — bind the testbench to
fwd_buf_badto watch it fail, tofwd_buf_goodto watch it pass. - Expected waveform. On a correct run,
doutlagsraddrby exactly one clock (two with the output register): asraddrsteps,doutshows each addressed location one cycle later. At a same-address collision,douton the next edge is the old value for a read-first memory and the new value for a write-first memory or a bypassed one. The visual signature of the bug isdoutshowing the old value at a collision when the design expected the new — a single wrong sample that only appears when read and write addresses coincide.
6. Common Mistakes
Read-during-write mode mismatch (the signature bug). The RTL model assumes read-new (write-first) but the target block RAM implements read-first (or vice-versa). On same-address collisions the reader gets the wrong generation of data — stale on a read-first RAM where read-new was expected. It passes every non-colliding test and corrupts only at the collision, so it survives block-level sim and surfaces on the FPGA or in gate-level sim. The fix is to match the modelled RDW mode to the target macro, or add the write-forwarding bypass (§4b) when you need read-new on a read-old RAM. Post-mortem in §7.
Read-latency off-by-one. Sampling the registered read data a cycle too early — treating a synchronous read as combinational — so the reader consumes the previous address's data. The pipeline that surrounds the RAM must delay everything correlated with the read (the address, a tag, a valid bit) by the same one cycle (two with an output register). The tell is data that is consistently shifted by one location; it hides until you pipeline something around the read. Verify by proving dout is valid at N+1, not N (§5).
Relying on an undefined RDW behaviour. A no-change block RAM leaves the read output undefined (or holding its prior value) on a same-address collision — that is the lowest-power mode. Depending on the collision result of a no-change RAM is a bug even if it happens to "work" in one simulator: the behaviour is not guaranteed and can differ between sim and silicon. If you need a defined collision result, use a write-first or read-first RAM (whose result is defined) or add a bypass; never build logic on top of an undefined collision.
Forgetting the bypass when read-new is required. Even with the RDW mode correctly identified as read-first, a design that needs the freshly-written value on a collision (a pipeline forwarding path, a FIFO at its boundary) is wrong without a write-forwarding bypass. The comparator (raddr == waddr && we) and the forwarding mux are not decoration — they are the mechanism that makes a read-first RAM behave read-new to the reader. Omitting them is the §7 failure.
Not registering din / the comparator to the read latency in the bypass. The forwarded data and the collision flag must be delayed to line up with the registered memory output. Forward the unregistered din against a one-cycle-late mem_q and the mux swaps in data on the wrong cycle — the bypass then corrupts the read it was meant to fix. Register din and the compare result by the same N cycles as the read (§4b).
Assuming a distributed-RAM (LUTRAM) read behaves like a block RAM. Distributed/LUT RAM can offer an asynchronous (combinational) read — zero read latency — while a block RAM's read is registered (one cycle, optionally two). Porting logic from one to the other without adjusting the pipeline stages re-introduces the read-latency off-by-one in the opposite direction. The read latency is part of the memory's contract; changing the memory implementation changes the contract.
7. DebugLab
The FIFO that returned yesterday's data — a read-during-write mode mismatch
The engineering lesson: a synchronous memory has a timing contract — a read latency and a read-during-write policy — and both are properties of the target block RAM, not free choices your RTL can assume away. The tell is a bug that depends on access timing ("the read returns stale data only when it hits the address being written this cycle"): timing-dependence in something you treated as an instant lookup means you left a clause of the memory's contract unmodelled. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: pin down the RDW mode and match it to the target (read-first, write-first, or no-change — never rely on undefined), and add a write-forwarding bypass whenever the design needs read-new on a read-old RAM — plus always pipeline for the read latency so the reader samples dout on the cycle it is actually valid.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "model the memory's timing contract, then match the target" habit.
Exercise 1 — Name the mode, name the value
For each scenario, state the read-during-write mode you'd expect and what a same-address collision returns: (a) an RTL block that reads the array before writing it inside the clocked process; (b) an RTL block that writes the array before reading it; (c) a low-power block RAM configured for its cheapest collision mode; (d) a read-first RAM wrapped in a write-forwarding bypass. For each, say in one line whether the collision result is old, new, or undefined, and why the read/write ordering (or the bypass) produces it.
Exercise 2 — Trace the latency
A reader presents raddr = A on cycle 1, raddr = B on cycle 2, and raddr = C on cycle 3, on a synchronous RAM with a one-cycle read latency. (i) On which cycle is mem[A] valid on dout? (ii) Redo it for a RAM with the optional output register enabled (two-cycle latency). (iii) A downstream unit uses dout together with a tag that arrived with each address. Describe exactly how many pipeline stages the tag needs so it lines up with the data, for both latency configurations, and what goes wrong if it has zero.
Exercise 3 — Design the bypass
You are given a read-first block RAM you cannot change, and a forwarding path that requires read-new on a same-address collision. Sketch (in words or RTL) the write-forwarding bypass: name the comparison you register, the signals you must delay to match the read latency, and the mux that produces dout. Then state the one bug that appears if you forward the unregistered din against the one-cycle-late memory output, and how the collision testbench would reveal it.
Exercise 4 — Break it at the boundary
A synchronous FIFO reads and writes its RAM every cycle. Explain where in the FIFO's operation a same-address read-during-write collision can occur (hint: a pointer boundary), why a read-first RAM would return stale data there while a read-new design expected the just-written word, and the two distinct fixes (RDW-mode selection vs bypass) with the trade-off each carries. Then describe the single directed test — the addresses, the write-enable, and the cycle you sample dout — that would have caught the bug before silicon.
10. Related Tutorials
Continue in RTL Design Patterns (forward links unlock as they ship):
- Inferring RAM — Chapter 6.1; the prerequisite — how to write RTL that a synthesis tool maps onto a block RAM (the clocked write, the array, the addressed read) that this page adds a timing contract to.
- Register Files — Chapter 6.3; a multi-ported register file where read-during-write forwarding between a write port and the read ports is a first-class design concern.
- ROM & Initialization — Chapter 6.4; a read-only memory whose read timing is the same registered read, without the write-collision half of the contract.
- Dual-Port RAM — Chapter 6.x; two ports sharing one array, where read-during-write between the ports is the central timing question this page introduces.
- Synchronous FIFO Architecture — Chapter 7.1; the FIFO built on this RAM, where the read latency and the RDW mode shape the read data path.
- FIFO Full/Empty Logic — Chapter 7.2; the boundary where a same-address read-during-write collision actually happens, and where the bypass earns its keep.
Backward / method:
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this page applies to a memory (read timing is interface, the RDW policy is state + verification).
- What Is an RTL Design Pattern? — Chapter 0.1; why read timing earns the name "pattern" — a recurring problem, a reusable form (registered read + RDW mode + bypass), and a signature failure.
- The Register Pattern (D-FF) — Chapter 2.1; the registered read output is a D flop — the read latency is that register, so this page is the register pattern on a memory's read path.
- Multiplexers (2:1, N:1, one-hot) — Chapter 1.1; the write-forwarding bypass is a 2:1 mux on the read path, selected by the collision comparator.
- Comparators & Equality — Chapter 5.x; the
raddr == waddrequality that detects the read-during-write collision and drives the bypass mux. - Synchronous Reset — Chapter 2.x; block-RAM contents and read registers typically have no reset — the reset discipline that explains why the read register (not the array) is what you may reset.
- FSM + Datapath — Chapter 4.6; the control/datapath split in which a memory's read latency forces the controller to wait the right number of cycles for read data.
11. Summary
- A synchronous RAM is a pipeline with a collision policy, not an instant lookup. Two clauses of its timing contract break real designs: the read latency (data is registered, valid N cycles after the address) and the read-during-write mode (what a same-address collision returns). Model both explicitly and match them to the target block RAM.
- Read latency is one cycle — two with the output register. The read output is a flop, so
doutreflects the address you gave N cycles ago. The reader must pipeline everything correlated with the read (address, tag, valid) by the same N, or it commits the read-latency off-by-one — consuming the previous location's data. - Read-during-write has three modes, selected by the read/write ordering. Read-first (read the array before the write → old value), write-first (write before read → new value / transparent), and no-change (undefined on collision — never depend on it). Which mode a block RAM implements is a per-technology property; your RTL model must match it, or same-address collisions diverge between sim and silicon.
- When you need read-new on a read-old RAM, add a write-forwarding bypass. Compare the registered read and write addresses (
raddr == waddr && we) and, on a collision, forward the registereddinontodoutin place of the stale memory output — a comparator (5.x) plus a mux (1.1) that makes a read-first RAM behave read-new. Registerdinand the compare to the read latency, or the bypass fires on the wrong cycle. - Verify the contract, not just the mapping — with clocked, self-checking testbenches. Prove
doutis valid at N+1 (read latency); read and write the same address the same cycle and assert old for read-first, new for write-first; prove the bypass forwards only on the collision. The RDW mismatch (§7) — a read-old RAM where the design assumed read-new, no bypass — passes every non-colliding test and returns stale data exactly at the collision; the deliberate collision test is what catches it. Shown here in SystemVerilog, Verilog, and VHDL.