The SoC Memory Map
A system-on-a-chip (SoC) memory map is the table that says which physical addresses belong to which piece of hardware — this range is boot ROM, that range is RAM, that other range is the transmit register of the UART. On a chip you designed yourself, that table is not documentation you look up. It is a contract you author, and that three independent things must then agree on: the Verilog address decoder that turns address bits into chip-select signals, the linker script that decides where
.textand.dataend up, and the C or Rust header that declares where the peripheral registers live. Change any one of the three without changing the other two and the system does not “mostly work” — it fetches garbage at reset, or writes characters into a memory hole and prints nothing. RISC-V itself has almost nothing to say about the layout: the ratified privileged specification only classifies address ranges by their physical memory attributes (PMAs), distinguishing “main memory” from “I/O regions” and noting that PMAs “of some memory regions are fixed at chip design time — for example, for an on-chip ROM” (RISC-V Instruction Set Manual Volume II: Privileged Architecture, version 20250508, Ratified, §Physical Memory Attributes). Where you put things is entirely yours to decide, and the decisions are load-bearing: aligning every region to a power of two turns the address decoder into a handful of equality comparisons on the top address bits, while an awkward size turns it into arithmetic on the critical path.This note is about a bare SoC with no operating system — no page tables, no
mmap, no kernel virtual address space. The physical address the core emits is the address the wires carry. For what this same idea becomes once an OS exists, see Linux Memory Management MOC and Memory-Mapped IO and ioremap; the concepts rhyme but the mechanisms are entirely different. Its sibling is Boot ROM and the Reset Vector, which covers the one address in the map that hardware chooses for you.
Mental Model — A Contract Between Three Parties
The single most useful way to hold a memory map in your head is not as a picture of memory. It is as a set of constants that appear, verbatim, in three different files written in three different languages, maintained by three different mental modes, and checked against each other by nothing at all.
The hardware side is a Verilog module that receives a 32-bit address and asserts one chip-select wire. The build side is a linker script that tells GNU ld where the read-only code may live and where writable data may live. The software side is a header or a Rust const that says “the UART transmit register is at this address”. Nothing in any toolchain cross-checks these. The compiler will happily emit a store to 0x1000_0000 whether or not any hardware answers there; the linker will happily place .text at 0x8000_0000 whether or not a ROM exists there; the synthesiser will happily build a decoder for a region no software ever touches. The only enforcement is that the system works or it doesn’t.
flowchart TB MAP["THE MEMORY MAP<br/>ROM 0x0000_0000 · 16 KiB<br/>MMIO 0x1000_0000 · 64 KiB<br/>RAM 0x2000_0000 · 64 KiB"] subgraph HW["1 · Hardware — Verilog"] DEC["soc_decode.v<br/>addr[31:14] == 18'h00000 -> cs_rom<br/>addr[31:16] == 16'h1000 -> cs_mmio<br/>addr[31:16] == 16'h2000 -> cs_ram"] end subgraph BUILD["2 · Build — linker script"] LD["link.ld<br/>ROM : ORIGIN = 0x00000000, LENGTH = 16K<br/>RAM : ORIGIN = 0x20000000, LENGTH = 64K"] end subgraph SW["3 · Software — C / Rust"] HDR["soc.h<br/>#define UART_BASE 0x10000000u"] end MAP --> DEC MAP --> LD MAP --> HDR DEC -->|"decides which<br/>device answers"| BUS["the running system"] LD -->|"decides where<br/>code and data land"| BUS HDR -->|"decides where<br/>stores are aimed"| BUS BUS -.->|"no tool checks<br/>these agree"| MAP
The memory map as a three-party contract. What it shows: one set of numbers, replicated by hand into an RTL decoder, a linker script, and a software header, with no automated consistency check anywhere in the loop. The insight to take: the dashed feedback arrow is the whole problem. Every other contract in a software stack has a compiler or a type system enforcing it; this one has only your discipline. That is why serious SoC projects generate all three artefacts from a single machine-readable source — a device tree, an IP-XACT description, a SystemRDL register model, or in a small project simply a Python script that emits soc.v, link.ld and soc.h from one table.
The three parties fail in characteristically different ways, and recognising the symptom tells you which one is wrong:
| Which party is wrong | Symptom on the bench | Why it looks like that |
|---|---|---|
| Decoder disagrees with linker | Core fetches at reset and gets 0x00000000 or X forever; no UART output at all | No chip select asserts for the fetch address, so the bus returns nothing (or never acknowledges) |
| Linker disagrees with software header | Code runs, but writes to the UART land in RAM or in a hole; program completes silently | The store executes fine — it is just aimed at the wrong place |
| Decoder disagrees with software header | Writing to one peripheral configures a different one | Two regions overlap, or a region is under-decoded (see the aliasing trap below) |
| All three agree but the region is too small | Link succeeds, runtime corrupts adjacent state | ld only checks against LENGTH; it has no idea how big the real memory is |
The Address Space, Drawn
Here is the map this note works with throughout — the one actually built, linked, and simulated for the worked example below. It is deliberately minimal: three regions, each power-of-two sized and aligned to its own size.
| Region | Base | Top | Size | Attributes | Decoded by |
|---|---|---|---|---|---|
| Boot ROM | 0x0000_0000 | 0x0000_3FFF | 16 KiB | R, X | addr[31:14] == 18'h00000 |
| MMIO (peripherals) | 0x1000_0000 | 0x1000_FFFF | 64 KiB | R, W, side effects | addr[31:16] == 16'h1000 |
| RAM | 0x2000_0000 | 0x2000_FFFF | 64 KiB | R, W, X | addr[31:16] == 16'h2000 |
| (everything else) | — | — | — | unmapped → bus error | cs_none |
Inside the MMIO region, peripherals get 4 KiB slots — one page’s worth each, even though a UART needs about sixteen bytes:
| Peripheral | Base | Size of slot | Registers used |
|---|---|---|---|
| UART 0 | 0x1000_0000 | 4 KiB | +0x00 TX data, +0x04 status, +0x08 RX data |
| CLINT (timer / software IRQ) | 0x1000_1000 | 4 KiB | mtime, mtimecmp, msip |
| GPIO | 0x1000_2000 | 4 KiB | direction, output, input |
| (reserved) | 0x1000_3000 | 52 KiB | headroom for the next peripheral |
Drawn as an address space, with the vast unmapped gaps that dominate a 4 GiB space made explicit:
0xFFFF_FFFF +--------------------------------+
| |
: unmapped (~3.75 GiB) : -> cs_none, bus error
| |
0x2001_0000 +--------------------------------+
| RAM 64 KiB RWX | .data (VMA) · .bss · stack
0x2000_0000 +--------------------------------+
| |
: unmapped (256 MiB - 64 KiB) :
| |
0x1001_0000 +--------------------------------+
| MMIO 64 KiB RW | side effects, no caching
| +0x0000 UART0 (4 KiB) |
| +0x1000 CLINT (4 KiB) |
| +0x2000 GPIO (4 KiB) |
| +0x3000 reserved |
0x1000_0000 +--------------------------------+
| |
: unmapped (256 MiB - 16 KiB) :
| |
0x0000_4000 +--------------------------------+
| ROM 16 KiB RX | .text · .rodata · .data (LMA)
0x0000_0000 +--------------------------------+ <-- reset vector: PC starts here
The address space of the example SoC, drawn to a logarithmic sense of scale rather than a linear one. Format note: this is an ASCII box diagram rather than mermaid because mermaid has no primitive for a linear address space — a flowchart would draw three boxes with no sense of the enormous gaps between them, and packet-beta describes a bit layout within a word, not a range of addresses. The vault’s default is mermaid; this is one of the cases where it genuinely cannot express the thing. The insight to take: the mapped regions occupy 144 KiB out of 4 GiB — about 0.0034% of the address space. On a bare SoC the address space is overwhelmingly empty, and that emptiness is free: it costs nothing to leave 256 MiB between regions, and it buys you a decoder that only ever compares the top few bits, plus room to add a peripheral later without renumbering anything.
Address Decoding Is Comparing High Bits
An address decoder is the combinational logic that looks at the address a bus master is presenting and asserts exactly one chip select — a one-bit “this transaction is for you” signal — to the device that owns that address. On a Wishbone Bus the chip select is what gates a slave’s ACK_O, its data-out mux, and its register writes. In a small SoC the decoder is not a lookup table, not a CAM, not a TLB. It is a set of equality comparisons on the high address bits, and the reason it is that cheap is entirely down to how you chose the region sizes.
The arithmetic, made concrete
Suppose a region of size S bytes based at address B. An address A is inside it when B <= A < B + S. In general that requires two magnitude comparators — subtract-and-check-sign on 32-bit values, which is a carry chain roughly as long as a 32-bit adder, sitting directly in the fetch path.
Now impose two constraints:
Sis a power of two:S = 2^k.Bis aligned to its own size:B mod S == 0, which means the lowkbits ofBare all zero.
Under those constraints, “A is in the region” collapses to “A and B agree on all bits above bit k-1”, because the low k bits of A can be anything at all and A still lands inside. Formally:
in_region(A) == (A[31:k] == B[31:k])
That is a 32 - k bit equality comparator: a bitwise XNOR of each bit pair followed by an AND-reduce. On an FPGA, a 4-input LUT can compute a 4-bit slice of that comparison, so an 18-bit comparison is a handful of LUTs and a shallow tree — nothing like a carry chain.
For the example map, k and the comparison width fall straight out of the sizes:
| Region | Size S | k = log2(S) | Bits compared | The comparison |
|---|---|---|---|---|
ROM, 16 KiB @ 0x0000_0000 | 2^14 | 14 | A[31:14], 18 bits | == 18'h00000 |
MMIO, 64 KiB @ 0x1000_0000 | 2^16 | 16 | A[31:16], 16 bits | == 16'h1000 |
RAM, 64 KiB @ 0x2000_0000 | 2^16 | 16 | A[31:16], 16 bits | == 16'h2000 |
Which is exactly what the RTL says:
module soc_decode (
input wire [31:0] addr,
output wire cs_rom,
output wire cs_mmio,
output wire cs_ram,
output wire cs_none // nothing claimed this address -> bus error
);
// FULL decoding: every bit above the region size is compared.
assign cs_rom = (addr[31:14] == 18'h00000);
assign cs_mmio = (addr[31:16] == 16'h1000);
assign cs_ram = (addr[31:16] == 16'h2000);
assign cs_none = ~(cs_rom | cs_mmio | cs_ram);
endmoduleLine by line: addr[31:14] discards the low fourteen bits, which is the same as asking “which 16 KiB block of the address space is this?”; comparing that block number against zero asks “is it the very first 16 KiB block?” — which is precisely the ROM. The MMIO and RAM lines ask the same question at 64 KiB granularity. cs_none is the default slave: if no device claimed the address, something must still terminate the bus cycle, or the core hangs waiting for an acknowledgement that never arrives. Wiring cs_none to a stub that asserts ERR_O turns a stray access into a synchronous trap you can actually debug, instead of a lockup.
flowchart LR A["addr[31:0]<br/>presented by the core"] A --> HI14["take addr[31:14]<br/>(which 16 KiB block?)"] A --> HI16["take addr[31:16]<br/>(which 64 KiB block?)"] A --> LOW["addr[13:0] / addr[15:0]<br/>offset WITHIN the region"] HI14 --> C1{"== 18'h00000 ?"} HI16 --> C2{"== 16'h1000 ?"} HI16 --> C3{"== 16'h2000 ?"} C1 -->|yes| ROM["cs_rom"] C2 -->|yes| MMIO["cs_mmio"] C3 -->|yes| RAM["cs_ram"] C1 -->|no| NOR C2 -->|no| NOR C3 -->|no| NOR["NOR of all<br/>chip selects"] NOR --> ERR["cs_none<br/>-> default slave, ERR_O"] LOW -.->|"goes straight to the<br/>device, undecoded"| ROM LOW -.-> MMIO LOW -.-> RAM
Address decoding as a bit split. What it shows: the address is cut in two — the high bits select which device, the low bits select what inside that device — and the high half is answered by nothing more than equality comparators feeding a NOR for the unmapped case. The insight to take: the low bits are never decoded centrally. They travel to the selected device untouched, which is why a 4 KiB peripheral slot costs the decoder exactly nothing more than a 16-byte one. Slot size is free; only the number of distinct regions costs comparators.
When the numbers are not powers of two
Break either constraint and the cheap identity evaporates. Give a region a size of 48 KiB and there is no k for which “in region” is an equality on high bits; you are back to two magnitude comparisons per region. Give a 64 KiB region a base of 0x1001_0000 — power-of-two sized but misaligned to a 64 KiB boundary — and the same thing happens: the region straddles two 64 KiB blocks, so no single addr[31:16] value identifies it.
This is the same naturally aligned power-of-two constraint that shows up all over RISC-V. It is why Physical Memory Protection regions use the NAPOT encoding, and it is the same reasoning the privileged specification applies to speculative fetch, where an implementation may over-read “any of the bytes within a naturally aligned power-of-2 region containing the address” (Privileged spec 20250508, §Idempotency PMAs). Power-of-two alignment is not aesthetics; it is what makes address ranges expressible as bit patterns rather than as arithmetic.
Uncertain
Verify: the specific LUT and logic-depth cost of a power-of-two decoder versus a magnitude-comparator decoder on the Tang Nano 20K’s Gowin GW2AR-18. Reason: no synthesis tool (Yosys, or Gowin’s own EDA) is installed on this machine, so the “handful of LUTs” claim is a structural argument about comparator width, not a measured LUT count. The measured facts here are the Verilator simulation results below; the area claim is inference. To resolve: synthesise both decoder variants with Gowin EDA or
yosys -p 'synth_gowin'and compare the reported LUT4 count and the critical-path delay in the timing report.#uncertain
Choosing Base Addresses
Three base addresses have to be picked before anything else can be written. The reasoning is different for each.
ROM goes at the reset vector. The reset vector is the address the program counter holds when reset is released, and on RISC-V it is your choice: the ratified specification says only that “the pc is set to an implementation-defined reset vector” (Privileged spec 20250508, §Reset), with the further note that “Reset and NMI vector locations are given in a platform specification” — that is, by the platform, not by the ISA. Since you are the platform, the simplest possible choice is 0x0000_0000, and the simplest possible consequence is that the ROM lives there. Choosing zero has a real benefit beyond tidiness: it makes ROM addresses small enough that the assembler can materialise them with a single li rather than an auipc/addi pair, which the disassembly in the worked example shows happening. It has one real cost: a null-pointer dereference reads valid ROM instead of faulting, so a bug that would trap on a hosted system silently returns instruction bytes as data. Some designs deliberately put ROM at 0x1000 or 0x1_0000 and leave the first page unmapped so that null dereferences hit cs_none and take an access fault. The ESP32-C3 takes the opposite extreme, placing its mask ROM at 0x4000_0000; the SiFive FU540-C000 splits the difference, reserving 0x0000_0000–0x0000_00FF, using 0x0000_1000–0x0000_1FFF as a tiny read-execute “Mode Select” ROM containing the reset vector proper, and putting the 32 KiB mask ROM at 0x0001_0000 (SiFive FU540-C000 Manual v1p4, Table 6).
RAM goes somewhere far away, on a round boundary. There is no hardware reason for 0x2000_0000 specifically; the reason to put a big gap between ROM and RAM is that you will want to grow both, and if they abut you cannot grow either without moving the other. A quarter-gigabyte gap costs nothing and means the ROM can grow from 16 KiB to 16 MiB without any renumbering.
Peripherals cluster, and cluster with generous slots. Every real chip does this. The ESP32-C3 puts every module in one contiguous run starting at 0x6000_0000, each in a 4 KiB slot: UART Controller 0 at 0x6000_0000, SPI Controller 1 at 0x6000_2000, SPI Controller 0 at 0x6000_3000, GPIO at 0x6000_4000, Low-Power Management at 0x6000_8000, IO MUX at 0x6000_9000, with explicitly Reserved holes between them (ESP32-C3 Technical Reference Manual v1.4, Table 3.3-3). The FU540 does the same thing in a run of on-chip peripherals from 0x0200_0000 upward — CLINT at 0x0200_0000, cache controller at 0x0201_0000 (FU540 manual v1p4, Table 6).
There are three independent reasons for the clustering, and it is worth separating them because they pull in the same direction for different causes:
- Decoder cost. One comparison selects the whole MMIO region; a cheap second-level decode on the next bits down picks the peripheral within it. The alternative — scattering peripherals across the space — needs one full-width comparator per peripheral at the top level.
- Attributes travel with the region. Everything in the MMIO region shares the same physical memory attributes: I/O rather than main memory, non-idempotent, not cacheable, not executable. The privileged specification’s PMA model is explicitly range-based (“Regions of the address space are classified as either main memory or I/O”), so grouping devices with identical attributes into one range is how you keep the attribute description short. On a system with Physical Memory Protection, it also means a single PMP entry can cover all peripherals.
- Slot size is free, and later-you needs the room. A UART needs three registers. Giving it 4 KiB costs the decoder nothing (the low twelve bits are not decoded centrally at all) and means adding a baud-rate divisor, a FIFO level register and an interrupt-enable bit later does not move anything. Choosing 16-byte slots to be “efficient” saves zero gates and guarantees a renumbering.
flowchart TB subgraph L1["Level 1 — which region?"] A["addr[31:16]"] --> R{"== 0x1000?"} end R -->|no| OTHER["ROM / RAM / cs_none"] R -->|yes| L2 subgraph L2["Level 2 — which peripheral? addr[15:12]"] P0["0x0 -> UART0"] P1["0x1 -> CLINT"] P2["0x2 -> GPIO"] P3["0x3..0xF -> reserved"] end L2 --> L3 subgraph L3["Level 3 — which register? addr[11:2]"] REG["+0x00 TX · +0x04 status · +0x08 RX<br/>(the device's own business)"] end
Hierarchical decoding of a clustered MMIO region. What it shows: the 32-bit address is consumed in three bites — 16 bits to pick the region, 4 bits to pick the peripheral, 10 bits to pick the register — with each level only looking at the slice it owns. The insight to take: clustering is what makes this hierarchy possible. If UART0 lived at 0x1000_0000 and GPIO at 0x9ABC_0000, level 1 would need one 16-bit comparator per device and level 2 would not exist. The cluster is not tidiness; it is what turns N comparators into 1 + a small mux.
The Aliasing Trap — Incomplete Decoding
This is the bug class that makes memory maps worth a whole note. Incomplete decoding means comparing fewer address bits than the region size demands — leaving high bits unexamined, so a device answers not only at its own base but at every address that agrees on the bits you did check. The device becomes a ghost, appearing at many addresses at once.
It is seductive because it always works at first. Every access your boot code makes uses the correct base address, and at the correct base address a fully-decoded and an under-decoded device behave identically. The bug is invisible until something touches one of the phantom copies — and by then the design has been “known good” for months.
Here is the broken decoder, side by side with the correct one. It differs by one number:
// A deliberately BROKEN decoder: it looks at too few bits, so the ROM
// answers to 0x0000_0000, 0x0000_4000, 0x0000_8000 ... every 16 KiB.
module soc_decode_aliased (
input wire [31:0] addr,
output wire cs_rom
);
assign cs_rom = (addr[31:28] == 4'h0); // only the top nibble!
endmoduleThe ROM is 16 KiB, so a full decode compares addr[31:14] — eighteen bits. This one compares four. The fourteen bits in between (addr[27:14]) are ignored, so 2^14 = 16,384 distinct 16 KiB windows all select the ROM. Simulated under Verilator 5.046, driving both decoders from the same address:
--- the aliasing trap: same ROM, too few address bits ---
addr=0x00000000 -> full-decode rom=1 under-decoded rom=1
addr=0x00004000 -> full-decode rom=0 under-decoded rom=1
addr=0x08000000 -> full-decode rom=0 under-decoded rom=1
addr=0x0ffffffc -> full-decode rom=0 under-decoded rom=1
At 0x0000_0000 the two decoders agree — which is exactly why the bug survives bring-up. At 0x0000_4000 and 0x0800_0000 they diverge: the correct decoder says “unmapped, take a bus error”, the broken one hands back a copy of the ROM.
flowchart TB subgraph GOOD["Full decode: addr[31:14] == 0"] G0["0x0000_0000<br/>ROM"] --- G1["0x0000_4000<br/>cs_none -> ERR"] --- G2["0x0000_8000<br/>cs_none -> ERR"] --- G3["0x0800_0000<br/>cs_none -> ERR"] end subgraph BAD["Under-decoded: addr[31:28] == 0"] B0["0x0000_0000<br/>ROM"] --- B1["0x0000_4000<br/>ROM (alias)"] --- B2["0x0000_8000<br/>ROM (alias)"] --- B3["0x0800_0000<br/>ROM (alias)"] end NOTE["16,384 aliases of a 16 KiB ROM<br/>fill the low 256 MiB"] BAD --> NOTE
The same ROM under two decoders. What it shows: dropping fourteen bits of comparison replicates a 16 KiB ROM across the entire low 256 MiB of the address space, 16,384 times over. The insight to take: the leftmost column is identical in both rows. Aliasing never breaks the access you designed for — it only breaks the access you did not, which is why it is discovered late and usually by something unrelated (a runaway pointer that should have faulted, a linker script that grew past the region, a DMA descriptor with a stale address).
Why aliasing is genuinely dangerous, not merely untidy
- It suppresses the error you needed. A wild pointer that should have hit
cs_noneand raised a load access fault instead returns plausible bytes. You lose the one mechanism a bare-metal system has for catching bad addresses. - It collides with future regions. The moment you add a second peripheral inside the aliased range, two devices assert chip select for the same address. On a shared bus that is a driver conflict in simulation (
Xon the data lines) and, on real silicon or an FPGA, whichever slave wins the mux — non-deterministically, and often temperature- or routing-dependent. - Writes go to more than one place. An under-decoded writable peripheral is worse than an under-decoded ROM: a store to what you think is scratch RAM can land in a control register.
- It makes the map a lie. Documentation says the ROM occupies
0x0000_0000–0x0000_3FFF. The hardware says it occupies0x0000_0000–0x0FFF_FFFF. Anyone reasoning from the document — including a future you writing a linker script — is reasoning from a false premise.
Deliberate aliasing exists, and it is not this
Confusingly, real chips do map the same storage at two addresses on purpose, and it looks superficially similar. The ESP32-C3’s Internal ROM 1 is reachable at 0x4004_0000–0x4005_FFFF through the instruction bus and at 0x3FF0_0000–0x3FF1_FFFF through the data bus, and the TRM spells the correspondence out: “address 0x4004_0000 and 0x3FF0_0000 correspond to the same word, 0x4004_0004 and 0x3FF0_0004 correspond to the same word … (the same ordering applies for Internal SRAM 1)” (ESP32-C3 TRM v1.4, §3.3.2). Internal SRAM 1 is likewise at 0x3FC8_0000 (data bus) and 0x4038_0000 (instruction bus). The TRM states the general rule directly: “Some internal and external memory can be accessed via both data bus and instruction bus. In such cases, the CPU can access the same memory using multiple addresses.”
The distinction is not whether two addresses reach the same storage. It is whether the aliasing is enumerated, bounded, and documented. The ESP32-C3’s is a Harvard-architecture artefact — one physical SRAM reachable from two buses — deliberately exposed at two known bases and written into the datasheet. Under-decoding produces thousands of undocumented aliases whose extent is an accident of which bits you happened to compare. One is a design; the other is a defect that has not been noticed yet.
How to catch it
The cheap test is a decoder sweep in the testbench, driving addresses that should be unmapped and asserting that cs_none is high. That is what the simulation above does:
--- decoder sweep ---
addr=0x00000000 -> rom=1 mmio=0 ram=0 none=0
addr=0x00003ffc -> rom=1 mmio=0 ram=0 none=0
addr=0x00004000 -> rom=0 mmio=0 ram=0 none=1
addr=0x10000000 -> rom=0 mmio=1 ram=0 none=0
addr=0x10000004 -> rom=0 mmio=1 ram=0 none=0
addr=0x20000000 -> rom=0 mmio=0 ram=1 none=0
addr=0x30000000 -> rom=0 mmio=0 ram=0 none=1
The four load-bearing lines are the two that assert none=1. 0x0000_3FFC is the last word of the ROM and must decode; 0x0000_4000 is the first byte past it and must not. Testing the last-inside and first-outside address of every region catches under-decoding immediately, and it is the single highest-value assertion in an SoC testbench. A second useful invariant is mutual exclusion: assert that at most one of cs_rom, cs_mmio, cs_ram is ever high, which catches overlap as well as aliasing. See Testbenches and RTL Verification for where these live in a test suite.
MMIO Semantics vs Memory Semantics
A region of the map is not just an address range. It carries semantics — what a read means, what a write means, whether repeating an access is harmless. RISC-V calls these physical memory attributes (PMAs), and the ratified specification is explicit that they are properties of the hardware, not of the software’s privilege: “PMAs are inherent properties of the underlying hardware and rarely change during system operation. Unlike physical memory protection values … PMAs do not vary by execution context” (Privileged spec 20250508, §Physical Memory Attributes). The single most important split is main memory versus I/O: “The most important characterization of a given memory address range is whether it holds regular main memory or I/O devices.”
The property that matters most in practice is idempotency. The specification defines it precisely: “Idempotency PMAs describe whether reads and writes to an address region are idempotent. Main memory regions are assumed to be idempotent. For I/O regions, idempotency on reads and writes can be specified separately (e.g., reads are idempotent but writes are not). If accesses are non-idempotent, i.e., there is potentially a side effect on any read or write access, then speculative or redundant accesses must be avoided.”
That single sentence is the whole reason MMIO is different from memory, and it constrains hardware and software alike. The spec makes the two-sided obligation explicit: “While hardware should always be designed to avoid speculative or redundant accesses to memory regions marked as non-idempotent, it is also necessary to ensure software or compiler optimizations do not generate spurious accesses to non-idempotent memory regions.”
| Property | Main memory (RAM, ROM) | I/O region (MMIO) |
|---|---|---|
| Reading twice returns the same value | Yes | Not necessarily — a read may pop a FIFO or clear a flag |
| A read has no effect | Yes | May have a side effect (read-to-clear, RX FIFO pop) |
| A write can be merged, delayed, or reordered by hardware | Yes (subject to the memory model) | No — must not be speculated or duplicated |
| Cacheable | Typically yes | No |
| Speculative / redundant access permitted | Yes | No |
| All access widths supported | “Main memory regions always support read and write of all access widths required by the attached devices” | “I/O regions can specify which combinations of read, write, or execute accesses to which data widths are supported” |
| Misaligned access | Generally supported or emulated | “Non-idempotent regions might not support misaligned accesses” |
| Instruction fetch | Usually permitted | Usually not |
What this means for the compiler: volatile is not advisory
C’s volatile is exactly the mechanism for telling the compiler that an object lives in a non-idempotent region. ISO/IEC 9899:2024 (C23) defines it in §6.7.4 ¶8: “An object that has volatile-qualified type may be modified in ways unknown to the implementation or have other unknown side effects. Therefore, any expression referring to such an object shall be evaluated strictly according to the rules of the abstract machine … Furthermore, at every sequence point the value last stored in the object shall agree with that prescribed by the abstract machine” (ISO/IEC 9899:2024 working draft N3220). §5.1.2.4 ¶2 classifies the access itself as observable behaviour: “An access to an object through the use of an lvalue of volatile-qualified type is a volatile access. A volatile access to an object, modifying an object, modifying a file, or calling a function that does any of those operations are all side effects.”
Omit it and the compiler does exactly what the abstract machine permits, which for a poll loop is fatal. Here are two functions that differ only by the qualifier, compiled with riscv64-linux-gnu-gcc 16.1.1 -march=rv32imc_zicsr -mabi=ilp32 -O2:
#define ST_NV (*(unsigned int *)(UART_BASE + 0x04)) /* WRONG */
#define ST_V (*(volatile unsigned int *)(UART_BASE + 0x04)) /* RIGHT */
void putc_broken(char c) { while (ST_NV & 1u) { } TX_NV = (unsigned char)c; }
void putc_correct(char c) { while (ST_V & 1u) { } TX_V = (unsigned char)c; }The generated code:
00000000 <putc_broken>:
0: 100007b7 lui a5,0x10000
4: 0791 addi a5,a5,4 # 10000004
6: 439c lw a5,0(a5) <-- status read ONCE, outside the loop
8: 10000737 lui a4,0x10000
c: 8b85 andi a5,a5,1
e: e399 bnez a5,14 <.L3>
10: c308 sw a0,0(a4)
12: 8082 ret
00000014 <.L3>:
14: a001 j 14 <.L3> <-- infinite loop, no memory access at all
00000016 <putc_correct>:
16: 10000737 lui a4,0x10000
1a: 0711 addi a4,a4,4
1c: 100006b7 lui a3,0x10000
00000020 <.L6>:
20: 431c lw a5,0(a4) <-- status re-read EVERY iteration
22: 8b85 andi a5,a5,1
24: fff5 bnez a5,20 <.L6>
26: c288 sw a0,0(a3)
28: 8082 retThis is worth reading carefully, because it is the single most common bare-metal bug and the failure mode is not what people expect. GCC did not merely “cache the value in a register” — it hoisted the load entirely out of the loop and then deleted the loop body, because a loop whose condition cannot change is either not entered or never exited. The result at .L3 is j .L3: a two-byte instruction that issues no bus transaction whatsoever. Attach a logic analyser and you will see the core fetch that address forever with no peripheral access at all, which looks nothing like “polling a busy flag” and sends people hunting for a hardware fault. The compiler is entirely correct: nothing in the abstract machine can modify a plain unsigned int between iterations of a loop that does not touch it.
The volatile version does the opposite and is required to: the load at .L6 is re-executed on every pass, because each is a distinct side effect that the abstract machine sequences.
Read-modify-write and the second read
volatile also forbids the compiler from reusing a value it just stored, which surfaces in an idiom that looks innocent:
unsigned int rmw(void){ P32 |= 1u; return P32; }0000001c <rmw>:
1c: 100007b7 lui a5,0x10000
20: 43d8 lw a4,4(a5) <-- read for the |=
22: 00176713 ori a4,a4,1
26: c3d8 sw a4,4(a5) <-- write back
28: 43c8 lw a0,4(a5) <-- SECOND read, for the return value
2a: 8082 retFour bus transactions where a novice expects one or two. On ordinary memory the compiler would return the value it just stored; on a volatile object it must issue a fresh read, because the abstract machine says the object may have changed. Against a real peripheral that is often what you want (a status register genuinely may have changed) but against a write-1-to-clear interrupt-pending register, |= is a bug regardless of volatile: the read pulls in every other pending bit, and the write-back clears them all. Write-1-to-clear registers must be written with a plain assignment of the single bit, never OR-ed.
What volatile does not give you
volatile orders accesses within the compiler. It does not emit a fence, and it says nothing about the hardware’s freedom to reorder. On a simple in-order core with a single Wishbone Bus master and strongly-ordered I/O that gap does not bite — the privileged spec notes that local strong ordering “is often straightforward to provide if there is only a single in-order communication path between the hart and the I/O device”. On anything with a store buffer, a write-combining path, or multiple bus masters, you need fence io,io (or the region declared as a channel-1 strongly-ordered I/O region, which the spec says “is equivalent to executing a fence io,io instruction before and after the instruction”). It is worth writing down which guarantee your SoC provides, because the software will assume something.
In Rust the equivalents are core::ptr::read_volatile / write_volatile, which is what a svd2rust-generated peripheral access crate calls under the hood — see Bare-Metal Rust. Rust deliberately has no volatile type qualifier; volatility is a property of the access, not the object, which avoids C’s ambiguity about what counts as an access.
Access Width, Alignment, and Byte Enables
RV32 has three load widths and three store widths, and the memory map has to decide, for every region, which of them work. The base ISA’s rule is simple: LW/SW move 32 bits, LH/LHU/SH move 16, LB/LBU/SB move 8, and “the SW, SH, and SB instructions store 32-bit, 16-bit, and 8-bit values from the low bits of register rs2 to memory” (RISC-V Instruction Set Manual Volume I: Unprivileged Architecture, version 20250508, Ratified, §Load and Store Instructions).
On the bus, a sub-word store is not a narrow transaction. Wishbone (and AXI, and almost every other on-chip fabric) keeps the data path 32 bits wide and adds byte-select lines — SEL_O[3:0] on Wishbone — one per byte lane, telling the slave which bytes of the 32-bit word are live. The compiler emits the narrow instruction; the core’s load/store unit turns the low two address bits plus the access size into the SEL pattern:
| Instruction | addr[1:0] | SEL_O[3:0] (little-endian) | Bytes written |
|---|---|---|---|
SW | 00 | 1111 | 0,1,2,3 |
SH | 00 | 0011 | 0,1 |
SH | 10 | 1100 | 2,3 |
SB | 00 | 0001 | 0 |
SB | 01 | 0010 | 1 |
SB | 10 | 0100 | 2 |
SB | 11 | 1000 | 3 |
Compiled for real (-march=rv32imc_zicsr -O2), against three MMIO addresses:
00000000 <w8>: /* P8 at 0x10000000, unsigned char */
0: 100007b7 lui a5,0x10000
4: 00a78023 sb a0,0(a5)
8: 8082 ret
0000000a <w16>: /* P16 at 0x10000002, unsigned short */
a: 100007b7 lui a5,0x10000
e: 00a79123 sh a0,2(a5)
12: 8082 ret
00000014 <w32>: /* P32 at 0x10000004, unsigned int */
14: 100007b7 lui a5,0x10000
18: c3c8 sw a0,4(a5)
1a: 8082 retThe compiler picks the width from the pointer type, full stop. That is the trap: the C type of your peripheral pointer chooses the bus transaction, and if the peripheral’s register only decodes 32-bit accesses, declaring it volatile unsigned char * produces an sb that the slave may ignore, may partially apply, or may treat as a full-word write of whatever happens to be on the other three lanes. The privileged specification allows exactly this: “I/O regions can specify which combinations of read, write, or execute accesses to which data widths are supported.”
Real chips do restrict widths. The ESP32-C3’s TRM states that “The CPU can access data via the data bus using single-byte, double-byte, 4-byte alignment. The CPU can also access data via the instruction bus, but only in 4-byte aligned manner” (ESP32-C3 TRM v1.4, §3.3.1) — the same physical SRAM has different access rules depending on which address alias you reach it through.
The pragmatic rule for a from-scratch SoC is: make every MMIO register 32 bits wide, word-aligned, and accessed only with LW/SW. Decoding SEL_O inside every peripheral is real logic you do not need to build, and the registers cost nothing extra — a UART’s 8-bit transmit datum sits in the low byte of a 32-bit register and the top 24 bits read as zero. This is why the example peripheral header uses volatile unsigned int for an 8-bit character:
#define UART_TX (*(volatile unsigned int *)(UART_BASE + 0x00))
UART_TX = (unsigned int)(unsigned char)c; /* 32-bit store of a byte value */Misalignment
A misaligned access is one whose address is not divisible by its size. The unprivileged specification’s rule has three tiers (Unprivileged spec 20250508):
- “Regardless of EEI, loads and stores whose effective addresses are naturally aligned shall not raise an address-misaligned exception.” Aligned always works.
- An execution environment interface (EEI) “may guarantee that misaligned loads and stores are fully supported”, handled “in hardware, or via an invisible trap into the execution environment implementation, or possibly a combination”.
- Or it may not, in which case a misaligned access “may either complete execution successfully or raise an exception”, and that exception “can be either an address-misaligned exception or an access-fault exception”.
Which exception matters, and the reason is the memory map. The spec says: “For a memory access that would otherwise be able to complete except for the misalignment, an access-fault exception can be raised instead of an address-misaligned exception if the misaligned access should not be emulated, e.g., if accesses to the memory region have side effects.” The privileged spec restates it from the PMA side: “Non-idempotent regions might not support misaligned accesses. Misaligned accesses to such regions should raise access-fault exceptions rather than address-misaligned exceptions, indicating that software should not emulate the misaligned access using multiple smaller accesses, which could cause unexpected side effects.”
That is a genuinely subtle design instruction and it falls directly out of the map. A misaligned-load trap handler’s normal job is to emulate the access by issuing two smaller ones and stitching the halves together. Against RAM that is transparent. Against a UART FIFO it pops two characters and returns a mangled one. So the cause code the hardware reports has to tell the handler whether emulation is safe — and the only thing that knows is the address decoder, because “does this region have side effects” is a property of the region. In practice, for a small RV32IMC core:
- Do not implement misaligned hardware support. It is a real amount of load/store-unit complexity for a case the compiler almost never emits.
- Raise
address-misaligned(mcause4 for loads, 6 for stores) for misaligned accesses that decode to ROM or RAM. - Raise
access-fault(mcause5 / 7) for misaligned accesses that decode to the MMIO region, so no handler is ever tempted to emulate them.
That is one extra input to the exception logic — the chip-select vector you already computed — and it makes the trap cause honest. See Control and Status Registers and RISC-V Trap Handling for what the handler does with it.
The Same Numbers in Three Places — A Worked Example
Everything above is theory until the same numbers appear three times. This section is the real artefact: a boot ROM written in RISC-V assembly, linked against the map, objcopy-ed to a raw image, loaded into a Verilog ROM and fetched by a simulated core. Tool versions: riscv64-linux-gnu-gcc (GCC) 16.1.1 20260501 (Red Hat Cross 16.1.1-1), GNU ld from the same cross binutils, and Verilator 5.046 2026-02-28.
Place 1 — the Verilog decoder
Already shown above: addr[31:14] == 18'h00000 for ROM, 16'h1000 for MMIO, 16'h2000 for RAM. The ROM module reads its contents from a hex file at elaboration time:
module boot_rom #(parameter WORDS = 4096) (
input wire clk,
input wire [31:0] addr,
output reg [31:0] rdata
);
reg [31:0] mem [0:WORDS-1];
initial $readmemh("rom.hex", mem);
// Word-addressed: drop the two byte-select bits.
always @(posedge clk) rdata <= mem[addr[31:2]];
endmoduleWORDS = 4096 is 4096 × 4 bytes = 16 KiB, the same 16 KiB the decoder’s addr[31:14] comparison implies and the same 16 KiB the linker script will declare. Three appearances of one number, already. addr[31:2] drops the byte-select bits because the ROM is word-addressed — instruction fetch is always word-aligned, so bits 1:0 carry no information here. ($readmemb/$readmemh are supported by Verilator; the language-support guide notes only that “Verilator and the Verilog specification do not include support for readmem to multi-dimensional arrays.”)
Place 2 — the linker script
OUTPUT_ARCH("riscv")
ENTRY(_start)
MEMORY
{
ROM (rx) : ORIGIN = 0x00000000, LENGTH = 16K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
SECTIONS
{
.text : {
KEEP(*(.text.init)) /* the reset-vector code, first, always kept */
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
} > ROM
.data : {
. = ALIGN(4);
_sdata = .; /* run address (VMA) of .data, in RAM */
*(.data .data.*)
*(.sdata .sdata.*)
. = ALIGN(4);
_edata = .;
} > RAM AT > ROM /* runs in RAM, is STORED in ROM */
_sidata = LOADADDR(.data); /* load address (LMA) of .data, in ROM */
.bss (NOLOAD) : {
. = ALIGN(4);
_sbss = .;
*(.bss .bss.*) *(.sbss .sbss.*) *(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
_stack_top = ORIGIN(RAM) + LENGTH(RAM);
/DISCARD/ : { *(.eh_frame) *(.comment) *(.riscv.attributes) }
}The MEMORY block is the linker’s copy of the memory map. ORIGIN and LENGTH are exactly the base and size the decoder implements; the (rx) and (rwx) attribute strings mirror the region’s PMAs, and GNU ld uses them to place any input section you did not explicitly assign (GNU ld manual, §3.7 MEMORY Command). _stack_top is computed from the region rather than hard-coded, using ld’s ORIGIN() and LENGTH() built-ins (§3.10.9 Builtin Functions) — one fewer place for the number to drift.
> RAM AT > ROM is the load-address/run-address split, and it is the mechanism that makes a ROM-based system possible at all. Every allocatable section has two addresses: “The first is the VMA, or virtual memory address. This is the address the section will have when the output file is run. The second is the LMA, or load memory address … An example of when they might be different is when a data section is loaded into ROM, and then copied into RAM when the program starts up” (§3.1 Basic Linker Script Concepts). AT> names a region rather than an address: “The load address of the section is set to the next free address in the region, aligned to the section’s alignment requirements” (§3.6.8.2 Output Section LMA). LOADADDR(.data) then hands the boot code that address as a symbol. The copy loop that uses _sidata, _sdata and _edata belongs to Boot ROM and the Reset Vector; see also Linker Scripts and Memory Layout for the script language itself.
The /DISCARD/ line is not cosmetic. Without it the build fails outright:
riscv64-linux-gnu-ld: section .eh_frame LMA [00000094,000000bb] overlaps section .data LMA [00000094,0000009b]
collect2: error: ld returned 1 exit status
GCC emits .eh_frame unwind tables by default, they are allocatable, and they land in ROM right where .data’s load image wants to go. -fno-asynchronous-unwind-tables is the other fix. A related surprise: .note.gnu.build-id is also allocatable and, left in, pushed _sidata from 0x94 to 0xb8 — harmless but confusing when you are reading raw bytes, so the build passes -Wl,--build-id=none.
Place 3 — the software header
#define UART_BASE 0x10000000u
#define UART_TX (*(volatile unsigned int *)(UART_BASE + 0x00))
#define UART_ST (*(volatile unsigned int *)(UART_BASE + 0x04))
#define UART_ST_TXFULL (1u << 0)0x1000_0000 is the third appearance of the MMIO base. Nothing links it to 16'h1000 in the Verilog or to the absence of a MEMORY entry in the linker script — note that MMIO deliberately has no MEMORY region, because no section is ever placed there; the linker must not be told about it, or it will happily allocate .bss into the UART.
flowchart TB subgraph N1["ROM base + size = 0x0000_0000 / 16 KiB"] V1["soc.v<br/>addr[31:14] == 18'h00000<br/>boot_rom #(.WORDS(4096))"] L1["link.ld<br/>ROM : ORIGIN = 0x00000000,<br/>LENGTH = 16K"] S1["(implicit — the reset<br/>vector is ROM base)"] end subgraph N2["MMIO base = 0x1000_0000"] V2["soc.v<br/>addr[31:16] == 16'h1000"] L2["link.ld<br/>NO MEMORY region<br/>(nothing is linked here)"] S2["main.c<br/>#define UART_BASE<br/>0x10000000u"] end subgraph N3["RAM base + size = 0x2000_0000 / 64 KiB"] V3["soc.v<br/>addr[31:16] == 16'h2000"] L3["link.ld<br/>RAM : ORIGIN = 0x20000000,<br/>LENGTH = 64K<br/>_stack_top = ORIGIN+LENGTH"] S3["(implicit — sp and all<br/>globals land here)"] end N1 --> OUT["boot.elf -> boot.bin -> rom.hex"] N2 --> OUT N3 --> OUT OUT --> SIM["Verilator: core fetches<br/>and the UART answers"]
The same three numbers in the three places that must agree. What it shows: for each region, which file states it and in what syntax — and the two “implicit” cells, where a number is assumed rather than written down. The insight to take: the implicit cells are where the bugs live. Nothing in main.c says “RAM is at 0x2000_0000”; the C code simply trusts that the linker put its globals somewhere the decoder answers. And nothing in link.ld says “MMIO is at 0x1000_0000” — deliberately, because the linker must never allocate there. A number that is stated in only one of the three places is a number nobody can check.
Building it, and the bytes that come out
$ riscv64-linux-gnu-gcc -march=rv32imc_zicsr -mabi=ilp32 -nostdlib -nostartfiles \
-ffreestanding -Os -Wall -Wl,--build-id=none -T link.ld start.S main.c -o boot.elf
-march=rv32imc_zicsr is required, not optional, on this toolchain. Building with plain rv32imc fails at the assembler:
start.S:9: Error: unrecognized opcode `csrw mtvec,t0', extension `zicsr' required
Zicsr was split out of the base integer ISA and modern GCC will not assemble a CSR instruction without it being named in -march. That is a version-sensitive fact: on this machine, GCC 16.1.1.
The linked result, read back with readelf:
$ riscv64-linux-gnu-readelf -S -W boot.elf
[ 1] .text PROGBITS 00000000 001000 000094 00 AX 0 0 2
[ 2] .data PROGBITS 20000000 002000 000008 00 WA 0 0 4
[ 3] .bss NOBITS 20000008 002008 000004 00 WA 0 0 4
$ riscv64-linux-gnu-readelf -l -W boot.elf
LOAD 0x001000 0x00000000 0x00000000 0x00094 0x00094 R E 0x1000
LOAD 0x002000 0x20000000 0x00000094 0x00008 0x0000c RW 0x1000
Every column here is the memory map asserting itself:
.texthas address0x0000_0000and is 0x94 = 148 bytes — inside the ROM region, at its base..datahas address (VMA)0x2000_0000but the secondLOADsegment’s PhysAddr is0x0000_0094.readelf -lprints VirtAddr then PhysAddr, and for a bare-metal image PhysAddr is the LMA. So.dataruns at0x2000_0000in RAM and is stored at0x94in ROM, immediately after.text. That isAT > ROMworking.- The second segment’s
FileSizis0x8butMemSizis0xc. The extra four bytes are.bss, which isNOBITS: it occupies memory at run time and zero bytes in the image.
The symbols the boot code needs come out exactly where the script put them:
$ riscv64-linux-gnu-readelf -s -W boot.elf | grep -E '_sidata|_sdata|_edata|_sbss|_ebss|_stack_top'
10: 00000094 NOTYPE GLOBAL ABS _sidata <- LMA of .data, in ROM
11: 20000008 NOTYPE GLOBAL 3 _sbss
12: 20000000 NOTYPE GLOBAL 2 _sdata
13: 2000000c NOTYPE GLOBAL 3 _ebss
14: 20010000 NOTYPE GLOBAL 3 _stack_top <- 0x20000000 + 0x10000
17: 20000008 NOTYPE GLOBAL 2 _edata
_stack_top = 0x2001_0000 is ORIGIN(RAM) + LENGTH(RAM), the byte just past the top of a 64 KiB RAM — the stack grows down from there, which is the RISC-V convention.
Flattening to a raw image and dumping it:
$ riscv64-linux-gnu-objcopy -O binary boot.elf boot.bin
$ ls -l boot.bin
-rwxr-xr-x. 1 linman linman 156 Sep 4 22:37 boot.bin
$ hexdump -C boot.bin
00000000 17 01 01 20 13 01 01 00 97 02 00 00 93 82 62 05 |... ..........b.|
00000010 73 90 52 30 93 02 40 09 17 03 00 20 13 03 83 fe |s.R0..@.... ....|
00000020 97 03 00 20 93 83 83 fe 63 09 73 00 03 ae 02 00 |... ....c.s.....|
00000030 23 20 c3 01 91 02 11 03 c5 bf 17 03 00 20 13 03 |# ........... ..|
00000040 e3 fc 97 03 00 20 93 83 a3 fc 63 06 73 00 23 20 |..... ....c.s.# |
00000050 03 00 11 03 dd bf 29 20 73 00 50 10 f5 bf 01 a0 |......) s.P.....|
00000060 b7 07 00 20 37 05 00 10 93 87 07 00 13 07 45 00 |... 7.........E.|
00000070 b7 05 00 20 03 c6 07 00 19 e2 01 45 82 80 14 43 |... .......E...C|
00000080 85 8a f5 fe 10 c1 83 a6 85 00 85 07 85 06 23 a4 |..............#.|
00000090 d5 00 cd b7 68 65 6c 6c 6f 0d 0a 00 |....hello...|
0000009c
156 bytes = 0x9c = 0x94 bytes of .text plus 8 bytes of .data image. And there at offset 0x94 are the bytes 68 65 6c 6c 6f 0d 0a 00 — "hello\r\n\0", the initialiser of a global char greeting[8], sitting in the ROM image at exactly the address _sidata names. The .bss variable tx_count contributes nothing to these bytes at all.
The first four bytes, 17 01 01 20, are little-endian for 0x20010117 — auipc sp, 0x20010, the first instruction of _start loading the stack pointer. That is the word the core must fetch at reset.
Loading it into hardware and fetching the first instruction
objcopy can emit a Verilog-format hex directly:
$ riscv64-linux-gnu-objcopy -O verilog --verilog-data-width=4 boot.elf boot.hex
@00000000
20010117 00010113 00000297 05628293
30529073 09400293 20000317 FE830313
...
B7CD00D5
@00000025
6C6C6568 000A0D6F
Two details worth flagging. The @ records are addresses in units of --verilog-data-width, so @00000025 is word 0x25 = byte 0x94 — the .data image again, at its LMA. And 6C6C6568 is "hell" read as a little-endian 32-bit word. For $readmemh into a simple reg [31:0] mem [0:N-1] array, a plain one-word-per-line file (no @ records) is easier to reason about, which is what the simulation below uses.
Fetching from it under Verilator, with the PC’s reset value set to 0x0000_0000:
reset asserted, pc is X until the first clock edge
--- in reset: pc = 0x00000000 (the PC's reset value) ---
cycle 0: presenting addr=0x00000000 cs_rom=1 | returning word for 0x00000000 = 0x20010117
cycle 1: presenting addr=0x00000004 cs_rom=1 | returning word for 0x00000000 = 0x20010117
cycle 2: presenting addr=0x00000008 cs_rom=1 | returning word for 0x00000004 = 0x00010113
cycle 3: presenting addr=0x0000000c cs_rom=1 | returning word for 0x00000008 = 0x00000297
cycle 4: presenting addr=0x00000010 cs_rom=1 | returning word for 0x0000000c = 0x05628293
cycle 5: presenting addr=0x00000014 cs_rom=1 | returning word for 0x00000010 = 0x30529073
cycle 6: presenting addr=0x00000018 cs_rom=1 | returning word for 0x00000014 = 0x09400293
Compare against the disassembly of the same ELF:
00000000 <_start>:
0: 20010117 auipc sp,0x20010
4: 00010113 mv sp,sp
8: 00000297 auipc t0,0x0
c: 05628293 addi t0,t0,86 # 5e <trap_handler>
10: 30529073 csrw mtvec,t0
14: 09400293 li t0,148
The words coming out of the simulated ROM are, in order, the instruction encodings the assembler produced. The loop closes: source → ELF → binary → hex → Verilog memory → the core’s fetch port, with the memory map holding it together at every step. Note also the one-cycle skew — boot_rom registers its output on posedge clk, so the word for an address arrives the cycle after the address is presented. That is not a bug; it is what an FPGA block RAM does, and it is why a fetch stage needs either a pipeline register or a stall. See Classic Five-Stage Pipeline.
One quiet confirmation of the “three places agree” claim is in the disassembly’s operands. la t0, _sidata assembled to li t0,148 — a single instruction with the literal 0x94, because the linker resolved _sidata to an address small enough to fit an addi immediate. Meanwhile la t1, _sdata became auipc t1,0x20000; addi t1,t1,-24, a two-instruction sequence, because 0x2000_0000 does not. The choice of base addresses is visible in the generated code, and putting ROM at zero is measurably cheaper per reference.
A Real Map for Comparison — the ESP32-C3
The ESP32-C3 is a useful yardstick because it is the part the definitely-not-esp32 project benchmarks against, it is the same ISA class (RV32IMC), and Espressif publishes the full map. Verified against the ESP32-C3 Technical Reference Manual Version 1.4 — the downloaded PDF’s first page reads “ESP32-C3 / Technical Reference Manual Version 1.4”, 903 pages, matching the filename it was served under, which is a check worth doing every time.
The C3’s map is organised by bus, not just by device, which is the single biggest structural difference from a small single-bus SoC:
“Addresses below
0x4000_0000are accessed using the data bus. Addresses in the range of0x4000_0000~0x4FFF_FFFFare accessed using the instruction bus. Addresses over and including0x5000_0000are shared by the data bus and the instruction bus.” (TRM v1.4, §3.3.1)
That is a Harvard-ish split exposed in the address map: the same physical SRAM is reachable at two addresses depending on which port you go through.
| Region | Bus | Address range | Size | Notes |
|---|---|---|---|---|
| Internal ROM 1 | Data | 0x3FF0_0000–0x3FF1_FFFF | 128 KB | same words as 0x4004_0000 |
| Internal SRAM 1 | Data | 0x3FC8_0000–0x3FCD_FFFF | 384 KB | same words as 0x4038_0000 |
| Internal ROM 0 | Instruction | 0x4000_0000–0x4003_FFFF | 256 KB | mask ROM, instruction bus only |
| Internal ROM 1 | Instruction | 0x4004_0000–0x4005_FFFF | 128 KB | |
| Internal SRAM 0 | Instruction | 0x4037_C000–0x4037_FFFF | 16 KB | configurable as instruction cache |
| Internal SRAM 1 | Data/Instruction | 0x4038_0000–0x403D_FFFF | 384 KB | |
| RTC FAST Memory | Data/Instruction | 0x5000_0000–0x5000_1FFF | 8 KB | retained through deep sleep |
| Peripherals | Data | 0x6000_0000–… | 4 KB slots | UART0 at 0x6000_0000 |
(from TRM v1.4 Tables 3.3-1 and 3.3-3)
Reading that table against the 144 KiB example map is the whole lesson:
| Design question | From-scratch SoC | ESP32-C3 |
|---|---|---|
| Number of distinct regions | 3 | ~12 internal + external flash windows |
| ROM | 16 KiB, $readmemh at simulation / bitstream at synthesis | 384 KB mask ROM, fixed in silicon, “strictly read-only and cannot be reprogrammed” |
| RAM | 64 KiB, one region | 400 KB SRAM split into three parts with different bus access rules |
| Deliberate aliasing | none | ROM 1 and SRAM 1 each at two documented bases |
| Executable regions | ROM and RAM | ROM 0 instruction-bus only; parts of SRAM instruction-bus only |
| Peripheral base | 0x1000_0000 | 0x6000_0000, 4 KiB slots, explicit Reserved gaps |
| External memory | none | up to 16 MB flash mapped through a cache/MMU in 64 KB blocks |
| Who decides the reset vector | you, in the RTL | Espressif, in the mask ROM |
| Where the map is written down | your soc.v, link.ld, soc.h | the TRM, and soc/esp32c3/include/soc/*.h in ESP-IDF |
Two things transfer directly from the C3’s map to a from-scratch one. First, 4 KiB peripheral slots with explicit reserved gaps — the C3 leaves 0x6000_1000, 0x6000_5000–0x6000_6FFF, 0x6000_A000–0x6000_FFFF reserved, which is exactly the “slot size is free” argument applied by a team that ships. Second, address-range classification by attribute, not just by device: the C3’s map is really a statement about which bus, which permissions, and which access widths apply to each range, which is the PMA model made concrete.
One thing that does not transfer is the external-flash window. The C3 maps up to 8 MB of instruction space and 8 MB of data space into external flash through a cache, “organized as individual 64-KB blocks”, with an MMU translating CPU addresses to flash physical addresses. That is an entire subsystem — cache, MMU, XTS-AES decryption — that a first SoC has no business building. It is also why the C3 needs a multi-stage boot at all, which is the subject of Boot ROM and the Reset Vector.
Failure Modes
The symptoms below are the ones that actually happen, ordered roughly by how long they take to diagnose.
Region overflow — the friendly one. GNU ld checks section sizes against LENGTH and refuses:
$ # ROM shrunk to 128 bytes
riscv64-linux-gnu-ld: section `.text' will not fit in region `ROM'
riscv64-linux-gnu-ld: region `ROM' overflowed by 28 bytes
$ # RAM shrunk to 4 bytes
riscv64-linux-gnu-ld: section `.data' will not fit in region `RAM'
riscv64-linux-gnu-ld: region `RAM' overflowed by 8 bytes
This is the best memory-map bug because it is caught at link time with an exact byte count. It is also the reason to declare LENGTH honestly: if the linker script says 16K and the Verilog ROM is WORDS = 1024 (4 KiB), the link succeeds and the system fetches garbage past 4 KiB. ld cannot know how big your memory really is; LENGTH is your assertion, and it is only as good as your discipline.
Silent output with a running core. The core executes, the program terminates, nothing appears on the UART. Almost always the software header’s base does not match the decoder — the store executes perfectly and lands in a hole. Diagnose by dumping the bus transaction addresses in the testbench and comparing against the map; the address will be plausible but wrong. This is why cs_none should drive a $display in simulation as well as ERR_O in hardware.
No fetch at all. pc advances but every instruction reads as 0x00000000 (which decodes as an illegal instruction) or X. Either the reset vector is outside every region, or the ROM’s $readmemh file was not found. Verilator warns about a missing $readmemh file at runtime, but under --binary that warning is easy to scroll past; an explicit check that mem[0] != 0 at time zero is worth adding.
Works in simulation, fails on the FPGA. The usual cause is that $readmemh is a simulation construct. Synthesis tools generally honour initial blocks with $readmemh for inferred block RAM, but not universally, and not for all memory styles. If the ROM’s contents come from $readmemh in simulation and from a different mechanism (a generated case statement, a vendor IP core, a .mi/.coe initialisation file) on hardware, the two can silently diverge. Keeping one generator that emits both from the same boot.bin is the fix. This is covered further in Boot ROM and the Reset Vector.
Two devices assert chip select. In simulation the shared read-data bus goes X, which is at least loud. On an FPGA, X is not a real value; the synthesised mux resolves to something, and which something can depend on placement. Add the mutual-exclusion assertion to the testbench.
The map drifted and nobody noticed. Someone moved the UART from 0x1000_0000 to 0x1000_1000 in the Verilog, updated soc.h, and forgot that the boot ROM’s .text had a hard-coded address in an assembly stub. The fix is structural, not procedural: generate all three artefacts from one source.
Common misunderstandings
- “The memory map is documentation.” It is not; it is source code, replicated three times. Treat drift between copies as a build break, not a doc bug.
- “Unmapped addresses read as zero.” Only if you build something that makes them read as zero. An unterminated bus cycle hangs the core; you must supply a default slave.
- “
volatilemakes the access atomic / ordered / uncached.” It does none of those. It only constrains the compiler, per ISO/IEC 9899:2024 §6.7.4. Ordering against other memory comes fromfence; uncachedness comes from the PMA of the region. - “Aliasing is harmless because I never use those addresses.” You do not control what a bug uses, and aliasing removes the fault that would have told you.
- “Bigger peripheral slots waste address space.” There are 4 GiB of it and you are using 0.0034%.
- “MMIO is just memory with side effects.” It is also memory with width restrictions, alignment restrictions, and no speculation — four separate constraints, per the PMA sections of the privileged spec.
Alternatives and When to Choose Them
The map is one design point in a space of ways to reach a device. The honest comparison:
| Approach | How the device is addressed | Where it is used | Trade-off |
|---|---|---|---|
| Memory-mapped I/O with full decode | ordinary loads/stores to a decoded region | RISC-V, ARM, MIPS, every modern SoC; this note | No new instructions; costs address space and a decoder. The default, and correct for a from-scratch SoC |
| Memory-mapped I/O with partial decode | same, but fewer bits compared | 1980s microcomputers where gates were scarce | Saves a comparator; buys thousands of aliases. Never worth it now — see MMIO and Port IO Emulation |
| Port-mapped I/O | a separate address space reached by dedicated instructions (IN/OUT) | x86 | Keeps I/O out of the memory space entirely; requires ISA support RISC-V does not have and does not want |
| Special registers / CSRs | csrr/csrw on a 12-bit CSR index | RISC-V mtvec, mcause, PMP config, mcycle | Fastest possible access, no bus transaction at all; only 4096 slots and only for core-local state. See Control and Status Registers |
| Coprocessor / custom instruction | a dedicated ISA extension | tightly coupled accelerators | Lowest latency; requires decoder and compiler work, and breaks toolchain compatibility |
| A bus with an address-map ROM | runtime-programmable base addresses | PCI BARs, PCIe | Devices are discoverable and relocatable; needs enumeration firmware and configuration registers — enormous overkill on-chip |
For a small RISC-V SoC the choice is genuinely made for you. RISC-V has no port I/O instructions, so MMIO is the only route to a peripheral, and CSRs are the only route to core-local state. The real decisions left are where the MMIO region goes, how completely it is decoded, and whether the map is hand-maintained or generated.
On that last question the alternatives are worth naming, because “three hand-edited copies” is the beginner default and the first thing to outgrow:
- A single Python/Make generator. One table (name, base, size, registers) emitting
soc.vparameters, theMEMORYblock oflink.ld, andsoc.h. Perhaps twenty lines of script; removes the entire class of drift bugs. This is the right answer for a project this size. - SystemRDL or IP-XACT. Industry-standard machine-readable register descriptions with mature generators (
peakrdl) that emit RTL, headers, documentation and UVM models from one source. Correct, and heavier than a first SoC needs. - A device tree. The Linux-world answer: the map is described in a
.dtsand the software discovers it at runtime rather than hard-coding it. This is how a Linux system learns its map, and it is the right destination once an OS exists — but it solves a different problem (one binary, many boards) than a from-scratch SoC has. - A CMSIS-SVD file. ARM’s format, but used by the RISC-V ecosystem too:
svd2rustturns an SVD description into a type-safe Rust peripheral access crate. If the kernel is Rust, this is how the software half of the contract stops being#defines. See Bare-Metal Rust.
Production Notes
Shipping chips publish their maps as tables, and the tables carry attributes. Both reference designs examined here do the same thing: a single table with base, top, size, and a permissions column. The FU540’s is headed “Memory Attributes: R - Read, W - Write, X - Execute, C - Cacheable, A - Atomics” (FU540 manual v1p4, Table 6) — the PMA vocabulary of the privileged spec, rendered as five letters per row. If your project’s map does not have an attributes column, it is under-specified: you have said where things are but not what they permit, and “what they permit” is what the decoder and the trap logic both need.
Reserved rows are load-bearing. The FU540’s map is roughly half Reserved rows by count — 0x0000_2000–0x0000_FFFF, 0x0001_8000–0x00FF_FFFF, 0x0100_2000–0x017F_FFFF, and so on. Every one of those is an explicit statement that nothing answers there, which is what makes a bus-error default slave meaningful. A map that only lists occupied ranges leaves the reader unable to tell “unmapped” from “undocumented”.
Real reset vectors point at tiny ROMs that immediately dispatch. The FU540 is the clearest published example. “On power-on, all cores jump to 0x1004 while running directly off of the external clock input, expected to be 33.3 MHz.” What lives there is six instructions:
| Address | Contents |
|---|---|
0x1000 | The MSEL pin state |
0x1004 | auipc t0, 0 |
0x1008 | lw t1, -4(t0) |
0x100C | slli t1, t1, 0x3 |
0x1010 | add t0, t0, t1 |
0x1014 | lw t0, 252(t0) |
0x1018 | jr t0 |
(FU540 manual v1p4, Table 8)
Read it as a program: auipc t0, 0 puts 0x1004 in t0; lw t1, -4(t0) reads the word at 0x1000, which the manual says holds the MSEL pin state; shift it left by 3 to index a table of 8-byte entries; add; load the target; jump. SiFive calls it “This small gate ROM implements an MSEL-dependent jump for all cores”, and the resulting targets are a table of boot sources — MSEL 0001 → 0x2000_0000 (memory-mapped QSPI0), 0101/0110 → 0x0001_0000 (the zeroth-stage boot loader in mask ROM), 0000 → 0x0000_1004, which “loops forever waiting for debugger”. Two things to steal: the reset vector is data-driven rather than hard-wired to one destination, and the “loop here for the debugger” MSEL setting is a deliberate, documented way to stop the chip before it runs anything.
Peripheral clustering is universal. ESP32-C3 at 0x6000_0000, FU540 on-chip peripherals from 0x0200_0000 with the CLINT at 0x0200_0000 and the cache controller at 0x0201_0000. Neither scatters. The 0x0200_0000 CLINT base in particular has become a de facto convention across RISC-V soft cores, inherited from SiFive’s Freedom platform; adopting it costs nothing and means existing CLINT drivers and OpenSBI platform definitions need no change.
The map constrains the software you can run later. A microkernel with Physical Memory Protection needs regions that are naturally aligned powers of two, because PMP’s NAPOT encoding cannot express anything else; a map designed with awkward sizes forces PMP entries to be spent on padding. If Sv32 Virtual Memory is ever on the roadmap, regions should also be 4 KiB-aligned so that a page can be wholly inside one region. Both constraints are free if you honour them from the start and expensive to retrofit.
Point-in-time note. The ESP32-C3 figures here are from TRM v1.4 (PDF dated 2026-03-26 in its metadata, document version 1.4 on page 1). The FU540 figures are from manual v1p4, © 2021. The RISC-V clauses are from the ratified 20250508 revision of both volumes, whose front matter declares :revnumber: 20250508 and :revremark: This document is in Ratified state.. Vendor maps do change between silicon revisions; re-check against the current TRM before trusting a base address.
See Also
- Boot ROM and the Reset Vector — the sibling note: where the very first instruction comes from, and the boot code that turns this map into a running program
- Linker Scripts and Memory Layout — the linker-script language itself: sections, the location counter, VMA/LMA, and symbol definition
- System on a Chip — what makes a core a system; the wider integration story this map is one facet of
- Wishbone Bus — the fabric the chip selects gate:
CYC,STB,ACK,SEL, and the default-slave/ERRstory - Universal Asynchronous Receiver-Transmitter — the first peripheral to land in the MMIO region, and the reason the map has to be right before anything else can be debugged
- Control and Status Registers — the other address space: 4096 CSR indices reached by
csrr/csrw, not by the bus - Physical Memory Protection — PMP’s NAPOT encoding, and why it wants the same power-of-two alignment the decoder does
- Bare-Metal Rust —
read_volatile/write_volatileandsvd2rust, the Rust half of the software party - ESP32-C3 — the reference part whose published map is compared above
- Tang Nano 20K — the FPGA the ROM and RAM are actually built out of
- Verilator — the simulator that produced the decoder sweep and fetch trace
- Testbenches and RTL Verification — where the decoder-sweep and mutual-exclusion assertions belong
- MMIO and Port IO Emulation — the same decode problem seen from a hypervisor’s side
- Memory-Mapped IO and ioremap — what MMIO becomes when an operating system owns the address space
- Linux Memory Management MOC — the contrast: virtual address spaces, page tables, and per-process maps, none of which exist here
- Computer Architecture MOC — the concept hub
- definitely-not-esp32 MOC — the project hub; this note is a Stage 5 rung