ROM Verilog Code: Parameterized Sync and Async Examples

A read-only memory (ROM) holds fixed data that your hardware reads but never writes at run time: microcode, lookup tables, sine samples, boot vectors, or character glyphs. In an FPGA or ASIC the synthesis tool maps a ROM onto block RAM, distributed logic, or a hard macro depending on how you describe it. This tutorial writes rom verilog code three ways — a combinational (asynchronous) ROM built from a case statement, a registered (synchronous) ROM loaded in an initial block, and a fully parameterized module you can reuse at any width and depth. Every block below is complete Verilog-2005 with a self-checking testbench, and the simulation logs are produced by running it under Icarus Verilog.

Asynchronous ROM with a case statement

The simplest ROM is a pure combinational function of the address. A case statement inside always @(*) maps each address to a constant word. There is no clock, so data changes the moment addr changes — an asynchronous read. This style is ideal for small decode tables.

// Asynchronous (combinational) ROM using a case statement
module rom_async #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 3
)(
    input  wire [ADDR_WIDTH-1:0] addr,
    output reg  [DATA_WIDTH-1:0] data
);
    always @(*) begin
        case (addr)
            3'd0: data = 8'h10;
            3'd1: data = 8'h32;
            3'd2: data = 8'h54;
            3'd3: data = 8'h76;
            3'd4: data = 8'h98;
            3'd5: data = 8'hBA;
            3'd6: data = 8'hDC;
            3'd7: data = 8'hFE;
            default: data = {DATA_WIDTH{1'b0}};
        endcase
    end
endmodule

module tb_rom_async;
    localparam DATA_WIDTH = 8;
    localparam ADDR_WIDTH = 3;

    reg  [ADDR_WIDTH-1:0] addr;
    wire [DATA_WIDTH-1:0] data;
    integer i;

    rom_async #(.DATA_WIDTH(DATA_WIDTH), .ADDR_WIDTH(ADDR_WIDTH)) dut (
        .addr(addr),
        .data(data)
    );

    initial begin
        $display("ADDR  DATA");
        for (i = 0; i < 8; i = i + 1) begin
            addr = i[ADDR_WIDTH-1:0];
            #5;
            $display("  %0d   0x%02h", addr, data);
        end
        $finish;
    end
endmodule
ADDR  DATA
  0   0x10
  1   0x32
  2   0x54
  3   0x76
  4   0x98
  5   0xba
  6   0xdc
  7   0xfe
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpxagmm9xs/rom_async.v:44: $finish called at 40 (1s)

Two details matter. The output is declared reg because it is assigned inside a procedural block — this still synthesizes to combinational logic, not a flip-flop. The default branch is essential: it gives every address a defined value and stops the tool from inferring an unwanted latch. The testbench sweeps all eight addresses and prints each stored byte.

Synchronous ROM loaded in an initial block

Larger ROMs use a memory array — reg [W-1:0] mem [0:DEPTH-1] — that you preload once in an initial block. Registering the read on a clock edge (a synchronous read) is what lets FPGA tools map the array onto dedicated block RAM, which is denser and faster than logic. The trade-off is one clock cycle of read latency.

// Synchronous ROM: memory array preloaded in an initial block, registered read
module rom_sync #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 4,
    parameter DEPTH      = (1 << ADDR_WIDTH)
)(
    input  wire                  clk,
    input  wire [ADDR_WIDTH-1:0] addr,
    output reg  [DATA_WIDTH-1:0] data
);
    reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];
    integer k;

    initial begin
        // Load the ROM contents once at elaboration
        for (k = 0; k < DEPTH; k = k + 1)
            mem[k] = k * 3;      // simple pattern: 0, 3, 6, 9, ...
        mem[0] = 8'hAA;          // override a couple of words explicitly
        mem[1] = 8'h55;
    end

    always @(posedge clk)
        data <= mem[addr];       // read is registered -> one cycle of latency
endmodule

module tb_rom_sync;
    localparam DATA_WIDTH = 8;
    localparam ADDR_WIDTH = 4;

    reg                   clk;
    reg  [ADDR_WIDTH-1:0] addr;
    wire [DATA_WIDTH-1:0] data;
    integer i;

    rom_sync #(.DATA_WIDTH(DATA_WIDTH), .ADDR_WIDTH(ADDR_WIDTH)) dut (
        .clk(clk),
        .addr(addr),
        .data(data)
    );

    initial clk = 1'b0;
    always #5 clk = ~clk;        // 100 MHz-style clock

    initial begin
        addr = 0;
        $display("TIME ADDR DATA (registered, 1-cycle latency)");
        for (i = 0; i < 6; i = i + 1) begin
            addr = i[ADDR_WIDTH-1:0];
            @(posedge clk);          // present address
            @(posedge clk);          // data valid on next edge
            #1 $display("%4t  %0d   0x%02h", $time, i, data);
        end
        $finish;
    end
endmodule
TIME ADDR DATA (registered, 1-cycle latency)
  16  0   0xaa
  36  1   0x55
  56  2   0x06
  76  3   0x09
  96  4   0x0c
 116  5   0x0f
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmp96nhejdw/rom_sync.v:53: $finish called at 116 (1s)

The initial block fills the array with a pattern, then overrides two words — proving explicit entries and loops can coexist. The read data <= mem[addr] fires on posedge clk, so a given address’s word appears on the following edge. The testbench deliberately waits that extra edge before sampling. Always use the non-blocking <= here so the register behaves predictably in simulation and synthesis.

Parameterizing the ROM for reuse

Hard-coding width and depth wastes effort. Promote them to parameters, derive DEPTH from ADDR_WIDTH, and override per instance with #(...). The module below is instantiated twice from identical source — an 8-bit by 16-word ROM and a 4-bit by 4-word ROM.

// Fully parameterized ROM: initial-block load + asynchronous read.
// The same module is instantiated twice with different parameters.
module rom #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 4,
    parameter DEPTH      = (1 << ADDR_WIDTH)
)(
    input  wire [ADDR_WIDTH-1:0] addr,
    output wire [DATA_WIDTH-1:0] data
);
    reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];
    integer k;

    initial begin
        for (k = 0; k < DEPTH; k = k + 1)
            mem[k] = (k * k) & {DATA_WIDTH{1'b1}};  // squares, masked to width
    end

    assign data = mem[addr];   // combinational (asynchronous) read
endmodule

module tb_rom_param;
    // Instance A: 8-bit x 16 words
    reg  [3:0] a_addr;
    wire [7:0] a_data;
    rom #(.DATA_WIDTH(8), .ADDR_WIDTH(4)) romA (.addr(a_addr), .data(a_data));

    // Instance B: 4-bit x 4 words (narrow, shallow)
    reg  [1:0] b_addr;
    wire [3:0] b_data;
    rom #(.DATA_WIDTH(4), .ADDR_WIDTH(2)) romB (.addr(b_addr), .data(b_data));

    integer i;

    initial begin
        $display("romA (8b x16)   romB (4b x4)");
        for (i = 0; i < 4; i = i + 1) begin
            a_addr = i[3:0];
            b_addr = i[1:0];
            #2;
            $display(" A[%0d]=0x%02h        B[%0d]=0x%01h", i, a_data, i, b_data);
        end
        // Show a deeper address on the wide ROM only
        a_addr = 4'd15; #2;
        $display(" A[15]=0x%02h (15*15=225=0xE1)", a_data);
        $finish;
    end
endmodule
romA (8b x16)   romB (4b x4)
 A[0]=0x00        B[0]=0x0
 A[1]=0x01        B[1]=0x1
 A[2]=0x04        B[2]=0x4
 A[3]=0x09        B[3]=0x9
 A[15]=0xe1 (15*15=225=0xE1)
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpjp93b42x/rom_param.v:46: $finish called at 10 (1s)

The array is loaded with squares masked to the data width, and the read is a plain assign (asynchronous again). Because DEPTH tracks ADDR_WIDTH, you never size the array by hand. In a real project you can replace the initial loop with $readmemh("rom.hex", mem); to load contents from an external hex file — the rest of the module stays identical.

Synchronous vs asynchronous read

PropertyAsynchronousSynchronous
Clock neededNoYes
Read latency0 cycles1 cycle
FPGA mappingLUTs / distributed logicDedicated block RAM
Best forSmall decode tablesLarge, dense tables

Common mistakes

  • Omitting the default branch. A partial case leaves some addresses undefined and the tool infers a latch. Always provide a default word.
  • Driving a procedural output as wire. Anything assigned inside always must be reg; a wire is only legal with a continuous assign.
  • Expecting block RAM from an asynchronous read. Most FPGA block RAMs have a registered output. If you want BRAM, register the read with a clock edge.
  • Depth and address-width mismatch. Deriving DEPTH from ADDR_WIDTH avoids reading past the array end, which returns x in simulation.
  • Sampling synchronous data one cycle too early. Registered reads are valid the cycle after the address is applied; your testbench must wait for it.
  • Using blocking = in a clocked read. Use non-blocking <= inside always @(posedge clk) to model the register correctly.

Quick check

Why must the output of the case-based ROM be declared reg instead of wire?

Because it is assigned inside a procedural always block. In Verilog, a signal written procedurally must be reg. It still synthesizes to combinational logic, not a flip-flop, since the block is sensitive to * and has no clock edge.

What is the main advantage of a synchronous (registered) read ROM on an FPGA, and its cost?

The tool can map it onto dedicated block RAM, which is much denser and faster than building the table from LUTs. The cost is one clock cycle of read latency: the data for an address appears on the next clock edge.

How does DEPTH relate to ADDR_WIDTH in the parameterized module?

DEPTH = 1 << ADDR_WIDTH, i.e. 2 raised to ADDR_WIDTH. Deriving it this way means the memory array always exactly matches the address range, so changing one parameter resizes everything automatically.

Why include a default branch in the ROM’s case statement?

It defines the output for every possible address value. Without it, unlisted addresses leave the output unassigned, which infers an unwanted latch and produces undefined behavior for those addresses.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.

Learn Verilog with mentors, labs and placement support

This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.

Explore VLSI Courses
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD