Byte-Enable RAM in Verilog with Code
A byte-enable RAM is a synchronous memory whose data word is split into independently writable byte lanes. A write-strobe bus (wstrb, one bit per byte) decides which lanes are updated, so software can do a single-byte or half-word store into a wide memory without a read-modify-write. This page gives working byte enable RAM Verilog code for a 32-bit, 4-lane RAM, with a self-checking testbench you can run under Icarus Verilog.
Per-byte write enables are exactly what AXI, AHB and processor data buses expect: the bus master drives the full word plus a strobe mask, and the memory merges only the enabled bytes. Below we build the clean parameterized version first, then a 16-bit variant that shows write-first read-during-write behavior.
A parameterized 32-bit RAM with four byte lanes. A for loop over the byte lanes writes only the bytes whose wstrb bit is set, using the Verilog-2005 indexed part-select [i*8 +: 8]. Read is synchronous.
module byte_enable_ram #(
parameter DATA_W = 32,
parameter ADDR_W = 4
)(
input wire clk,
input wire we,
input wire [DATA_W/8-1:0] wstrb,
input wire [ADDR_W-1:0] addr,
input wire [DATA_W-1:0] wdata,
output reg [DATA_W-1:0] rdata
);
localparam NBYTES = DATA_W/8;
reg [DATA_W-1:0] mem [0:(1<<ADDR_W)-1];
integer i;
always @(posedge clk) begin
if (we) begin
for (i = 0; i < NBYTES; i = i + 1) begin
if (wstrb[i])
mem[addr][i*8 +: 8] <= wdata[i*8 +: 8];
end
end
rdata <= mem[addr];
end
endmodule
module tb;
localparam DATA_W = 32;
localparam ADDR_W = 4;
reg clk = 0;
reg we;
reg [3:0] wstrb;
reg [ADDR_W-1:0] addr;
reg [DATA_W-1:0] wdata;
wire [DATA_W-1:0] rdata;
integer errors = 0;
byte_enable_ram #(.DATA_W(DATA_W), .ADDR_W(ADDR_W)) dut (
.clk(clk), .we(we), .wstrb(wstrb), .addr(addr), .wdata(wdata), .rdata(rdata));
always #5 clk = ~clk;
task do_write;
input [ADDR_W-1:0] a; input [DATA_W-1:0] d; input [3:0] s;
begin
@(negedge clk); we = 1; addr = a; wdata = d; wstrb = s;
@(posedge clk); @(negedge clk); we = 0; wstrb = 4'b0000;
end
endtask
task do_read;
input [ADDR_W-1:0] a;
begin
@(negedge clk); we = 0; addr = a;
@(posedge clk); @(negedge clk);
end
endtask
reg [DATA_W-1:0] expect_word;
initial begin
we = 0; wstrb = 0; addr = 0; wdata = 0;
do_write(4'd3, 32'hDEADBEEF, 4'b1111);
do_read(4'd3); expect_word = 32'hDEADBEEF;
if (rdata !== expect_word) begin $display("FAIL full-word: got %08h exp %08h", rdata, expect_word); errors=errors+1; end
else $display("PASS full-word write: mem[3]=%08h", rdata);
do_write(4'd3, 32'h000000AA, 4'b0001);
do_read(4'd3); expect_word = 32'hDEADBEAA;
if (rdata !== expect_word) begin $display("FAIL single-byte: got %08h exp %08h", rdata, expect_word); errors=errors+1; end
else $display("PASS single-byte masked write: mem[3]=%08h", rdata);
do_write(4'd3, 32'h1234FFFF, 4'b1100);
do_read(4'd3); expect_word = 32'h1234BEAA;
if (rdata !== expect_word) begin $display("FAIL two-byte: got %08h exp %08h", rdata, expect_word); errors=errors+1; end
else $display("PASS two-byte masked write: mem[3]=%08h", rdata);
do_write(4'd3, 32'hFFFFFFFF, 4'b0000);
do_read(4'd3); expect_word = 32'h1234BEAA;
if (rdata !== expect_word) begin $display("FAIL zero-strobe: got %08h exp %08h", rdata, expect_word); errors=errors+1; end
else $display("PASS zero-strobe write is a no-op: mem[3]=%08h", rdata);
if (errors == 0) $display("ALL TESTS PASSED"); else $display("%0d TEST(S) FAILED", errors);
$finish;
end
endmodule
PASS full-word write: mem[3]=deadbeef PASS single-byte masked write: mem[3]=deadbeaa PASS two-byte masked write: mem[3]=1234beaa PASS zero-strobe write is a no-op: mem[3]=1234beaa ALL TESTS PASSEDThe log proves the lane-masking is correct: after a full-word write of
DEADBEEF, a single-byte write withwstrb=0001changes only byte 0 (givingDEADBEAA), a two-byte write withwstrb=1100changes only the upper half (1234BEAA), and a write withwstrb=0000is a no-op. Every asserted value is checked against a hand-computed expected word, so any lane leaking into a disabled byte would print FAIL.A 16-bit, 2-lane variant with write-first read-during-write: on a write cycle the read port returns the freshly merged word instead of the old contents. The testbench carries an independent golden model and compares every cycle.
module be_ram_wf #( parameter ADDR_W = 3 )( input wire clk, input wire we, input wire [1:0] wstrb, input wire [ADDR_W-1:0] addr, input wire [15:0] wdata, output reg [15:0] rdata ); reg [15:0] mem [0:(1<<ADDR_W)-1]; reg [15:0] newword; integer i; always @(posedge clk) begin if (we) begin newword = mem[addr]; for (i = 0; i < 2; i = i + 1) if (wstrb[i]) newword[i*8 +: 8] = wdata[i*8 +: 8]; mem[addr] <= newword; rdata <= newword; end else begin rdata <= mem[addr]; end end endmodule module tb; localparam ADDR_W = 3; reg clk = 0; reg we; reg [1:0] wstrb; reg [ADDR_W-1:0] addr; reg [15:0] wdata; wire [15:0] rdata; integer errors = 0; reg [15:0] gold [0:(1<<ADDR_W)-1]; reg [15:0] gexp; integer j; be_ram_wf #(.ADDR_W(ADDR_W)) dut ( .clk(clk), .we(we), .wstrb(wstrb), .addr(addr), .wdata(wdata), .rdata(rdata)); always #5 clk = ~clk; task wr_check; input [ADDR_W-1:0] a; input [15:0] d; input [1:0] s; begin @(negedge clk); we = 1; addr = a; wdata = d; wstrb = s; gexp = gold[a]; if (s[0]) gexp[7:0] = d[7:0]; if (s[1]) gexp[15:8] = d[15:8]; gold[a] = gexp; @(posedge clk); @(negedge clk); we = 0; wstrb = 0; if (rdata !== gexp) begin $display("FAIL wr-first addr=%0d got %04h exp %04h", a, rdata, gexp); errors=errors+1; end else $display("PASS wr-first addr=%0d data=%04h strb=%b -> %04h", a, d, s, rdata); end endtask task rd_check; input [ADDR_W-1:0] a; begin @(negedge clk); we = 0; addr = a; @(posedge clk); @(negedge clk); if (rdata !== gold[a]) begin $display("FAIL readback addr=%0d got %04h exp %04h", a, rdata, gold[a]); errors=errors+1; end else $display("PASS readback addr=%0d -> %04h", a, rdata); end endtask initial begin we = 0; wstrb = 0; addr = 0; wdata = 0; for (j = 0; j < (1<<ADDR_W); j = j + 1) gold[j] = 16'h0000; wr_check(3'd1, 16'hABCD, 2'b11); wr_check(3'd1, 16'h0012, 2'b01); wr_check(3'd1, 16'h3400, 2'b10); wr_check(3'd1, 16'hFFFF, 2'b00); rd_check(3'd1); wr_check(3'd5, 16'h7788, 2'b11); wr_check(3'd5, 16'h0099, 2'b01); rd_check(3'd5); if (errors == 0) $display("ALL TESTS PASSED"); else $display("%0d TEST(S) FAILED", errors); $finish; end endmodulePASS wr-first addr=1 data=abcd strb=11 -> abcd PASS wr-first addr=1 data=0012 strb=01 -> ab12 PASS wr-first addr=1 data=3400 strb=10 -> 3412 PASS wr-first addr=1 data=ffff strb=00 -> 3412 PASS readback addr=1 -> 3412 PASS wr-first addr=5 data=7788 strb=11 -> 7788 PASS wr-first addr=5 data=0099 strb=01 -> 7799 PASS readback addr=5 -> 7799 ALL TESTS PASSEDThe log shows each masked write and its merged result matching the golden reference (e.g. old
ABCD+ low-byte write12→AB12, then high-byte write34→3412), plus plain read-backs. Because the RAM is write-first, the write-cycle output equals the post-merge word; the golden model computes the same merge, so a wrong lane or a read-first mistake would print FAIL.Why byte lanes exist
A CPU storing a single
charinto a 32-bit memory must not disturb the other three bytes sharing that word. Without byte enables the only option is a read-modify-write: read the word, patch one byte, write it back — two memory cycles and a hazard if another agent writes in between. Byte-enable RAM solves this in hardware: the memory merges only the enabled lanes in one write cycle.This is why every mainstream bus carries a byte-strobe signal: AXI's
WSTRB, AHB's size/offset decoding, Wishbone'sSEL. Each strobe bit corresponds to one 8-bit lane of the data bus. For a 32-bit bus you get 4 strobe bits; for 64-bit, 8 bits.How the wstrb mask works
The core of the design is a per-lane conditional write:
for (i = 0; i < NBYTES; i = i + 1) if (wstrb[i]) mem[addr][i*8 +: 8] <= wdata[i*8 +: 8];The indexed part-select
[i*8 +: 8]means "8 bits starting at biti*8", which is legal Verilog-2005 and maps cleanly to synthesis. Laneicovers bytei: lane 0 is bits [7:0], lane 1 is [15:8], and so on. A strobe of4'b1111is a full-word write;4'b0001writes only the least-significant byte;4'b0000writes nothing.Endianness note
The RAM itself is endian-agnostic: lane i always maps to data bits
[i*8 +: 8]. Whether lane 0 is the "first" byte of a word in memory is a bus-level convention. On a little-endian system the lowest address maps to lane 0 (LSB); on big-endian it maps to the MSB lane. The strobe decoder in your bus bridge, not this memory, owns that mapping.Read-during-write behavior
When the same address is read and written in the same cycle, three behaviors are possible and you must pick one:
- Read-first (old data): the read port returns the pre-write contents. This is the behavior of Example 1 —
rdata <= mem[addr]samples the array before the non-blocking lane writes take effect on the clock edge.- Write-first (new data): the read port returns the just-written word. Example 2 implements this by computing the merged word into a variable and driving both the array and
rdatafrom it.- No-change / undefined: some block-RAM primitives hold the previous output or produce X on a collision. Avoid relying on this.
Pick the mode your pipeline needs and state it explicitly; mismatches here are a classic source of simulation-vs-silicon bugs because the vendor block RAM may not match a naive RTL guess.
Mapping to block RAM
FPGA block RAMs (Xilinx BRAM, Intel M20K) natively support byte-write-enable, so this RTL infers a single BRAM rather than four narrow ones — provided you follow the inference template:
- Use a single clock and synchronous read (register the output). An unregistered/asynchronous read forces LUT-based distributed RAM instead of block RAM.
- Keep the per-lane write inside one clocked
alwaysblock, guarded by the strobe bit. Vendors recognise this pattern and map it to the BRAM's byte-write ports.- Match the width to a multiple of 8 (or 9 with parity). A 32-bit word with 4 byte enables maps directly.
Check the synthesis log for "RAM_STYLE = block" or an inferred RAMB primitive. If the tool reports distributed RAM, the read path is almost always the culprit.
Common mistakes
- Blocking vs non-blocking: use non-blocking (
<=) for the array and output registers in the clocked block. Mixing blocking assignments onmemcan create race-dependent simulation results.- Forgetting the no-op case: a write with
wstrb=0whilewe=1must change nothing. The per-laneifhandles this automatically, but a careless "write whole word then overwrite lanes" style does not.- Asynchronous read: writing
assign rdata = mem[addr];gives combinational read and usually loses block-RAM inference and timing closure.- Wrong lane order: double-check that strobe bit
igates data bits[i*8 +: 8], especially after a width change.FAQ
Is byte enable the same as wstrb?
Yes — "byte enable", "write strobe" and AXI's
WSTRBall name the same mask: one bit per byte lane that gates whether that byte is written. A high bit writes the lane; a low bit preserves its old value.How many strobe bits do I need?
One per 8-bit lane:
DATA_W/8. A 32-bit RAM needs 4, a 64-bit RAM needs 8. The code parameterizes this asDATA_W/8-1:0.Does this synthesize to real block RAM?
Yes, if you keep synchronous (registered) read and a single clocked write block with per-lane strobe guards. Modern FPGA block RAMs have dedicated byte-write-enable inputs that this template targets directly.
Can I read and write the same address at once?
Yes, but you must choose read-first or write-first behavior and code it deliberately, as shown in the two examples. Do not assume the default.
Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.▶ Try it in the ChipXpert LabRun it in the LabOpen this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.
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 Hi! Ask me about courses, fees, batches or discounts. ×ChipXpert AssistantAsk about courses, fees, batches and centres