RTL Design Patterns · Chapter 6 · Memories & Register Files
Dual-Port & Simple-Dual-Port RAM
A single-port RAM does only one thing per cycle, but real designs often need to read and write at the same time. A two-port memory lets a producer write one side while a consumer reads the other, at independent addresses. This lesson covers the two shapes it takes: the simple-dual-port RAM, one write port plus one read port, the workhorse behind every FIFO and buffer, and the true-dual-port RAM, two fully independent read and write ports often on two clocks, the substrate under clock-domain crossing. It also makes the cost of the extra bandwidth explicit. When both ports touch the same address in the same cycle you inherit a collision you must define, and when the two ports run on two clocks you inherit a clock crossing you must synchronize. Built and self-check-verified in SystemVerilog, Verilog, and VHDL.
Intermediate16 min readRTL Design PatternsDual-Port RAMSimple-Dual-PortMemory CollisionBlock RAMClock-Domain Crossing
Chapter 6 · Section 6.5 · Memories & Register Files
1. The Engineering Problem
You are building the storage core of a FIFO. A producer upstream hands you a 32-bit word whenever it has one; a consumer downstream takes a word whenever it wants one. The two run at their own pace — the producer might push three words while the consumer takes none, then stall while the consumer drains. Crucially, in a busy cycle both happen at once: the producer writes a new word and the consumer reads an old one, in the same clock cycle. The single-port RAM from 6.1 cannot do this. It has one address bus and one data path; presenting a write and a read to it in the same cycle is a request the port physically cannot serve — one of them has to wait, and a FIFO that stalls its producer every time its consumer reads is not a FIFO, it is a bottleneck.
This is not a niche need. It is the shape of nearly every buffer in a chip: a producer writes one location while a consumer reads a different location, concurrently. A network packet buffer receives on one port and transmits on another. A video line buffer stores the incoming scanline while the previous one is being filtered. A CPU register file (6.6) reads two source operands while writing back a result. In every one of these, two independent addresses are live in the same cycle, and the memory must serve both — a write on one side, a read on the other, without either blocking the other.
The structure that provides this is a memory with two ports. But two ports is not just "one port, twice." The moment a second address bus exists, a new question appears that a single-port RAM never had to answer: what happens when the two ports name the same address in the same cycle? When the read address and the write address collide, does the read see the old word or the new one? When both ports write the same location, which write wins — or does the location corrupt? A single port serialized these by construction; a second port makes them simultaneous, and someone must define the answer. That definition — the collision behaviour — is what this pattern is really about. The second port is easy; the collision it creates is the engineering.
// Producer and consumer run at their own pace, but in a busy cycle BOTH fire:
wire wr_en; wire [$clog2(DEPTH)-1:0] waddr; wire [31:0] din; // producer side
wire rd_en; wire [$clog2(DEPTH)-1:0] raddr; wire [31:0] dout; // consumer side
// A SINGLE-PORT RAM (6.1) has ONE address bus — it cannot serve waddr AND raddr
// in the same cycle. One access must stall. That stall is the bug.
// A TWO-PORT RAM serves both at once: WRITE din@waddr while READ dout@raddr,
// independent addresses, same cycle. Free bandwidth...
// ...until waddr == raddr in that cycle: which value does the read return?
// That same-address COLLISION is the question this pattern must answer (§2, §7).2. Mental Model
3. Pattern Anatomy
The two-port family is one array of storage exposed through two access ports, and it comes in two shapes that differ in how much each port can do and how many clocks are involved.
The shared array, two ports. Both shapes are the same mem array you inferred in 6.1 — WIDTH-bit words, DEPTH of them, ADDRW = $clog2(DEPTH) address bits. What changes is the ports. Each port is an (address, data, enable) bundle; the number of ports and their direction is the whole taxonomy.
Shape 1 — Simple-Dual-Port (SDP): one write port + one read port. The write port is (waddr, din, we, clk); the read port is (raddr, dout, clk) (with an optional read enable). The two addresses are independent. In a synchronous SDP the write is clocked (if (we) mem[waddr] <= din;) and the read is registered (dout <= mem[raddr];) — a one-cycle read latency, exactly the 6.2 read pipeline. This is what a good synthesis tool infers onto a block RAM's two ports (one wired write-only, the other read-only), and it is the minimum viable FIFO storage: producer on the write port, consumer on the read port.
Shape 2 — True-Dual-Port (TDP): two full read/write ports, optionally two clocks. Port A is (addr_a, din_a, dout_a, we_a, clk_a); port B is (addr_b, din_b, dout_b, we_b, clk_b) — each can read or write any location any cycle, on its own clock. TDP is strictly more capable than SDP (an SDP is a TDP with one port's write disabled and the other's read unused) and correspondingly more expensive in collision cases. Its defining feature is the two-clock option: clk_a and clk_b can be asynchronous, which makes TDP the storage under a clock-domain-crossing bridge.
The collision cases — the price of the second port. Whenever two ports name the same address in the same cycle, the memory must decide what each port observes:
- Read/write collision (one port reads address
Xwhile the other writesX). This is the read-during-write question from 6.2, now happening across two ports instead of on one. The read returns the old value (read-first), the new value (write-first / read-new), or undefined (no-change) — per the block RAM's collision mode. Your RTL models one of these; if the model says read-new while the target does read-old (or vice-versa), the collision returns the wrong word — a two-port version of the 6.2 mismatch bug. If the consumer needs the new value, you add a write-forwarding bypass (6.2), comparing the two addresses and muxingdinonto the read output on a hit. - Write/write collision (both ports write address
Xthe same cycle). This is undefined — the macro does not define which write lands, and the location may end up with either word, a blend, orX. It is not a mode you select; it is a hazard. The only correct responses are to arbitrate (make one port win by construction) or to guarantee it cannot happen (partition the address space, or use an SDP where there is only one writer).
The two-clock crossing — a preview of CDC. In a two-clock TDP, a word written on clk_a and read on clk_b crosses clock domains. The data word itself sitting in the array is stable, so it is safe to read once it has settled — but the control that tells the read side "a new word is here" (a pointer, a full/empty flag, a valid pulse) is a signal generated in one domain and sampled in the other, and that is a genuine CDC hazard: sample it at the wrong instant and you get a metastable, garbage value. A two-clock TDP therefore never stands alone; it is always paired with CDC-safe control — a two-flop synchronizer for a level, a Gray-coded pointer for a multi-bit count. The full treatment is the asynchronous FIFO (11.6); here it is enough to know that the two-clock TDP creates the crossing and does not, by itself, make it safe.
Two shapes of the second port — and the collision (and clock crossing) each one owns
data flowThe anatomy closes on one discipline that unifies both shapes: a second port is defined only when its collision behaviour is defined. For a read/write hit that means stating and matching the read-during-write mode (and adding a bypass if you need read-new). For a write/write hit it means arbitrating or forbidding the case. For a two-clock TDP it means synchronizing the control that crosses the clocks. Skip any of these and the RTL still simulates in the common case — the ports stay apart most of the time — and fails intermittently exactly at the boundary where they meet (§7).
4. Real RTL Implementation
This is the core of the page. We build three patterns — (a) a simple-dual-port RAM (one write port + one read port, independent addresses, WIDTH/DEPTH generic — the FIFO substrate), (b) a true-dual-port RAM (two read/write ports, with the two-clock variant and its CDC caveat annotated), and (c) the same-address collision bug vs the handled fix — and each is shown in SystemVerilog, Verilog, and VHDL with an RTL block and a self-checking clocked testbench. The idea is identical in all three; the syntax differs, and seeing them side by side makes the pattern language-independent in your head.
Two disciplines run through every RTL block. First, these are synchronous memories — write on the clock edge, read registered (one-cycle latency), the 6.2 read pipeline — because that is what infers a block RAM rather than a flop farm (6.1). Second, every collision case is either modelled or forbidden: the read/write hit gets an explicit read-during-write behaviour (and a bypass where read-new is needed), and the write/write hit is arbitrated, never left to chance.
4a. Simple-Dual-Port RAM — one write port, one read port (the FIFO substrate)
Start with the SDP: independent waddr/raddr, a clocked write, a registered read. This is the smallest two-port memory that does something a single-port RAM cannot — accept a producer write and serve a consumer read in the same cycle — and it is exactly the storage a synchronous FIFO wraps. Its only collision is a read/write hit, so we pin the read-during-write mode explicitly (read-first here — the safe FIFO default, matching most block RAMs), and note where a bypass would flip it to read-new.
module sdp_ram #(
parameter int WIDTH = 32, // data word width
parameter int DEPTH = 512, // number of words
localparam int ADDRW = $clog2(DEPTH) // address width
)(
input logic clk, // ONE clock: this SDP is synchronous
// Write port (producer side) --------------------------------------------------
input logic we, // write enable
input logic [ADDRW-1:0] waddr, // write address (independent)
input logic [WIDTH-1:0] din, // write data
// Read port (consumer side) ---------------------------------------------------
input logic [ADDRW-1:0] raddr, // read address (independent)
output logic [WIDTH-1:0] dout // registered read data (1-cyc latency)
);
logic [WIDTH-1:0] mem [DEPTH]; // the shared array (infers block RAM)
// Write port and read port share ONE clock but INDEPENDENT addresses, so they
// serve a producer and a consumer in the SAME cycle. Because the read samples
// mem[raddr] on the SAME edge the write updates mem[waddr], a same-address hit
// (raddr == waddr) returns the OLD word -> READ-FIRST. This is the safe FIFO
// default. For read-new, add the bypass shown in the comment below (per 6.2).
always_ff @(posedge clk) begin
if (we) mem[waddr] <= din; // clocked write (producer)
dout <= mem[raddr]; // REGISTERED read (consumer): read-first
end
// read-new variant (write-forwarding bypass, 6.2):
// dout <= (we && waddr == raddr) ? din : mem[raddr];
endmoduleThe self-checking testbench is clocked: it writes a word on the write port and, a cycle later (honouring the read latency), reads it back on the read port at a different address to prove the two ports are independent — then forces raddr == waddr in the same cycle and asserts the read-first result (the read returns the OLD word, not the just-written one). Sweeping the collision is the whole point.
module sdp_ram_tb;
localparam int WIDTH = 32, DEPTH = 16, ADDRW = $clog2(DEPTH);
logic clk = 0, we;
logic [ADDRW-1:0] waddr, raddr;
logic [WIDTH-1:0] din, dout;
int errors = 0;
sdp_ram #(.WIDTH(WIDTH), .DEPTH(DEPTH)) dut
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout));
always #5 clk = ~clk; // 100 MHz clock
task automatic wr(input [ADDRW-1:0] a, input [WIDTH-1:0] d);
@(negedge clk); we = 1; waddr = a; din = d; // drive off the edge
@(negedge clk); we = 0;
endtask
initial begin
we = 0; din = 0; waddr = 0; raddr = 0;
// 1) Independent addresses: write @3, write @7, then read each back.
wr(3, 32'hAAAA_0003);
wr(7, 32'hBBBB_0007);
@(negedge clk); raddr = 3; @(posedge clk); #1; // read latency = 1 cycle
assert (dout === 32'hAAAA_0003)
else begin $error("indep read @3: dout=%h", dout); errors++; end
@(negedge clk); raddr = 7; @(posedge clk); #1;
assert (dout === 32'hBBBB_0007)
else begin $error("indep read @7: dout=%h", dout); errors++; end
// 2) SAME-ADDRESS COLLISION: write @5=NEW while reading @5 the same edge.
// read-first RAM => the read must return the OLD word, not NEW.
wr(5, 32'h0000_1111); // preload OLD value at addr 5
@(negedge clk); we = 1; waddr = 5; din = 32'hFFFF_9999; raddr = 5; // COLLISION
@(posedge clk); #1; // this edge: write NEW, read addr 5
we = 0;
assert (dout === 32'h0000_1111) // OLD value -> read-first honoured
else begin $error("collision read-first FAIL: dout=%h (want OLD 00001111)", dout); errors++; end
if (errors == 0) $display("PASS: sdp_ram independent ports + read-first collision");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog SDP is the same memory with reg/wire typing and an always @(posedge clk); the block-RAM inference and the read-first collision behaviour are identical. The testbench compares with if (dout !== exp) $display("FAIL").
module sdp_ram #(
parameter WIDTH = 32,
parameter DEPTH = 512,
parameter ADDRW = 9 // = clog2(DEPTH); set by instantiator
)(
input wire clk,
input wire we, // write port (producer)
input wire [ADDRW-1:0] waddr,
input wire [WIDTH-1:0] din,
input wire [ADDRW-1:0] raddr, // read port (consumer), independent addr
output reg [WIDTH-1:0] dout // registered read (1-cycle latency)
);
reg [WIDTH-1:0] mem [0:DEPTH-1]; // shared array -> block RAM
// Write and read on the same edge with independent addresses. Same-address hit
// returns the OLD word (read-first): read samples mem[raddr] as the write lands.
always @(posedge clk) begin
if (we) mem[waddr] <= din; // clocked write
dout <= mem[raddr]; // registered read -> read-first
end
// read-new (bypass): dout <= (we && waddr==raddr) ? din : mem[raddr];
endmodule module sdp_ram_tb;
parameter WIDTH = 32, DEPTH = 16, ADDRW = 4;
reg clk, we;
reg [ADDRW-1:0] waddr, raddr;
reg [WIDTH-1:0] din;
wire [WIDTH-1:0] dout;
integer errors;
sdp_ram #(.WIDTH(WIDTH), .DEPTH(DEPTH), .ADDRW(ADDRW)) dut
(.clk(clk), .we(we), .waddr(waddr), .din(din), .raddr(raddr), .dout(dout));
initial clk = 0;
always #5 clk = ~clk;
task wr; input [ADDRW-1:0] a; input [WIDTH-1:0] d;
begin
@(negedge clk); we = 1; waddr = a; din = d;
@(negedge clk); we = 0;
end
endtask
initial begin
errors = 0; we = 0; din = 0; waddr = 0; raddr = 0;
// 1) independent addresses
wr(3, 32'hAAAA0003);
wr(7, 32'hBBBB0007);
@(negedge clk); raddr = 3; @(posedge clk); #1;
if (dout !== 32'hAAAA0003) begin $display("FAIL indep @3: %h", dout); errors=errors+1; end
@(negedge clk); raddr = 7; @(posedge clk); #1;
if (dout !== 32'hBBBB0007) begin $display("FAIL indep @7: %h", dout); errors=errors+1; end
// 2) same-address collision -> read-first returns OLD
wr(5, 32'h00001111);
@(negedge clk); we = 1; waddr = 5; din = 32'hFFFF9999; raddr = 5;
@(posedge clk); #1; we = 0;
if (dout !== 32'h00001111) begin
$display("FAIL collision: dout=%h want OLD 00001111", dout); errors=errors+1;
end
if (errors == 0) $display("PASS: sdp_ram independent ports + read-first collision");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the array is a type ram_t is array ... of std_logic_vector; the write and registered read live in one clocked process, and rising_edge(clk) is the edge. The read-first behaviour is the natural result of reading mem(to_integer(...)) on the same edge the write assigns it.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity sdp_ram is
generic ( WIDTH : positive := 32; DEPTH : positive := 512 );
port (
clk : in std_logic; -- single clock (synchronous)
we : in std_logic; -- write port (producer)
waddr : in std_logic_vector(natural(ceil(log2(real(DEPTH))))-1 downto 0);
din : in std_logic_vector(WIDTH-1 downto 0);
raddr : in std_logic_vector(natural(ceil(log2(real(DEPTH))))-1 downto 0);
dout : out std_logic_vector(WIDTH-1 downto 0) -- registered read (1-cyc)
);
end entity;
architecture rtl of sdp_ram is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t; -- shared array -> block RAM
begin
-- Independent waddr/raddr, one clock. The registered read samples mem(raddr) on
-- the same edge the write updates mem(waddr): a same-address hit returns the OLD
-- word (read-first). For read-new, forward din when waddr = raddr and we = '1'.
process (clk)
begin
if rising_edge(clk) then
if we = '1' then
mem(to_integer(unsigned(waddr))) <= din; -- clocked write
end if;
dout <= mem(to_integer(unsigned(raddr))); -- registered read: read-first
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity sdp_ram_tb is
end entity;
architecture sim of sdp_ram_tb is
constant WIDTH : positive := 32;
constant DEPTH : positive := 16;
constant ADDRW : positive := 4;
signal clk : std_logic := '0';
signal we : std_logic := '0';
signal waddr : std_logic_vector(ADDRW-1 downto 0) := (others => '0');
signal din : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal raddr : std_logic_vector(ADDRW-1 downto 0) := (others => '0');
signal dout : std_logic_vector(WIDTH-1 downto 0);
begin
dut : entity work.sdp_ram
generic map (WIDTH => WIDTH, DEPTH => DEPTH)
port map (clk => clk, we => we, waddr => waddr, din => din,
raddr => raddr, dout => dout);
clk <= not clk after 5 ns; -- 100 MHz
stim : process
procedure wr(a : natural; d : std_logic_vector(WIDTH-1 downto 0)) is
begin
wait until falling_edge(clk);
we <= '1'; waddr <= std_logic_vector(to_unsigned(a, ADDRW)); din <= d;
wait until falling_edge(clk); we <= '0';
end procedure;
begin
-- 1) independent addresses
wr(3, x"AAAA0003");
wr(7, x"BBBB0007");
wait until falling_edge(clk); raddr <= std_logic_vector(to_unsigned(3, ADDRW));
wait until rising_edge(clk); wait for 1 ns; -- read latency
assert dout = x"AAAA0003" report "indep read @3 FAIL" severity error;
wait until falling_edge(clk); raddr <= std_logic_vector(to_unsigned(7, ADDRW));
wait until rising_edge(clk); wait for 1 ns;
assert dout = x"BBBB0007" report "indep read @7 FAIL" severity error;
-- 2) same-address collision -> read-first returns OLD
wr(5, x"00001111");
wait until falling_edge(clk);
we <= '1'; waddr <= std_logic_vector(to_unsigned(5, ADDRW)); din <= x"FFFF9999";
raddr <= std_logic_vector(to_unsigned(5, ADDRW)); -- COLLISION
wait until rising_edge(clk); wait for 1 ns; we <= '0';
assert dout = x"00001111"
report "collision read-first FAIL: expected OLD 00001111" severity error;
report "sdp_ram self-check complete" severity note;
wait;
end process;
end architecture;4b. True-Dual-Port RAM — two read/write ports (and the two-clock CDC caveat)
Now both ports are full read/write, each with its own address, enable, data-in, and registered data-out — and each on its own clock. This is the TDP: strictly more capable than the SDP, and the storage under a clock-domain-crossing bridge. The RTL is two symmetric clocked processes, one per clock. The write/write collision is undefined and is called out as an invariant the driver must prevent; the two-clock crossing is annotated as a CDC hazard that this module creates but does not resolve (11.6 does).
module tdp_ram #(
parameter int WIDTH = 32,
parameter int DEPTH = 512,
localparam int ADDRW = $clog2(DEPTH)
)(
// Port A (its own clock) -----------------------------------------------------
input logic clk_a,
input logic we_a,
input logic [ADDRW-1:0] addr_a,
input logic [WIDTH-1:0] din_a,
output logic [WIDTH-1:0] dout_a,
// Port B (its own clock -- may be ASYNCHRONOUS to clk_a) ----------------------
input logic clk_b,
input logic we_b,
input logic [ADDRW-1:0] addr_b,
input logic [WIDTH-1:0] din_b,
output logic [WIDTH-1:0] dout_b
);
logic [WIDTH-1:0] mem [DEPTH]; // one array, two ports
// Each port: clocked write + registered read on ITS OWN clock. Two clocks =>
// a word written on clk_a and read on clk_b CROSSES CLOCK DOMAINS. The DATA in
// the array is stable once written, but the CONTROL that says "a new word is
// ready" must be synchronized (two-flop / Gray pointer) -- that is 11.6, NOT here.
always_ff @(posedge clk_a) begin
if (we_a) mem[addr_a] <= din_a; // port-A write
dout_a <= mem[addr_a]; // port-A registered read (read-first here)
end
always_ff @(posedge clk_b) begin
if (we_b) mem[addr_b] <= din_b; // port-B write
dout_b <= mem[addr_b]; // port-B registered read
end
// WRITE/WRITE COLLISION (we_a && we_b && addr_a == addr_b on aligned edges) is
// UNDEFINED: the macro does not define which write wins -> the location may
// corrupt. It is an INVARIANT the driver must prevent (arbitrate or partition),
// NOT a mode to select. There is no correct RTL that "resolves" it in the array.
endmoduleThe TDP testbench drives both ports: it writes on A and reads the same location on B (proving the shared array), exercises both ports at independent addresses, and then demonstrates the write/write hazard by asserting the design-rule invariant — the stimulus must never assert we_a and we_b at the same address on aligned edges. (Because the hazard's result is undefined, the testbench does not assert a value for it; it asserts that the stimulus never creates it — the only checkable statement about an undefined case.)
module tdp_ram_tb;
localparam int WIDTH = 32, DEPTH = 16, ADDRW = $clog2(DEPTH);
logic clk = 0; // single testbench clock (aligned A=B here)
logic we_a = 0, we_b = 0;
logic [ADDRW-1:0] addr_a = 0, addr_b = 0;
logic [WIDTH-1:0] din_a = 0, din_b = 0, dout_a, dout_b;
int errors = 0;
// For a checkable test we tie clk_a = clk_b = clk (the two-clock case is verified
// with CDC-aware methods in 11.6). This proves the SHARED ARRAY and the invariant.
tdp_ram #(.WIDTH(WIDTH), .DEPTH(DEPTH)) dut (
.clk_a(clk), .we_a(we_a), .addr_a(addr_a), .din_a(din_a), .dout_a(dout_a),
.clk_b(clk), .we_b(we_b), .addr_b(addr_b), .din_b(din_b), .dout_b(dout_b));
always #5 clk = ~clk;
// Design-rule check: the two ports must never write the SAME address the SAME edge.
always @(posedge clk)
assert (!(we_a && we_b && addr_a == addr_b))
else begin $error("WRITE/WRITE COLLISION @%0d -> UNDEFINED", addr_a); errors++; end
initial begin
// Port A writes @4; Port B reads @4 next cycle -> shared array proven.
@(negedge clk); we_a = 1; addr_a = 4; din_a = 32'hCAFE_0004;
@(negedge clk); we_a = 0;
@(negedge clk); we_b = 0; addr_b = 4; // B reads @4
@(posedge clk); #1;
assert (dout_b === 32'hCAFE_0004)
else begin $error("cross-port read FAIL: dout_b=%h", dout_b); errors++; end
// Both ports active, DIFFERENT addresses -> no collision, both serve.
@(negedge clk); we_a = 1; addr_a = 2; din_a = 32'h1111_0002;
we_b = 1; addr_b = 9; din_b = 32'h2222_0009; // addr_a != addr_b: legal
@(negedge clk); we_a = 0; we_b = 0;
if (errors == 0) $display("PASS: tdp_ram shared array + no write/write collision");
else $display("FAIL: %0d issues", errors);
$finish;
end
endmoduleThe Verilog TDP mirrors this with two always @(posedge) blocks (one per clock) and the same undefined-write/write caveat; the testbench uses if (...) $display("FAIL") for the invariant and the cross-port read.
module tdp_ram #(
parameter WIDTH = 32,
parameter DEPTH = 512,
parameter ADDRW = 9
)(
input wire clk_a,
input wire we_a,
input wire [ADDRW-1:0] addr_a,
input wire [WIDTH-1:0] din_a,
output reg [WIDTH-1:0] dout_a,
input wire clk_b, // may be asynchronous to clk_a
input wire we_b,
input wire [ADDRW-1:0] addr_b,
input wire [WIDTH-1:0] din_b,
output reg [WIDTH-1:0] dout_b
);
reg [WIDTH-1:0] mem [0:DEPTH-1]; // one array, two ports
// Two clocks -> a word written on clk_a, read on clk_b crosses domains. Array
// DATA is stable once written; the "new word ready" CONTROL needs a synchronizer
// (11.6). Write/write at the same address the same edge is UNDEFINED -> arbitrate.
always @(posedge clk_a) begin
if (we_a) mem[addr_a] <= din_a; // port-A write
dout_a <= mem[addr_a]; // port-A registered read
end
always @(posedge clk_b) begin
if (we_b) mem[addr_b] <= din_b; // port-B write
dout_b <= mem[addr_b]; // port-B registered read
end
endmodule module tdp_ram_tb;
parameter WIDTH = 32, DEPTH = 16, ADDRW = 4;
reg clk;
reg we_a, we_b;
reg [ADDRW-1:0] addr_a, addr_b;
reg [WIDTH-1:0] din_a, din_b;
wire [WIDTH-1:0] dout_a, dout_b;
integer errors;
// clk_a = clk_b = clk here (aligned); the true two-clock case is checked in 11.6.
tdp_ram #(.WIDTH(WIDTH), .DEPTH(DEPTH), .ADDRW(ADDRW)) dut (
.clk_a(clk), .we_a(we_a), .addr_a(addr_a), .din_a(din_a), .dout_a(dout_a),
.clk_b(clk), .we_b(we_b), .addr_b(addr_b), .din_b(din_b), .dout_b(dout_b));
initial clk = 0;
always #5 clk = ~clk;
// invariant: never write the same address on both ports the same edge
always @(posedge clk)
if (we_a && we_b && addr_a == addr_b) begin
$display("FAIL: WRITE/WRITE COLLISION @%0d -> UNDEFINED", addr_a);
errors = errors + 1;
end
initial begin
errors = 0; we_a = 0; we_b = 0; addr_a = 0; addr_b = 0; din_a = 0; din_b = 0;
@(negedge clk); we_a = 1; addr_a = 4; din_a = 32'hCAFE0004; // A writes @4
@(negedge clk); we_a = 0;
@(negedge clk); we_b = 0; addr_b = 4; // B reads @4
@(posedge clk); #1;
if (dout_b !== 32'hCAFE0004) begin
$display("FAIL cross-port read: dout_b=%h", dout_b); errors = errors + 1;
end
@(negedge clk); we_a = 1; addr_a = 2; din_a = 32'h11110002; // different addrs: legal
we_b = 1; addr_b = 9; din_b = 32'h22220009;
@(negedge clk); we_a = 0; we_b = 0;
if (errors == 0) $display("PASS: tdp_ram shared array + no write/write collision");
else $display("FAIL: %0d issues", errors);
$finish;
end
endmoduleIn VHDL the TDP is two clocked processes, one per clock, over a shared mem signal; the two-clock crossing and the undefined write/write case are annotated identically. The testbench asserts the cross-port read and the no-write/write-collision invariant with assert ... severity error.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity tdp_ram is
generic ( WIDTH : positive := 32; DEPTH : positive := 512 );
port (
clk_a : in std_logic;
we_a : in std_logic;
addr_a : in std_logic_vector(natural(ceil(log2(real(DEPTH))))-1 downto 0);
din_a : in std_logic_vector(WIDTH-1 downto 0);
dout_a : out std_logic_vector(WIDTH-1 downto 0);
clk_b : in std_logic; -- may be async to clk_a
we_b : in std_logic;
addr_b : in std_logic_vector(natural(ceil(log2(real(DEPTH))))-1 downto 0);
din_b : in std_logic_vector(WIDTH-1 downto 0);
dout_b : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of tdp_ram is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t; -- one array, two ports
begin
-- Port A on clk_a: clocked write + registered read.
process (clk_a)
begin
if rising_edge(clk_a) then
if we_a = '1' then
mem(to_integer(unsigned(addr_a))) <= din_a; -- port-A write
end if;
dout_a <= mem(to_integer(unsigned(addr_a))); -- port-A registered read
end if;
end process;
-- Port B on clk_b: symmetric. A word written on clk_a, read on clk_b crosses
-- domains -> the "ready" CONTROL needs a synchronizer (11.6). Write/write at the
-- same address the same edge is UNDEFINED -> arbitrate; do not rely on the array.
process (clk_b)
begin
if rising_edge(clk_b) then
if we_b = '1' then
mem(to_integer(unsigned(addr_b))) <= din_b; -- port-B write
end if;
dout_b <= mem(to_integer(unsigned(addr_b))); -- port-B registered read
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity tdp_ram_tb is
end entity;
architecture sim of tdp_ram_tb is
constant WIDTH : positive := 32;
constant DEPTH : positive := 16;
constant ADDRW : positive := 4;
signal clk : std_logic := '0'; -- aligned A=B here (11.6 = 2-clk)
signal we_a, we_b : std_logic := '0';
signal addr_a, addr_b : std_logic_vector(ADDRW-1 downto 0) := (others => '0');
signal din_a, din_b : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal dout_a, dout_b : std_logic_vector(WIDTH-1 downto 0);
begin
dut : entity work.tdp_ram
generic map (WIDTH => WIDTH, DEPTH => DEPTH)
port map (clk_a => clk, we_a => we_a, addr_a => addr_a, din_a => din_a, dout_a => dout_a,
clk_b => clk, we_b => we_b, addr_b => addr_b, din_b => din_b, dout_b => dout_b);
clk <= not clk after 5 ns;
-- invariant: the two ports must never write the same address the same edge
inv : process (clk)
begin
if rising_edge(clk) then
assert not (we_a = '1' and we_b = '1' and addr_a = addr_b)
report "WRITE/WRITE COLLISION -> UNDEFINED" severity error;
end if;
end process;
stim : process
begin
-- Port A writes @4, Port B reads @4 -> shared array proven.
wait until falling_edge(clk);
we_a <= '1'; addr_a <= std_logic_vector(to_unsigned(4, ADDRW)); din_a <= x"CAFE0004";
wait until falling_edge(clk); we_a <= '0';
wait until falling_edge(clk); we_b <= '0'; addr_b <= std_logic_vector(to_unsigned(4, ADDRW));
wait until rising_edge(clk); wait for 1 ns;
assert dout_b = x"CAFE0004" report "cross-port read FAIL" severity error;
-- both ports, DIFFERENT addresses -> legal, both serve
wait until falling_edge(clk);
we_a <= '1'; addr_a <= std_logic_vector(to_unsigned(2, ADDRW)); din_a <= x"11110002";
we_b <= '1'; addr_b <= std_logic_vector(to_unsigned(9, ADDRW)); din_b <= x"22220009";
wait until falling_edge(clk); we_a <= '0'; we_b <= '0';
report "tdp_ram self-check complete" severity note;
wait;
end process;
end architecture;4c. The same-address collision bug — buggy vs handled, in all three HDLs
Pattern (c) is the signature failure, shown here as buggy vs fixed RTL in each language, then dramatized narratively in §7. The buggy version treats the two ports as if they can never touch the same address — it assumes safe — so on a read/write hit it silently returns whatever the array happens to give (here, read-first, the OLD word) even though the consumer needs the new word, and on a write/write hit it lets the location go undefined. The fix defines and honours the collision: it adds a write-forwarding bypass (6.2) so the read/write hit returns read-new, and it arbitrates the write/write hit (port A wins by construction) so the location is never left undefined.
// BUGGY: a shared buffer where the read side must see the JUST-written word, but
// the RTL assumes the ports never collide -> on raddr==waddr it returns the
// OLD word (read-first array), and a write/write hit is left UNDEFINED.
module dp_buf_bad #(parameter WIDTH = 32, DEPTH = 512, ADDRW = 9)(
input wire clk,
input wire we_a, we_b,
input wire [ADDRW-1:0] addr_a, addr_b,
input wire [WIDTH-1:0] din_a, din_b,
output reg [WIDTH-1:0] dout_b // consumer reads on port B
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we_a) mem[addr_a] <= din_a; // port-A write
if (we_b) mem[addr_b] <= din_b; // port-B write -- W/W hit UNDEFINED!
dout_b <= mem[addr_b]; // read-first: returns OLD on a R/W hit
end
// Symptom: when addr_a == addr_b and A writes while B reads, dout_b is the STALE
// old word; when both write addr X, the location corrupts. Ports-apart: OK.
endmodule
// FIXED: DEFINE the collision. (1) write-forwarding bypass -> read-new on a R/W hit.
// (2) arbitrate the W/W hit: port A wins by construction (never undefined).
module dp_buf_good #(parameter WIDTH = 32, DEPTH = 512, ADDRW = 9)(
input wire clk,
input wire we_a, we_b,
input wire [ADDRW-1:0] addr_a, addr_b,
input wire [WIDTH-1:0] din_a, din_b,
output reg [WIDTH-1:0] dout_b
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
// ARBITRATE write/write: if both target addr_a==addr_b, port A wins (defined).
if (we_a) mem[addr_a] <= din_a;
if (we_b && !(we_a && addr_a == addr_b))
mem[addr_b] <= din_b;
// BYPASS read/write: if A is writing what B is reading, forward din_a (read-new).
if (we_a && addr_a == addr_b) dout_b <= din_a; // read-new via forwarding
else dout_b <= mem[addr_b];
end
endmodule module dp_collision_tb;
localparam int WIDTH = 32, DEPTH = 16, ADDRW = $clog2(DEPTH);
logic clk = 0, we_a = 0, we_b = 0;
logic [ADDRW-1:0] addr_a = 0, addr_b = 0;
logic [WIDTH-1:0] din_a = 0, din_b = 0, dout_b;
int errors = 0;
// Bind to dp_buf_good to PASS; to dp_buf_bad to see the stale/undefined collision.
dp_buf_good #(.WIDTH(WIDTH), .DEPTH(DEPTH), .ADDRW(ADDRW)) dut (
.clk(clk), .we_a(we_a), .we_b(we_b),
.addr_a(addr_a), .addr_b(addr_b), .din_a(din_a), .din_b(din_b), .dout_b(dout_b));
always #5 clk = ~clk;
initial begin
// preload OLD value at addr 6
@(negedge clk); we_a = 1; addr_a = 6; din_a = 32'h0000_0006; @(negedge clk); we_a = 0;
// READ/WRITE COLLISION: A writes NEW @6 while B reads @6. Fixed => read-NEW.
@(negedge clk); we_a = 1; addr_a = 6; din_a = 32'hFEED_BEEF;
we_b = 0; addr_b = 6;
@(posedge clk); #1; we_a = 0;
assert (dout_b === 32'hFEED_BEEF) // read-new via bypass
else begin $error("R/W collision: dout_b=%h want NEW FEEDBEEF", dout_b); errors++; end
// WRITE/WRITE COLLISION: both write @8. Fixed => port A wins (DEFINED), not undefined.
@(negedge clk); we_a = 1; addr_a = 8; din_a = 32'hA000_0008;
we_b = 1; addr_b = 8; din_b = 32'hB000_0008;
@(negedge clk); we_a = 0; we_b = 0;
@(negedge clk); we_b = 0; addr_b = 8; // read @8 back on port B
@(posedge clk); #1;
assert (dout_b === 32'hA000_0008) // A won -> defined, no corruption
else begin $error("W/W collision: dout_b=%h want A-wins A0000008", dout_b); errors++; end
if (errors == 0) $display("PASS: collision defined (read-new + arbitrated write)");
else $display("FAIL: %0d mismatches (undefined/stale collision)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same story with reg outputs and always @(posedge clk). Forcing both the R/W hit and the W/W hit in the testbench is what exposes the assume-safe version — the ports-together cycle is the whole bug.
// BUGGY: assumes the ports never collide -> stale read on R/W hit, undefined on W/W.
module dp_buf_bad #(parameter WIDTH = 32, DEPTH = 512, ADDRW = 9)(
input wire clk, we_a, we_b,
input wire [ADDRW-1:0] addr_a, addr_b,
input wire [WIDTH-1:0] din_a, din_b,
output reg [WIDTH-1:0] dout_b
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we_a) mem[addr_a] <= din_a;
if (we_b) mem[addr_b] <= din_b; // W/W hit UNDEFINED
dout_b <= mem[addr_b]; // read-first: OLD word on a R/W hit
end
endmodule
// FIXED: bypass gives read-new on R/W; arbitration makes W/W defined (port A wins).
module dp_buf_good #(parameter WIDTH = 32, DEPTH = 512, ADDRW = 9)(
input wire clk, we_a, we_b,
input wire [ADDRW-1:0] addr_a, addr_b,
input wire [WIDTH-1:0] din_a, din_b,
output reg [WIDTH-1:0] dout_b
);
reg [WIDTH-1:0] mem [0:DEPTH-1];
always @(posedge clk) begin
if (we_a) mem[addr_a] <= din_a; // A always writes
if (we_b && !(we_a && addr_a == addr_b))
mem[addr_b] <= din_b; // B yields on W/W hit
if (we_a && addr_a == addr_b) dout_b <= din_a; // read-new (bypass)
else dout_b <= mem[addr_b];
end
endmodule module dp_collision_tb;
parameter WIDTH = 32, DEPTH = 16, ADDRW = 4;
reg clk, we_a, we_b;
reg [ADDRW-1:0] addr_a, addr_b;
reg [WIDTH-1:0] din_a, din_b;
wire [WIDTH-1:0] dout_b;
integer errors;
// Bind to dp_buf_good to PASS; to dp_buf_bad to observe stale/undefined collision.
dp_buf_good #(.WIDTH(WIDTH), .DEPTH(DEPTH), .ADDRW(ADDRW)) dut (
.clk(clk), .we_a(we_a), .we_b(we_b),
.addr_a(addr_a), .addr_b(addr_b), .din_a(din_a), .din_b(din_b), .dout_b(dout_b));
initial clk = 0;
always #5 clk = ~clk;
initial begin
errors = 0; we_a = 0; we_b = 0; addr_a = 0; addr_b = 0; din_a = 0; din_b = 0;
@(negedge clk); we_a = 1; addr_a = 6; din_a = 32'h00000006; @(negedge clk); we_a = 0;
// read/write collision -> expect read-NEW
@(negedge clk); we_a = 1; addr_a = 6; din_a = 32'hFEEDBEEF; we_b = 0; addr_b = 6;
@(posedge clk); #1; we_a = 0;
if (dout_b !== 32'hFEEDBEEF) begin
$display("FAIL R/W: dout_b=%h want NEW FEEDBEEF", dout_b); errors = errors + 1;
end
// write/write collision -> expect port A to win (DEFINED)
@(negedge clk); we_a = 1; addr_a = 8; din_a = 32'hA0000008;
we_b = 1; addr_b = 8; din_b = 32'hB0000008;
@(negedge clk); we_a = 0; we_b = 0;
@(negedge clk); we_b = 0; addr_b = 8;
@(posedge clk); #1;
if (dout_b !== 32'hA0000008) begin
$display("FAIL W/W: dout_b=%h want A-wins A0000008", dout_b); errors = errors + 1;
end
if (errors == 0) $display("PASS: collision defined (read-new + arbitrated write)");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the same bug appears when the clocked process writes both ports and reads without a bypass or arbitration guard. The fix adds the address-compare guard on we_b (arbitration) and a forwarding branch on the read (read-new) — the VHDL mirror of the Verilog fix.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: no bypass, no arbitration -> stale read on R/W hit, undefined on W/W hit.
entity dp_buf_bad is
generic ( WIDTH : positive := 32; DEPTH : positive := 512; ADDRW : positive := 9 );
port (
clk : in std_logic; we_a, we_b : in std_logic;
addr_a, addr_b : in std_logic_vector(ADDRW-1 downto 0);
din_a, din_b : in std_logic_vector(WIDTH-1 downto 0);
dout_b : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of dp_buf_bad is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t;
begin
process (clk)
begin
if rising_edge(clk) then
if we_a = '1' then mem(to_integer(unsigned(addr_a))) <= din_a; end if;
if we_b = '1' then mem(to_integer(unsigned(addr_b))) <= din_b; end if; -- W/W UNDEFINED
dout_b <= mem(to_integer(unsigned(addr_b))); -- OLD on R/W hit
end if;
end process;
end architecture;
-- FIXED: arbitrate W/W (port A wins) and forward din_a on a R/W hit (read-new).
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity dp_buf_good is
generic ( WIDTH : positive := 32; DEPTH : positive := 512; ADDRW : positive := 9 );
port (
clk : in std_logic; we_a, we_b : in std_logic;
addr_a, addr_b : in std_logic_vector(ADDRW-1 downto 0);
din_a, din_b : in std_logic_vector(WIDTH-1 downto 0);
dout_b : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of dp_buf_good is
type ram_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : ram_t;
begin
process (clk)
begin
if rising_edge(clk) then
if we_a = '1' then
mem(to_integer(unsigned(addr_a))) <= din_a; -- A always writes
end if;
if we_b = '1' and not (we_a = '1' and addr_a = addr_b) then
mem(to_integer(unsigned(addr_b))) <= din_b; -- B yields on W/W
end if;
if we_a = '1' and addr_a = addr_b then
dout_b <= din_a; -- read-new (bypass)
else
dout_b <= mem(to_integer(unsigned(addr_b)));
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity dp_collision_tb is
end entity;
architecture sim of dp_collision_tb is
constant WIDTH : positive := 32;
constant DEPTH : positive := 16;
constant ADDRW : positive := 4;
signal clk : std_logic := '0';
signal we_a, we_b : std_logic := '0';
signal addr_a, addr_b : std_logic_vector(ADDRW-1 downto 0) := (others => '0');
signal din_a, din_b : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal dout_b : std_logic_vector(WIDTH-1 downto 0);
begin
-- Bind to dp_buf_good to PASS; to dp_buf_bad to see stale/undefined collision.
dut : entity work.dp_buf_good
generic map (WIDTH => WIDTH, DEPTH => DEPTH, ADDRW => ADDRW)
port map (clk => clk, we_a => we_a, we_b => we_b, addr_a => addr_a, addr_b => addr_b,
din_a => din_a, din_b => din_b, dout_b => dout_b);
clk <= not clk after 5 ns;
stim : process
begin
wait until falling_edge(clk);
we_a <= '1'; addr_a <= std_logic_vector(to_unsigned(6, ADDRW)); din_a <= x"00000006";
wait until falling_edge(clk); we_a <= '0';
-- read/write collision -> read-new
wait until falling_edge(clk);
we_a <= '1'; addr_a <= std_logic_vector(to_unsigned(6, ADDRW)); din_a <= x"FEEDBEEF";
we_b <= '0'; addr_b <= std_logic_vector(to_unsigned(6, ADDRW));
wait until rising_edge(clk); wait for 1 ns; we_a <= '0';
assert dout_b = x"FEEDBEEF" report "R/W collision: expected NEW FEEDBEEF" severity error;
-- write/write collision -> port A wins (defined)
wait until falling_edge(clk);
we_a <= '1'; addr_a <= std_logic_vector(to_unsigned(8, ADDRW)); din_a <= x"A0000008";
we_b <= '1'; addr_b <= std_logic_vector(to_unsigned(8, ADDRW)); din_b <= x"B0000008";
wait until falling_edge(clk); we_a <= '0'; we_b <= '0';
wait until falling_edge(clk); we_b <= '0'; addr_b <= std_logic_vector(to_unsigned(8, ADDRW));
wait until rising_edge(clk); wait for 1 ns;
assert dout_b = x"A0000008" report "W/W collision: expected A-wins A0000008" severity error;
report "dp_collision self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: a second port is only defined when its collision is defined — forward or accept the read-during-write mode on a read/write hit, arbitrate (or forbid) the write/write hit, and never leave either to chance.
5. Verification Strategy
A dual-port RAM is verified on two fronts: the ports-apart front (does each port serve its own address correctly and independently?) and the ports-together front (is the collision behaviour exactly what the RTL claims?). The second front is where dual-port bugs live, because in a random workload the two addresses agree only occasionally — so the collision must be directed, not left to chance. The single invariant that captures a correct two-port memory is:
Each port serves its own address correctly and independently; and when the ports meet at one address, the result is exactly the defined collision behaviour — never stale, never undefined.
Everything below is that invariant made checkable, and it maps directly onto the clocked testbenches in §4.
- Independent-port integrity (self-checking, clocked). Write on the write/A port at address
X, read on the read/B port at a different addressY, and — honouring the one-cycle read latency (6.2) — assert the read returns the value last written atY, unaffected by the write atX. The three §4a testbenches do exactly this: SVassert (dout === exp), Verilogif (dout !== exp) $display("FAIL"), VHDLassert dout = exp report ... severity error. Independence is the reason the second port exists; prove it first. - Directed read/write collision — assert the defined mode. Force
raddr == waddr(SDP) oraddr_a == addr_bwith one port writing (TDP) in the same cycle, and assert the result the RTL claims: read-first → the OLD word, write-first / bypass → the NEW word. This is the 6.2 read-during-write check, now across two ports. A test that never aligns the two addresses will pass a broken collision — the alignment is the test. - Directed write/write collision — assert defined, not a value. For a TDP, force both ports to write the same address the same cycle. Because the array result is undefined, you cannot assert a data value on the raw macro; instead you assert one of two things: (i) on an unhandled design, that the stimulus never creates the hit (the no-write/write-collision invariant of §4b — an assertion that the driver's arbitration/partitioning holds), or (ii) on a handled design (arbitrated, as in §4c), that the defined winner's value lands (
dout == A-wins value). Either way the property is checkable; "undefined" is only unverifiable if you leave it undefined. - Two-clock CDC awareness. A two-clock TDP cannot be verified by aligned-clock RTL simulation alone: the bug — a control signal sampled mid-transition — only appears with realistic clock phase relationships and metastability injection. Verification must drive
clk_aandclk_bat different frequencies/phases and confirm the control crossing the domains (pointer, flag, valid) is synchronized (two-flop / Gray) before it is used. The data word itself, once stable in the array, is safe to read; the readiness is the CDC object. Full method: the async FIFO (11.6). The §4b testbench aligns the clocks deliberately to check the array; the crossing is checked separately, CDC-aware. - Corner cases and parameter-generic runs. Exercise: same-address R/W hit (both directions of which port writes), same-address W/W hit, back-to-back writes then reads of the same location (read latency), full traversal of the address space on each port, and both ports at address 0 and at
DEPTH-1(boundary addresses). Re-run forWIDTH = 1, 8, 32andDEPTH = 2, 16, 512— a two-port RAM that passes at one depth can mis-size an address bus at another. - Expected waveform. Ports-apart: as the write port drives
(waddr, din, we)on an edge, the read port'sdoutreflectsmem[raddr]one cycle later — two independent streams. Collision: on the cycle where the addresses align,doutshows the defined word (old for read-first, new for write-first/bypass) — and the tell of the bug isdoutshowing the wrong one, or a write/write location that reads back as neither written value (orX) exactly on the aligned cycle.
6. Common Mistakes
Unhandled same-address collision (the signature bug). The RTL assumes the two ports never touch the same address, so it defines no collision behaviour. On a read/write hit the read silently returns whatever the array gives (typically read-first — the OLD word) even when the consumer needed read-new; on a write/write hit the location goes undefined. It simulates fine as long as the addresses stay apart — which they usually do — and drops or corrupts an entry exactly at the boundary where the ports meet. The fix is to define the collision: match/forward the read-during-write mode (6.2) on a read/write hit, and arbitrate or forbid the write/write hit. (Full post-mortem in §7; buggy-vs-fixed RTL for all three HDLs in §4c.)
Assuming read-new on a read-old RAM at two ports. This is the 6.2 read-during-write mismatch, now across the two ports. A consumer that must see the just-written word (a forwarding buffer, a FIFO exactly at its boundary) reads the write port's new data — but the block RAM's collision mode is read-first, so the read returns the old word. It matches in a naive RTL model that happens to do write-first and fails on the real macro (RTL-vs-gate mismatch), or vice-versa. Pin the mode to the target and add a write-forwarding bypass when you need read-new; never assume the mode.
Using a two-clock TDP without CDC handling. Wiring clk_a and clk_b as independent clocks and then letting the read side use a control signal (a write pointer, a "data valid" flag) generated in the other domain without synchronizing it samples that signal mid-transition — metastability, then garbage or a lost update. The data in the array is stable, but the control that says it is ready is a genuine clock-domain crossing. A two-clock TDP is only half a design; it must be paired with a two-flop synchronizer for a level and a Gray-coded pointer for a multi-bit count (11.6). The array does not make the crossing safe.
Asking for more ports than the target block RAM has. FPGA and ASIC block RAMs are natively dual-port (two ports, sometimes with a simple-dual-port mode). Describe RTL that needs three or more concurrent ports — three readers, two writers plus a reader — and the tool cannot map it onto a single macro: it either replicates the whole RAM (one copy per read port, N× the area, with a write-broadcast problem) or falls back to flops (blowing area and timing). Know your target's port count; if you truly need more ports, that is a banking/replication architecture decision, not a wider port list.
Ignoring the collision mode entirely (treating "dual-port" as free). Reaching for a dual-port RAM for the bandwidth and never stating its collision behaviour — no comment, no bypass, no arbitration, no assertion — ships a memory whose same-address behaviour is undocumented and target-dependent. It works on the first FPGA and mis-behaves on a re-target, or works in the block-level sim and fails in the system when the addresses finally align. A second port is a contract with a collision clause; leaving the clause blank is the mistake.
7. DebugLab
The shared buffer that dropped an entry — an undefined dual-port collision
The engineering lesson: a second port doubles bandwidth and, in the same stroke, creates a collision (and possibly a clock crossing) you must define — free bandwidth is not free correctness. The tell is a bug that depends on when the two ports coincide ("the filter drops a sample only on certain input streams"): coincidence-dependence in a memory means an undefined same-address case you never specified. Three habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: define the read/write collision (match or forward the read-during-write mode from 6.2), arbitrate or forbid the write/write collision (never leave a location undefined), and synchronize the control of any two-clock crossing (11.6). Then verify by directly forcing the addresses to align — the aligned cycle is the whole bug.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "a second port creates a collision I must define" habit.
Exercise 1 — Pick the port shape for the workload
For each scenario, state whether you'd use a simple-dual-port or a true-dual-port RAM, and why in one line: (a) a synchronous FIFO's storage, one producer and one consumer on the same clock; (b) a scratchpad shared by two processing elements that both read and write it, same clock; (c) a buffer written by a 100 MHz ingress domain and read by a 150 MHz egress domain; (d) a video line buffer, one write stream in, one read stream out. (Hint: match the shape to how many operations each agent does and how many clocks are involved.)
Exercise 2 — Predict the collision, then the bug
Two engineers store a shared buffer in a dual-port RAM. Engineer A writes the two ports as plain accesses with no address compare. Engineer B adds a bypass on the read path and an arbitration guard on the second write. Both pass a random-traffic block test. Describe (i) the two distinct same-address cases that can occur and what each returns in A's design versus B's, and (ii) exactly what stimulus you would add to the testbench to expose A's bug that random traffic misses — and why random traffic misses it.
Exercise 3 — Define read-new on a read-old macro
Your target block RAM is read-first, but your consumer on the read port must see the word the write port is writing this cycle. Describe the bypass you would add: which two signals you compare, what condition triggers the forward, what drives the read output on a hit versus a miss, and the hardware cost (gates on which path). Then state one corner case the bypass itself must get right (hint: what if the write enable is low but the addresses match?).
Exercise 4 — Make the two-clock crossing safe
You build a buffer on a true-dual-port RAM with clk_a (write, 100 MHz) and clk_b (read, 133 MHz, asynchronous). The data words in the array are fine, yet the read side occasionally acts on a write that has not really arrived. Explain (i) which signal is actually crossing the clock domains and causing this (it is not the data bus), (ii) why you must not two-flop-synchronize the multi-bit version of that signal directly, and (iii) the two constructs (one for a level, one for a multi-bit count) you would use instead — and name the later tutorial that builds this in full.
10. Related Tutorials
Continue in RTL Design Patterns (sibling and forward links unlock as they ship):
- Register Files — Chapter 6.6; a multi-read-port memory built by replicating the read side — the answer to "more ports than one macro has," and the next section.
- ROM & Initialization — Chapter 6.4; the read-only sibling — initialized contents, no write port, no write collision.
- Synchronous FIFO Architecture — Chapter 7.1; the first design that wraps this SDP RAM — producer on the write port, consumer on the read port, with pointer control.
- FIFO Full/Empty Logic — Chapter 7.2; the pointer logic that decides when the SDP's read/write ports may fire — and handles the boundary collision.
- Asynchronous FIFO — Chapter 11.6; the two-clock TDP made safe — Gray-pointer synchronization across the domains this page previews.
- Two-Flop Synchronizer — Chapter 11.2; the level-crossing primitive that synchronizes a two-clock TDP's control.
- Gray-Code Pointer Synchronization — Chapter 11.x; the multi-bit-count crossing for a two-clock buffer's pointers.
Prerequisites & method (in-track):
- Inferring RAM — Chapter 6.1; the single-port RAM this page adds a second port to — the
memarray and block-RAM inference. - Read Timing & Read-During-Write — Chapter 6.2; the read latency and the collision modes (read-first / write-first / undefined) this page applies at two ports, plus the write-forwarding bypass.
- The Register Pattern (D-FF) — Chapter 2.1; the clocked write/registered-read discipline every port here is built on.
- Comparators — Chapter 1.x; the
addr_a == addr_bequality that detects a collision and drives the bypass/arbitration. - Multiplexers — Chapter 1.1; the mux on the read path that a write-forwarding bypass and the arbitration decision are built from.
- Synchronous Reset — Chapter 2.x; the reset discipline for the registered read outputs (a block RAM's array is typically not reset).
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses applied here to two-port memory (two interfaces, one shared state, a collision to control).
- What Is an RTL Design Pattern? — Chapter 0.1; why two-port memory earns the name "pattern" — a recurring need, a reusable form, a synthesis reality (block-RAM ports), and a signature failure (the collision).
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Blocking and Non-Blocking Assignments — why the clocked write and registered read use non-blocking (
<=), and how that orders the same-edge write and read. - Generate Blocks — the elaboration-time replication behind a parameterized, multi-port or multi-bank memory.
- Physical Data Types — the
reg/wire(and array) typing the memory array and registered outputs rely on. - Race Conditions & Determinism — why the same-edge write/read ordering is deterministic under NBA, and how the collision would race if written with blocking assignments.
11. Summary
- Most real memory is two-port. A producer writes one side while a consumer reads the other at independent addresses — the need a single-port RAM (6.1) cannot serve, because one address bus cannot do a write and a read in the same cycle.
- Two shapes. Simple-dual-port (SDP) = one write port + one read port, independent addresses — the leanest two-port RAM and the substrate behind every FIFO and buffer (Chapter 7). True-dual-port (TDP) = two full read/write ports, optionally on two clocks — the substrate under clock-domain crossing (Chapter 11).
- A second port creates a collision you must define. When the two ports name the same address the same cycle: a read/write hit is the read-during-write question from 6.2 — old (read-first), new (write-first / bypass), or undefined, per the macro; a write/write hit is undefined — not a mode, a hazard you must arbitrate or forbid.
- A two-clock TDP creates a clock crossing. The array data is stable once written, but the control that says "a word is ready" (pointer, flag, valid) crosses domains and must be synchronized — two-flop for a level, Gray-coded for a multi-bit count (previewed here; built in the async FIFO, 11.6).
- Free bandwidth is not free correctness. Define the read/write collision (match/forward the RDW mode), arbitrate or forbid the write/write collision, synchronize any two-clock control, and don't ask a single dual-port macro for more than two concurrent ports. Verify by directly forcing the addresses to align — the aligned cycle is the whole bug — with self-checking clocked testbenches shown here in SystemVerilog, Verilog, and VHDL.