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 PASSED

The log proves the lane-masking is correct: after a full-word write of DEADBEEF, a single-byte write with wstrb=0001 changes only byte 0 (giving DEADBEAA), a two-byte write with wstrb=1100 changes only the upper half (1234BEAA), and a write with wstrb=0000 is 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
endmodule
PASS 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 PASSED

The log shows each masked write and its merged result matching the golden reference (e.g. old ABCD + low-byte write 12 → AB12, then high-byte write 34 → 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 char into 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's SEL. 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 bit i*8", which is legal Verilog-2005 and maps cleanly to synthesis. Lane i covers byte i: lane 0 is bits [7:0], lane 1 is [15:8], and so on. A strobe of 4'b1111 is a full-word write; 4'b0001 writes only the least-significant byte; 4'b0000 writes 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 rdata from 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 always block, 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 on mem can create race-dependent simulation results.
  • Forgetting the no-op case: a write with wstrb=0 while we=1 must change nothing. The per-lane if handles 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 i gates 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 WSTRB all 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 as DATA_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 Lab

Open this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.

Run it in the Lab

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