Single-Port RAM Verilog Code with Testbench
Nearly every digital design needs somewhere to park data for a few cycles: a pixel line buffer, a FIFO body, a register file, a lookup table. The workhorse behind all of them is on-chip RAM. In this tutorial you will write clean, synthesizable single-port ram verilog code that is parameterized by width and depth, performs synchronous read and write through one shared address port, and comes with a testbench that writes a handful of locations and reads them back to prove the memory works.
“Single-port” means there is exactly one address bus shared by reads and writes, so you can do one access per clock cycle. “Synchronous” means both the write and the read happen on the rising clock edge, which is what FPGA block RAM and most ASIC memory compilers actually implement. Get this template right and the synthesis tool will map it straight onto a hardware RAM primitive instead of thousands of flip-flops.
Single-port RAM Verilog code
Here is the complete module plus a testbench. The design uses two parameters, DATA_WIDTH for the bits per word and ADDR_WIDTH for the address bus, so the memory is 2**ADDR_WIDTH words deep. The testbench writes 10*i + 5 into locations 0 to 3, then reads the same four locations back.
// single_port_ram.v
// Parameterized synchronous single-port RAM (read-first on collision).
module single_port_ram #(
parameter DATA_WIDTH = 8, // bits per word
parameter ADDR_WIDTH = 4 // 2**ADDR_WIDTH words deep
) (
input wire clk,
input wire we, // write enable
input wire [ADDR_WIDTH-1:0] addr, // shared read/write address
input wire [DATA_WIDTH-1:0] din, // write data
output reg [DATA_WIDTH-1:0] dout // registered read data
);
// Storage: 2**ADDR_WIDTH locations, DATA_WIDTH bits each.
reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1];
always @(posedge clk) begin
if (we)
mem[addr] <= din; // synchronous write
dout <= mem[addr]; // synchronous read: returns OLD contents
end
endmodule
// tb_single_port_ram.v
module tb_single_port_ram;
localparam DATA_WIDTH = 8;
localparam ADDR_WIDTH = 4;
reg clk;
reg we;
reg [ADDR_WIDTH-1:0] addr;
reg [DATA_WIDTH-1:0] din;
wire [DATA_WIDTH-1:0] dout;
integer i;
single_port_ram #(
.DATA_WIDTH(DATA_WIDTH),
.ADDR_WIDTH(ADDR_WIDTH)
) dut (
.clk(clk), .we(we), .addr(addr), .din(din), .dout(dout)
);
// 10 ns clock
initial clk = 1'b0;
always #5 clk = ~clk;
initial begin
we = 0; addr = 0; din = 0;
// ---- WRITE phase: store 10*i + 5 into locations 0..3 ----
for (i = 0; i < 4; i = i + 1) begin
@(negedge clk);
we = 1'b1;
addr = i[ADDR_WIDTH-1:0];
din = 10*i + 5;
@(posedge clk); // write commits here
$display("WRITE addr=%0d din=%0d", addr, din);
end
// ---- READ phase: read the same locations back ----
@(negedge clk);
we = 1'b0;
for (i = 0; i < 4; i = i + 1) begin
@(negedge clk);
addr = i[ADDR_WIDTH-1:0];
@(posedge clk); // dout latches here
#1; // settle after the clock edge
$display("READ addr=%0d dout=%0d", addr, dout);
end
$finish;
end
endmodule
WRITE addr=0 din=5 WRITE addr=1 din=15 WRITE addr=2 din=25 WRITE addr=3 din=35 READ addr=0 dout=5 READ addr=1 dout=15 READ addr=2 dout=25 READ addr=3 dout=35 /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpsxflcar5/single_port_ram.v:70: $finish called at 96 (1s)
How the code works
The storage itself is a two-dimensional register array: reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1];. The first range sets the word width; the second sets the number of words. Nothing about this line is a flip-flop bank yet. Because every access is clocked, synthesis recognizes the pattern and infers a dedicated RAM block.
Everything happens inside a single always @(posedge clk) block:
- Write: when
weis high,mem[addr] <= din;stores the input word at the addressed location on the clock edge. - Read:
dout <= mem[addr];captures the addressed word into the output register on the same edge. Becausedoutis declaredregand assigned with a non-blocking assignment, the read is registered — data appears one cycle after you present the address.
That one-cycle read latency is not a bug; it is exactly how synchronous memory behaves in silicon. The testbench accounts for it by presenting the address, waiting for the next rising edge, and only then sampling dout. Run it and you will see each location return the value that was written to it.
Write-first vs read-first collisions
A subtle but important question: what should the read port return when you write and read the same address on the same cycle? Two answers are common. Read-first returns the old stored value (the memory as it was before this write). Write-first forwards the new value you are writing. The example below instantiates both variants side by side and drives an identical collision so you can watch them diverge.
// spram_collision.v
// Same single-port RAM, with a parameter that selects what happens when a
// write and a read hit the SAME address in the SAME clock cycle.
module spram #(
parameter DATA_WIDTH = 8,
parameter ADDR_WIDTH = 3,
parameter WRITE_FIRST = 0 // 0 = read-first (old data), 1 = write-first (new data)
) (
input wire clk,
input wire we,
input wire [ADDR_WIDTH-1:0] addr,
input wire [DATA_WIDTH-1:0] din,
output reg [DATA_WIDTH-1:0] dout
);
reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1];
always @(posedge clk) begin
if (we) begin
mem[addr] <= din;
if (WRITE_FIRST)
dout <= din; // forward the freshly written value
else
dout <= mem[addr]; // return the value stored before this write
end else begin
dout <= mem[addr];
end
end
endmodule
// tb_spram_collision.v
module tb_spram_collision;
localparam DW = 8, AW = 3;
reg clk, we;
reg [AW-1:0] addr;
reg [DW-1:0] din;
wire [DW-1:0] dout_rf; // read-first instance
wire [DW-1:0] dout_wf; // write-first instance
spram #(.DATA_WIDTH(DW), .ADDR_WIDTH(AW), .WRITE_FIRST(0)) rf (
.clk(clk), .we(we), .addr(addr), .din(din), .dout(dout_rf));
spram #(.DATA_WIDTH(DW), .ADDR_WIDTH(AW), .WRITE_FIRST(1)) wf (
.clk(clk), .we(we), .addr(addr), .din(din), .dout(dout_wf));
initial clk = 0;
always #5 clk = ~clk;
initial begin
we = 0; addr = 0; din = 0;
// Seed address 2 with the value 99.
@(negedge clk); we = 1; addr = 2; din = 99;
@(posedge clk); #1 $display("seed : wrote 99 to addr 2");
// COLLISION cycle: write 123 to addr 2 while reading addr 2.
@(negedge clk); we = 1; addr = 2; din = 123;
@(posedge clk); #1;
$display("collide: read-first dout=%0d write-first dout=%0d", dout_rf, dout_wf);
// Next cycle, plain read of addr 2 (no write): both see the new value.
@(negedge clk); we = 0; addr = 2;
@(posedge clk); #1;
$display("after : read-first dout=%0d write-first dout=%0d", dout_rf, dout_wf);
$finish;
end
endmodule
seed : wrote 99 to addr 2 collide: read-first dout=99 write-first dout=123 after : read-first dout=123 write-first dout=123 /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpd97dy6cb/spram_collision.v:65: $finish called at 36 (1s)
In the collision cycle the read-first instance returns 99 (the value written on the previous cycle) while the write-first instance returns 123 (the value being written right now). One cycle later, with no write in progress, both read back 123. Pick the mode your design actually needs: FIFOs and pipelines often rely on write-first forwarding, while plain data buffers are happy with read-first.
Preloading and self-checking
You can also give the memory known contents at time zero using an initial block with a for loop — handy for coefficient tables or for seeding a test. This last example preloads each location k with k*k, reads them all back with a self-checking testbench, overwrites one word, and confirms the update.
// spram_preload.v
// Single-port RAM preloaded at time 0, with a self-checking testbench
// that overwrites one word and reads it back.
module spram_preload #(
parameter DATA_WIDTH = 8,
parameter ADDR_WIDTH = 3
) (
input wire clk,
input wire we,
input wire [ADDR_WIDTH-1:0] addr,
input wire [DATA_WIDTH-1:0] din,
output reg [DATA_WIDTH-1:0] dout
);
localparam DEPTH = (1 << ADDR_WIDTH);
reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];
integer k;
// Preload: location k holds value k*k (0,1,4,9,...).
initial begin
for (k = 0; k < DEPTH; k = k + 1)
mem[k] = (k*k);
end
always @(posedge clk) begin
if (we)
mem[addr] <= din;
dout <= mem[addr];
end
endmodule
// tb_spram_preload.v
module tb_spram_preload;
localparam DW = 8, AW = 3;
reg clk, we;
reg [AW-1:0] addr;
reg [DW-1:0] din;
wire [DW-1:0] dout;
integer i, errors;
spram_preload #(.DATA_WIDTH(DW), .ADDR_WIDTH(AW)) dut (
.clk(clk), .we(we), .addr(addr), .din(din), .dout(dout));
initial clk = 0;
always #5 clk = ~clk;
initial begin
we = 0; addr = 0; din = 0; errors = 0;
// Read back the preloaded squares and check them.
for (i = 0; i < 8; i = i + 1) begin
@(negedge clk); addr = i[AW-1:0];
@(posedge clk); #1;
if (dout !== (i*i)) begin
errors = errors + 1;
$display("MISMATCH addr=%0d got=%0d want=%0d", addr, dout, i*i);
end else
$display("OK addr=%0d dout=%0d", addr, dout);
end
// Overwrite address 5 with 200, then read it back.
@(negedge clk); we = 1; addr = 5; din = 200;
@(posedge clk);
@(negedge clk); we = 0; addr = 5;
@(posedge clk); #1;
$display("UPDATED addr=5 dout=%0d (expected 200)", dout);
if (errors == 0)
$display("RESULT: all preloaded values verified");
else
$display("RESULT: %0d mismatches", errors);
$finish;
end
endmodule
OK addr=0 dout=0 OK addr=1 dout=1 OK addr=2 dout=4 OK addr=3 dout=9 OK addr=4 dout=16 OK addr=5 dout=25 OK addr=6 dout=36 OK addr=7 dout=49 UPDATED addr=5 dout=200 (expected 200) RESULT: all preloaded values verified /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmp73u9smh5/spram_preload.v:73: $finish called at 106 (1s)
Common mistakes
- Expecting read data in the same cycle. With a registered read,
doutis valid one clock after the address is applied. Sampling it too early reads stale or unknown data. If you genuinely need combinational (same-cycle) read, useassign dout = mem[addr];instead — but know that this infers distributed/LUT RAM, not a block RAM. - Using blocking assignments inside the clocked block. Writing
mem[addr] = din;ordout = mem[addr];with=creates simulation-versus-synthesis mismatches and unpredictable collision behavior. Always use non-blocking<=for clocked logic. - Declaring
doutaswire. A registered output must beregbecause it is assigned inside analwaysblock. Leaving it awireis a compile error. - Adding an asynchronous reset to the memory array. Real RAM blocks have no reset on their contents. Trying to clear every word with
if (rst) mem[i] <= 0;prevents the tool from inferring block RAM and bloats the design into flip-flops. Initialize through aninitialblock or by writing locations explicitly. - Mismatched address width. If
ADDR_WIDTHdoes not match2**ADDR_WIDTHwords, you either waste storage or truncate addresses silently. Keep the depth tied to the address width as shown.
With this template you have a portable, synthesizable single-port memory you can drop into larger designs by changing two parameters. Simulate it first with the included testbench, confirm the read-back matches what you wrote, then integrate it with confidence.
Quick check
Why does dout appear one clock cycle after the address is applied?
Because the read is registered: dout <= mem[addr]; is a non-blocking assignment inside always @(posedge clk), so the addressed word is captured into the output flip-flop on the clock edge and is only valid on the following cycle. This matches how synchronous block RAM behaves in hardware.
How deep (how many words) is a RAM with ADDR_WIDTH = 4?
2 raised to the power 4, which is 16 words. The depth is always 2**ADDR_WIDTH, and each word is DATA_WIDTH bits wide.
During a same-address write-and-read collision, what does a read-first memory return?
The old value — the data stored at that address before the current write. A write-first memory would instead forward the new value being written on that same cycle.
Why should you avoid resetting the entire memory array with an asynchronous reset?
Hardware RAM primitives have no reset on their stored contents, so a per-location reset stops the synthesizer from inferring a block RAM and forces it to build flip-flops instead, wasting area. Use an initial block or explicit writes to set known contents.
Share your question in comments or talk to our mentor team for batch guidance.
Learn Verilog with mentors, labs and placement support
This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.
Explore VLSI Courses