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 we is 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. Because dout is declared reg and 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, dout is 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, use assign 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; or dout = mem[addr]; with = creates simulation-versus-synthesis mismatches and unpredictable collision behavior. Always use non-blocking <= for clocked logic.
  • Declaring dout as wire. A registered output must be reg because it is assigned inside an always block. Leaving it a wire is 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 an initial block or by writing locations explicitly.
  • Mismatched address width. If ADDR_WIDTH does not match 2**ADDR_WIDTH words, 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.

Have questions about this topic?
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
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD