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
endmoduleread 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
endmoduleA 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
endmoduleasync 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_WIDTHsets the word size andADDR_WIDTHsets the number of address bits, soDEPTHis1 << 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 clockedif (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 continuousassignproduces 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
| Mistake | What goes wrong |
|---|---|
Using blocking = in a clocked RAM block | Creates simulation races and a read/write ordering that does not match hardware; always use <=. |
| Expecting zero read latency from a registered port | A 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 array | Block 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 once | True 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 literals | Breaks reuse and invites off-by-one bugs; derive DEPTH from ADDR_WIDTH. |
| Mixing read-first and write-first without deciding | The 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.
Share your question in comments or talk to our mentor team for batch guidance.
Open this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.
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