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
endmoduleADDR 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
endmoduleTIME 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
endmoduleromA (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
| Property | Asynchronous | Synchronous |
|---|---|---|
| Clock needed | No | Yes |
| Read latency | 0 cycles | 1 cycle |
| FPGA mapping | LUTs / distributed logic | Dedicated block RAM |
| Best for | Small decode tables | Large, dense tables |
Common mistakes
- Omitting the
defaultbranch. A partialcaseleaves some addresses undefined and the tool infers a latch. Always provide a default word. - Driving a procedural output as
wire. Anything assigned insidealwaysmust bereg; awireis only legal with a continuousassign. - 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
DEPTHfromADDR_WIDTHavoids reading past the array end, which returnsxin 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<=insidealways @(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.
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