On a desktop, a lot happens before main: the kernel maps your ELF, the
dynamic loader resolves libraries, libc runs constructors. On a
microcontroller you (or a small file of startup code you own) do all of
it, and the hardware does almost nothing. This lesson walks the path from
power-on to main on the board this track runs on, a Cortex-M4F on ARM's
MPS2 AN386 (emulated by qemu), and shows the two files that make it work:
CodeRigor's startup.cpp and the linker script mps2-an386.ld.
1. The memory map
A microcontroller has one flat 32-bit address space with no MMU and no virtual memory. Different address ranges are wired to different things:
Touching an address nothing answers to (0x3000_0000, say) is a bus
fault: there's no OS to send you SIGSEGV, the core takes the BusFault /
HardFault exception instead. CodeRigor's startup turns that into a report
with your source line, so you'll see crashed: SIGSEGV … at solution.hpp:12.
2. The vector table: what the hardware reads at reset
At reset a Cortex-M core does exactly two loads, both from the start of the
vector table (at address 0 unless the VTOR register moves it):
Two things to notice:
- Word 0 is data, not code: the initial stack pointer. The core needs a stack before running a single instruction, because an exception could arrive immediately and exceptions push registers onto the stack.
- Every handler address is odd. Cortex-M only executes Thumb code, and bit 0 of a branch target says "Thumb". The compiler and linker set it for you whenever you take the address of a Thumb function; loading an even address into PC is a UsageFault ("invalid state").
Here is CodeRigor's table (from crucible/mcu/startup.cpp):
__attribute__((section(".vectors"), used)) const Handler crucible_vectors[16 + 32] = {
reinterpret_cast<Handler>(&__stack_top),
Reset_Handler,
NMI_Handler,
Fault_Handler, // HardFault
Fault_Handler, // MemManage
Fault_Handler, // BusFault
Fault_Handler, // UsageFault
nullptr, nullptr, nullptr, nullptr,
SVC_Handler,
DebugMon_Handler,
nullptr,
PendSV_Handler,
SysTick_Handler,
// External interrupts …It's an ordinary array of function pointers. Two attributes make it work:
section(".vectors") puts it in its own section so the linker script can
place it first, and used stops the compiler from deleting an array that
no code references. The handlers that a program doesn't define are weak
aliases of Default_Handler:
void SysTick_Handler() __attribute__((weak, alias("Default_Handler")));so writing your own extern "C" void SysTick_Handler() { … } replaces the
weak one at link time — no registration call, no table editing.
This program reads the first two words back:
#include <cstdint>
#include <cstdio>
extern "C" unsigned __stack_top; // defined by the linker script
extern "C" void Reset_Handler(); // defined in startup.cpp
int main() {
auto sp = reinterpret_cast<std::uintptr_t>(&__stack_top);
auto reset = reinterpret_cast<std::uintptr_t>(&Reset_Handler);
std::printf("initial SP = 0x%08lx\n", (unsigned long)sp);
std::printf("Reset_Handler = 0x%08lx (Thumb bit %lu)\n", (unsigned long)reset, (unsigned long)(reset & 1));
}3. The reset handler's chores
On a real flash-based part, before main can run the startup code must:
- Enable the FPU if the code uses floating point (Cortex-M4F/M7):
set CP10 and CP11 to full access in
SCB->CPACR, thendsb; isb. Until then any FP instruction is a UsageFault. With-mfloat-abi=hardthe compiler may emit FP instructions in code you didn't expect to (savingsregisters,memcpy), so this goes first. - Copy
.data— initialised globals (int counter = 42;) — from flash, where the image stores their initial values, to RAM, where the program reads and writes them. - Zero
.bss— zero-initialised globals (int hits;,static char buf[256];). They take no space in the image at all; the startup code just clears their RAM range. - Run static constructors: everything in
.preinit_arrayand.init_array(C++ globals with dynamic initialisation,__attribute__((constructor))). - Call
main. - Decide what returning from
mainmeans. There's nobody to return to: typical startup code loops forever, resets, or (as here) callsexit, which runs destructors andatexithandlers.
CodeRigor's Reset_Handler does step 1 itself and hands the rest to newlib's
_start (from rdimon-crt0, the semihosting variant of the C runtime):
[[noreturn]] void Reset_Handler() {
// Enable the FPU (CP10, CP11 full access) before any FP instruction.
*reinterpret_cast<volatile std::uint32_t*>(0xE000ED88) |= 0xFu << 20;
asm volatile("dsb\n isb" ::: "memory");
_start();
for (;;) {}
}There is no .data copy on this board: qemu loads the ELF's segments
straight into SSRAM1, so initialised data already sits at its run address.
On a real part with flash at 0 you'd write the copy loop yourself — that's
this skill's build task.
4. Linker scripts: where everything goes
The compiler produces sections (.text, .rodata, .data, .bss,
.init_array…) in each object file; the linker script says which
memory each output section lives in and defines symbols for the startup
code. CodeRigor's:
MEMORY {
SSRAM1 (rwx) : ORIGIN = 0x00000000, LENGTH = 4M
SSRAM2 (rwx) : ORIGIN = 0x20000000, LENGTH = 4M
}
ENTRY(Reset_Handler)
SECTIONS {
.text : {
KEEP(*(.vectors)) ← vector table first, at address 0
*(.text*) *(.rodata*)
…
__init_array_start = .; KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array)) __init_array_end = .;
} > SSRAM1
.data : { *(.data*) } > SSRAM1
.bss (NOLOAD) : { __bss_start__ = .; *(.bss*) *(COMMON) . = ALIGN(8); __bss_end__ = .; } > SSRAM1
end = .;
__stack_top = ORIGIN(SSRAM2) + LENGTH(SSRAM2);
}MEMORYnames the regions;> SSRAM1places a section in one..is the location counter;__bss_start__ = .;defines a symbol whose address is that location. In C++ you see it asextern "C" char __bss_start__[];— it has no storage, only an address.KEEPprotects sections from--gc-sections(nothing references the vector table or the init array, but they must stay).NOLOADsays.bsshas no contents in the file.ENTRYnames the first instruction for loaders and debuggers (the hardware ignores it — it reads the vector table).
VMA and LMA
A section has two addresses. The VMA (virtual memory address) is where
the code expects it at run time; the LMA (load memory address) is where
the bytes are stored in the image. For .text they're the same. For .data
on a flash part they differ:
.data : { … } > RAM AT > FLASH VMA in RAM, LMA in flash
_sidata = LOADADDR(.data); where the initial values are
_sdata / _edata where they must end up_sidata); the code reads and writes them at the VMA (_sdata.._edata). The startup loop copies one to the other; .bss has no bytes in flash at all.and the startup code copies [_sidata, …) to [_sdata, _edata). Getting
VMA and LMA backwards copies garbage over your flash image (or faults).
#include <cstdint>
#include <cstdio>
extern "C" char __bss_start__[], __bss_end__[], end[];
int counter = 42; // .data
int hits; // .bss
static char buffer[256];
int main() {
buffer[0] = 1;
std::printf(".bss %p .. %p (%lu bytes)\n", (void*)__bss_start__, (void*)__bss_end__,
(unsigned long)(__bss_end__ - __bss_start__));
std::printf("hits %p is %s .bss\n", (void*)&hits,
(char*)&hits >= __bss_start__ && (char*)&hits < __bss_end__ ? "in" : "outside");
std::printf("counter %p = %d (.data)\n", (void*)&counter, counter);
std::printf("heap starts at end = %p\n", (void*)end);
}Reading the result of a link without running it: arm-none-eabi-nm -n prog
lists symbols by address, arm-none-eabi-size prog gives
text data bss, and a map file (-Wl,-Map=prog.map) shows which object
contributed every byte — the first thing to read when an image is too big.
5. Static initialisation on a microcontroller
Globals initialised by a constant expression go into .data (or .bss
when zero) — no code runs. Globals that need code at run time get a small
constructor function whose address goes into .init_array, and the startup
code calls each entry in order:
#include <cstdio>
struct Noisy {
const char* name;
explicit Noisy(const char* n) : name(n) { std::printf("init %s\n", name); }
~Noisy() { std::printf("fini %s\n", name); }
};
Noisy a("a");
Noisy b("b");
int main() { std::printf("main\n"); }Within one translation unit, definitions initialise in order; across
translation units the order is unspecified (the "static initialisation
order fiasco"). On an MCU there's a second trap: constructors run before
main, so a global UartDriver console{uart0()}; whose constructor
writes to the UART runs before anything has set up clocks or pins. Prefer:
constexpr/constinitobjects — guaranteed to be initialised at compile time (so they live in.data/.bssand no code runs).constinitturns "this accidentally needs a constructor" into a compile error;- explicit
init()calls frommain, in an order you can read.
6. What returning from main means here
main returns into newlib's _start, which calls exit(status): it runs
atexit handlers and global destructors, then makes a semihosting call
that asks the host (qemu) to stop with that status. On real hardware with
no debugger attached there's no host — the usual startup code just spins in
for (;;) {} (or resets), and firmware's main never returns at all.
Summary
- Reset loads SP from word 0 and PC from word 1 of the vector table; handler addresses have bit 0 set (Thumb).
- The reset handler enables the FPU, copies
.data(flash LMA → RAM VMA), zeroes.bss, runs.init_array, callsmain. - The linker script places sections in memory regions and defines the
symbols (
__bss_start__,__stack_top…) the startup code uses — symbols are addresses, declared asextern "C" char sym[];. - Weak aliases let a program override a handler just by defining it.
- Keep work out of global constructors; use
constinitand explicitinit()calls.