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 .text and .data end 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 wrongSymptom on the benchWhy it looks like that
Decoder disagrees with linkerCore fetches at reset and gets 0x00000000 or X forever; no UART output at allNo chip select asserts for the fetch address, so the bus returns nothing (or never acknowledges)
Linker disagrees with software headerCode runs, but writes to the UART land in RAM or in a hole; program completes silentlyThe store executes fine — it is just aimed at the wrong place
Decoder disagrees with software headerWriting to one peripheral configures a different oneTwo regions overlap, or a region is under-decoded (see the aliasing trap below)
All three agree but the region is too smallLink succeeds, runtime corrupts adjacent stateld 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.

RegionBaseTopSizeAttributesDecoded by
Boot ROM0x0000_00000x0000_3FFF16 KiBR, Xaddr[31:14] == 18'h00000
MMIO (peripherals)0x1000_00000x1000_FFFF64 KiBR, W, side effectsaddr[31:16] == 16'h1000
RAM0x2000_00000x2000_FFFF64 KiBR, W, Xaddr[31:16] == 16'h2000
(everything else)unmapped → bus errorcs_none

Inside the MMIO region, peripherals get 4 KiB slots — one page’s worth each, even though a UART needs about sixteen bytes:

PeripheralBaseSize of slotRegisters used
UART 00x1000_00004 KiB+0x00 TX data, +0x04 status, +0x08 RX data
CLINT (timer / software IRQ)0x1000_10004 KiBmtime, mtimecmp, msip
GPIO0x1000_20004 KiBdirection, output, input
(reserved)0x1000_300052 KiBheadroom 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:

  1. S is a power of two: S = 2^k.
  2. B is aligned to its own size: B mod S == 0, which means the low k bits of B are 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:

RegionSize Sk = log2(S)Bits comparedThe comparison
ROM, 16 KiB @ 0x0000_00002^1414A[31:14], 18 bits== 18'h00000
MMIO, 64 KiB @ 0x1000_00002^1616A[31:16], 16 bits== 16'h1000
RAM, 64 KiB @ 0x2000_00002^1616A[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);
endmodule

Line 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_00000x0000_00FF, using 0x0000_10000x0000_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:

  1. 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.
  2. 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.
  3. 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!
endmodule

The 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_none and 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 (X on 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_00000x0000_3FFF. The hardware says it occupies 0x0000_00000x0FFF_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_00000x4005_FFFF through the instruction bus and at 0x3FF0_00000x3FF1_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.”

PropertyMain memory (RAM, ROM)I/O region (MMIO)
Reading twice returns the same valueYesNot necessarily — a read may pop a FIFO or clear a flag
A read has no effectYesMay have a side effect (read-to-clear, RX FIFO pop)
A write can be merged, delayed, or reordered by hardwareYes (subject to the memory model)No — must not be speculated or duplicated
CacheableTypically yesNo
Speculative / redundant access permittedYesNo
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 accessGenerally supported or emulated“Non-idempotent regions might not support misaligned accesses”
Instruction fetchUsually permittedUsually 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        ret

This 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        ret

Four 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:

Instructionaddr[1:0]SEL_O[3:0] (little-endian)Bytes written
SW0011110,1,2,3
SH0000110,1
SH1011002,3
SB0000010
SB0100101
SB1001002
SB1110003

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        ret

The 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):

  1. “Regardless of EEI, loads and stores whose effective addresses are naturally aligned shall not raise an address-misaligned exception.” Aligned always works.
  2. 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”.
  3. 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 (mcause 4 for loads, 6 for stores) for misaligned accesses that decode to ROM or RAM.
  • Raise access-fault (mcause 5 / 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]];
endmodule

WORDS = 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:

  • .text has address 0x0000_0000 and is 0x94 = 148 bytes — inside the ROM region, at its base.
  • .data has address (VMA) 0x2000_0000 but the second LOAD segment’s PhysAddr is 0x0000_0094. readelf -l prints VirtAddr then PhysAddr, and for a bare-metal image PhysAddr is the LMA. So .data runs at 0x2000_0000 in RAM and is stored at 0x94 in ROM, immediately after .text. That is AT > ROM working.
  • The second segment’s FileSiz is 0x8 but MemSiz is 0xc. The extra four bytes are .bss, which is NOBITS: 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 0x20010117auipc 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_0000 are accessed using the data bus. Addresses in the range of 0x4000_0000 ~ 0x4FFF_FFFF are accessed using the instruction bus. Addresses over and including 0x5000_0000 are 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.

RegionBusAddress rangeSizeNotes
Internal ROM 1Data0x3FF0_00000x3FF1_FFFF128 KBsame words as 0x4004_0000
Internal SRAM 1Data0x3FC8_00000x3FCD_FFFF384 KBsame words as 0x4038_0000
Internal ROM 0Instruction0x4000_00000x4003_FFFF256 KBmask ROM, instruction bus only
Internal ROM 1Instruction0x4004_00000x4005_FFFF128 KB
Internal SRAM 0Instruction0x4037_C0000x4037_FFFF16 KBconfigurable as instruction cache
Internal SRAM 1Data/Instruction0x4038_00000x403D_FFFF384 KB
RTC FAST MemoryData/Instruction0x5000_00000x5000_1FFF8 KBretained through deep sleep
PeripheralsData0x6000_0000–…4 KB slotsUART0 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 questionFrom-scratch SoCESP32-C3
Number of distinct regions3~12 internal + external flash windows
ROM16 KiB, $readmemh at simulation / bitstream at synthesis384 KB mask ROM, fixed in silicon, “strictly read-only and cannot be reprogrammed”
RAM64 KiB, one region400 KB SRAM split into three parts with different bus access rules
Deliberate aliasingnoneROM 1 and SRAM 1 each at two documented bases
Executable regionsROM and RAMROM 0 instruction-bus only; parts of SRAM instruction-bus only
Peripheral base0x1000_00000x6000_0000, 4 KiB slots, explicit Reserved gaps
External memorynoneup to 16 MB flash mapped through a cache/MMU in 64 KB blocks
Who decides the reset vectoryou, in the RTLEspressif, in the mask ROM
Where the map is written downyour soc.v, link.ld, soc.hthe 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_50000x6000_6FFF, 0x6000_A0000x6000_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.
  • volatile makes 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 from fence; 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:

ApproachHow the device is addressedWhere it is usedTrade-off
Memory-mapped I/O with full decodeordinary loads/stores to a decoded regionRISC-V, ARM, MIPS, every modern SoC; this noteNo new instructions; costs address space and a decoder. The default, and correct for a from-scratch SoC
Memory-mapped I/O with partial decodesame, but fewer bits compared1980s microcomputers where gates were scarceSaves a comparator; buys thousands of aliases. Never worth it now — see MMIO and Port IO Emulation
Port-mapped I/Oa separate address space reached by dedicated instructions (IN/OUT)x86Keeps I/O out of the memory space entirely; requires ISA support RISC-V does not have and does not want
Special registers / CSRscsrr/csrw on a 12-bit CSR indexRISC-V mtvec, mcause, PMP config, mcycleFastest possible access, no bus transaction at all; only 4096 slots and only for core-local state. See Control and Status Registers
Coprocessor / custom instructiona dedicated ISA extensiontightly coupled acceleratorsLowest latency; requires decoder and compiler work, and breaks toolchain compatibility
A bus with an address-map ROMruntime-programmable base addressesPCI BARs, PCIeDevices 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.v parameters, the MEMORY block of link.ld, and soc.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 .dts and 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: svd2rust turns 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_20000x0000_FFFF, 0x0001_80000x00FF_FFFF, 0x0100_20000x017F_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:

AddressContents
0x1000The MSEL pin state
0x1004auipc t0, 0
0x1008lw t1, -4(t0)
0x100Cslli t1, t1, 0x3
0x1010add t0, t0, t1
0x1014lw t0, 252(t0)
0x1018jr 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 00010x2000_0000 (memory-mapped QSPI0), 0101/01100x0001_0000 (the zeroth-stage boot loader in mask ROM), 00000x0000_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