True Dual-Port RAM in Verilog: Two R/W Ports with Code

A true dual-port (TDP) RAM is a memory block with two completely independent access ports. Each port has its own clock, address, data-in, write-enable and data-out, and each port can both read and write. This is the key difference from a simple dual-port RAM, where one port is read-only and the other write-only.

This page gives you working, simulator-verified true dual port RAM Verilog code: a parameterized TDP RAM with registered reads, plus a second version that lets you pick read-first or write-first behavior on the read port. Both come with self-checking testbenches so you can see exactly what each port reads back and how a same-address collision behaves.

A parameterized true dual-port RAM. Both ports (A and B) have their own clock, address, data and write-enable, both can read and write, and reads are registered (one-cycle latency, read-old-on-write). The testbench writes different data through each port, reads it back on both, and performs cross-reads where each port sees data written by the other.

module tdp_ram #(
    parameter DW = 8,
    parameter AW = 4
)(
    input               clka,
    input      [AW-1:0] addra,
    input      [DW-1:0] dina,
    input               wea,
    output reg [DW-1:0] douta,
    input               clkb,
    input      [AW-1:0] addrb,
    input      [DW-1:0] dinb,
    input               web,
    output reg [DW-1:0] doutb
);
    reg [DW-1:0] mem [0:(1<<AW)-1];

    // Port A: independent clock, read-first (registered read returns old data)
    always @(posedge clka) begin
        if (wea) mem[addra] <= dina;
        douta <= mem[addra];
    end

    // Port B: fully independent clock, address, data and write-enable
    always @(posedge clkb) begin
        if (web) mem[addrb] <= dinb;
        doutb <= mem[addrb];
    end
endmodule

module tb;
    localparam DW = 8;
    localparam AW = 4;
    reg              clka, clkb;
    reg  [AW-1:0]    addra, addrb;
    reg  [DW-1:0]    dina, dinb;
    reg              wea, web;
    wire [DW-1:0]    douta, doutb;
    integer errors;

    tdp_ram #(.DW(DW), .AW(AW)) dut (
        .clka(clka), .addra(addra), .dina(dina), .wea(wea), .douta(douta),
        .clkb(clkb), .addrb(addrb), .dinb(dinb), .web(web), .doutb(doutb)
    );

    // Two independent (asynchronous) clocks
    initial clka = 0;  always #5 clka = ~clka;   // 100 MHz
    initial clkb = 0;  always #7 clkb = ~clkb;   // ~71 MHz

    initial begin
        errors = 0;
        wea = 0; web = 0; addra = 0; addrb = 0; dina = 0; dinb = 0;

        // --- Port A writes 0xA5 @ addr 3 (drive on negedge, hold across posedge) ---
        @(negedge clka); addra = 4'd3; dina = 8'hA5; wea = 1;
        @(negedge clka); wea = 0;

        // --- Port B writes 0x3C @ addr 9 ---
        @(negedge clkb); addrb = 4'd9; dinb = 8'h3C; web = 1;
        @(negedge clkb); web = 0;

        // --- Port A reads back addr 3 ---
        @(negedge clka); addra = 4'd3; wea = 0;
        @(posedge clka); #1;
        if (douta !== 8'hA5) begin
            errors = errors + 1; $display("FAIL portA read addr3 got %02h exp A5", douta);
        end else $display("PASS portA read addr3 = %02h", douta);

        // --- Port B reads back addr 9 ---
        @(negedge clkb); addrb = 4'd9; web = 0;
        @(posedge clkb); #1;
        if (doutb !== 8'h3C) begin
            errors = errors + 1; $display("FAIL portB read addr9 got %02h exp 3C", doutb);
        end else $display("PASS portB read addr9 = %02h", doutb);

        // --- Cross reads: each port sees data written by the OTHER port ---
        @(negedge clkb); addrb = 4'd3; web = 0;
        @(posedge clkb); #1;
        if (doutb !== 8'hA5) begin
            errors = errors + 1; $display("FAIL portB cross-read addr3 got %02h exp A5", doutb);
        end else $display("PASS portB cross-read addr3 = %02h", doutb);

        @(negedge clka); addra = 4'd9; wea = 0;
        @(posedge clka); #1;
        if (douta !== 8'h3C) begin
            errors = errors + 1; $display("FAIL portA cross-read addr9 got %02h exp 3C", douta);
        end else $display("PASS portA cross-read addr9 = %02h", douta);

        if (errors == 0) $display("ALL TESTS PASSED");
        else $display("TESTS FAILED: %0d", errors);
        $finish;
    end
endmodule
PASS portA read addr3 = a5
PASS portB read addr9 = 3c
PASS portB cross-read addr3 = a5
PASS portA cross-read addr9 = 3c
ALL TESTS PASSED

The log shows port A reading back 0xA5 (which it wrote to address 3) and port B reading back 0x3C (which it wrote to address 9), then each port reading the other port's data at the shared memory array. Because the two clocks run at 5 ns and 7 ns half-periods, the ports are genuinely asynchronous, confirming the single mem array is shared across two independent clock domains.

The same TDP RAM with a WRITE_FIRST parameter that selects the read-during-write behavior. With WRITE_FIRST=0 the read port returns the old stored value (read-first); with WRITE_FIRST=1 it returns the value just written (write-first). The testbench instantiates both modes side by side and drives an identical write-and-read-same-address sequence so the only difference in the outputs is the mode.

module tdp_ram_mode #(
    parameter DW = 8,
    parameter AW = 4,
    parameter WRITE_FIRST = 0   // 0 = read-first (read-old), 1 = write-first (read-new)
)(
    input               clka,
    input      [AW-1:0] addra,
    input      [DW-1:0] dina,
    input               wea,
    output reg [DW-1:0] douta,
    input               clkb,
    input      [AW-1:0] addrb,
    input      [DW-1:0] dinb,
    input               web,
    output reg [DW-1:0] doutb
);
    reg [DW-1:0] mem [0:(1<<AW)-1];

    always @(posedge clka) begin
        if (WRITE_FIRST) begin
            if (wea) begin mem[addra] <= dina; douta <= dina; end  // read-new
            else           douta <= mem[addra];
        end else begin
            if (wea) mem[addra] <= dina;
            douta <= mem[addra];                                   // read-old
        end
    end

    always @(posedge clkb) begin
        if (WRITE_FIRST) begin
            if (web) begin mem[addrb] <= dinb; doutb <= dinb; end
            else           doutb <= mem[addrb];
        end else begin
            if (web) mem[addrb] <= dinb;
            doutb <= mem[addrb];
        end
    end
endmodule

module tb;
    localparam DW = 8;
    localparam AW = 4;
    reg              clk;
    reg  [AW-1:0]    addra_r, addrb_r;
    reg  [DW-1:0]    dina_r, dinb_r;
    reg              wea_r, web_r;
    wire [DW-1:0]    douta_rf, doutb_rf;   // read-first instance
    wire [DW-1:0]    douta_wf, doutb_wf;   // write-first instance
    integer errors;

    // Both instances driven by the same stimulus so outputs differ only by mode
    tdp_ram_mode #(.DW(DW), .AW(AW), .WRITE_FIRST(0)) rf (
        .clka(clk), .addra(addra_r), .dina(dina_r), .wea(wea_r), .douta(douta_rf),
        .clkb(clk), .addrb(addrb_r), .dinb(dinb_r), .web(web_r), .doutb(doutb_rf)
    );
    tdp_ram_mode #(.DW(DW), .AW(AW), .WRITE_FIRST(1)) wf (
        .clka(clk), .addra(addra_r), .dina(dina_r), .wea(wea_r), .douta(douta_wf),
        .clkb(clk), .addrb(addrb_r), .dinb(dinb_r), .web(web_r), .doutb(doutb_wf)
    );

    initial clk = 0;  always #5 clk = ~clk;

    initial begin
        errors = 0;
        wea_r = 0; web_r = 0; addra_r = 0; addrb_r = 0; dina_r = 0; dinb_r = 0;

        // Preload addr 5 with 0x11 via port A
        @(negedge clk); addra_r = 4'd5; dina_r = 8'h11; wea_r = 1;
        @(negedge clk); wea_r = 0;

        // Collision cycle: write 0x22 to addr 5 AND read addr 5 on the same edge
        @(negedge clk); addra_r = 4'd5; dina_r = 8'h22; wea_r = 1;
        @(posedge clk); #1;              // sample the output registered on THIS edge
        if (douta_rf !== 8'h11) begin
            errors = errors + 1; $display("FAIL read-first expected OLD 11 got %02h", douta_rf);
        end else $display("PASS read-first returned OLD data = %02h", douta_rf);
        if (douta_wf !== 8'h22) begin
            errors = errors + 1; $display("FAIL write-first expected NEW 22 got %02h", douta_wf);
        end else $display("PASS write-first returned NEW data = %02h", douta_wf);
        @(negedge clk); wea_r = 0;

        // Both memories must now hold 0x22 (clean registered read via port B)
        @(negedge clk); addrb_r = 4'd5; web_r = 0;
        @(posedge clk); #1;
        if (doutb_rf !== 8'h22 || doutb_wf !== 8'h22) begin
            errors = errors + 1; $display("FAIL post-write mem rf=%02h wf=%02h exp 22", doutb_rf, doutb_wf);
        end else $display("PASS memory holds updated value 22 on both instances");

        if (errors == 0) $display("ALL TESTS PASSED");
        else $display("TESTS FAILED: %0d", errors);
        $finish;
    end
endmodule
PASS read-first returned OLD data = 11
PASS write-first returned NEW data = 22
PASS memory holds updated value 22 on both instances
ALL TESTS PASSED

On the collision cycle the read-first instance outputs the old data 0x11 while the write-first instance outputs the freshly written 0x22, proving the two behaviors diverge exactly as intended. A following clean read on port B shows both memories now hold 0x22, confirming the write landed in storage in both modes and only the read-out timing differs.

Simple dual-port vs true dual-port RAM

These two are often confused, but they are different hardware primitives:

  • Simple dual-port (SDP): one write-only port and one read-only port. Good for FIFOs, line buffers and streaming where producer and consumer are distinct.
  • True dual-port (TDP): two full read/write ports. Either port may read or write any address on any cycle. Needed when two masters (two CPUs, a CPU and a DMA engine, two pipeline stages) both need to modify shared memory.

A TDP RAM costs more silicon than an SDP RAM because every bit cell needs two independent access paths, but it is strictly more flexible.

Independent (dual-clock) ports

In a true dual-port RAM each port has its own clock. Example 1 drives clka and clkb at different frequencies on purpose to show the ports are asynchronous. The single mem array is shared, so a value written by one clock domain becomes readable from the other after it has been committed.

When the two clocks are asynchronous, crossing data between them is a clock-domain-crossing (CDC) problem. The RAM array itself is safe to share, but you must not assume a value written on port A is visible on port B on a specific port-B edge unless you add your own synchronization/handshake. Treat the address/control buses of each port as belonging solely to that port's clock.

The same-address write collision hazard

The dangerous case in a TDP RAM is when both ports write the same address on the same (aligned) edge, or one writes while the other reads the same address. The outcomes:

  • Write/write to the same address: the stored result is undefined. Hardware block RAMs give no guarantee which port wins; the location may even become corrupted (X). You must prevent this with arbitration or address partitioning.
  • Write on one port, read on the other port, same address: the reading port may see the old data, the new data, or (on real block RAM) X, depending on the device and timing. This is the cross-port read-during-write hazard and is also generally undefined.
  • Write and read on the same port, same address: this is well-defined and controlled by the read-first / write-first choice shown in Example 2.

Golden rule: never rely on the data result of a simultaneous same-address collision between the two ports. Design so it cannot happen.

Read-first vs write-first

Read-during-write on a single port has two standard, synthesizable behaviors:

Read-first (read-old)

if (we) mem[addr] <= din;
dout <= mem[addr];   // sees the OLD contents

Because both assignments are non-blocking, the read samples mem[addr] before the write updates it, so dout gets the previous value.

Write-first (read-new / write-through)

if (we) begin mem[addr] <= din; dout <= din; end
else          dout <= mem[addr];

Here the data being written is forwarded directly to the output, so the port reads back exactly what it just wrote. FPGA vendors expose this as a RAM attribute (for example WRITE_FIRST / READ_FIRST / NO_CHANGE). Match the HDL to the mode you want or the tool may infer extra bypass logic.

Mapping to FPGA block RAM

To get this inferred as a hardware block RAM (BRAM) rather than registers:

  • Use a single shared 2-D reg array indexed by both ports, exactly as shown.
  • Register the read outputs (the dout <= inside the clocked block). Unregistered combinational reads usually prevent BRAM inference and force distributed RAM.
  • Keep each port's logic in its own always @(posedge clk) block so the tool recognizes two true ports.
  • Avoid reset on the RAM contents and avoid byte-enable logic the target BRAM cannot support, or the array may fall back to LUTRAM/flip-flops.

On Xilinx a TDP RAM maps to a block RAM primitive in TDP mode; on Intel/Altera it maps to an M-block in bidirectional dual-port mode. Both require registered outputs and one write-enable per port, which is precisely the structure in the examples above.

FAQ

Can both ports write at the same time?

Yes, as long as they target different addresses. Simultaneous writes to the same address are undefined and must be avoided by design.

Why is the read registered?

Registering the read output is what lets the tool map the memory to a dedicated block RAM and meet timing. It adds one cycle of read latency, which the testbenches account for.

Do the two ports need the same clock?

No. A true dual-port RAM supports two independent clocks. If they are asynchronous, add proper clock-domain-crossing handling around the RAM; the array itself can be shared.

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