1. Verilog RAM - Single Port Async Design

A Single Port asynchronous RAM is the simplest memory you can write in Verilog — one bidirectional data port, address, and a pair of control signals. It's also a useful teaching design because it forces you to confront tristate buses, race conditions, and the gap between simulatable RTL and synthesizable, FPGA-friendly RTL. This post walks the design, shows the timing, and ends with where async RAM is the wrong choice (most modern silicon).

Where async RAM fits — and where it doesn't

Async RAMSynchronous RAM
Read latencyCombinational (data appears as soon as o_en is high)One clock cycle
Timing closureHard — read path is combinational across the arrayEasy — read is registered
FPGA block RAMNot natively supported — synthesizes to LUTs (huge area)Native; what BRAM is built for
ASIC SRAM compilersRare — most generate sync-only macrosStandard
Use cases todayLegacy interfaces, small register files, behavioral modelsAlmost everything

Modern designs use synchronous RAM. Async is what you'll find in a textbook, a 1990s-era IP block, or as a behavioral abstraction inside a verification model. Knowing the design is still valuable — many small register banks are async-style under the hood, and understanding the tristate semantics is essential when you read older code.

Features of this design

  • Parameterized: WIDTH, DEPTH, and ADDR width are all parameters
  • Asynchronous: no clock — reads and writes complete in zero simulation time
  • Bidirectional data port: the same data bus serves both read (driven by RAM) and write (driven by external master)
  • Async reset: clears all locations to zero on reset assertion

Port description

PortDirectionDescription
datainoutBidirectional data bus [WIDTH-1:0]
addrinputAddress bus [ADDR-1:0]
resetinputAsynchronous reset (active high)
wr_eninputWrite enable
o_eninputOutput enable (read)

RTL Implementation

// Module: sp_ram.v
// Parameterized Single Port Asynchronous RAM

module sp_ram #(
  parameter WIDTH = 8,
  parameter DEPTH = 16,
  parameter ADDR  = 4
)(
  inout  [WIDTH-1:0] data,
  input  [ADDR-1:0]  addr,
  input              reset,
  input              wr_en,
  input              o_en
);

  // Memory array
  reg [WIDTH-1:0] mem [DEPTH-1:0];
  integer i;

  // Read: drive data when o_en asserted and not writing
  assign data = (o_en && !wr_en) ? mem[addr] : {WIDTH{1'bz}};

  // Write / Reset (async, level-sensitive)
  always @(reset, data, addr, wr_en, o_en) begin
    if (reset) begin
      for (i = 0; i < DEPTH; i = i + 1)
        mem[i] = 0;
    end
    else if (wr_en && !o_en) begin
      mem[addr] = data;
    end
  end

endmodule

Operation modes

wr_eno_enOperation
00Idle (data = Hi-Z)
01Read from mem[addr]
10Write to mem[addr]
11Invalid (avoid — bus contention)

Read/write timing

Race conditions to be aware of

  • Write-then-read same address. Because the array update is in a level-sensitive always, simulating a write immediately followed by a read at the same address can return the new or the old value depending on event scheduling. Use blocking assignments inside the always block (as shown), and put a small delay between write and read in your testbench.
  • wr_en and o_en both high. The bus is driven by both the master and the RAM — contention. The truth table marks this combination as Invalid; production code should add an assertion to catch it: // synthesis_off, assert (!(wr_en && o_en));
  • Hi-Z propagation. The read path drives z when neither read nor write is active. Downstream logic that doesn't tolerate X/Z must either tristate or register the bus.
  • Sensitivity list completeness. The always @(reset, data, addr, wr_en, o_en) needs all signals it reads. Modern style: use always @* and let the simulator infer.

Sketch of a self-checking testbench

module sp_ram_tb;
  parameter WIDTH = 8;
  parameter ADDR  = 4;

  reg  [ADDR-1:0]  addr;
  reg              reset, wr_en, o_en;
  reg  [WIDTH-1:0] wdata;
  wire [WIDTH-1:0] data;

  // Drive bus during writes, release during reads/idle
  assign data = (wr_en && !o_en) ? wdata : {WIDTH{1'bz}};

  sp_ram #(.WIDTH(WIDTH), .ADDR(ADDR)) dut(.*);

  initial begin
    reset = 1; #5 reset = 0;

    // Write 0x55 to addr 0
    wr_en = 1; o_en = 0; addr = 4'h0; wdata = 8'h55; #2;
    wr_en = 0;                                       #1;

    // Read back and check
    o_en  = 1;                                       #1;
    if (data !== 8'h55) $error("mem[0] expected 0x55, got %h", data);
    o_en = 0;

    $display("Test passed");
    $finish;
  end
endmodule

This is the bare minimum self-check. For real verification, run a constrained-random sequence: pick random addresses, write random data, build a reference model, and compare on every read.

Next: Part 2 — Single Port RAM Testbench (full directed + random flow)

Author
Mayur Kubavat
VLSI Design and Verification Engineer sharing knowledge about SystemVerilog, UVM, and hardware verification methodologies.

Comments (0)

Leave a Comment