Skip to content

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.

the-need.v — a FIFO core needs write and read IN THE SAME CYCLE
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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 X while the other writes X). 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 muxing din onto the read output on a hit.
  • Write/write collision (both ports write address X the same cycle). This is undefined — the macro does not define which write lands, and the location may end up with either word, a blend, or X. 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 flow
Two shapes of the second port — and the collision (and clock crossing) each one ownsshared mem arrayWIDTH x DEPTH, from 6.1 — one store, two portsSDP: write portwaddr / din / we — the producer sideSDP: read portraddr / dout — the consumer side (1-cyc read)TDP: port A (R/W)addr_a / din_a / dout_a / we_a on clk_aTDP: port B (R/W)addr_b / din_b / dout_b / we_b on clk_bread/writecollisionsame addr → read-during-write mode (6.2)write/writecollisionsame addr, both write → UNDEFINED — arbitratetwo-clockcrossingwrite clk_a, read clk_b → CDC control (11.6)
One shared array, exposed two ways. SDP wires one port write-only and one read-only at independent addresses (the FIFO substrate) — its only collision is a read/write hit, which is the 6.2 read-during-write question. TDP makes both ports full read/write, optionally on two clocks (the CDC substrate) — it adds a write/write collision, which is UNDEFINED and must be arbitrated, and a two-clock crossing whose control must be synchronized (11.6). Same array; the second port's power is paid for in collision definition. This is language-independent — SystemVerilog, Verilog, and VHDL infer the same two ports on the same macro.

The 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.

sdp_ram.sv — simple-dual-port: 1 write port + 1 read port, read-first RDW
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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];
   endmodule

The 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.

sdp_ram_tb.sv — write one port, read the other; then force raddr==waddr, assert read-first
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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").

sdp_ram.v — simple-dual-port in Verilog (read-first, independent addresses)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
sdp_ram_tb.v — self-checking clocked TB; independent ports + read-first collision
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

In 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.

sdp_ram.vhd — simple-dual-port in VHDL (rising_edge, read-first, independent addr)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
sdp_ram_tb.vhd — self-checking clocked TB (assert report severity); read-first collision
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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).

tdp_ram.sv — true-dual-port: two R/W ports, two clocks (write/write UNDEFINED; CDC caveat)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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.
   endmodule

The 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.)

tdp_ram_tb.sv — drive both ports; assert the no-write/write-collision invariant
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.

tdp_ram.v — true-dual-port in Verilog (two clocks; write/write UNDEFINED)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
tdp_ram_tb.v — self-checking; cross-port read + no-write/write-collision invariant
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

In 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.

tdp_ram.vhd — true-dual-port in VHDL (two rising_edge processes; write/write UNDEFINED)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
tdp_ram_tb.vhd — self-checking; cross-port read + no-write/write-collision invariant
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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.

dp_collision.sv — BUGGY (assume-safe) vs FIXED (bypass + arbitrate)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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
dp_collision_tb.sv — force R/W and W/W hits; assert read-new + defined W/W
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.

dp_collision.v — BUGGY (assume-safe) vs FIXED (bypass + arbitrate)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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
dp_collision_tb.v — self-checking; force R/W + W/W hits, expect read-new + A-wins
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

In 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.

dp_collision.vhd — BUGGY (assume-safe) vs FIXED (bypass + arbitrate)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
dp_collision_tb.vhd — self-checking; R/W + W/W hits, expect read-new + A-wins
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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 address Y, and — honouring the one-cycle read latency (6.2) — assert the read returns the value last written at Y, unaffected by the write at X. The three §4a testbenches do exactly this: SV assert (dout === exp), Verilog if (dout !== exp) $display("FAIL"), VHDL assert 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) or addr_a == addr_b with 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_a and clk_b at 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 for WIDTH = 1, 8, 32 and DEPTH = 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's dout reflects mem[raddr] one cycle later — two independent streams. Collision: on the cycle where the addresses align, dout shows the defined word (old for read-first, new for write-first/bypass) — and the tell of the bug is dout showing the wrong one, or a write/write location that reads back as neither written value (or X) 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.

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 mem array 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_b equality 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.