Linker Scripts and Memory Layout
A linker script is the file that tells the linker where in the address space each piece of your program goes. On a hosted system you never write one: the operating system’s loader reads the ELF program headers and maps the file into a fresh virtual address space, so the linker’s built-in default script is always right. On a bare-metal target there is no loader at all — the CPU comes out of reset and fetches an instruction from a fixed physical address, and whatever bytes are sitting in the ROM at that address are your program. The script is the only thing standing between “I compiled some code” and “the code is at the address the hardware will look at.”
The GNU linker’s script language has two commands that do almost all of the work.
MEMORYdeclares the physical regions that exist — “there is 64 KiB of ROM at0x20000000and 64 KiB of RAM at0x80000000” — andSECTIONSsays which output section goes into which region (GNUld2.46 manual, §3.6–§3.7). The single idea that makes the whole topic click is that every allocatable section has two addresses: a VMA (virtual memory address), where it will live when the program runs, and an LMA (load memory address), where its bytes are stored in the image. For.textthese are the same. For.data— initialized global variables — they are deliberately different: the initial values must be stored in non-volatile ROM but used from writable RAM, so the LMA is in ROM, the VMA is in RAM, and your startup code copies between them. Theldmanual states this exact use case: “An example of when they might be different is when a data section is loaded into ROM, and then copied into RAM when the program starts up (this technique is often used to initialize global variables in a ROM based system)” (§3.1).This note is about the static linker script for a target with no operating system. It is deliberately distinct from The Dynamic Linker, which is about glibc’s
ld.soresolving symbols at run time for a process that already has an address space, and from The Go Linker. Here nothing happens at run time that you did not write yourself. Everything below is verified against a binary actually built on this machine withrustc 1.98.0for theriscv32imc-unknown-none-elftarget and cross-checked with GNUld2.46; the section addresses quoted are realreadelfoutput, not illustrations.
Mental Model — The Script Is a Placement Contract
The linker takes a pile of input sections — the compiler emits one per function and per variable when you build with -ffunction-sections -fdata-sections, which Rust does by default — and produces a much smaller number of output sections in a single output file. A linker script is a set of rules for that grouping, plus an address for each group.
It helps to see it as a contract with three signatories, all of which must agree on the same numbers. The hardware fixes where ROM and RAM are decoded (see The SoC Memory Map) and where the CPU fetches its first instruction (see Boot ROM and the Reset Vector). The linker script declares those same numbers to the toolchain. The startup code consumes symbols the script exports in order to finish what the script could not do statically — copying .data and zeroing .bss. If any of the three disagrees, nothing warns you: the link succeeds, the image is produced, and the board is silent.
flowchart LR subgraph IN["Inputs to the link step"] OBJ["object files<br/>.o / .rlib<br/>many small input sections"] LIBS["archives<br/>libcompiler_builtins.rlib"] SCR["linker script<br/>link.x<br/>MEMORY + SECTIONS"] end LD{{"linker<br/>rust-lld / GNU ld 2.46"}} subgraph OUT["Outputs"] ELF["ELF executable<br/>sections with VMA and LMA<br/>+ symbol table"] MAP["link map<br/>-Map=dne32.map<br/>who put what where"] end BIN["raw ROM image<br/>objcopy -O binary<br/>324 bytes"] OBJ --> LD LIBS --> LD SCR --> LD LD --> ELF LD --> MAP ELF -->|"objcopy strips everything<br/>that is not loadable"| BIN BIN -->|"burned into / preloaded as<br/>the boot ROM contents"| HW["the SoC's ROM<br/>at 0x20000000"]
The link step, drawn. What it shows: the script is a third input alongside the object files, not a post-processing step — and there are two useful outputs, the ELF and the map file, plus a third artifact (objcopy -O binary) that is what actually reaches hardware. The insight to take: the ELF is for your tools; the raw binary is for the machine. Because objcopy -O binary walks sections in LMA order and emits only loadable ones, the raw image is the clearest possible demonstration that LMA is a real, physical thing: the .data initializers appear in it, at the end of ROM, even though .data’s VMA is in RAM.
What the Linker Actually Does to Sections
Before the script language makes sense, the vocabulary has to. The ld manual defines it precisely (§3.1 Basic Linker Script Concepts): every object file holds a list of sections, each with a name and a size; most also have contents, a block of bytes. A section may be loadable (its contents should be placed in memory) or merely allocatable (memory is set aside but nothing is loaded there — this is .bss). Sections that are neither, such as .debug_info, hold information for tools and never occupy target memory.
The compiler’s naming convention is what makes the script’s wildcards work. A function foo compiled with function sections lands in .text.foo; a static array lands in .bss.NAME or .data.NAME. The script’s job is to write patterns that sweep these up: *(.text .text.*) means “from every input file (*), take the section literally named .text and every section whose name starts with .text.”. The four groups that matter on a bare-metal target are:
| Section | Contents | Has bytes in the image? | Where it must live at run time |
|---|---|---|---|
.text | executable code | yes | ROM (execute in place) |
.rodata | string literals, const data, jump tables | yes | ROM |
.data | globals with a non-zero initializer | yes — the initial values | RAM (writable) |
.bss | globals initialized to zero | no | RAM (writable) |
.bss is the interesting one. “Block Started by Symbol” is a fossil of a 1950s assembler, but the modern rationale is pure economy: a 1 KiB array of zeros would cost 1 KiB of ROM if it were stored, and the ROM is the scarce resource. So .bss is marked SHT_NOBITS in ELF — it has a size and an address but no file contents — and the startup code writes the zeros at boot. In the binary built for this note, .bss is 0x400 bytes (a [u32; 256]) and contributes exactly zero bytes to the 324-byte ROM image.
RISC-V adds two more that catch people out: .sdata and .sbss, the “small data” sections addressed by a 12-bit offset from the gp register, and .srodata, small read-only data. A script that sweeps .data and .bss but forgets .sdata/.sbss/.srodata will silently leave them as orphan sections — more on that, and on how to catch it, in Failure Modes.
flowchart TB subgraph INPUT["input sections, from the compiler"] A[".text._start"] B[".text.rust_start"] C[".text.main"] D[".rodata..Lanon.BANNER"] E[".data.TICKS"] F[".bss.SCRATCH"] G[".debug_info"] end subgraph OUTPUT["output sections, per the script"] T[".text<br/>0xac bytes<br/>VMA=LMA=0x20000000"] R[".rodata<br/>0x0c bytes<br/>VMA=LMA=0x200000ac"] DA[".data<br/>0x04 bytes<br/>VMA 0x80000000<br/>LMA 0x200000b8"] BS[".bss<br/>0x400 bytes<br/>VMA 0x80000004<br/>NOBITS"] DBG[".debug_info<br/>addr 0<br/>not allocatable"] end A -->|"KEEP(*(.text.init))"| T B -->|"*(.text .text.*)"| T C -->|"*(.text .text.*)"| T D -->|"*(.rodata .rodata.*)"| R E -->|"*(.data .data.*)"| DA F -->|"*(.bss .bss.*)"| BS G -.->|"no rule — orphan,<br/>placed at address 0"| DBG
Input sections funnelled into output sections, with the real sizes from this note’s build. What it shows: the script’s wildcards are a routing table, and every input section takes exactly one route. The insight to take: look at the dotted arrow. .debug_info matched no rule, so the linker created an output section for it anyway — that is the orphan placement rule, and it is why an incomplete script does not fail loudly. Debug sections landing at address 0 is harmless; a .srodata landing in the middle of your ROM is not.
MEMORY — Declaring the Regions That Physically Exist
By default the linker assumes it may allocate anywhere in the address space. MEMORY overrides that: it “describes the location and size of blocks of memory in the target… The linker will set section addresses based on the memory regions, and will warn about regions that become too full. The linker will not shuffle sections around to fit into the available regions” (§3.7). That last sentence is the design philosophy in one line — MEMORY is a declaration of physical fact, not an optimizer’s hint.
The syntax is name [(attr)] : ORIGIN = origin, LENGTH = len. Here is the block from the script built for this note, matching a plausible definitely-not-esp32 map:
MEMORY
{
ROM (rx) : ORIGIN = 0x20000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 64K
}Line by line. ROM and RAM are names used only inside the script — the manual is explicit that “the region name has no meaning outside of the linker script” and lives in its own namespace, so it cannot collide with a symbol or a section name. ORIGIN must evaluate to a constant with no symbols in it; it may be abbreviated org or o, and LENGTH may be len or l. The parenthesized attribute string is the subtle part: it does not enforce permissions on the region. It is a filter used to place unmapped input sections — the orphans. The manual lists the letters: R read-only, W read/write, X executable, A allocatable, I/L initialized, and ! to invert the sense of everything after it. So RW!X matches any unmapped section that is readable or writable but not executable. Writing (rx) on ROM therefore means “if you find a homeless read-only or executable section, put it here” — it does not mean the linker will stop you writing a .data section into ROM if you ask it to explicitly.
Two derived expressions are worth knowing because startup code needs them. ORIGIN(RAM) and LENGTH(RAM) are usable in expressions, which is how a stack pointer gets defined without hard-coding a second copy of the map:
_stack_top = ORIGIN(RAM) + LENGTH(RAM); /* 0x80000000 + 0x10000 = 0x80010000 */In the built binary, nm reports 80010000 A _stack_top — the A meaning absolute, a symbol with a value but no section. The arithmetic was done by the linker, and the only place the number 64 K appears is the MEMORY block. That is the property to aim for: one statement of each fact.
| Region | Origin | Length | Attributes | What lands here |
|---|---|---|---|---|
ROM | 0x20000000 | 64 KiB | rx | .text, .rodata, and the LMA of .data |
RAM | 0x80000000 | 64 KiB | rwx | VMA of .data, .bss, the stack |
The declared regions. What it shows: two regions, four consumers. The insight to take: ROM appears in the fourth column twice over — it holds .text and .rodata outright, and it also holds a copy of .data’s initial values that will never be executed from where they sit. The ROM budget is .text + .rodata + sizeof(.data); the RAM budget is sizeof(.data) + sizeof(.bss) + stack. .data costs you space in both.
SECTIONS, the Location Counter, and Alignment
SECTIONS is the body of the script: an ordered list of output-section descriptions, each naming the input sections it absorbs and, optionally, an address and a region.
SECTIONS
{
.text ORIGIN(ROM) :
{
KEEP(*(.text.init))
*(.text .text.*)
. = ALIGN(4);
} > ROM
...
}Reading it as the linker does: .text is the output section’s name; ORIGIN(ROM) is its address, given explicitly here so the reset vector is unambiguous; the colon is required syntax; the braces hold the input-section descriptions; and > ROM assigns the section to the ROM region.
The dot — . — is the location counter, “a special linker variable [that] always contains the current output location counter” (§3.10.5). It advances as input sections are placed. Assigning to it moves it, creating a hole; the manual warns it “may not be moved backwards inside an output section.” There is a genuinely confusing subtlety, stated outright in the manual: “. actually refers to the byte offset from the start of the current containing object. Normally this is the SECTIONS statement, whose start address is 0, hence . can be used as an absolute address. If . is used inside a section description however, it refers to the byte offset from the start of that section, not an absolute address.” In practice this bites when you assign a symbol from . inside braces versus outside them.
ALIGN(n) rounds the location counter up to a multiple of n. On RV32 this matters for three separate reasons, all real:
- Instruction fetch. RISC-V requires instructions to be 2-byte aligned when the
C(compressed) extension is present and 4-byte aligned otherwise. A trap vector table read bymtvecin vectored mode must be 4-byte aligned —riscv-rtenforces exactly this withASSERT(_pre_init_trap % 4 == 0, ...)in its own script. - The copy loop. Startup code copies
.dataa word at a time. If_sdata,_edata, or the LMA are not 4-byte aligned, the loop either faults on a misaligned store or silently copies the wrong number of bytes.riscv-rt’s script says this in a comment — “it’s important for correctness that the VMA boundaries of both.bssand.dataand the LMA of.dataare all${ARCH_WIDTH}-byte aligned. These alignments are assumed by the RAM initialization routine” — and then backs it withASSERT(__sidata % ${ARCH_WIDTH} == 0, "BUG(riscv-rt): the LMA of .data is not ...-byte aligned")(link.x.in, riscv-rt 0.18.0). - Tooling. The same comment notes that unaligned boundaries produce “Address (..) is out of bounds” noise in
objdumpdisassembly.
The pattern . = ALIGN(4); placed just before the closing brace of each section is therefore not decoration — it is what makes the next section’s start, and hence the previous section’s end symbol, land on a word boundary.
Because the location counter is easier to see than to describe, here is one output section being built, with . shown at each step. This is an ASCII box rather than a mermaid diagram because what matters is the linear byte offsets and where the padding lands — a graph would obscure exactly the thing being explained.
ROM region, starting at ORIGIN(ROM) = 0x20000000
. statement being evaluated bytes emitted
─────────── ───────────────────────────────────────── ───────────────
0x20000000 .text ORIGIN(ROM) : { (section opens)
0x20000000 KEEP(*(.text.init)) _start, 22 B ├─ 0x20000000
0x20000016 *(.text .text.*) main, 72 B ├─ 0x20000016
0x2000005e rust_start,78B ├─ 0x2000005e
0x200000ac . = ALIGN(4); already aligned│ (0 bytes pad)
0x200000ac } > ROM └─ .text ends
0x200000ac .rodata : {
0x200000ac *(.rodata .rodata.*) "dne32 up\0" 9B├─ 0x200000ac
0x200000b5 . = ALIGN(4); pad to 0xb8 │ ███ 3 bytes
0x200000b8 } > ROM └─ .rodata ends
0x80000000 .data : ALIGN(4) { ← VMA jumps to RAM;
LMA stays at 0x200000b8
The location counter walking one region. What it shows: . is a single running cursor, advanced by each input section and by explicit ALIGN, and it is what every symbol assignment reads. The insight to take: look at the last two lines. When .data opens, . jumps from 0x200000b8 to 0x80000000 — because the location counter tracks the VMA, and > RAM changed which region the VMA comes from. The LMA cursor kept going in ROM, unbroken, which is exactly why LOADADDR(.data) returns 0x200000b8 and not 0x80000000. Two cursors, one dot.
ASSERT(condition, "message") deserves a mention on its own. It is the only mechanism the script language has for failing a build on a violated invariant, and riscv-rt uses eleven of them. Adding two or three to a hand-written script — “the reset vector is 4-byte aligned”, “the LMA of .data is word-aligned” — converts a class of silent runtime failures into link errors.
LMA vs VMA — The Distinction the Whole Topic Turns On
This is the concept the topic exists for, and the one that is worth reading twice.
Every loadable or allocatable output section carries two addresses. The VMA is “the address the section will have when the output file is run.” The LMA is “the address at which the section will be loaded.” The manual adds: “In most cases the two addresses will be the same” (§3.1). On a hosted system they are effectively always the same, which is why the distinction is invisible to most programmers for their entire career.
On a ROM-based system it cannot be the same, for a reason that is physical rather than conceptual. Consider a single Rust global:
static mut TICKS: u32 = 0xDEAD_BEEF;At run time this variable must be writable, so it must live in RAM. But RAM is volatile — at power-on it holds garbage, or on an FPGA’s block RAM, zeros. Nobody put 0xDEADBEEF there. The value has to be stored somewhere that survives power-off, which is the ROM. So the bytes EF BE AD DE are placed in the ROM image, and the variable’s address, as far as every instruction that touches it is concerned, is in RAM. The gap between those two facts is closed by four instructions of startup code that copy from one to the other.
The linker expresses this with AT (or AT>), and here is the section from the script used for this note:
.data : ALIGN(4)
{
_sdata = .;
*(.data .data.*)
*(.sdata .sdata.*)
. = ALIGN(4);
_edata = .;
} > RAM AT> ROM
_sidata = LOADADDR(.data);> RAM sets the VMA: allocate this section out of the RAM region. AT> ROM sets the LMA: store its bytes in the next free address of the ROM region. The manual describes AT> precisely: it “takes the name of a memory region as an argument. The load address of the section is set to the next free address in the region, aligned to the section’s alignment requirements” (§3.6.8.2). LOADADDR(.data) then hands you that LMA as a symbol the startup code can read.
Here is what the real build produced. objdump -h is the tool that prints both addresses side by side:
Idx Name Size VMA LMA File off Algn
0 .text 000000ac 20000000 20000000 00001000 2**1
1 .rodata 0000000c 200000ac 200000ac 000010ac 2**0
2 .data 00000004 80000000 200000b8 00002000 2**2
3 .bss 00000400 80000004 80000004 00002004 2**2
.data has VMA 0x80000000 and LMA 0x200000b8. That LMA is exactly where .rodata ended — 0x200000ac + 0xc = 0x200000b8 — which is AT> ROM doing “next free address in the region.” The same fact appears in the ELF program headers, where readelf -l prints VMA as VirtAddr and LMA as PhysAddr:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x002000 0x80000000 0x200000b8 0x00004 0x00004 RW 0x1000
And, most concretely, dumping the raw ROM image shows the initializer physically present in ROM:
$ riscv64-linux-gnu-objcopy -O binary dne32 rom.bin
$ xxd -s 0x138 rom.bin
00000138: 0a00 0000 efbe adde
0a 00 00 00 is the tail of the "dne32 up\n" string plus its alignment padding; ef be ad de is 0xDEADBEEF little-endian — TICKS’s initial value, sitting in ROM, 0x140 bytes from the start of the image, while the symbol TICKS itself resolves to 0x80000000.
flowchart TB subgraph ROMV["ROM — 0x20000000, non-volatile, what objcopy writes"] RT[".text<br/>0x20000000 .. 0x200000ab<br/>executed in place"] RR[".rodata<br/>0x200000ac .. 0x200000b7<br/>read in place"] RD["<b>.data initialisers</b><br/>0x200000b8 .. 0x200000bb<br/>ef be ad de<br/><i>LMA of .data</i>"] end subgraph RAMV["RAM — 0x80000000, volatile, garbage at power-on"] AD["<b>.data</b><br/>0x80000000 .. 0x80000003<br/><i>VMA of .data</i>"] AB[".bss<br/>0x80000004 .. 0x80000403<br/>NOBITS — no ROM cost"] GAP["free RAM"] ST["stack<br/>grows down from<br/>_stack_top = 0x80010000"] end RD ==>|"<b>startup copies</b><br/>_sidata → _sdata.._edata<br/>4 bytes, one lw/sw pair"| AD AB -.->|"<b>startup zeroes</b><br/>_sbss .. _ebss<br/>0x400 bytes"| AB RT -.->|"no copy — code runs<br/>from ROM directly"| RT
The LMA-versus-VMA picture. What it shows: .data appears twice — once in ROM as stored bytes at its LMA, once in RAM as a live variable at its VMA — with the boot-time copy as the thick arrow between them. .text needs no arrow because the CPU fetches instructions straight out of ROM. .bss needs no ROM box at all. The insight to take: the copy arrow is the only reason initialized globals work, and you have to write it. Nothing in the linker, the compiler, or the hardware performs it. Delete those four instructions and every static mut X: u32 = 0xDEADBEEF reads as zero — verified below, in a simulator, where removing the loop turned a value of 0xdeadbef0 into 0x00000001.
Why
readelf -Salone will mislead youThe
Addrcolumn ofreadelf -Sis the VMA only. Run it on this binary and.datalooks like a perfectly ordinary section at0x80000000with nothing unusual about it. The LMA is visible inreadelf -l(asPhysAddr), inobjdump -h(as a separateLMAcolumn), and in the linker map. If you are debugging a “my global is zero” bug,readelf -Sis the one tool that will not show you the problem.
Region Assignment — >ROM, >RAM AT>ROM
Region assignment is two independent decisions that happen to share a line of syntax, and separating them is what makes the notation stop being cryptic.
Written after } | Sets | Meaning |
|---|---|---|
> ROM | VMA | allocate this section’s run-time address from ROM |
AT> ROM | LMA | store this section’s bytes at the next free address in ROM |
> RAM AT> ROM | both | runs from RAM, is stored in ROM |
AT ( expr ) | LMA | store at this exact computed address, not “next free in a region” |
| (nothing) | both | falls back to the heuristic below |
The four forms. What it shows: > and AT> answer different questions — “where does it run?” and “where is it kept?” — and .data is the only common section that needs different answers. The insight to take: > RAM AT> ROM is not one operator with a modifier; it is two assignments juxtaposed. Reading it as “VMA from RAM, LMA from ROM” makes every other form obvious.
When neither AT nor AT> is given, the linker applies a documented heuristic (§3.6.8.2): if the section has an explicit VMA, that becomes the LMA; if it is not allocatable, LMA equals VMA; otherwise, if a compatible region can be found that already contains a section, the LMA is set so that the VMA-to-LMA difference matches the previous section in that region; failing all that, LMA equals VMA. That third rule is why a naive script sometimes appears to work: a .data placed immediately after a .text that had no AT will inherit a zero difference and end up with LMA = VMA in RAM — which produces an ELF that loads correctly under a debugger (which writes RAM directly) and fails completely from ROM. Debugger-loads-fine-but-cold-boot-fails is the classic symptom, and this heuristic is the cause.
There is one more measured difference worth recording, because it is the kind of thing that makes a script behave differently under two toolchains that both claim to implement the same language. The same script, linked by GNU ld 2.46 via the C cross-compiler, gave .bss an LMA of 0x200000bc — inside ROM — while rust-lld left .bss’s LMA equal to its VMA at 0x80000004. .bss is NOBITS, so no bytes are emitted either way and the difference is cosmetic; but if you ever write a tool that computes ROM usage from LMA extents, the two linkers will give you different answers. Likewise _stack_top came out as A (absolute) under rust-lld and T (text) under GNU ld. Both are correct; neither is what you would predict.
The Symbols the Script Exports, and How Startup Code Consumes Them
A linker script can define symbols. This is the mechanism by which the script’s static knowledge — where did you actually put things? — is handed to code that runs at boot. Five or six symbols do the whole job:
| Symbol | Defined as | Consumed by | Purpose |
|---|---|---|---|
_sdata | . at the start of .data | .data copy loop | first word of .data in RAM |
_edata | . at the end of .data | .data copy loop | one past the last word |
_sidata | LOADADDR(.data) | .data copy loop | first word of the initializers in ROM |
_sbss | . at the start of .bss | .bss zero loop | first word to zero |
_ebss | . at the end of .bss | .bss zero loop | one past the last word |
_stack_top | ORIGIN(RAM) + LENGTH(RAM) | reset stub | initial sp |
_end | . after .bss | heap allocator, if any | first free RAM address |
The startup contract. What it shows: three loops’ worth of bounds plus one stack pointer. The insight to take: every one of these is an address, not a value. In C you write &_sdata; in Rust you write &raw mut _sdata. Reading _sdata as if it were a variable gives you whatever four bytes happen to be at the start of .data — a bug that compiles cleanly and is genuinely hard to see.
The GNU manual makes the same point in its own worked example, and its idiom is worth quoting because it is thirty years old and still exactly right:
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;Every &. The manual introduces this by noting that the location counter “holds the VMA value, not the LMA value” — which is precisely why a separate LOADADDR(.data) symbol is needed at all.
Here is the corresponding Rust, from the crate built for this note, together with the values nm reported for those symbols in the resulting binary:
unsafe extern "C" {
static mut _sbss: u32; // nm: 80000004 B _sbss
static mut _ebss: u32; // nm: 80000404 B _ebss
static mut _sdata: u32; // nm: 80000000 D _sdata
static mut _edata: u32; // nm: 80000004 D _edata
static _sidata: u32; // nm: 200000b8 A _sidata
static _stack_top: u32; // nm: 80010000 A _stack_top
}
// Zero .bss.
let mut p = &raw mut _sbss;
let e = &raw mut _ebss;
while p < e { write_volatile(p, 0); p = p.add(1); }
// Copy .data from its load address in ROM to its run address in RAM.
let mut d = &raw mut _sdata;
let de = &raw mut _edata;
let mut s = &raw const _sidata;
while d < de { write_volatile(d, read_volatile(s)); d = d.add(1); s = s.add(1); }Check the arithmetic against the map: _ebss − _sbss = 0x80000404 − 0x80000004 = 0x400, which is the 256-element u32 array; _edata − _sdata = 4, the single u32 global; _stack_top = 0x80010000, the top of a 64 KiB RAM starting at 0x80000000. Every number in the startup code came from the script, and the script got them from MEMORY.
The disassembly is worth one look, because it shows the copy is genuinely four instructions:
2000011e: 4214 lw a3,0(a2) # a2 walks _sidata in ROM
20000120: c194 sw a3,0(a1) # a1 walks _sdata in RAM
20000122: 0591 addi a1,a1,4
20000124: 0611 addi a2,a2,4
20000126: fea5ece3 bltu a1,a0,2000011e
sequenceDiagram autonumber participant HW as CPU / reset logic participant ROM as boot ROM<br/>0x20000000 participant ASM as _start<br/>(.text.init) participant RS as rust_start participant RAM as RAM<br/>0x80000000 participant M as main HW->>ROM: reset deasserts, pc := 0x20000000 ROM->>ASM: fetch first instruction Note over ASM: sp is UNDEFINED here —<br/>no call is safe yet ASM->>ASM: la sp, _stack_top<br/>(auipc sp,0x60010 then mv sp,sp) Note over ASM: sp = 0x80010000 ASM->>RS: call rust_start RS->>RAM: zero _sbss.._ebss (0x400 bytes) Note over RAM: .bss now reads as 0 RS->>ROM: read words from _sidata (0x200000b8) RS->>RAM: store them to _sdata.._edata (0x80000000) Note over RAM: TICKS now reads 0xDEADBEEF RS->>M: call main M->>M: safe Rust may now touch any global
Reset to main, in order. What it shows: four things must happen before ordinary code is allowed to run, and they must happen in this order. The insight to take: the stack pointer comes first and is set in assembly, not Rust, because a Rust function may spill to the stack in its prologue — calling one before sp is valid is an immediate memory corruption. .bss is zeroed before .data is copied purely by convention; the two are independent. But both must finish before main, because main is allowed to assume the C-and-Rust invariant that globals hold their declared initial values.
ENTRY() and the Reset Vector
ENTRY(symbol) sets the ELF entry point. The manual gives the full precedence: -e on the command line, then ENTRY() in the script, then a target-specific default symbol (usually start), then the address of the first byte of the code section, then address 0 (§3.4.1).
Here is the trap, and it is worth stating as bluntly as possible: on bare metal, ENTRY() does almost nothing. The ELF entry point is a field in the file header. It is read by a loader — an operating system’s execve, a debugger’s load command, a simulator that understands ELF. Your SoC’s reset logic reads none of that. It sets the program counter to a hard-wired constant and fetches. Whatever bytes are at that physical address are the first instruction, entry-point field or no entry-point field.
So ENTRY() is worth writing for two secondary reasons — it makes gdb’s load-and-run behave, and (importantly) it makes the entry symbol a garbage-collection root, so --gc-sections will not delete your reset stub. But the thing that actually determines what runs first is the order of input sections inside the output section placed at the reset address.
This failed in a genuinely instructive way while building the examples for this note. A script that put a trap vector table first —
.text ORIGIN(ROM) :
{
KEEP(*(.vector_table)) /* WRONG: this is now at 0x20000000 */
KEEP(*(.text.init))
...
} > ROM— linked with no errors and produced Entry point address: 0x20000084, correctly pointing at _start. But 0x20000000, the address the hardware fetches, held j default_trap, the first entry of the vector table. Running the image in a simulator produced an infinite loop and nothing else: the CPU jumped into the default trap handler forever, having never executed a single instruction of the program. Swapping the two lines fixed it, and readelf -h then reported Entry point address: 0x20000000.
The rule that falls out is the one riscv-rt writes as a comment in its own script: “Put reset handler first in .text section so it ends up as the entry point of the program.” The convention of giving the reset stub its own input section (.text.init here, .init in riscv-rt) exists precisely so the script can name it and place it first.
flowchart TB R["reset deasserts"] --> PC["pc := reset vector<br/>0x20000000<br/><i>hard-wired in RTL</i>"] PC --> FETCH["fetch 4 bytes at 0x20000000"] FETCH --> Q{"what did the script<br/>put at that address?"} Q -->|"KEEP(*(.text.init)) first"| OK["_start<br/>set sp → init RAM → main<br/>✅ boots"] Q -->|"vector table first"| BAD["j default_trap<br/>❌ infinite loop, silent"] Q -->|"nothing — .text starts<br/>at a different ORIGIN"| BAD2["executes ROM garbage<br/>❌ illegal instruction trap"] ELF["ENTRY(_start)<br/>ELF header field<br/>= 0x20000084"] -.->|"read by gdb, simulators,<br/>and OS loaders — <b>not by silicon</b>"| Q
Why ENTRY() and the reset vector are different things. What it shows: three possible outcomes of the same successful link, decided entirely by input-section order. The insight to take: the dotted arrow is doing no work at boot. If your board is silent, readelf -h telling you the entry point is correct proves nothing — disassemble the reset address itself with objdump -d --start-address=0x20000000 and read what is actually there.
KEEP() and Why --gc-sections Deletes Your Vector Table
Link-time garbage collection walks the reference graph from a set of roots — the entry symbol, anything named with -u, and anything the script marks — and discards every input section nothing reaches. On a target where .text must fit in 64 KiB this is worth a great deal. It is also, on exactly one class of section, catastrophic.
A trap vector table is referenced by nothing in your program. Its address goes into mtvec, and the CPU jumps into it when a trap fires. To the garbage collector that is an unreferenced blob of code. The manual’s answer is one sentence: “When link-time garbage collection is in use (--gc-sections), it is often useful to mark sections that should not be eliminated. This is accomplished by surrounding an input section’s wildcard entry with KEEP()” (§3.6.4.4).
The measurement, run for this note on the real crate, is stark. The program defines a 32-entry vector table in a .vector_table section that nothing references. Two scripts, identical except for one wildcard:
| Script | .text size | _vector_table in nm | Link exit code | Warnings |
|---|---|---|---|---|
KEEP(*(.vector_table)) | 0x130 | 20000000 T _vector_table | 0 | none |
*(.vector_table) | 0x0ac | absent | 0 | none |
The silent deletion, measured. What it shows: 132 bytes — thirty-three 4-byte j instructions — vanished, and the build reported success both times with zero diagnostics. The insight to take: this is not a warning you missed. There is no warning. The first symptom is on hardware: the first trap jumps into whatever code the linker packed into that address instead, and the machine wanders off. KEEP() on the vector table, the reset stub, and any .init_array-style table is not optional hygiene — it is load-bearing.
flowchart TB subgraph ROOTS["GC roots — sections reachable from here survive"] E["ENTRY(_start)<br/><i>the entry symbol is always a root</i>"] K["KEEP(...) in the script"] U["-u SYMBOL on the command line"] end E --> TI[".text.init<br/>_start"] TI -->|"call rust_start"| RS[".text.rust_start"] RS -->|"call main"| MN[".text.main"] MN -->|"reads BANNER"| RO[".rodata"] MN -->|"writes TICKS"| DA[".data"] MN -->|"writes SCRATCH"| BS[".bss"] K -.->|"<b>the only edge that reaches it</b>"| VT[".vector_table<br/>32 j-entries, 132 bytes"] HW["the CPU, via mtvec"] -.->|"jumps here on a trap —<br/>an edge the linker cannot see"| VT VT -.-> DT["default_trap"] style VT stroke-dasharray: 5 5 style HW stroke-dasharray: 5 5
The garbage collector’s view of the program. What it shows: a reachability graph with one node hanging off a dashed line. Every solid edge is a relocation the linker can see; the edge from the CPU into the vector table exists only at run time, in a CSR the linker never reads. The insight to take: KEEP() is not a hint about importance — it is you supplying the one edge the linker is structurally incapable of discovering. Anything the hardware jumps to, rather than your code calling, needs it: the reset stub (covered here by ENTRY), the trap vectors, and any table gathered by section name.
The deletion is observable if you ask. Passing --print-gc-sections to the same failing link produced, verbatim:
warning: linker stdout: removing unused section .../dne32...rcgu.o:(.text)
removing unused section .../dne32...rcgu.o:(.vector_table)
Making that flag part of a CI build, or reading it once after every script change, converts a silent failure into a visible one. The three things that essentially always need KEEP() on a bare-metal target are: the reset stub, the trap vector table, and any table of function pointers gathered by section name (constructors, driver registration, test lists).
A Complete Script, Built and Verified
Here is the whole script, exactly as built, then the verification. Nothing below is illustrative — every address is copied from a tool’s output.
/* definitely-not-esp32 — RV32IMC bare-metal linker script
Built with rustc 1.98.0 / riscv32imc-unknown-none-elf (rust-lld),
and cross-checked with GNU ld 2.46. */
OUTPUT_ARCH(riscv)
ENTRY(_start)
MEMORY
{
ROM (rx) : ORIGIN = 0x20000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 64K
}
/* Stack lives at the top of RAM and grows down. */
_stack_top = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.text ORIGIN(ROM) :
{
KEEP(*(.text.init)) /* reset stub FIRST, and GC-proof */
. = ALIGN(4);
KEEP(*(.vector_table)) /* referenced only by mtvec — must be KEEPt */
*(.text .text.*)
. = ALIGN(4);
} > ROM
.rodata :
{
*(.rodata .rodata.*)
*(.srodata .srodata.*) /* RISC-V small read-only data — gp-relative */
. = ALIGN(4);
} > ROM
/* .data: VMA in RAM, LMA in ROM. Startup copies it. */
.data : ALIGN(4)
{
_sdata = .;
*(.data .data.*)
*(.sdata .sdata.*)
. = ALIGN(4);
_edata = .;
} > RAM AT> ROM
_sidata = LOADADDR(.data);
.bss (NOLOAD) : ALIGN(4)
{
_sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
_end = .;
/DISCARD/ : { *(.eh_frame) *(.eh_frame_hdr) *(.comment)
*(.note.GNU-stack) *(.riscv.attributes) }
}Three details in there are not obvious. .bss (NOLOAD) marks the section as not loadable so no bytes are emitted; for ELF output the manual notes that memory is still allocated at run time (§3.6.8.1) — the section becomes SHT_NOBITS, which is what you want. *(COMMON) catches tentative definitions from C code (Rust never emits them, but a mixed project will). /DISCARD/ throws sections away entirely; unwind tables and toolchain notes are pure ROM cost on a target with no unwinder.
Building it and asking readelf -S — the manual’s own recommendation for seeing where things landed — gives:
$ cargo build --release # rustc 1.98.0, riscv32imc-unknown-none-elf
$ riscv64-linux-gnu-readelf -S -W target/.../dne32
[Nr] Name Type Addr Off Size ES Flg
[ 1] .text PROGBITS 20000000 001000 0000ac 00 AX
[ 2] .rodata PROGBITS 200000ac 0010ac 00000c 00 A
[ 3] .data PROGBITS 80000000 002000 000004 00 WA
[ 4] .bss NOBITS 80000004 002004 000400 00 WA
Every address matches the script’s intent: .text at ORIGIN(ROM); .rodata immediately after; .data at ORIGIN(RAM); .bss at the word after .data. The Type column confirms .bss is NOBITS, contributing nothing to the file. And the symbol table shows the exported symbols resolving to exactly the boundaries you would compute by hand:
$ riscv64-linux-gnu-nm -n target/.../dne32
20000000 T _start
2000005e T rust_start
200000b8 A _sidata <- LOADADDR(.data)
80000000 D _sdata
80000004 D _edata
80000004 B _sbss
80000404 B _ebss
80000404 B _end
80010000 A _stack_top <- ORIGIN(RAM) + LENGTH(RAM)
One nice confirmation that the arithmetic is genuinely the linker’s: la sp, _stack_top compiled to a PC-relative pair, auipc sp, 0x60010 followed by mv sp, sp (an addi sp, sp, 0). 0x20000000 + 0x60010000 = 0x80010000. The offset was computed at link time from the MEMORY block.
Executing it. Static inspection proves placement; it does not prove the startup contract. There is no qemu-system-riscv32 on this machine, so a ~200-line RV32IC interpreter was written with this exact memory map (ROM at 0x20000000, RAM at 0x80000000, a memory-mapped UART at 0x10000000) and fed the objcopy -O binary image:
$ riscv64-linux-gnu-objcopy -O binary dne32 rom.bin && ./sim rom.bin
[sim] loaded 324 bytes at 0x20000000
dne32 up
[sim] wfi at pc=0x200000de after 875 instructions
[sim] RAM[0x80000000] (.data ticks) = 0xdeadbef0
[sim] RAM[0x80000004] (.bss scratch0) = 0xdeadbef0
[sim] sp = 0x80010000
0xdeadbef0 is 0xDEADBEEF incremented once — so the initializer really did travel from ROM to RAM. sp really is _stack_top. The UART really printed. This is the Stage 5 milestone from definitely-not-esp32 MOC in nine lines of output.
The negative control is what makes it evidence rather than decoration. Deleting only the four-instruction .data copy loop and rebuilding:
[sim] loaded 284 bytes at 0x20000000
dne32 up
[sim] wfi at pc=0x200000de after 863 instructions
[sim] RAM[0x80000000] (.data ticks) = 0x00000001 <-- initialiser lost
Twelve fewer instructions executed, and TICKS read as 0 instead of 0xDEADBEEF. The program still linked, still booted, still printed its banner. Only the initialized global was wrong — which is exactly how this bug presents in the field, and why it is so often misdiagnosed as a compiler or hardware fault.
Uncertain
Verify: that this binary behaves identically on the project’s real RV32IMC RTL. Reason: it was executed only in a purpose-built interpreter written for this note, not on the
definitely-not-esp32core, on an FPGA, or under a released simulator (qemu-system-riscv32andspikeare both absent from this machine). The interpreter implements RV32I plus the RV32C expansions the binary uses, with no CSRs, no traps, and a two-register UART stub. To resolve: run the samerom.binunder the project’s Verilator testbench, or underqemu-system-riscv32 -bios none -device loader, and confirm the same UART output and the same.datavalue.#uncertain
Failure Modes
Almost every linker-script bug shares a shape: the link succeeds and the board is silent. The script language has no type system and very few diagnostics, so the failure surfaces as behaviour, thousands of cycles later, with no message. What follows are the ones actually reproduced while writing this note, with their real symptoms.
| Failure | Symptom | Diagnosis |
|---|---|---|
Missing .data copy in startup | initialized globals read as 0; .bss globals fine | TICKS = 0x00000001 instead of 0xdeadbef0 in the run above |
Missing KEEP() on the vector table | first trap goes somewhere arbitrary | nm shows no _vector_table; --print-gc-sections names it |
| Vector table placed before the reset stub | dead silence, no output at all | objdump -d --start-address=<reset vector> shows the wrong code |
| Region too small | loud — the one that does fail at link | rust-lld: error: section '.text' will not fit in region 'ROM' |
| Orphan section | image grows, LMAs shift, ROM budget wrong | ld --orphan-handling=warn |
RUSTFLAGS set in the environment | undefined symbol: _stack_top | Cargo’s config rustflags were discarded (see below) |
| Symbol read as a value, not an address | garbage bounds; copy loop runs forever or not at all | &_sdata in C, &raw mut _sdata in Rust |
The catalogue. What it shows: exactly one of these seven produces a link-time error. The insight to take: budget your debugging accordingly — after any script change, verify with readelf/objdump/the map file before flashing, because the tools will tell you in a second what the hardware will take an afternoon to hint at.
Region overflow is the honourable exception, and the message is good. Shrinking ROM to 160 bytes produced:
rust-lld: error: section '.text' will not fit in region 'ROM': overflowed by 12 bytes
rust-lld: error: section '.rodata' will not fit in region 'ROM': overflowed by 24 bytes
rust-lld: error: section '.data' will not fit in region 'ROM': overflowed by 28 bytes
Note that it reports the cumulative overflow for each subsequent section, not three independent problems — .rodata is “24 bytes over” because .text already consumed the budget. Read the first line.
Orphan sections are the quiet ROM thief. The manual describes them plainly: sections “not explicitly placed into the output file by the linker script… the linker will still copy these sections into the output file by either finding, or creating a suitable output section” (§3.10.4). Running the same script through GNU ld with --orphan-handling=warn produced six warnings, and one of them was a genuine bug in the script written for this note:
ld: warning: orphan section `.note.gnu.build-id' ... being placed in section `.note.gnu.build-id'
ld: warning: orphan section `.srodata' ... being placed in section `.srodata'
ld: warning: orphan section `.note.GNU-stack' ... being placed in section `.note.GNU-stack'
.note.gnu.build-id — 36 bytes of build hash the script never mentioned — was placed in ROM between .rodata and .data, shifting .data’s LMA from 0x20000094 to 0x200000b8. Rebuilding with --build-id=none moved it back, confirming the cause. .srodata was worse: RISC-V small read-only data was landing in an orphan output section instead of the .rodata block, meaning gp-relative addressing could reach data outside the region the script thought it controlled. Adding *(.srodata .srodata.*) fixed it — and riscv-rt’s own script has that exact line, which is a useful reminder that a mature script is mostly a list of these lessons.
The RUSTFLAGS footgun is Cargo-specific and worth its own paragraph because it looks like a linker bug. Cargo documents four “mutually exclusive sources of extra flags… checked in order, with the first one being used”: CARGO_ENCODED_RUSTFLAGS, then RUSTFLAGS, then target.<triple>.rustflags, then build.rustflags (Cargo config reference). Mutually exclusive, not additive. So if your .cargo/config.toml carries rustflags = ["-C", "link-arg=-Tlink.x"] and anything in your shell or CI sets RUSTFLAGS — even something innocuous like -C debuginfo=0 — the linker-script argument silently disappears. The measured result:
$ RUSTFLAGS="-C debuginfo=0" cargo build --release
rust-lld: error: undefined symbol: _stack_top
rust-lld: error: undefined symbol: _ebss
rust-lld: error: undefined symbol: _sbss
Confusing, because nothing about the message mentions a linker script. The fix is to move the flags into [target.<triple>] rustflags and never set RUSTFLAGS globally, or better, put -Tlink.x in a build.rs-emitted cargo:rustc-link-arg — which is what riscv-rt and cortex-m-rt do, precisely so an ambient environment variable cannot break the build.
One more Cargo-specific trap, verified: Cargo does not track the linker script as a build input. Editing link.x and re-running cargo build produced Finished with no relink at all; the change only took effect after touch src/main.rs. build.rs scripts in the -rt crates emit cargo:rerun-if-changed=memory.x for exactly this reason. Hand-rolled setups need the equivalent, or you will spend an hour debugging a script edit that was never applied.
Alternatives and When to Choose Them
Write your own script, or use a runtime crate’s? The mainstream embedded-Rust answer is to depend on riscv-rt (0.18.0 as of 2026-09-04, per the crates.io index) or cortex-m-rt, supply a tiny memory.x naming only your regions, and let the crate’s link.x.in do the rest. That script is much more capable than a hand-written one: it handles multi-hart startup, floating-point enable, __global_pointer$ for RISC-V linker relaxation, a .uninit section that is deliberately not zeroed, fictitious .heap and .stack sections for size accounting, and eleven ASSERTs. It also contains a trick worth stealing — a fake .got (INFO) output section whose only purpose is to detect relocatable code in the inputs and error out, since dynamic relocations are meaningless here.
Writing your own is the right call in exactly two situations: when you are learning (the whole point of Stage 5 in definitely-not-esp32 MOC is that you cannot debug a boot failure you did not construct), and when your platform is odd enough that the generic script fights you. For a microkernel that will hand-manage its own address space, the second reason tends to apply eventually anyway.
GNU ld or LLD? Rust’s riscv32imc-unknown-none-elf target ships with rust-lld and uses it by default — visible in the build’s own error output as "rust-lld" "-flavor" "gnu". LLD implements the GNU script language closely enough that the script in this note linked correctly under both, but the differences measured above (.bss LMA, _stack_top’s symbol class) are real, and LLD has historically been stricter about some alignment cases — riscv-rt’s script carries the comment “This is required by LLD to ensure the LMA of the following .data section will have the correct alignment.” If a script works under one and not the other, that comment is where to look first.
No script at all? -Ttext=0x20000000 and friends set an address without a script. It is enough for a single-section experiment and nothing more: you get no MEMORY regions, so no overflow checking, and no way to separate LMA from VMA — which means no .data. Useful for a five-line assembly test, useless the moment you have a global variable.
Versus a dynamic linker. The instructive contrast is The Dynamic Linker. ld.so does at run time, for every process, what the script does once at link time: decide where code lives, resolve symbols, apply relocations. It can do so because the kernel has already built a virtual address space, mapped the ELF according to its program headers, and handed control to PT_INTERP. Here there is no kernel, no virtual memory, no relocation processing, and no symbol resolution after the link — the addresses baked into the instructions at link time are the addresses the hardware will use, forever. That is why riscv-rt treats the presence of a .got as an error rather than something to fix up. It is also why a bare-metal image is fully deterministic in a way a dynamically linked binary never is.
Production Notes
Verify, do not trust. The single most valuable habit is to treat the script as a hypothesis and readelf/objdump as the test. After every change: readelf -S for VMAs and section types, readelf -l or objdump -h for LMAs, nm -n for the exported symbols, and objdump -d --start-address=<reset vector> to confirm what the CPU will actually fetch first.
Always generate a map. -Map=out.map costs nothing and answers “which object file pulled that in?” instantly. LLD’s map format leads with the two columns that matter:
VMA LMA Size Align Out
80000000 200000b8 4 4 .data
80000000 200000b8 0 1 _sdata = .
80000004 200000bc 0 1 _edata = .
80000004 200000bc 0 1 _sidata = LOADADDR(.data)
80000004 80000004 400 4 .bss
Symbol assignments appear inline at the point they are evaluated, so you can watch _sidata pick up the LMA. This is the clearest single view of the VMA/LMA split any tool offers.
Turn silent failures into loud ones. Three flags pay for themselves: --orphan-handling=warn (or =error once the script is clean), --print-gc-sections, and --gc-sections itself only alongside disciplined KEEP(). Add ASSERTs to the script for the invariants your startup code depends on — the LMA’s alignment, the reset vector’s alignment, the total size against the region.
Keep the map in one place. The SoC Memory Map describes the same addresses from the hardware side, and the RTL’s address decoder is a third copy. The numbers must agree in the Verilog, the linker script, and any Rust const. The standard mitigation is to generate two of the three from the first — a small script emitting both a memory.x and a Verilog localparam header — so that a change to the map cannot be applied to only one consumer.
Size accounting. riscv-rt’s trick of declaring fictitious NOLOAD sections for the heap and the stack is worth copying even in a hand-written script: it makes the stack visible to size and to the region-overflow check, so a stack that no longer fits in RAM becomes a link error instead of a runtime corruption. The ROM cost of a program is .text + .rodata + sizeof(.data), and the RAM cost is sizeof(.data) + sizeof(.bss) + stack + heap — two different totals from one script, and both worth watching per commit.
See Also
- The SoC Memory Map — the same addresses from the hardware side; the script’s
MEMORYblock must mirror the RTL’s address decoder - Boot ROM and the Reset Vector — where the first fetch comes from, and why
ENTRY()does not decide it - The RISC-V Cross-Compilation Toolchain —
-march/-mabi,-nostdlib, and theobjdump/readelf/nmkit used throughout this note - Bare-Metal Rust — the
no_stdcrate on the other end of this script, and the.cargo/config.tomlthat wires them together - Context Switching in a Microkernel — the other place a stack pointer’s exact value is load-bearing
- The Dynamic Linker — the run-time counterpart:
ld.so, relocation, and symbol resolution in a process that has a loader - The Go Linker — a third point of comparison, a language toolchain that links statically but hosted
- Struct Memory Layout and Alignment — alignment one level down, inside a single object
- Computer Architecture MOC — the concept hub
- definitely-not-esp32 MOC — this note is Stage 5 of that build ladder