Registers, Linker Scripts & Startup Code¶
Module 3-01 wrote to registers and referenced _estack and Reset_Handler
without explaining where they come from. Those symbols are defined by two
files every bare-metal project needs and every framework normally hides: a
linker script (.ld) that tells the linker where flash and RAM live and
how to lay out sections, and startup code (startup_stm32.c or .s)
that runs between reset and main() to make C's assumptions about global
variables actually true.
What C assumes that hardware doesn't provide for free¶
C guarantees that a zero-initialized global starts at 0, and an
initialized global starts at its initializer value:
int counter = 0; /* .bss — zero-initialized */
int max_retries = 5; /* .data — non-zero initialized */
On flash-based microcontrollers, .data's initial value (5) is stored in
flash alongside your code — flash is not writable at runtime the way RAM is,
so the variable itself must live in RAM, but its startup value has to come
from somewhere. Nothing does this automatically. Startup code must copy
.data from flash to RAM and zero .bss in RAM before main() runs, or
max_retries reads as garbage.
The linker script: describing memory to the linker¶
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.isr_vector : { *(.isr_vector) } > FLASH
.text : {
*(.text*)
*(.rodata*)
} > FLASH
.data : {
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT> FLASH /* lives in RAM, initial values loaded from FLASH */
.bss : {
_sbss = .;
*(.bss*)
_ebss = .;
} > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM); /* stack grows down from top of RAM */
}
AT> FLASH is the detail that makes .data initialization possible: the
section's run-time address is in RAM (where code reads and writes it), but
its load-time address — where the linker actually places the initial
bytes — is in FLASH. The linker exposes both addresses as symbols
(LOADADDR(.data) and .data's own address), which startup code uses to
copy one to the other.
Startup code: making the assumptions true¶
extern uint32_t _sdata, _edata, _sbss, _ebss;
extern uint32_t _sidata; /* LOADADDR(.data) — where init values live in flash */
extern int main(void);
void Reset_Handler(void) {
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata) *dst++ = *src++; /* copy .data: flash -> RAM */
dst = &_sbss;
while (dst < &_ebss) *dst++ = 0; /* zero .bss */
main();
while (1) { /* main() must never return on bare metal */ }
}
This is the single piece of code every C runtime on every platform has in
some form — on a hosted OS it's part of the C library's _start; here you
write it. Skipping it doesn't produce a compile error or even a crash
necessarily — it produces globals that silently hold whatever bytes were
already in RAM at power-on, which is a uniquely painful class of bug because
the code looks correct.
Simulating the copy/zero logic in portable C¶
The mechanics — not the real flash/RAM split — are verified below by
modeling "flash" and "RAM" as two arrays and running the same loop shape as
Reset_Handler. Compiled and run with gcc:
#include <stdio.h>
#include <string.h>
#include <assert.h>
int main(void) {
unsigned char flash_image[8] = {5, 0, 0, 0, 7, 0, 0, 0}; /* two uint32 inits: 5, 7 */
unsigned char ram[16];
memset(ram, 0xAA, sizeof(ram)); /* simulate power-on garbage */
/* copy .data (first 8 bytes of "ram") from "flash" */
memcpy(ram, flash_image, 8);
/* zero .bss (remaining 8 bytes) */
memset(ram + 8, 0, 8);
int *data_vars = (int *)ram;
int *bss_vars = (int *)(ram + 8);
assert(data_vars[0] == 5 && data_vars[1] == 7);
assert(bss_vars[0] == 0 && bss_vars[1] == 0);
printf("startup simulation OK: data={%d,%d} bss={%d,%d}\n",
data_vars[0], data_vars[1], bss_vars[0], bss_vars[1]);
return 0;
}
Traps in linker scripts and startup code¶
- Forgetting
AT> FLASHon.datamakes the linker place both the section and its load image in RAM — it will link cleanly, then every.datavalue reads as whatever RAM happened to hold at boot, because nothing ever copies from flash. _estackpointing outside RAM (off-by-one onORIGIN + LENGTH, or reusing a linker symbol name that collides with another script) crashes before a single line ofmain()runs, with no diagnostic beyond "nothing happens."- Stack/heap collision: the stack grows down from
_estack, the heap (if you usemalloc) grows up from the end of.bss. Neither the linker nor the startup code stops them from meeting in the middle — this is a purely runtime failure mode with no static check. - Missing
KEEP()on the vector table section lets an aggressive linker garbage-collect.isr_vectorbecause nothing in.textvisibly references it, producing a binary that boots without a vector table at address0.
How It Actually Works¶
Why the linker needs two separate addresses for one section at all: a
linker script's job is to assign every symbol and section a virtual
memory address (VMA) — where it will be found while running — but for
.data that isn't enough information to produce a working image, because
flash is only capable of holding the initial bytes, never receiving writes
from ordinary str instructions during normal execution. AT> FLASH
introduces a second, independent address: the load memory address (LMA)
— where the linker physically places those bytes in the output binary file
that gets flashed. The linker emits both LOADADDR(.data) (the LMA, i.e.
_sidata) and the section's own address (the VMA, _sdata) purely as
numbers in the symbol table; nothing copies anything at link time — the
gap between "where it's stored" and "where it's used" is real only until
Reset_Handler's copy loop closes it at runtime, which is exactly why
skipping that loop leaves .data's RAM location holding whatever was
already there (uninitialized SRAM contents from the previous power cycle,
or all-zero/all-one depending on the specific chip's power-on SRAM state) —
the flash bytes are sitting right there in the image, just never moved.
Why .bss costs no flash space at all, unlike .data: the linker script
allocates .bss a VMA range in RAM but gives it no AT> load section and no
corresponding bytes in the output file — it's pure address-space bookkeeping,
because a run of zero bytes needs no actual storage in the binary the way
non-zero initial values do (this is the same principle as a sparse file on a
filesystem). This is precisely why zero-initializing a large global array is
"free" in flash terms while a large non-zero-initialized one is not — the
compiler and linker treat the two categories completely differently based on
initializer value, and Reset_Handler's job for .bss is simpler
accordingly: it never reads from flash, it only writes zeros into a RAM range
whose boundaries (_sbss/_ebss) the linker computed from how much RAM your
zero-initialized globals actually need.
Why KEEP() matters mechanically: linkers perform dead code/data
elimination (usually invoked as --gc-sections) by starting from a set of
known-referenced roots (typically the entry symbol, here effectively
Reset_Handler since it's the vector table's second word) and transitively
keeping only sections reachable through actual symbol references in the
object code. The vector table is referenced only by address — hardware
reads it directly from a fixed memory location, never via a C pointer
dereference the linker's reachability analysis can see — so from the
linker's perspective, nothing "uses" .isr_vector and it's a candidate for
elimination exactly like genuinely unused code. KEEP() is a linker-script
directive telling the garbage collector to retain a section unconditionally,
regardless of whether its reachability analysis finds a reference to it —
without it, an aggressive -O2/--gc-sections build can link successfully,
produce a plausible-sized binary, and simply have no valid instructions at
address 0 for the CPU's reset sequence to find.
Cheat sheet¶
| Concept | Detail |
|---|---|
.text/.rodata |
Code and constants — stay in FLASH, never copied |
.data |
Non-zero globals — live in RAM, load image in FLASH, copied by startup code |
.bss |
Zero-initialized globals — live in RAM only, zeroed by startup code, occupy no flash |
AT> FLASH |
Tells linker the load address differs from the run address |
_sdata/_edata/_sbss/_ebss |
Linker-defined symbols marking section boundaries, used by startup code |
| Stack direction | Grows down from _estack (top of RAM) |
KEEP() |
Prevents the linker from discarding a section nothing references directly (e.g. .isr_vector) |
| Verification here | Copy/zero logic simulated and run with gcc; real flash/RAM layout not testable off-chip |
Exercise¶
Write a portable C program that models a linker's .data/.bss split with
three arrays: flash_image[] (holds the .data load values), ram[]
(the target), and a struct { size_t data_off, data_len, bss_off, bss_len; }
describing the layout — then write a single function apply_startup(...)
that performs the copy-then-zero using only those offsets (no hardcoded
sizes). Compile and run it with assertions proving arbitrary .data/.bss
sizes work, then add a deliberate bug — swap the copy and zero order — and
explain in a comment exactly which values end up wrong and why order matters
here even though the two operations touch disjoint memory.