Dual-Port RAM Verilog Code: Simple and True Dual-Port

A dual-port RAM is an on-chip memory that exposes two access paths to the same storage array, so two agents can touch memory in the same clock cycle. On FPGAs these map directly onto Block RAM primitives, which is why getting the inference pattern right matters: write the HDL the way the tools expect and you get a hard RAM block instead of a pile of flip-flops. This tutorial builds dual port RAM Verilog code from the ground up in synthesizable Verilog-2005, starting with a simple (pseudo) dual-port where one port only writes and the other only reads, then a true dual-port where both ports read and write, plus an asynchronous-read variant. Every module is parameterized on data and address width, and each ships with a self-checking testbench you can run in Icarus Verilog.

A parameterized simple dual-port RAM

The most common case is one dedicated write port and one dedicated read port, each with its own clock. This “simple dual-port” (SDP) layout is what you want for FIFOs, line buffers, and clock-crossing stores. The read output here is registered, meaning data appears one clock after you present the address. That single register is what lets the synthesizer map the array onto a Block RAM with a built-in output register.

// Simple (pseudo) dual-port RAM: one write port, one read port.
// Each port has its own clock, so the read and write sides are independent.
module dp_ram_simple #(
  parameter DATA_WIDTH = 8,
  parameter ADDR_WIDTH = 4
)(
  input                        wclk,
  input                        we,
  input       [ADDR_WIDTH-1:0] waddr,
  input       [DATA_WIDTH-1:0] wdata,
  input                        rclk,
  input       [ADDR_WIDTH-1:0] raddr,
  output reg  [DATA_WIDTH-1:0] rdata
);
  localparam DEPTH = 1 << ADDR_WIDTH;
  reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];

  // Write port
  always @(posedge wclk) begin
    if (we)
      mem[waddr] <= wdata;
  end

  // Read port: registered (synchronous) output, one-cycle latency
  always @(posedge rclk) begin
    rdata <= mem[raddr];
  end
endmodule

module tb_dp_ram_simple;
  localparam DW = 8, AW = 4;
  reg             clk = 1'b0;
  reg             we  = 1'b0;
  reg  [AW-1:0]   waddr = 0, raddr = 0;
  reg  [DW-1:0]   wdata = 0;
  wire [DW-1:0]   rdata;
  integer i;

  dp_ram_simple #(.DATA_WIDTH(DW), .ADDR_WIDTH(AW)) dut (
    .wclk(clk), .we(we), .waddr(waddr), .wdata(wdata),
    .rclk(clk), .raddr(raddr), .rdata(rdata)
  );

  always #5 clk = ~clk;  // 10 ns period

  initial begin
    // Fill addresses 0..3 with 0xA0..0xA3
    for (i = 0; i < 4; i = i + 1) begin
      @(negedge clk);
      we    = 1'b1;
      waddr = i[AW-1:0];
      wdata = 8'hA0 + i[DW-1:0];
    end
    @(negedge clk);
    we = 1'b0;

    // Read back (one-cycle latency on a registered read port)
    for (i = 0; i < 4; i = i + 1) begin
      @(negedge clk);
      raddr = i[AW-1:0];
      @(posedge clk);
      #1 $display("read  addr=%0d  rdata=0x%02h", i, rdata);
    end
    $finish;
  end
endmodule
read  addr=0  rdata=0xa0
read  addr=1  rdata=0xa1
read  addr=2  rdata=0xa2
read  addr=3  rdata=0xa3
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpo6wd6biw/dp_ram_simple.v:64: $finish called at 96 (1s)

The testbench writes four bytes, deasserts the write enable, then reads them back. Because the read is registered, the code asserts the address, waits for the next rising edge, and only then samples rdata. The log confirms each address returns the value that was written to it.

True dual-port RAM (two read/write ports)

A true dual-port (TDP) RAM gives each of its two ports a full read/write capability against the shared array. This is what you reach for when two subsystems both need to update a shared scratchpad, or for a register file read/written from two pipelines. Each port owns a write enable, an address, a data-in bus, and a registered data-out bus. The pattern below is read-first: on a write, the data-out reflects the old contents of that location, not the value just written.

// True dual-port RAM: two ports, each able to read AND write, sharing one array.
module tdp_ram #(
  parameter DATA_WIDTH = 8,
  parameter ADDR_WIDTH = 4
)(
  input                        clk,
  // Port A
  input                        we_a,
  input       [ADDR_WIDTH-1:0] addr_a,
  input       [DATA_WIDTH-1:0] din_a,
  output reg  [DATA_WIDTH-1:0] dout_a,
  // Port B
  input                        we_b,
  input       [ADDR_WIDTH-1:0] addr_b,
  input       [DATA_WIDTH-1:0] din_b,
  output reg  [DATA_WIDTH-1:0] dout_b
);
  localparam DEPTH = 1 << ADDR_WIDTH;
  reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];

  // Port A (read-first: dout gets the OLD contents on a write)
  always @(posedge clk) begin
    if (we_a)
      mem[addr_a] <= din_a;
    dout_a <= mem[addr_a];
  end

  // Port B (read-first)
  always @(posedge clk) begin
    if (we_b)
      mem[addr_b] <= din_b;
    dout_b <= mem[addr_b];
  end
endmodule

module tb_tdp_ram;
  localparam DW = 8, AW = 4;
  reg            clk = 1'b0;
  reg            we_a = 0, we_b = 0;
  reg  [AW-1:0]  addr_a = 0, addr_b = 0;
  reg  [DW-1:0]  din_a = 0, din_b = 0;
  wire [DW-1:0]  dout_a, dout_b;

  tdp_ram #(.DATA_WIDTH(DW), .ADDR_WIDTH(AW)) dut (
    .clk(clk),
    .we_a(we_a), .addr_a(addr_a), .din_a(din_a), .dout_a(dout_a),
    .we_b(we_b), .addr_b(addr_b), .din_b(din_b), .dout_b(dout_b)
  );

  always #5 clk = ~clk;  // 10 ns period

  initial begin
    // Cycle 1: Port A writes 0x11 -> addr 2, Port B writes 0x22 -> addr 5
    @(negedge clk);
    we_a = 1; addr_a = 2; din_a = 8'h11;
    we_b = 1; addr_b = 5; din_b = 8'h22;

    // Cycle 2: Port A reads addr 5, Port B reads addr 2 (cross read-back)
    @(negedge clk);
    we_a = 0; addr_a = 5;
    we_b = 0; addr_b = 2;

    // Let the reads register out
    @(posedge clk);
    #1 $display("A reads addr5 -> 0x%02h   B reads addr2 -> 0x%02h", dout_a, dout_b);

    // Cycle 3: both ports write different data to the SAME address (6)
    @(negedge clk);
    we_a = 1; addr_a = 6; din_a = 8'hAA;
    we_b = 1; addr_b = 6; din_b = 8'hBB;  // write-write conflict: result is racey

    @(negedge clk);
    we_a = 0; addr_a = 6;
    we_b = 0; addr_b = 6;
    @(posedge clk);
    #1 $display("addr6 after same-address write: dout_a=0x%02h dout_b=0x%02h", dout_a, dout_b);

    $finish;
  end
endmodule
A reads addr5 -> 0x22   B reads addr2 -> 0x11
addr6 after same-address write: dout_a=0xbb dout_b=0xbb
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpdutkb7_b/tdp_ram.v:78: $finish called at 46 (1s)

The log first shows a clean cross read-back: port A reads what port B just stored and vice versa. The second line is the interesting one. When both ports write different data to the same address in the same cycle, the outcome is a write-write collision and the stored value is whichever assignment the simulator schedules last. Hardware behaves no better, so your design must never let two ports write the same location simultaneously.

An asynchronous-read variant

Sometimes you want data the instant the address changes, with no clock on the read side. This asynchronous (combinational) read maps to LUT-based distributed RAM rather than Block RAM, and is handy for small, latency-critical tables.

// Dual-port RAM with a synchronous write port and an ASYNCHRONOUS read port.
// The read data follows raddr combinationally (no clock, zero latency).
module dp_ram_async #(
  parameter DATA_WIDTH = 8,
  parameter ADDR_WIDTH = 4
)(
  input                    clk,
  input                    we,
  input   [ADDR_WIDTH-1:0] waddr,
  input   [DATA_WIDTH-1:0] wdata,
  input   [ADDR_WIDTH-1:0] raddr,
  output  [DATA_WIDTH-1:0] rdata
);
  localparam DEPTH = 1 << ADDR_WIDTH;
  reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];

  always @(posedge clk)
    if (we)
      mem[waddr] <= wdata;

  // Asynchronous (combinational) read: no register, data available same cycle
  assign rdata = mem[raddr];
endmodule

module tb_dp_ram_async;
  localparam DW = 8, AW = 4;
  reg            clk = 1'b0;
  reg            we  = 1'b0;
  reg  [AW-1:0]  waddr = 0, raddr = 0;
  reg  [DW-1:0]  wdata = 0;
  wire [DW-1:0]  rdata;
  integer i;

  dp_ram_async #(.DATA_WIDTH(DW), .ADDR_WIDTH(AW)) dut (
    .clk(clk), .we(we), .waddr(waddr), .wdata(wdata),
    .raddr(raddr), .rdata(rdata)
  );

  always #5 clk = ~clk;  // 10 ns period

  initial begin
    // Write 0x30..0x32 into addresses 0..2
    for (i = 0; i < 3; i = i + 1) begin
      @(negedge clk);
      we    = 1'b1;
      waddr = i[AW-1:0];
      wdata = 8'h30 + i[DW-1:0];
    end
    @(negedge clk);
    we = 1'b0;

    // Asynchronous read: just set the address and look, no clock edge needed
    for (i = 0; i < 3; i = i + 1) begin
      raddr = i[AW-1:0];
      #1 $display("async read addr=%0d -> rdata=0x%02h", i, rdata);
    end
    $finish;
  end
endmodule
async read addr=0 -> rdata=0x30
async read addr=1 -> rdata=0x31
async read addr=2 -> rdata=0x32
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpvcu67ptg/dp_ram_async.v:57: $finish called at 43 (1s)

Notice the testbench reads without waiting for any clock edge: it sets raddr, waits one time unit for the combinational path to settle, and prints. The data is available in the same cycle the address is valid.

How the code works

Three ideas carry across all three modules:

  • Parameters keep the RAM reusable. DATA_WIDTH sets the word size and ADDR_WIDTH sets the number of address bits, so DEPTH is 1 << ADDR_WIDTH. Override them at instantiation and the same source builds an 8×16 or a 32×1024 memory.
  • The memory array is a 2D reg: reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]. Writes use a clocked if (we) so only the addressed word updates; everything else holds its value.
  • Read style sets the port type. A read inside always @(posedge clk) produces a registered output and Block RAM; a continuous assign produces a combinational output and distributed RAM. The two ports are independent because each lives in its own always block or expression.

Use nonblocking assignments (<=) in every clocked block. That models register behavior correctly and keeps read and write ordering deterministic in simulation.

Common mistakes

MistakeWhat goes wrong
Using blocking = in a clocked RAM blockCreates simulation races and a read/write ordering that does not match hardware; always use <=.
Expecting zero read latency from a registered portA registered read returns data one clock late. Sampling rdata in the same cycle you set the address reads stale data.
Adding a reset to the memory arrayBlock RAM has no asynchronous reset. A reset on mem blocks BRAM inference and forces logic fabric instead.
Writing the same address from both ports at onceTrue dual-port write-write collisions are undefined. Arbitrate in logic so only one port writes a given address per cycle.
Hardcoding depth or width as literalsBreaks reuse and invites off-by-one bugs; derive DEPTH from ADDR_WIDTH.
Mixing read-first and write-first without decidingThe collision behavior on a same-address read-while-write differs; pick one intentionally and document it.

Quick check

Why is DEPTH written as 1 << ADDR_WIDTH instead of a fixed number?

Because the number of addressable words is 2 raised to the address-bit count. Deriving it from ADDR_WIDTH keeps the module parameterized, so one source scales to any depth without edits or off-by-one errors.

What is the difference between the registered read and the asynchronous read?

The registered read lives in always @(posedge clk), so data appears one clock after the address and typically infers Block RAM. The asynchronous read uses a continuous assign, so data follows the address combinationally with zero latency and typically infers distributed (LUT) RAM.

In the true dual-port example, why do both ports read 0xBB after writing the same address?

Both ports wrote different data to address 6 in the same cycle. That is a write-write collision with no defined winner; the last scheduled assignment wins. The design must prevent two ports from writing the same location simultaneously.

Why should you avoid resetting the mem array?

Dedicated Block RAM primitives have no reset on their storage. Coding a reset over the whole array stops the tool from inferring BRAM and pushes the memory into flip-flops, wasting fabric and hurting timing.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.
▶ Try it in the ChipXpert Lab

Open this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.

Run it in the Lab

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