A Parameterized Memory Model
The last two articles covered parameters and conditional generate. In this one we use them to build a memory model that we will keep coming back to.
Every FIFO, cache model, register file and SRAM controller testbench needs the same thing underneath: a block of memory you can read and write, sized however the project needs. Build it once, parameterized properly and with its edge cases thought through, and you will reach for it constantly. The FIFO in Section 7.4 is built directly on it.
Single-port RAM, fully parameterized
`timescale 1ns / 1ps
`default_nettype none
module ram_sp #(
parameter DATA_WIDTH = 8,
parameter DEPTH = 256,
parameter ADDR_WIDTH = $clog2(DEPTH)
) (
input wire clk,
input wire we,
input wire [ADDR_WIDTH-1:0] addr,
input wire [DATA_WIDTH-1:0] data_in,
output reg [DATA_WIDTH-1:0] data_out
);
reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we) mem[addr] <= data_in;
data_out <= mem[addr]; // registered read, see below
end
endmodule
Every dimension comes from a parameter: DATA_WIDTH is the word width, DEPTH the number of words, and ADDR_WIDTH derives itself from DEPTH with the $clog2 pattern from the first article, so you never compute or update an address width by hand.
The read-during-write question you cannot skip
Look at the always block again. On a cycle where the write address and the read address are the same, data_out gets the old value at that address, because both the write and the read register update are non-blocking assignments in the same block and the read sees the pre-write contents. This is called read-first behaviour. It is one of three legitimate choices, and every real memory generator makes you pick one.
| Behavior | What happens on a same-address read+write | Typical use |
|---|---|---|
| Read-first | data_out gets the old value; the write commits normally |
FIFOs, pipeline buffers where old data must still be visible one more cycle |
| Write-first | data_out gets the new value being written |
Register files with same-cycle write-then-read forwarding |
| No-change | data_out holds its previous value, unaffected by this write |
Simple ROM-like models, or when the read port is unused during writes |
This is not something to leave ambiguous. Downstream logic depends on one behaviour or the other, and the mismatch is the kind of bug that passes simulation with your behavioural model and then behaves differently when a real memory macro with the opposite choice is dropped in. Here is write-first, for comparison:
always @(posedge clk) begin
if (we) begin
mem[addr] <= data_in;
data_out <= data_in; // write-first: forward the new value immediately
end else begin
data_out <= mem[addr];
end
end
Decide which one your design needs before you write the model. Do not let whichever one you typed first become the accidental specification.
Dual-port RAM: independent read and write addresses
A single-port RAM shares one address bus between reads and writes. Many designs, FIFOs very much included, need a separate read address and write address active on the same clock edge:
module ram_dp #(
parameter DATA_WIDTH = 8,
parameter DEPTH = 256,
parameter ADDR_WIDTH = $clog2(DEPTH)
) (
input wire clk,
input wire we,
input wire [ADDR_WIDTH-1:0] waddr,
input wire [DATA_WIDTH-1:0] wdata,
input wire [ADDR_WIDTH-1:0] raddr,
output reg [DATA_WIDTH-1:0] rdata
);
reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we) mem[waddr] <= wdata;
rdata <= mem[raddr]; // independent read address, read-first if raddr == waddr
end
endmodule
This is the shape a synchronous FIFO needs: a write pointer on waddr, a read pointer on raddr, both from independent counters that only meet at the full and empty boundaries.
Preloading memory for simulation
Testbenches often need a memory filled with known contents before time zero: a boot ROM image, a set of test vectors, a lookup table. You met $readmemh in Module 6. Applied to this model:
initial begin
$readmemh("init_data.hex", mem);
end
Put the initial block inside the memory model. The file is plain text, one hex value per line, and mem fills from index 0. Synthesis tools generally either ignore $readmemh or, for FPGA block RAM, use it to set the initial contents; Xilinx, Intel and Lattice block RAM all support initialisation this way, and it is how boot ROMs and lookup tables get into the bitstream. Check your tool’s documentation either way.
Practice Exercise
Write a testbench for ram_dp that writes a distinct value to every address from 0 to 15 on consecutive cycles, then reads them all back on the following 16 cycles through the independent read port, checking every value. Then do one same-cycle read and write to the same address and confirm the read-first behaviour.
Solution
`timescale 1ns / 1ps
`default_nettype none
module tb_ram_dp;
parameter DATA_WIDTH = 8;
parameter DEPTH = 16;
reg clk = 0, we;
reg [$clog2(DEPTH)-1:0] waddr, raddr;
reg [DATA_WIDTH-1:0] wdata;
wire [DATA_WIDTH-1:0] rdata;
ram_dp #(.DATA_WIDTH(DATA_WIDTH), .DEPTH(DEPTH)) dut (
.clk(clk), .we(we), .waddr(waddr), .wdata(wdata),
.raddr(raddr), .rdata(rdata)
);
always #5 clk = ~clk;
integer i, errors = 0;
initial begin
$dumpfile("tb.vcd");
$dumpvars(0, tb_ram_dp);
we = 0; waddr = 0; wdata = 0; raddr = 0;
@(negedge clk);
// Write a distinct pattern to every address
we = 1;
for (i = 0; i < DEPTH; i = i + 1) begin
waddr = i; wdata = i * 2 + 1; // arbitrary distinct pattern
@(negedge clk); // the write happens at the posedge in between
end
we = 0;
// Read them all back. raddr is set at a negedge, the posedge registers
// mem[raddr] into rdata, and at the following negedge rdata is valid.
for (i = 0; i < DEPTH; i = i + 1) begin
raddr = i;
@(negedge clk);
if (rdata !== i * 2 + 1) begin
$display("FAIL: addr %0d expected %0d got %0d", i, i * 2 + 1, rdata);
errors = errors + 1;
end
end
// Same-cycle read/write to address 5: expect read-first (old value)
waddr = 5; wdata = 8'hFF; we = 1; raddr = 5;
@(negedge clk);
we = 0;
if (rdata === 5 * 2 + 1)
$display("PASS: read-first behaviour, rdata=%0d (old value)", rdata);
else begin
$display("FAIL: expected old value %0d, got %0d", 5 * 2 + 1, rdata);
errors = errors + 1;
end
if (errors == 0) $display("ALL TESTS PASSED");
$finish;
end
endmodule
The detail to internalise is the one-cycle registered read: rdata always reflects the address that was on raddr at the previous clock edge, not the current one. Any design that integrates this memory has to account for that, and the testbench drives at the negative edge so the timing is unambiguous.
A memory model with width, depth and address width all derived from a few parameters, and with a documented read-during-write choice, is one of the more useful pieces of RTL in this course, because it turns up inside nearly everything that follows. In the next article we apply the same discipline to an arithmetic unit and close Section 7.1, before Section 7.2 gets into the signed arithmetic and saturation issues that unit has to get right.