On-chip memory and FIFOs

FPGA design · Building blocks · Jul 2026

Every real design needs to hold data, and where it holds it decides how much fits and how fast it runs. An FPGA offers two kinds of on-chip memory. Small amounts can be built from the lookup tables themselves, called distributed memory, which is handy for a few words of storage near the logic that uses them. Anything larger belongs in block RAM, the dedicated memory tiles the fabric provides. The trick with both is that you do not instantiate them directly. You write a memory in a particular shape and the tools recognize the pattern and map it onto the right resource. This page covers those shapes and builds the buffer that memory most often becomes.

Inferring a block RAM

Block RAM (BRAM) is a dedicated memory tile, roughly 18 or 36 kilobits each, with two independent ports so one part of a design can read while another writes. To get one, you describe an array and read it through a register, because the register is the part the tools use to decide this is a block RAM rather than a field of flip-flops. The registered read costs one cycle of latency, and accepting that latency is the price of the dense, fast memory. Here is the pattern for a simple dual-port memory with one write port and one read port.

dual_port_ram.svmodule dual_port_ram #(
    parameter int DATA_W = 32,
    parameter int DEPTH  = 512,
    parameter int ADDR_W = $clog2(DEPTH)
)(
    input  logic                clk,
    input  logic                we,
    input  logic [ADDR_W-1:0]   waddr,
    input  logic [DATA_W-1:0]   wdata,
    input  logic [ADDR_W-1:0]   raddr,
    output logic [DATA_W-1:0]   rdata
);
    logic [DATA_W-1:0] mem [DEPTH];

    always_ff @(posedge clk) begin
        if (we) mem[waddr] <= wdata;
        rdata <= mem[raddr];           // registered read, one cycle of latency
    end
endmodule

The $clog2 function computes the address width from the depth so the interface stays honest when the depth changes. The read and the write are in the same clocked block and on the same clock, which is the shape a block RAM wants. If you instead read the array combinationally, without the register, the tools will usually give you distributed memory built from lookup tables, which is fine for a tiny array and wasteful for a large one.

The FIFO, memory as a buffer

The most common thing a memory becomes is a first-in first-out buffer, a FIFO, which decouples a producer from a consumer so that short bursts and brief stalls do not force the two to run in lockstep. A FIFO is a circular buffer in memory with a write pointer, a read pointer, and a way to know how full it is. The subtlety people trip on is telling full from empty, because both leave the two pointers equal. The clean fix is to track the count of items directly rather than inferring it from the pointers, which also means no entry is wasted.

sync_fifo.svmodule sync_fifo #(
    parameter int DATA_W = 8,
    parameter int DEPTH  = 16,
    parameter int ADDR_W = $clog2(DEPTH)
)(
    input  logic              clk,
    input  logic              rst,
    input  logic              wr,       // push wdata when not full
    input  logic [DATA_W-1:0] wdata,
    input  logic              rd,       // pop when not empty
    output logic [DATA_W-1:0] rdata,
    output logic              full,
    output logic              empty,
    output logic [ADDR_W:0]   count
);
    logic [DATA_W-1:0] mem [DEPTH];
    logic [ADDR_W-1:0] wptr, rptr;

    assign empty = (count == 0);
    assign full  = (count == DEPTH);
    assign rdata = mem[rptr];           // first word available to read

    wire do_wr = wr && !full;
    wire do_rd = rd && !empty;

    always_ff @(posedge clk) begin
        if (rst) begin
            wptr <= '0; rptr <= '0; count <= '0;
        end else begin
            if (do_wr) begin mem[wptr] <= wdata; wptr <= wptr + 1'b1; end
            if (do_rd) rptr <= rptr + 1'b1;
            case ({do_wr, do_rd})
                2'b10:   count <= count + 1'b1;   // write only
                2'b01:   count <= count - 1'b1;   // read only
                default: count <= count;          // both or neither, count holds
            endcase
        end
    end
endmodule

The guards do_wr and do_rd make the FIFO ignore a push when full and a pop when empty, so it can never corrupt itself no matter how it is driven. When a push and a pop land on the same cycle the count stays put, which is correct, because one entry came in as another left. The testbench in the repository fills the buffer, drains it, and checks that the data comes out in the order it went in with the full and empty flags behaving.

This FIFO lives in one clock domain. When a buffer has to bridge two different clocks the pointers can no longer be compared directly, and the design needs the careful Gray-code crossing covered on the clock domain crossing page. Before that, the next two pages make the datapath fast, with pipelining and then timing closure.

Next, pipelining for throughput.