Hardware Engineering · FPGAs
Block RAM Primitives and Memory Inference from RTL
How dedicated memory blocks are structured internally, why inference from an array declaration produces different hardware than instantiation, and how read-during-write behaviour decides correctness.
A logic fabric built only from lookup tables and flip-flops can hold state, but at a brutal price: a 32-bit-wide, 1024-deep array needs 32,768 flip-flops plus the multiplexing to select a word, which exceeds the register capacity of small devices and wastes the larger ones. Every commercial FPGA therefore embeds dedicated memory columns alongside the logic. These blocks are the reason a design can hold a frame buffer, a packet queue, or a coefficient table without consuming the fabric that computes on it.
What sits inside a memory block
The block is a dual-port static RAM of fixed width and depth, wrapped in the logic needed to make it usable from a synchronous design. A typical mid-range block holds 36 kilobits, arranged as 32K entries of one bit, 4K of 8 bits, 2K of 18 bits, or 1K of 36 bits. The widths are not arbitrary: the parity bits that come with 9-bit and 36-bit organizations are the reason for the odd numbers, and a 36-bit word is exactly four 9-bit bytes with four parity bits.
Around the array sit optional registers on every input and both outputs. Address, data in, write enable, and data out can each be latched, and the block can therefore operate fully pipelined with one register stage per port. A block used with output registers has predictable, clock-to-clock timing; the same block used combinationally has a longer path that begins at the address register and ends at whatever consumes the data. This choice is one of the largest single influences a designer has over memory timing, and it is invisible in the RTL unless the coding style makes it explicit.
The ports are symmetric in a true dual-port block: either port can read or write any address. That symmetry is more expensive in silicon than the alternative, and several families offer a simpler arrangement in which one port writes and the other only reads, with the write port optionally configured as two independent narrower write ports sharing one read port. The narrow-write mode is what makes a simple byte-enable possible: instead of adding byte-write enables to a wide port, the block is configured with two write ports of half width and the logic steers writes to the appropriate half.
Byte enables deserve a note of their own. A memory that must be writable in units smaller than its word width, as any byte-addressed buffer is, needs per-byte write masking, and not every configuration of every block supports it at full width. Writing a 32-bit word where only the low byte should change requires either a read-modify-write sequence, which costs a cycle and creates a race with the other port, or the use of a block mode that provides the masking. The distinction between these two implementations is invisible in the RTL and visible only in the resource report.
Inference: the array declaration is a request, not an instruction
The idiomatic way to describe memory in synthesisable RTL is a two-dimensional array indexed by address, with a clocked process that writes on an enable and a separate assignment that reads. Synthesis tools recognize this pattern and map it to a block. The recognition is heuristic, and its rules are stricter than most designers expect.
The pattern that infers a block RAM has these properties: the array is written only inside a clocked process, on a single clock edge; every write is conditional on a write enable; the read is either a continuous assignment from the array (asynchronous read) or a clocked assignment (synchronous read); and no element of the array is written from more than one place in the code. If a design reads and writes the same address within one always block, or if the read is used to compute the value being written in the same cycle, the tool must either infer a bypass path around the memory or fall back to registers.
-- Synchronous-read, single-write-port memory.
-- The read address register is inside the process, so the read is
-- registered and the block can use its output register.
process (clk)
begin
if rising_edge(clk) then
if wr_en = '1' then
mem(to_integer(unsigned(wr_addr))) := wr_data;
end if;
rd_data <= mem(to_integer(unsigned(rd_addr)));
end if;
end process;
Whether this description infers one block or a chain of them depends on the declared depth. A 4096-by-32 array needs four 36-kilobit blocks side by side, and a 64K-by-8 array needs sixteen blocks cascaded in depth, with the address decoded to select one and the outputs multiplexed. Depth cascading is where address decoding delay appears: a deep memory built from many blocks adds a multiplexer level that a shallow one does not have, and that multiplexer sits between the memory output and the consumer.
When inference fails, the tool does not report an error. It silently builds the array from flip-flops and multiplexers, which may consume a large fraction of the device and produce timing that no constraint anticipated. Reading the synthesis log for memory-related messages, and comparing the inferred memory count against the number of array declarations, is the only reliable check.
Instantiation, and why it still exists
Instantiation of the vendor primitive gives direct control over the things inference decides implicitly: the exact width and depth organization, the number of pipeline stages, whether the output register is used, the collision behaviour between ports, and the initial contents. It also fixes the design to one family. Inference produces portable code that maps reasonably on any device; instantiation produces code that behaves exactly as specified on one device and does not compile elsewhere.
The pragmatic split is to infer ordinary single-clock memories and to instantiate where a specific property matters: a memory that must be initialized to a known pattern at configuration, a memory whose two ports must have defined collision semantics, or a memory whose timing must be identical across synthesis versions. Initialization is a good example, since block RAM contents are part of the bitstream and are loaded at configuration, but only if the tool is told the contents; a design that relies on an array being zero after configuration and does not specify an initial value gets whatever the tool decided, which in simulation is often zero and in hardware is often not.
Read-during-write: the collision that decides correctness
Both ports of a dual-port block can address the same location in the same cycle, one writing and one reading. What the reading port returns is a hardware property, not a language property, and there are three common answers: the old contents, the new value being written, or undefined.
The first is the natural behaviour of the array itself: the read happens before the write takes effect within the cycle, so the reader sees the previous value. The second requires a bypass path from the write data to the read output, which is extra logic inside the block. The third is what a vendor may specify when it declines to guarantee either, and it is the reason a design that happens to simulate correctly can fail in hardware if the simulation model chose one behaviour and the silicon implements another.
// Same-address collision: check the documented read-old-data behaviour.
always @(posedge clk) begin
if (we && rd_addr == wr_addr)
assert (rd_data !== wr_data)
else $error("read-during-write returned the new data");
end
The safe design pattern is to avoid the collision: partition the address space so that writes and reads never target the same location in the same cycle, or accept a stall when they would. Where the collision is inherent, as in a FIFO built from a dual-port memory, the standard solution is to compare the read and write pointers and to bypass explicitly in the RTL rather than rely on the primitive’s mode. A first-word-fall-through FIFO, which presents the head of the queue without a read latency, is built exactly this way.
There is also a subtlety about read latency that catches designs during integration. A memory with a registered read returns data one cycle after the address, and a memory with two register stages returns it two cycles later. Code that assumes a particular latency and is correct against a behavioural model may be off by one when the tool changes the number of inferred stages. Making the latency explicit in the RTL, by registering the read address in the design’s own code, removes the ambiguity and is why the pattern above places the read inside the clocked process.
Distributed memory and the boundary
Small arrays, typically a few hundred bits, are better built from the lookup tables themselves, using them as 32-bit or 64-bit shift registers with the write logic folded into the table. These distributed memories have no fixed placement, can be replicated per instance cheaply, and have short paths to nearby logic. Their cost is fabric: a 64-bit deep, 16-bit wide array consumes a substantial number of lookup tables that the design might need for computation.
The choice between distributed and block memory is a placement question as much as a capacity one. A block RAM is a physical column resource; many small arrays scattered across the design force the tool to place them in specific columns and route to them from a distance, which can create long nets that no amount of logic optimization fixes. Merging small arrays into one wider block with an address prefix is a standard transformation, and it converts many short local routes into one set of longer ones, which is not always the improvement it appears to be.
The timing consequences of all these choices land on the paths that connect memory to logic, and those paths are analysed like any other, as described in static timing analysis. The clock that drives the memory’s registers reaches them through a network with its own skew, which is the subject of clock distribution.
References
- 7 Series FPGAs Memory Resources User Guide (UG473) — Xilinx / AMD
- IEEE Std 1076-2019: VHDL Language Reference Manual — IEEE
- IEEE 1800-2017: SystemVerilog Language Reference Manual — IEEE