Boot ROM and the Reset Vector
The reset vector is the address the program counter holds the instant reset is released, and the boot ROM is the memory that answers there. On a from-scratch SoC neither is given to you: RISC-V’s ratified privileged specification says only that “the
pcis set to an implementation-defined reset vector” (RISC-V Instruction Set Manual Volume II: Privileged Architecture, version 20250508, Ratified, §Reset), and elsewhere that “Reset and NMI vector locations are given in a platform specification.” You are the platform. The reset vector is one line of Verilog — the reset value of the PC register — and the boot ROM is a memory array whose contents you produce with a compiler, anobjcopy, and either$readmemhin simulation or the FPGA bitstream on hardware.What that first code has to do is short and non-obvious. Before a single line of C or Rust can run, something must set up a stack pointer (the compiler assumes
spis valid and will spill to it immediately), zero.bss(the C and Rust abstract machines both promise zero-initialised statics), copy.datafrom its load address in ROM to its run address in RAM (initialised globals cannot live in read-only memory), and pointmtvecsomewhere sane so that the first bug produces a diagnosable trap rather than an infinite loop of traps. That is precisely the job ofcrt0in a hosted C toolchain and ofriscv-rtin a Rust one — and writing it yourself once is the fastest way to understand what those libraries have been doing on your behalf. This note builds that code for real, links it against the map in The SoC Memory Map, disassembles it, and fetches its first instruction under Verilator.This is a bare SoC with no operating system: no bootloader chain, no firmware interface, no kernel image to load. For the multi-stage, signed, OS-oriented version of the same story see Linux Boot and Init MOC and Boot Loaders Beyond GRUB — they solve a different problem (find and validate an arbitrary kernel on arbitrary media) and share almost no mechanism with this one.
Mental Model — Reset Is a Wire, Not an Instruction
The mental model that trips people up is thinking of reset as something the processor does — an instruction it executes, a special routine it runs. It is not. Reset is a wire, and the reset vector is the constant that wire loads into a flip-flop.
Every register in a synchronous design can have an asynchronous or synchronous reset input, and the value it takes on reset is written into the RTL. The program counter is no different from any other register: it has a reset value, and that value is the reset vector.
localparam [31:0] RESET_VECTOR = 32'h0000_0000; // a DESIGN DECISION
always @(posedge clk or negedge rst_n)
if (!rst_n) pc <= RESET_VECTOR;
else pc <= pc + 32'd4;That is the entire mechanism. When rst_n is low, pc is forced to RESET_VECTOR on every clock edge, and the fetch unit is presenting that address to the bus the whole time. When rst_n goes high, the next edge does something else — increments, or takes a branch target — and the machine is running. There is no “boot sequence” in the hardware; there is a register that was being held and then stopped being held.
stateDiagram-v2 [*] --> PowerOn PowerOn: Power on / brownout / watchdog PowerOn --> InReset: reset asserted (rst_n = 0) state InReset { [*] --> Held Held: pc <- RESET_VECTOR every edge<br/>privilege mode <- M<br/>mstatus.MIE <- 0, mstatus.MPRV <- 0<br/>mcause <- reset cause<br/>PMP A and L fields <- 0<br/>all other hart state UNSPECIFIED } InReset --> Fetching: reset released (rst_n = 1) state Fetching { [*] --> Fetch0 Fetch0: address RESET_VECTOR is on the bus<br/>decoder asserts cs_rom Fetch0 --> Fetch1: ROM returns the first word Fetch1: pc advances; _start executes } Fetching --> Running: sp set, .bss zeroed,<br/>.data copied, mtvec set Running: main() / kernel entry
Reset as a held register, not a routine. What it shows: the state the hardware guarantees while reset is asserted, and the very small set of things the RISC-V specification actually promises about that state. The insight to take: the box labelled “all other hart state UNSPECIFIED” is the important one. The specification resets the privilege mode, three mstatus bits, misa, mcause, PMP A/L fields, and the PC — and explicitly nothing else. The 32 general-purpose registers are not reset, mtvec is not reset, and the stack pointer in particular is garbage. Every line of _start exists to fill in one of those gaps before any compiled code can safely run.
Three consequences follow immediately, and each one explains a line of the boot code:
spis undefined, so the first thing_startmust do is load it. Any compiled function may spill registers to the stack in its prologue; callingmainbeforespis valid corrupts whatever address the garbage pointed at.mtvecis undefined, so any trap taken before it is set jumps to an arbitrary address. On a system whose ROM is at0x0000_0000, an unsetmtvecof zero happens to land back at_start, which produces a boot loop that looks like a reset problem and is actually a trap.- RAM is undefined. SRAM and FPGA block RAM come up with arbitrary or vendor-defined contents. Anything the program expects to be zero must be zeroed by the program.
What the Specification Does and Does Not Mandate
It is worth being precise about the boundary between “the ISA guarantees this” and “you decided this”, because a great deal of confusion about RISC-V boot comes from assuming an x86-style architectural reset vector exists. It does not.
The complete reset text from the ratified specification (Privileged 20250508, §Reset) is short enough to quote in full:
Upon reset, a hart’s privilege mode is set to M. The
mstatusfields MIE and MPRV are reset to 0. If little-endian memory accesses are supported, themstatus/mstatushfield MBE is reset to 0. Themisaregister is reset to enable the maximal set of supported extensions, as described inmisa. For implementations with the “A” standard extension, there is no valid load reservation. Thepcis set to an implementation-defined reset vector. Themcauseregister is set to a value indicating the cause of the reset. Writable PMP registers’ A and L fields are set to 0, unless the platform mandates a different reset value for some PMP registers’ A and L fields. If the hypervisor extension is implemented, thehgatp.MODE andvsatp.MODE fields are reset to 0. If the Smrnmi extension is implemented, themnstatus.NMIE field is reset to 0. No WARL field contains an illegal value. If the Zicfilp extension is implemented, themseccfg.MLPE field is reset to 0. All other hart state is UNSPECIFIED.
| Question | Answer | Source |
|---|---|---|
| What address does the PC start at? | Implementation-defined; you choose it in RTL | Priv 20250508 §Reset |
| Where should the vector live? | “given in a platform specification” | Priv 20250508 §Machine-Level CSRs |
| What privilege mode? | M-mode, always | Priv 20250508 §Reset |
| Are interrupts enabled? | No — mstatus.MIE = 0 | Priv 20250508 §Reset |
Is mtvec initialised? | No — not listed, therefore UNSPECIFIED | Priv 20250508 §Reset |
Are x1–x31 initialised? | No — UNSPECIFIED | Priv 20250508 §Reset |
| Is there a reset cause? | Yes, in mcause — implementation-specific values | Priv 20250508 §Reset |
| Multiple reset sources? | Allowed and expected | Priv 20250508 §Reset, NOTE |
The mcause-on-reset provision is easy to overlook and genuinely useful on a real board. The specification’s note explains the intent: “Some designs may have multiple causes of reset (e.g., power-on reset, external hard reset, brownout detected, watchdog timer elapse, sleep-mode wake-up), which machine-mode software and debuggers may wish to distinguish.” It goes on: “mcause reset values may alias mcause values following synchronous exceptions. There should be no ambiguity in this overlap, since on reset the pc is typically set to a different value than on other traps.” The recommended convention is that “the value 0 should be returned on implementations that do not distinguish different reset conditions”, and that implementations which do distinguish “should only use 0 to indicate the most complete reset.”
For a from-scratch SoC that means: wire mcause to 0 at reset unless and until you add a watchdog, at which point give the watchdog reset a distinct non-zero code so the boot ROM can print “I was reset by the watchdog” instead of pretending it was a power-on. That one distinction pays for itself the first time an FPGA board reboots in a loop.
Choosing your reset vector
The choice is genuinely free, and real designs land in different places:
| System | Reset vector | Rationale |
|---|---|---|
| This project’s SoC | 0x0000_0000 | ROM at the bottom of the map; small addresses assemble to single li instructions |
| SiFive FU540-C000 | 0x0000_1004 | a six-instruction “gate ROM” that reads boot-mode pins and dispatches — see The SoC Memory Map |
| ESP32-C3 | inside the 384 KB mask ROM | fixed by Espressif; “The reset vector code is located in the mask ROM of the ESP32-C3 chip and cannot be modified” |
QEMU virt machine | 0x0000_1000 | #define DEFAULT_RSTVEC 0x1000 in target/riscv/cpu_bits.h, applied as env->pc = env->resetvec in cpu.c and overridable with the CPU’s resetvec property; a six-instruction stub in the mask ROM (VIRT_MROM at 0x1000) loads the firmware entry and jumps |
The two considerations worth weighing:
- Zero is convenient but hides null-pointer bugs. With ROM at zero,
*(int*)0returns an instruction word instead of trapping. Putting the ROM at0x0000_1000and leaving the first 4 KiB unmapped costs one comparison in the decoder and turns null dereferences into load access faults. - The vector must be inside a region that decodes and is executable at reset. This sounds obvious and is the single most common bring-up failure: the linker put
.textat one address, the PC resets to another, and the core fetches from a hole forever. Both numbers must come from the same table — see the three-places argument in The SoC Memory Map.
Where the ROM Contents Come From
A ROM has no write port, so its contents must be established before the first clock edge. There are three distinct mechanisms and they belong to three different stages of the flow — which is precisely why “it works in simulation but not on the board” is such a common boot-ROM failure.
flowchart TB SRC["start.S + main.c<br/>+ link.ld"] --> ELF["boot.elf<br/>(ELF32, riscv)"] ELF -->|"objcopy -O binary"| BIN["boot.bin<br/>raw image: .text + .data LMA"] ELF -->|"objcopy -O verilog"| VHEX["boot.hex<br/>@addr records"] BIN -->|"script: 1 word per line"| HEX["rom.hex<br/>plain 32-bit hex"] HEX -->|"$readmemh in an initial block"| SIM["SIMULATION<br/>Verilator / Icarus<br/>read at elaboration time"] HEX -->|"synthesis infers BRAM init<br/>from the same initial block"| FPGA["FPGA<br/>init values baked into<br/>the bitstream"] BIN -->|"vendor .mi / .coe / .mem file"| FPGA BIN -->|"generated case statement<br/>or LUT ROM"| FPGA BIN -->|"mask layer at tape-out"| ASIC["ASIC<br/>mask ROM<br/>unchangeable forever"] SIM -.->|"MUST match"| FPGA
Three destinations for one image. What it shows: the same boot.bin reaches simulation, FPGA, and (hypothetically) silicon by three different routes, and only the leftmost one is guaranteed by the language standard. The insight to take: the dashed “MUST match” arrow is the risk. $readmemh is a simulation construct that synthesis tools may honour for inferred block RAM; if they do not, the FPGA silently gets an uninitialised ROM while simulation gets the right one. Generating every downstream artefact from one boot.bin — never hand-editing a .coe or a hex file — is what keeps the arrow honest.
$readmemh in simulation
reg [31:0] mem [0:WORDS-1];
initial $readmemh("rom.hex", mem);The initial block runs once at time zero, before any clock edge, so the memory is populated before the first fetch. Verilator supports it — the language-support guide lists “$readmemb, $readmemh — Read memory commands are supported”, with the single caveat that “Verilator and the Verilog specification do not include support for readmem to multi-dimensional arrays.”
The file format is one hex value per line (or whitespace-separated), optionally with @address records to jump the load pointer. objcopy -O verilog emits exactly that format, but with a subtlety: the @ addresses are in units of --verilog-data-width, so with --verilog-data-width=4 the record @00000025 means word index 0x25, i.e. byte address 0x94. For a plain reg [31:0] mem[0:N-1] the simplest thing is to strip the records and emit one word per line:
b = open('boot.bin','rb').read()
b += b'\x00' * ((-len(b)) % 4) # pad to a whole word
for i in range(0, len(b), 4):
print("%08x" % int.from_bytes(b[i:i+4], 'little'))Little-endian is not optional: RISC-V instruction encodings are stored little-endian, so the four bytes 17 01 01 20 in boot.bin are the word 0x20010117, and $readmemh expects the word, not the byte order.
Two failure modes worth knowing. First, if the file is missing, simulators warn but continue with X (or zeros) — and under Verilator’s --binary mode that warning scrolls past in the build output. An explicit if (mem[0] === 32'hx) $fatal(1, "rom.hex not loaded"); in the testbench is cheap insurance. Second, $readmemh silently ignores extra data beyond the array’s size, so a ROM image larger than WORDS truncates without complaint — which is why the linker script’s LENGTH and the Verilog WORDS parameter must be the same number, checked by a human or generated from one source.
Block RAM initialisation on an FPGA
On an FPGA there is no separate ROM primitive in the usual sense. A “ROM” is a block RAM (BRAM) whose write port is never driven, and its power-up contents come from the bitstream: the configuration data that programs the fabric also carries the initial contents of every memory cell. That is why an FPGA boot ROM is genuinely read-only at run time yet trivially reprogrammable at build time — you rebuild the bitstream.
Vendors get the initial contents into the bitstream by two routes. The portable one is to infer it: the synthesis tool recognises a memory array with an initial block containing $readmemh and folds the file’s contents into the BRAM’s INIT attributes. Most modern tools do this, including Vivado, Quartus, Yosys and Gowin’s EDA. The vendor-specific one is an explicit initialisation file attached to a memory IP core — a .coe (Xilinx), .mif (Intel/Altera, Lattice), or .mi (Gowin) — generated alongside the hex.
Uncertain
Verify: that Gowin’s EDA on the Tang Nano 20K’s GW2AR-LV18QN88C8/I7 infers BRAM initialisation from
$readmemhin aninitialblock, and how many BSRAM blocks the part has (i.e. the practical maximum boot-ROM size). Reason: no synthesis tool is installed on this machine and no Gowin document was retrieved. The Sipeed Tang Nano 20K page states the part has “20736 LUT4 logic cells and 15552 Flip-Flops” and mentions 2 PLLs and DSP units, but gives no BSRAM figure. The$readmemh-inference claim above is general FPGA-toolchain knowledge, not a measurement, and the “most modern tools do this” wording should be read as a hypothesis for this specific toolchain. To resolve: build a trivial ROM with Gowin EDA, read the synthesis report’s BSRAM utilisation, and confirm the initialised contents in the generated netlist or by reading the ROM back over the UART on hardware.#uncertain
Mask ROM in silicon
On an ASIC the contents are literally a metal or diffusion layer in the mask set, fixed at tape-out. The ESP32-C3’s TRM is blunt about the consequence: “The Internal ROM of the ESP32-C3 is a Mask ROM, meaning it is strictly read-only and cannot be reprogrammed” (TRM v1.4, §3.3.2). That is 384 KB of code Espressif can never patch on shipped parts, which is why the mask ROM’s job is deliberately narrow — decide the boot mode and load a second stage from flash — and why ESP-IDF’s documentation warns that the ROM version reported in the boot log “may be lower than the actual chip revision number” because engineering change orders that do not touch ROM behaviour leave the ROM firmware unchanged (ESP-IDF, Application Startup Flow).
The design lesson transfers even though you will never tape out: put as little as possible in the immutable stage. On an FPGA the boot ROM is cheap to change, but the discipline of keeping it to “set up the machine and jump” rather than “implement features” is what makes it debuggable.
The Minimum Job of the Boot Code
There are exactly five things the code at the reset vector must do before compiled C or Rust is safe to enter, and the order matters. Here is the whole boot ROM used in this note, in RISC-V assembly, followed by a walk-through of every instruction group.
.section .text.init, "ax"
.global _start
_start:
# 1. Set up the stack pointer at the top of RAM.
la sp, _stack_top
# 2. Point mtvec at the trap handler (direct mode: low 2 bits = 00).
la t0, trap_handler
csrw mtvec, t0
# 3. Copy .data from its load address in ROM to its run address in RAM.
la t0, _sidata # source: end of .text in ROM
la t1, _sdata # dest start in RAM
la t2, _edata # dest end in RAM
1: beq t1, t2, 2f
lw t3, 0(t0)
sw t3, 0(t1)
addi t0, t0, 4
addi t1, t1, 4
j 1b
# 4. Zero .bss.
2: la t1, _sbss
la t2, _ebss
3: beq t1, t2, 4f
sw x0, 0(t1)
addi t1, t1, 4
j 3b
# 5. Hand over to C.
4: call main
# main must not return; park the hart.
5: wfi
j 5b
trap_handler:
j trap_handler.section .text.init, "ax" puts this code in its own input section so the linker script can force it to the front of .text with KEEP(*(.text.init)). KEEP matters because _start is not referenced by anything — with --gc-sections the linker would otherwise be entitled to discard the entry point. The "ax" flags mark the section allocatable and executable.
Step 1, the stack pointer. la sp, _stack_top loads the linker-computed symbol _stack_top = ORIGIN(RAM) + LENGTH(RAM). This must be first, or nearly so: the RISC-V calling convention makes sp callee-saved and every non-leaf function’s prologue immediately does addi sp, sp, -N. If sp holds garbage from power-up, that store lands somewhere arbitrary. RISC-V stacks grow downward and the ABI requires 16-byte alignment; _stack_top at 0x2001_0000 is 16-byte aligned by construction, but a hand-computed value may not be, which is why riscv-rt ends its stack setup with andi sp, t1, -16 to force it.
Step 2, mtvec. Setting the trap vector before anything else that could fault means the very first bug is diagnosable. This is covered in its own section below.
Step 3, the .data copy, and step 4, the .bss zero, are the heart of the routine and are explained in the next two sections.
Step 5, call main, and then a parking loop. wfi (wait for interrupt) is a hint instruction — the hart may stall until an interrupt is pending — and looping around it means a spurious wake-up goes back to sleep rather than falling through into whatever bytes follow. Returning from main on a bare-metal system has no meaning; there is nothing to return to.
Why the ordering is what it is
| Step | Must come before | Because |
|---|---|---|
Set sp | any call | function prologues spill to the stack |
Set mtvec | anything that can trap | an unset mtvec sends a trap to an arbitrary address |
Copy .data | main | initialised globals are garbage until copied |
Zero .bss | main | the language guarantees zero-initialised statics |
call main | — | everything above is its precondition |
Two orderings are debatable rather than forced. Setting sp before mtvec is conventional but either works, since neither uses the stack. Copying .data before or after zeroing .bss is arbitrary — they touch disjoint address ranges — although doing .bss last has the mild advantage that a bug in the copy loop that overruns into .bss gets overwritten with zeros rather than left as corrupt data.
One thing deliberately not in this list: setting up a global pointer. riscv-rt does it (la gp, __global_pointer$ inside a .option norelax block) to enable GP-relative addressing of small globals, and it must be inside norelax or the linker will relax the la into a GP-relative load of GP itself, which is circular. This example omits it because the linker script does not define __global_pointer$, and GP-relative addressing is an optimisation, not a correctness requirement.
Why .data Must Be Copied but .text Must Not
This is the single most confusing point in bare-metal startup, and it is worth slowing down for, because the answer is not “because .data is writable”. Plenty of writable things are not copied.
Consider three variables in the example program:
char greeting[8] = "hello\r\n"; /* .data — writable, non-zero initialiser */
unsigned int tx_count; /* .bss — writable, zero initialiser */
static void uart_putc(char c); /* .text — code, read-only */Each needs something different at run time:
| Section | Needs to be writable? | Has a non-zero initial value? | Occupies ROM? | Boot action |
|---|---|---|---|---|
.text | no | yes (the instructions) | yes | nothing — execute in place |
.rodata | no | yes | yes | nothing — read in place |
.data | yes | yes | yes (the initial image) | copy ROM → RAM |
.bss | yes | no (all zero) | no | zero in RAM |
| stack / heap | yes | no | no | nothing (or set sp) |
The rule falls straight out of the two independent questions in columns 2 and 3:
.textis not copied because it does not need to be. It is read-only and it has an initial value, so the ROM can serve it directly, forever. Executing from ROM costs nothing on this SoC — the decoder assertscs_romfor a fetch exactly as it does for a load. Copying.textto RAM would burn RAM you do not have and buy nothing. (Systems do copy code to RAM, but for performance — external flash is slow — not for correctness. The ESP32-C3 does exactly that: its second-stage bootloader copies IRAM segments out of flash.).datamust be copied because it needs both properties at once. It has a non-zero initial value, so that value must be stored somewhere non-volatile — ROM. And it is writable, so at run time it must live somewhere writable — RAM. No single memory can be both, so the initial image sits in ROM and is duplicated into RAM at boot. This is exactly the load-address/run-address split:.data’s VMA is in RAM, its LMA is in ROM..bssis not copied because there is nothing to copy. Its initial value is all zeros, and storing thousands of zero bytes in ROM to copy them into RAM would be absurd. Instead the linker records only its size (the ELF section type isNOBITS) and the boot code writes zeros directly.
ROM (read-only) RAM (writable)
0x0000_0000 0x2000_0000
BEFORE boot: +---------------------+ +---------------------+
| .text 0x00..0x93 | | ??? ??? ??? ??? |
| 148 bytes of code | | (power-up garbage) |
+---------------------+ | |
_sidata -> | .data IMAGE 0x94.. | | |
| 68 65 6c 6c 6f 0d | | |
| 0a 00 "hello\r\n" | | |
+---------------------+ +---------------------+
^ _sdata ^ _sbss
| step 3: copy _sidata -> [_sdata, _edata)
| step 4: zero [_sbss, _ebss)
v
AFTER boot: +---------------------+ +---------------------+
| .text 0x00..0x93 | unchanged | .data 0x2000_0000: |
| | | 68 65 6c 6c 6f 0d |
+---------------------+ | 0a 00 (COPY) |
_sidata -> | .data IMAGE (still | +---------------------+
| there, now dead | | .bss 0x2000_0008: |
| weight) | | 00 00 00 00 (ZERO) |
+---------------------+ +---------------------+
| ...unused RAM... |
+---------------------+
| stack, growing down |
+---------------------+
^ _stack_top = 0x2001_0000
Memory before and after the boot code runs, with the real byte values from boot.bin. Format note: ASCII rather than mermaid because this is a two-column memory figure with byte-level contents; mermaid’s packet-beta describes bit fields inside one word and a flowchart would lose the spatial adjacency that is the entire point. The insight to take: the .data image in ROM is still there after boot and is never read again — it is pure overhead, one byte of ROM per byte of initialised global. That is why embedded style guides push you to leave globals uninitialised (landing them in .bss, costing zero ROM) rather than writing = 0 explicitly, which some compilers will honour by placing them in .data. Also note that .bss starts exactly where .data ends, at 0x2000_0008: they are adjacent in RAM but come from opposite worlds.
The linker’s own manual describes this technique and even prints the loop, which is a nice confirmation that the hand-written assembly above is the canonical shape rather than an idiosyncrasy (GNU ld manual, §3.6.8.2 Output Section LMA):
extern char _etext, _data, _edata, _bstart, _bend;
char *src = &_etext;
char *dst = &_data;
/* ROM has data at end of text; copy it. */
while (dst < &_edata)
*dst++ = *src++;
/* Zero bss. */
for (dst = &_bstart; dst< &_bend; dst++)
*dst = 0;The evidence in the ELF
The claim “.bss occupies no ROM” is checkable directly. From 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
.text and .data are PROGBITS — they have contents in the file. .bss is NOBITS — it has an address and a size but no bytes. The program headers say the same thing more sharply:
LOAD 0x001000 0x00000000 0x00000000 0x00094 0x00094 R E 0x1000
LOAD 0x002000 0x20000000 0x00000094 0x00008 0x0000c RW 0x1000
Read the second LOAD carefully — it contains the whole story in one line. VirtAddr = 0x20000000 is where .data runs. PhysAddr = 0x00000094 is where it is stored, in ROM, right after .text’s 0x94 bytes. FileSiz = 0x8 is .data’s eight bytes. MemSiz = 0xc is twelve — the extra four bytes are .bss, present in memory and absent from the file. MemSiz - FileSiz is exactly the number of bytes the boot code must zero.
And the raw image confirms it byte for byte. boot.bin is 156 bytes = 0x9c = 0x94 + 0x8, and its tail is:
00000090 d5 00 cd b7 68 65 6c 6c 6f 0d 0a 00 |....hello...|
0000009c
68 65 6c 6c 6f 0d 0a 00 — "hello\r\n\0", the initialiser of greeting, at file offset 0x94, which is exactly the value the linker gave _sidata:
10: 00000094 NOTYPE GLOBAL ABS _sidata
The 4-byte .bss variable tx_count contributes nothing to those 156 bytes. If it had been written unsigned int tx_count = 0; some toolchains would still place it in .bss (GCC does, treating an explicit zero initialiser as equivalent), but unsigned int tx_count = 1; would move it to .data and add four bytes of ROM plus four bytes of copy loop work. On a 16 KiB ROM that arithmetic starts to matter surprisingly quickly.
Setting mtvec Early
mtvec — the machine trap-vector base-address register, CSR 0x305 — is where the hardware sends the PC when a trap occurs. Reset does not initialise it (it is not in the list of reset-guaranteed state), so until the boot code writes it, a trap goes to whatever bits happen to be in the register. Setting it in the first handful of instructions is the difference between “an illegal instruction prints mcause” and “the machine reboots and you have no idea why”.
The register packs two fields (Privileged spec 20250508, §Machine Trap-Vector Base-Address Register): a BASE in bits XLEN-1:2 and a two-bit MODE in bits 1:0. Because the CSR stores only bits above 1, “the lower two bits are filled with zeroes to obtain an XLEN-bit address that is always aligned on a 4-byte boundary” — so a handler address must be 4-byte aligned or the low bits collide with MODE.
| MODE | Name | Behaviour |
|---|---|---|
| 0 | Direct | “All traps set pc to BASE.” |
| 1 | Vectored | “Asynchronous interrupts set pc to BASE+4×cause.” Synchronous exceptions still go to BASE |
| ≥2 | — | Reserved |
In vectored mode a machine timer interrupt (cause 7) lands at BASE + 0x1c, which the spec gives as its worked example. The BASE must then point at a table of jumps rather than at code, and the spec warns that “MODE=Vectored may have stricter alignment constraints than MODE=Direct”, with the rationale that “allowing coarser alignments in Vectored mode enables vectoring to be implemented without a hardware adder circuit” — the implementation ORs the cause into the low bits instead of adding, which only works if BASE is aligned to the table size.
mtvec is WARL (write-any, read-legal), and the spec is explicit that “the mtvec register must always be implemented, but can contain a read-only value.” That is not hypothetical. The ESP32-C3 hardwires MODE to Vectored: its TRM’s mtvec register diagram shows the MODE field with reset value 0x1 and the annotation “MODE Only vectored mode 0x1 is available. (RO)”, with BASE as “Higher 24 bits of trap vector base address aligned to 256 bytes. (R/W)”, and a footnote stating “mtvec only provides configuration for trap handling in vectored mode with the base address aligned to 256 bytes” (ESP32-C3 TRM v1.4, Register 1.7). A shipping RV32IMC part narrowed an implementation-defined choice to one value and 256-byte alignment — exactly the kind of decision you are also making, and a reminder that software which assumes Direct mode will not run there.
In the boot ROM, the write is two instructions:
la t0, trap_handler
csrw mtvec, t0trap_handler is 4-byte aligned (the assembler aligns instructions and .text.init is 2-byte aligned for RVC, but the linker placed it at 0x5e… which is not 4-byte aligned). That is worth staring at, because it is a real bug the compressed extension makes easy:
0000005e <trap_handler>:
5e: a001 j 5e <trap_handler>
0x5e has bits 1:0 = 10, so csrw mtvec, t0 would write MODE = 2 — a reserved value. Because mtvec is WARL, the hardware may legally hold any legal value instead, and what it actually does is implementation-defined. On this design nothing traps before the parking loop so it never bites, but a real handler must be forced to alignment:
.align 2 /* 2^2 = 4-byte alignment */
trap_handler:
j trap_handler.align 2 on RISC-V takes a power-of-two exponent, so this requests 4-byte alignment; for vectored mode on a part like the C3 it would need .align 8 (256 bytes). The general rule: the trap handler’s address is not just a label, it is a field of a CSR, and the low two bits are not yours. See Control and Status Registers and RISC-V Trap Handling for what happens after the jump.
Uncertain
Verify: what this specific SoC’s
mtvecimplementation does when written a reserved MODE value of0b10. Reason:mtvecis WARL, so the specification permits any legal value to be substituted, and the RTL in this note does not yet implementmtvecat all — the CSR write is executed by the assembler’s encoding but there is no CSR file in the minimal fetch-only design that was simulated. To resolve: implement the CSR block, write0x5e, readmtvecback and print it. Until then the alignment discussion above is a specification-derived hazard, not an observed one.#uncertain
A second thing worth doing early, though not strictly required: riscv-rt sets a pre-init trap vector before it does anything else, so that a fault during .data/.bss setup has somewhere to land, and only later installs the real one:
// Set pre-init trap vector
"la t0, _pre_init_trap",
"csrw mtvec, t0",
(riscv-rt/src/asm.rs). That is a good pattern to steal: a trivial vector that spins or drives an LED, installed at instruction three, replaced by the real handler once RAM is initialised.
The Real Boot ROM — Built, Linked, Disassembled
Everything above was built and run. Tool versions, stated because they matter: riscv64-linux-gnu-gcc (GCC) 16.1.1 20260501 (Red Hat Cross 16.1.1-1) with GNU binutils from the same cross package, and Verilator 5.046 2026-02-28. The linker script and memory map are the ones documented in The SoC Memory Map: ROM 16 KiB at 0x0000_0000, RAM 64 KiB at 0x2000_0000, MMIO at 0x1000_0000.
$ 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
Every flag earns its place:
-march=rv32imc_zicsr— the_zicsris mandatory on this toolchain. Without it the assembler refuses the trap-vector write outright:start.S:9: Error: unrecognized opcode 'csrw mtvec,t0', extension 'zicsr' required. CSR access was split out of the base integer ISA into the Zicsr extension, and modern GCC enforces the split. This is a version-sensitive fact worth re-checking on any other toolchain.-mabi=ilp32— the soft-float 32-bit ABI. It must match-march; a mismatch is a link error, not a silent miscompile.-nostdlib -nostartfiles— do not link the C runtime and do not link the toolchain’s owncrt0.o. This is what makesstart.Sthe entry point rather than a routine some other startup code calls.-ffreestanding— tells GCC there is no hosted environment, so it must not assumemainhas its usual meaning or that it may synthesise calls tomemcpy/memsetfrom a library that does not exist. (It may still emit calls to those symbols for large struct copies; a freestanding project usually has to provide them.)-Wl,--build-id=none— suppresses.note.gnu.build-id, which is allocatable and otherwise lands in ROM between.textand.data’s load image, pushing_sidatafrom0x94to0xb8. Harmless, but it makes the raw bytes harder to read.
The build also required /DISCARD/ : { *(.eh_frame) *(.comment) *(.riscv.attributes) } in the linker script. Without it:
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 and they are allocatable, so they compete for the same ROM address as .data’s load image. -fno-asynchronous-unwind-tables is the alternative fix.
The disassembly, walked
$ riscv64-linux-gnu-objdump -d boot.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
18: 20000317 auipc t1,0x20000
1c: fe830313 addi t1,t1,-24 # 20000000 <greeting>
20: 20000397 auipc t2,0x20000
24: fe838393 addi t2,t2,-24 # 20000008 <tx_count>
28: 00730963 beq t1,t2,3a <_start+0x3a>
2c: 0002ae03 lw t3,0(t0)
30: 01c32023 sw t3,0(t1)
34: 0291 addi t0,t0,4
36: 0311 addi t1,t1,4
38: bfc5 j 28 <_start+0x28>
3a: 20000317 auipc t1,0x20000
3e: fce30313 addi t1,t1,-50 # 20000008 <tx_count>
42: 20000397 auipc t2,0x20000
46: fca38393 addi t2,t2,-54 # 2000000c <_ebss>
4a: 00730663 beq t1,t2,56 <_start+0x56>
4e: 00032023 sw zero,0(t1)
52: 0311 addi t1,t1,4
54: bfdd j 4a <_start+0x4a>
56: 2029 jal 60 <main>
58: 10500073 wfi
5c: bff5 j 58 <_start+0x58>
0000005e <trap_handler>:
5e: a001 j 5e <trap_handler>
Several things in this listing teach something the source does not.
la is a pseudo-instruction, and what it expands to depends on the address. la sp, _stack_top became auipc sp,0x20010; mv sp,sp — a PC-relative upper-immediate plus a zero offset, two instructions and eight bytes. But la t0, _sidata became a single li t0,148, because _sidata = 0x94 fits in the 12-bit signed immediate of an addi, and the linker’s relaxation pass collapsed the pair. That is the base-address choice from The SoC Memory Map showing up as measurable code size: putting the ROM at 0x0000_0000 makes every ROM-address reference one instruction instead of two.
The mv sp,sp looks like a no-op and is not. auipc sp,0x20010 computes pc + 0x20010000 = 0 + 0x20010000 = 0x2001_0000, which is _stack_top exactly, so the paired addi sp, sp, 0 (disassembled as mv sp,sp) adds nothing. The assembler still emits it because it does not know at assembly time that the offset will be zero; relaxation can shrink the pair only in some cases. Four bytes of a 148-byte ROM spent on an addition of zero.
The compressed extension is doing real work. addi t0,t0,4 at 0x34 is two bytes (0291), not four; j at 0x38 is two bytes (bfc5); jal main at 0x56 is 2029, two bytes. Roughly a third of the instructions in this routine encoded to 16 bits. On a 16 KiB ROM the C extension is not an optimisation, it is what makes the code fit — which is the argument RV32IMC makes for including it.
The .data copy loop’s bounds are RAM addresses, the source is a ROM address. t0 starts at 0x94 (ROM, from _sidata), t1 at 0x2000_0000 (RAM, _sdata), t2 at 0x2000_0008 (RAM, _edata). The loop compares t1 against t2 — the destination — and increments both pointers. Eight bytes, two iterations.
The .bss loop’s bounds are 0x2000_0008 to 0x2000_000c — exactly _sbss to _ebss, four bytes, the tx_count variable. Note _edata and _sbss are the same address: .bss begins where .data ends.
csrw mtvec,t0 encoded as 0x30529073. Decoding by hand: bits 31:20 are the CSR number 0x305 = mtvec; bits 19:15 are rs1 = 5 = t0; bits 14:12 are funct3 = 001 = CSRRW; bits 11:7 are rd = 0 = x0, which is what makes it a write-only csrw rather than a read-write csrrw (writing to x0 discards the old value, and the spec makes rd=x0 suppress the read side effect); bits 6:0 are 1110011, the SYSTEM opcode. See Zicsr Extension.
The bytes
$ riscv64-linux-gnu-objcopy -O binary boot.elf 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 total. Reading the first sixteen against the disassembly, little-endian:
| Offset | Bytes | Word | Instruction |
|---|---|---|---|
0x00 | 17 01 01 20 | 0x20010117 | auipc sp,0x20010 |
0x04 | 13 01 01 00 | 0x00010113 | mv sp,sp |
0x08 | 97 02 00 00 | 0x00000297 | auipc t0,0x0 |
0x0c | 93 82 62 05 | 0x05628293 | addi t0,t0,86 |
0x10 | 73 90 52 30 | 0x30529073 | csrw mtvec,t0 |
and the last twelve are .data’s load image: 68 65 6c 6c 6f 0d 0a 00 at offset 0x94 is "hello\r\n\0", preceded by the final two instructions of main.
The objcopy Verilog-format output for the same ELF:
$ 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
@00000025 is a word index (0x25 × 4 = 0x94), and 6C6C6568 is "hell" as a little-endian word.
Fetching the First Instruction in Verilator
The last step closes the loop: put those bytes into a Verilog ROM, hold the core in reset, release it, and check that the word coming back is the first instruction of _start.
The design under test is deliberately not a CPU. It is a PC register with a reset value, the address decoder from The SoC Memory Map, and a $readmemh-initialised ROM — the smallest thing that can demonstrate a reset vector:
module top;
localparam [31:0] RESET_VECTOR = 32'h0000_0000; // a DESIGN DECISION
reg clk = 1'b0;
reg rst_n = 1'b0;
always #5 clk = ~clk;
reg [31:0] pc, pc_q; // pc_q = the address whose word is on 'instr' now
wire [31:0] instr;
wire cs_rom, cs_mmio, cs_ram, cs_none;
soc_decode dec (.addr(pc), .cs_rom(cs_rom), .cs_mmio(cs_mmio),
.cs_ram(cs_ram), .cs_none(cs_none));
boot_rom rom (.clk(clk), .addr(pc), .rdata(instr));
// THE reset vector: the PC's reset value, written in RTL.
always @(posedge clk or negedge rst_n)
if (!rst_n) pc <= RESET_VECTOR;
else pc <= pc + 32'd4;
always @(posedge clk) pc_q <= pc;Built and run:
$ verilator --binary --timing -Wno-fatal --top-module top soc.v -o simsoc
$ ./obj_dir/simsoc
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
Line up the right-hand column against objdump: 0x20010117, 0x00010113, 0x00000297, 0x05628293, 0x30529073, 0x09400293 — auipc sp, mv sp,sp, auipc t0, addi t0, csrw mtvec, li t0,148. The words leaving the simulated ROM are the assembler’s encodings, byte for byte, arriving at the reset vector.
sequenceDiagram autonumber participant PWR as Power / reset controller participant PC as PC register (RTL) participant DEC as Address decoder participant ROM as Boot ROM (16 KiB @ 0x0) participant RAM as RAM (64 KiB @ 0x2000_0000) participant UART as UART @ 0x1000_0000 PWR->>PC: rst_n = 0 (hold) Note over PC: pc <- RESET_VECTOR every edge<br/>privilege = M, mstatus.MIE = 0<br/>x1..x31 and mtvec UNSPECIFIED PWR->>PC: rst_n = 1 (release) PC->>DEC: present 0x0000_0000 DEC->>ROM: cs_rom = 1 ROM-->>PC: 0x20010117 (auipc sp,0x20010) Note over PC: one-cycle BRAM latency:<br/>the word arrives the cycle after<br/>the address is presented PC->>PC: sp <- 0x2001_0000 (_stack_top) PC->>PC: csrw mtvec, trap_handler loop copy .data, 2 words PC->>ROM: lw from 0x94.. (_sidata) ROM-->>PC: the greeting bytes 68 65 6c 6c / 6f 0d 0a 00 PC->>RAM: sw to 0x2000_0000.. (_sdata) end loop zero .bss, 1 word PC->>RAM: sw zero to 0x2000_0008 (_sbss) end PC->>PC: jal main loop for each char of greeting PC->>UART: lw status @ 0x1000_0004 UART-->>PC: TXFULL bit PC->>UART: sw char @ 0x1000_0000 end Note over UART: "hello" appears on the wire
The full path from a released reset wire to a character on the UART. What it shows: every bus transaction the boot ROM performs, in order, with the addresses that come from the memory map. The insight to take: there are only four kinds of transaction in the whole sequence — fetch from ROM, load from ROM, store to RAM, store to MMIO — and every one of them is an ordinary load or store that happens to decode to a different device. Nothing about “booting” is architecturally special; it is the same bus, the same instructions, the same decoder, running before anything else has had a chance to run.
One detail visible in the trace deserves naming: cycle 0 and cycle 1 both report the word for address 0x00000000. boot_rom registers its output on posedge clk (always @(posedge clk) rdata <= mem[addr[31:2]]), so data for an address arrives the cycle after the address is presented. That is not a modelling artefact — it is exactly what an FPGA block RAM does, and it is why a real fetch stage needs either a pipeline register or a stall cycle. Building the ROM as an asynchronous-read array would make the trace tidier and would not synthesise to block RAM; it would become distributed LUT RAM, which is far more expensive per bit. See Classic Five-Stage Pipeline and Field-Programmable Gate Array.
What crt0 and riscv-rt Do for You
The satisfying discovery, once you have written start.S by hand, is that the real runtimes do exactly the same thing — and that reading their source is now easy, because you know what you are looking at.
picolibc’s crt0 reduces the whole job to two library calls (picocrt/crt0.h):
__start(void)
{
#ifndef NO_FLASH
/* Initialize .data from FLASH when enabled */
memcpy(__data_start, __data_source, (uintptr_t)__data_size);
#endif
#ifndef CRT0_LINUX
memset(__bss_start, '\0', (uintptr_t)__bss_size);
#endif
...
#if defined(__INIT_FINI_ARRAY) && CONSTRUCTORS
__libc_init_array();
#endifwith the symbols and sizes coming from the linker script:
extern char __data_source[];
extern char __data_start[];
extern char __bss_start[];
extern char __bss_end[];
#define __data_size (__data_end - __data_start)
#define __bss_size (__bss_end - __bss_start)__data_source is picolibc’s name for _sidata; __data_start/__data_end are _sdata/_edata; __bss_start/__bss_end are _sbss/_ebss. Same three pointers, same two loops. The NO_FLASH guard is the case where there is no separate load address at all (a RAM-only image loaded by a debugger), and CRT0_LINUX is the hosted case where the kernel has already zeroed the pages.
riscv-rt, the Rust equivalent, writes the same loops in inline assembly (riscv-rt/src/asm.rs):
la t1, _stack_start
andi sp, t1, -16 // align stack to 16-bytes
// Copy .data from flash to RAM
la t0, __sdata
la a3, __edata
la t1, __sidata
bgeu t0, a3, 2f
1: lw t2, 0(t1)
addi t1, t1, 4
sw t2, 0(t0)
addi t0, t0, 4
bltu t0, a3, 1b
2: // Zero out .bss
la t0, __sbss
la t2, __ebss
bgeu t0, t2, 4f
3: sw zero, 0(t0)
addi t0, t0, 4
bltu t0, t2, 3b
4: // RAM initializedThat is line-for-line the start.S in this note, down to the register allocation. The differences are the extras a general-purpose runtime needs and a single-hart project does not.
| Job | This note’s start.S | picolibc crt0 | riscv-rt |
|---|---|---|---|
Set sp | la sp, _stack_top | linker-provided, in machine crt0.c | la t1, _stack_start then andi sp, t1, -16 |
| Force 16-byte stack alignment | implicit (top of a 64 KiB RAM) | yes | explicit andi sp, t1, -16 |
Set gp (global pointer) | no | yes | la gp, __global_pointer$ inside .option norelax |
| Pre-init trap vector | no | no | la t0, _pre_init_trap; csrw mtvec, t0 |
| Multi-hart guard | n/a | n/a | compares mhartid against _max_hart_id, aborts if higher; only the boot hart initialises RAM |
| Per-hart stacks | n/a | n/a | mul t0, mhartid, _hart_stack_size, subtract from _stack_start |
Copy .data | 6-instruction loop | memcpy | lw/sw loop |
Zero .bss | 4-instruction loop | memset | sw zero loop |
| Enable FPU | n/a (no F/D) | target-specific | sets mstatus.FS when riscvf/riscvd |
Run C++/constructor functions | no | __libc_init_array() | via #[pre_init] / Rust has none |
| Enter user code | call main | main(argc, argv, envp) | la t0, _start_rust; jr t0 |
Two entries are worth dwelling on because they are the ones a from-scratch project will eventually need.
gp and .option norelax. RISC-V’s ABI reserves x3 as a global pointer so that small globals can be reached with a single lw rd, offset(gp) instead of an auipc/lw pair. Setting it must be wrapped in .option push; .option norelax; ... .option pop, because otherwise the linker’s relaxation pass rewrites la gp, __global_pointer$ into a GP-relative load — which reads GP to compute GP. riscv-rt does exactly that. The example in this note skips gp entirely, which is correct-but-slower and one fewer thing to get wrong.
The multi-hart guard. On a multi-core system, every hart comes out of reset at the same vector and runs the same code. If all of them zero .bss simultaneously the result is merely wasteful; if all of them copy .data while one has already started using it, it is a race. riscv-rt reads mhartid, aborts harts above _max_hart_id, and gates RAM initialisation behind _mp_hook so only the boot hart does it — the others spin until released. A single-hart SoC does not need this, but adding a second core later means adding it.
The general lesson: crt0 is not magic and it is not large. It is the twenty or so instructions above, plus whatever the target’s ABI and runtime happen to require. Writing your own is a couple of hours; understanding that you can is what makes the rest of a bare-metal project tractable. See Bare-Metal Rust for the #![no_std]/#![no_main] side, and Linker Scripts and Memory Layout for where all these symbols come from.
Failure Modes
Boot failures are the hardest to debug because none of the usual instruments work yet: no UART, no printf, no debugger attach point. The list below is ordered by how early the failure occurs, because that is also roughly the order in which you should suspect them.
Nothing happens at all; the PC does not advance. Either the clock is not running, or reset is never released, or the bus never acknowledges. On an FPGA this is usually a PLL that has not locked or a reset that is asserted by a signal you forgot to deassert. In simulation, a fetch to an address no slave claims and no default slave terminates will hang forever — which is the argument for wiring cs_none to an error responder from day one.
The PC advances but every instruction is 0x00000000. That encoding is an illegal instruction, so the machine traps, and with mtvec unset it traps to somewhere arbitrary. Causes: the ROM was never initialised (a missing rom.hex, or $readmemh silently doing nothing), or the reset vector is outside the ROM region so the decoder never asserts cs_rom. Check mem[0] at time zero in the testbench.
Works in Verilator, dead on the FPGA. The classic cause is that the ROM’s initial contents reached simulation through $readmemh but did not reach the bitstream. Verify by reading the ROM back over the UART, or by adding a known magic word at a known offset and checking it. Related: a $readmemh path that is relative to the simulation working directory but absolute-or-missing at synthesis time.
A boot loop that looks like a reset problem. With ROM at 0x0000_0000 and mtvec unset (so effectively zero), any trap jumps to 0x0 — which is _start. The machine appears to reset repeatedly. The tell is that it is not a reset: mcause would have a trap code, not a reset code, if you could read it. This is the strongest practical argument for setting mtvec as instruction three and for putting the ROM somewhere other than zero.
main runs but every global is garbage. The .data copy loop is wrong or was skipped. Common variants: _sidata computed as ADDR(.text) + SIZEOF(.text) when an allocatable section (.eh_frame, .note.gnu.build-id) sneaked in between; or the loop’s bounds accidentally use _sidata for the termination test rather than _edata; or the linker script uses AT (…) with a stale expression instead of AT > ROM.
main runs but every global is stale rather than garbage. The copy ran and the values are the previous run’s. On an FPGA that has been reconfigured without a power cycle, RAM contents survive; if the copy loop’s length computed to zero (because _sdata == _edata due to a section-placement mistake) nothing is copied and the old values persist. Deceptively “working” until a value changes.
Corruption that appears only after the first function call. sp was never set, or was set to an address that is not in RAM, or is not 16-byte aligned. Misalignment is subtle: the ABI requires 16-byte alignment and most code tolerates 8, so a misaligned sp can work until the first function that needs a 16-byte-aligned spill slot.
A trap during .data/.bss setup goes nowhere. This is what riscv-rt’s pre-init trap vector exists to prevent. If the copy loop itself faults — a bad _sidata, a sw into an unmapped address — the trap is taken before the real handler is installed.
The ROM overflows and the linker tells you, if you let it. region 'ROM' overflowed by 28 bytes is a good outcome. It only happens if the linker script’s LENGTH is honest. If link.ld says 16K and the Verilog says WORDS = 1024 (4 KiB), the link succeeds and the top three quarters of the image are never fetchable.
Common misunderstandings
- “The reset vector is
0x0on RISC-V.” It is whatever you make it. QEMU’svirtuses0x1000, the FU540 uses0x1004, this project uses0x0. The specification defines none. - “The hardware zeroes the registers at reset.” It does not. “All other hart state is UNSPECIFIED.” Assuming
x1–x31are zero is a portability bug waiting for a different core. - “
.bssis zeroed by the loader.” There is no loader. On a hosted system the kernel maps anonymous zero pages; here the boot code writes zeros with aswloop, and if you delete that loop your statics are whatever the SRAM powered up with. - “
.datais in RAM, so it comes from RAM.” Its run address is in RAM; its initial value comes from ROM. Two addresses, one section. - “
crt0is part of the compiler.” It is an object file the linker adds by default and that-nostartfilesremoves. It is ordinary code and you can replace it. - “A boot ROM needs to be big.” This one is 148 bytes of code. Espressif’s is 384 KB because it has to handle secure boot, flash encryption, USB download modes and a partition scheme — features, not fundamentals.
A Real-World Contrast — the ESP32-C3 Mask ROM
The ESP32-C3 is the reference part the definitely-not-esp32 project measures itself against, and its boot path is the clearest illustration of what a from-scratch SoC is choosing not to build. Verified source: the ESP32-C3 Technical Reference Manual Version 1.4 (page 1 reads “ESP32-C3 / Technical Reference Manual Version 1.4”; 903 pages; matches the filename it was served under) and Espressif’s ESP-IDF Application Startup Flow guide.
One ROM versus three stages
flowchart TB subgraph SCRATCH["From-scratch SoC — one stage"] S1["reset: pc <- 0x0000_0000"] --> S2["boot ROM, 148 bytes<br/>sp · mtvec · .data · .bss"] S2 --> S3["main()"] end subgraph C3["ESP32-C3 — three stages"] R1["reset: pc <- inside the<br/>384 KB mask ROM"] --> R2["FIRST STAGE — mask ROM<br/>read GPIO_STRAP_REG<br/>check reset reason<br/>configure SPI flash from eFuse"] R2 -->|"deep-sleep wake +<br/>valid RTC_CNTL_STORE6/7"| RW["jump to the deep-sleep stub<br/>in RTC FAST memory"] R2 -->|"strapping pins request<br/>download mode"| RD["UART / USB-Serial-JTAG /<br/>SPI download loader, in ROM"] R2 -->|"SPI Boot"| R3["SECOND STAGE bootloader<br/>loaded from flash offset 0x0<br/>into IRAM and DRAM"] R3 --> R4["read partition table at 0x8000<br/>consult otadata, pick factory or OTA<br/>verify signature if Secure Boot"] R4 --> R5["APPLICATION<br/>copy IRAM/DRAM segments from flash<br/>map IROM/DROM through the cache"] R5 --> R6["call_start_cpu0 — port init<br/>initialize .data and .bss<br/>configure MMU cache, clocks, PMS"] R6 --> R7["start_cpu0 — heap, libc, syscalls<br/>C++ constructors"] R7 --> R8["FreeRTOS scheduler starts<br/>main_task runs app_main"] end
Two boot paths at the same scale. What it shows: the whole from-scratch boot is one box wide; the C3’s is eleven, with three separate decision points before any user code exists. The insight to take: every extra box exists to solve a problem a from-scratch SoC does not have — code that lives in external flash rather than on-die ROM, a field-updatable application, cryptographic verification, multiple recovery paths for a chip you cannot physically reach. The single-ROM design is not primitive; it is what “the program is already in the chip” buys you.
What the mask ROM actually does first
ESP-IDF’s documentation is direct about the immutability: “After SoC reset, the CPU will start running immediately to perform initialization. The reset vector code is located in the mask ROM of the ESP32-C3 chip and cannot be modified. Startup code called from the reset vector determines the boot mode by checking GPIO_STRAP_REG register for bootstrap pin states” (ESP-IDF, Application Startup Flow).
The dispatch depends on the reset reason:
- Reset from deep sleep — “if the value in
RTC_CNTL_STORE6_REGis non-zero, and CRC value of RTC memory inRTC_CNTL_STORE7_REGis valid, useRTC_CNTL_STORE6_REGas an entry point address and jump immediately to it.” That is a software-programmable secondary reset vector held in retained RTC memory, which is a genuinely elegant trick: the chip resets, but a stub of your choosing runs first. If the register is zero or the CRC fails, boot proceeds as a power-on. - Power-on, software SoC reset, watchdog SoC reset — check the strapping pins for a download mode; if requested, “this custom loader mode is executed from ROM.”
- Software CPU reset, watchdog CPU reset — “configure SPI flash based on eFUSE values, and attempt to load the code from flash.”
The TRM’s boot-control chapter enumerates the SPI boot variants (TRM v1.4, Chapter 7):
Normal Flash Boot: supports Security Boot. The ROM bootloader loads the program from flash into SRAM and executes it. In most practical scenarios, this program is the 2nd stage bootloader, which later boots the target application. Direct Boot: does not support Security Boot and programs run directly from flash. To enable this mode, make sure that the first two words of the bin file downloaded to flash (address:
0x42000000) are0xaedb041d.
Direct Boot is the closest the C3 gets to a from-scratch SoC’s model — no second stage, run straight out of the flash window — and it is gated by a magic word rather than a signature, which is why it excludes Secure Boot. Several eFuses (EFUSE_DIS_FORCE_DOWNLOAD, EFUSE_DIS_DOWNLOAD_MODE, EFUSE_ENABLE_SECURITY_DOWNLOAD, EFUSE_DIS_DIRECT_BOOT) permanently disable individual paths, which is the production-security half of the design.
The mask ROM also prints. “ROM code is always printed to UART0 during boot”, controllable by EFUSE_UART_PRINT_CONTROL and GPIO8 (TRM v1.4, Table 7.3-1) — the familiar rst:0x1 (POWERON),boot:0xc banner. That is worth stealing directly: the first thing your boot ROM should do once the UART works is print one line saying it is alive and why it reset.
The comparison, honestly
| Dimension | From-scratch SoC | ESP32-C3 |
|---|---|---|
| Reset vector | 0x0000_0000, chosen by you in RTL | inside the mask ROM, fixed by Espressif |
| Boot ROM size | 148 bytes | 384 KB mask ROM (Internal ROM 0 + ROM 1) |
| Mutable? | rebuild the bitstream | never, on shipped silicon |
| Stages before user code | 1 | 3 (mask ROM → 2nd-stage bootloader → app) |
| Where the app lives | on-die ROM | external SPI flash, up to 16 MB, mapped in 64 KB blocks through a cache |
| Boot-source selection | none — there is one ROM | GPIO strapping pins + eFuses + reset reason |
| Secure boot / flash encryption | none | signature verification and XTS-AES, eFuse-locked |
| Field update | reflash the FPGA | OTA partitions, otadata, rollback |
.data / .bss init | your 10-instruction loop in _start | call_start_cpu0 “Initialize internal memory (data & bss)” |
| Time to first user instruction | a few clock cycles | milliseconds |
| Debuggability of the first stage | total — it is your source | none — it is a binary you cannot read or patch |
The last row is the one that matters for a learning project. On the C3 the first several hundred milliseconds of every boot are somebody else’s code, and when it goes wrong you get a four-character abbreviation on a UART. On a from-scratch SoC every instruction between reset and main is yours, visible in a waveform, and 148 bytes long. That is the actual product of Stage 5 — not the hello, but the fact that nothing between power-on and the hello is opaque.
See Also
- The SoC Memory Map — the sibling note: where ROM, RAM and MMIO live, how the decoder finds them, and the three-places contract this boot code depends on
- Linker Scripts and Memory Layout — the script language behind
_sidata,_sdata,_ebssandAT > ROM - Bare-Metal Rust —
#![no_std],#![no_main], the panic handler, andriscv-rtas the Rustcrt0 - Control and Status Registers —
mtvec,mepc,mcause,mstatus, and what reset does and does not do to them - RISC-V Trap Handling — what happens after
mtvecsends the PC somewhere - Zicsr Extension — why
-march=rv32imc_zicsris required to assemblecsrw - System on a Chip — the wider integration context
- Wishbone Bus — the fabric the reset fetch travels over
- Universal Asynchronous Receiver-Transmitter — the peripheral that turns a successful boot into visible evidence
- Verilator — the simulator that produced the fetch trace
- Field-Programmable Gate Array — block RAM, bitstream initialisation, and why a “ROM” is a BRAM with no write port
- Tang Nano 20K — the FPGA this ROM is destined for
- The RISC-V Cross-Compilation Toolchain —
gcc,objcopy,objdump,readelf, and the-march/-mabipairing - RV32IMC — and specifically why
Cis what makes a 148-byte boot ROM 148 bytes - ESP32-C3 — the reference part whose mask ROM and multi-stage boot are contrasted above
- Linux Boot and Init MOC — the contrast at the other end of the scale: firmware, bootloader, kernel, init
- Boot Loaders Beyond GRUB — what a boot loader becomes when it has to find an arbitrary OS on arbitrary media
- Computer Architecture MOC — the concept hub
- definitely-not-esp32 MOC — the project hub; this note is a Stage 5 rung