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 pc is 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, an objcopy, and either $readmemh in 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 sp is valid and will spill to it immediately), zero .bss (the C and Rust abstract machines both promise zero-initialised statics), copy .data from its load address in ROM to its run address in RAM (initialised globals cannot live in read-only memory), and point mtvec somewhere sane so that the first bug produces a diagnosable trap rather than an infinite loop of traps. That is precisely the job of crt0 in a hosted C toolchain and of riscv-rt in 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:

  • sp is undefined, so the first thing _start must do is load it. Any compiled function may spill registers to the stack in its prologue; calling main before sp is valid corrupts whatever address the garbage pointed at.
  • mtvec is undefined, so any trap taken before it is set jumps to an arbitrary address. On a system whose ROM is at 0x0000_0000, an unset mtvec of 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 mstatus fields MIE and MPRV are reset to 0. If little-endian memory accesses are supported, the mstatus/mstatush field MBE is reset to 0. The misa register is reset to enable the maximal set of supported extensions, as described in misa. For implementations with the “A” standard extension, there is no valid load reservation. The pc is set to an implementation-defined reset vector. The mcause register 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, the hgatp.MODE and vsatp.MODE fields are reset to 0. If the Smrnmi extension is implemented, the mnstatus.NMIE field is reset to 0. No WARL field contains an illegal value. If the Zicfilp extension is implemented, the mseccfg.MLPE field is reset to 0. All other hart state is UNSPECIFIED.

QuestionAnswerSource
What address does the PC start at?Implementation-defined; you choose it in RTLPriv 20250508 §Reset
Where should the vector live?“given in a platform specification”Priv 20250508 §Machine-Level CSRs
What privilege mode?M-mode, alwaysPriv 20250508 §Reset
Are interrupts enabled?No — mstatus.MIE = 0Priv 20250508 §Reset
Is mtvec initialised?No — not listed, therefore UNSPECIFIEDPriv 20250508 §Reset
Are x1x31 initialised?No — UNSPECIFIEDPriv 20250508 §Reset
Is there a reset cause?Yes, in mcause — implementation-specific valuesPriv 20250508 §Reset
Multiple reset sources?Allowed and expectedPriv 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:

SystemReset vectorRationale
This project’s SoC0x0000_0000ROM at the bottom of the map; small addresses assemble to single li instructions
SiFive FU540-C0000x0000_1004a six-instruction “gate ROM” that reads boot-mode pins and dispatches — see The SoC Memory Map
ESP32-C3inside the 384 KB mask ROMfixed by Espressif; “The reset vector code is located in the mask ROM of the ESP32-C3 chip and cannot be modified”
QEMU virt machine0x0000_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:

  1. Zero is convenient but hides null-pointer bugs. With ROM at zero, *(int*)0 returns an instruction word instead of trapping. Putting the ROM at 0x0000_1000 and leaving the first 4 KiB unmapped costs one comparison in the decoder and turns null dereferences into load access faults.
  2. 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 .text at 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 $readmemh in an initial block, 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

StepMust come beforeBecause
Set spany callfunction prologues spill to the stack
Set mtvecanything that can trapan unset mtvec sends a trap to an arbitrary address
Copy .datamaininitialised globals are garbage until copied
Zero .bssmainthe language guarantees zero-initialised statics
call maineverything 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:

SectionNeeds to be writable?Has a non-zero initial value?Occupies ROM?Boot action
.textnoyes (the instructions)yesnothing — execute in place
.rodatanoyesyesnothing — read in place
.datayesyesyes (the initial image)copy ROM → RAM
.bssyesno (all zero)nozero in RAM
stack / heapyesnononothing (or set sp)

The rule falls straight out of the two independent questions in columns 2 and 3:

  • .text is 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 asserts cs_rom for a fetch exactly as it does for a load. Copying .text to 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.)
  • .data must 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.
  • .bss is 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 is NOBITS) 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.

MODENameBehaviour
0Direct“All traps set pc to BASE.”
1Vectored“Asynchronous interrupts set pc to BASE+4×cause.” Synchronous exceptions still go to BASE
≥2Reserved

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, t0

trap_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 mtvec implementation does when written a reserved MODE value of 0b10. Reason: mtvec is WARL, so the specification permits any legal value to be substituted, and the RTL in this note does not yet implement mtvec at 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, write 0x5e, read mtvec back 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 _zicsr is 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 own crt0.o. This is what makes start.S the entry point rather than a routine some other startup code calls.
  • -ffreestanding — tells GCC there is no hosted environment, so it must not assume main has its usual meaning or that it may synthesise calls to memcpy/memset from 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 .text and .data’s load image, pushing _sidata from 0x94 to 0xb8. 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:

OffsetBytesWordInstruction
0x0017 01 01 200x20010117auipc sp,0x20010
0x0413 01 01 000x00010113mv sp,sp
0x0897 02 00 000x00000297auipc t0,0x0
0x0c93 82 62 050x05628293addi t0,t0,86
0x1073 90 52 300x30529073csrw 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, 0x09400293auipc 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();
#endif

with 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 initialized

That 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.

JobThis note’s start.Spicolibc crt0riscv-rt
Set spla sp, _stack_toplinker-provided, in machine crt0.cla t1, _stack_start then andi sp, t1, -16
Force 16-byte stack alignmentimplicit (top of a 64 KiB RAM)yesexplicit andi sp, t1, -16
Set gp (global pointer)noyesla gp, __global_pointer$ inside .option norelax
Pre-init trap vectornonola t0, _pre_init_trap; csrw mtvec, t0
Multi-hart guardn/an/acompares mhartid against _max_hart_id, aborts if higher; only the boot hart initialises RAM
Per-hart stacksn/an/amul t0, mhartid, _hart_stack_size, subtract from _stack_start
Copy .data6-instruction loopmemcpylw/sw loop
Zero .bss4-instruction loopmemsetsw zero loop
Enable FPUn/a (no F/D)target-specificsets mstatus.FS when riscvf/riscvd
Run C++/constructor functionsno__libc_init_array()via #[pre_init] / Rust has none
Enter user codecall mainmain(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 0x0 on RISC-V.” It is whatever you make it. QEMU’s virt uses 0x1000, the FU540 uses 0x1004, this project uses 0x0. The specification defines none.
  • “The hardware zeroes the registers at reset.” It does not. “All other hart state is UNSPECIFIED.” Assuming x1x31 are zero is a portability bug waiting for a different core.
  • .bss is 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 a sw loop, and if you delete that loop your statics are whatever the SRAM powered up with.
  • .data is in RAM, so it comes from RAM.” Its run address is in RAM; its initial value comes from ROM. Two addresses, one section.
  • crt0 is part of the compiler.” It is an object file the linker adds by default and that -nostartfiles removes. 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_REG is non-zero, and CRC value of RTC memory in RTC_CNTL_STORE7_REG is valid, use RTC_CNTL_STORE6_REG as 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) are 0xaedb041d.

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

DimensionFrom-scratch SoCESP32-C3
Reset vector0x0000_0000, chosen by you in RTLinside the mask ROM, fixed by Espressif
Boot ROM size148 bytes384 KB mask ROM (Internal ROM 0 + ROM 1)
Mutable?rebuild the bitstreamnever, on shipped silicon
Stages before user code13 (mask ROM → 2nd-stage bootloader → app)
Where the app liveson-die ROMexternal SPI flash, up to 16 MB, mapped in 64 KB blocks through a cache
Boot-source selectionnone — there is one ROMGPIO strapping pins + eFuses + reset reason
Secure boot / flash encryptionnonesignature verification and XTS-AES, eFuse-locked
Field updatereflash the FPGAOTA partitions, otadata, rollback
.data / .bss inityour 10-instruction loop in _startcall_start_cpu0 “Initialize internal memory (data & bss)”
Time to first user instructiona few clock cyclesmilliseconds
Debuggability of the first stagetotal — it is your sourcenone — 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