CodeRigor

LESSON

From reset to main: vector tables, startup code, linker scripts

What a Cortex-M does between power-on and main: the vector table, the reset handler's chores (FPU, .data, .bss, constructors), linker scripts with VMA/LMA and their symbols, and the board's memory map.

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:

The MPS2 AN386 memory map, lowest address first
One 4 GiB address space: memory and peripherals alike are just addresses. Hover a region for what lives there.
The MPS2 AN386 memory map, lowest address first
One 4 GiB address space: memory and peripherals alike are just addresses. Hover a region for what lives there.

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):

The vector table: word 0 is data, every other word a handler
At reset the core loads SP from offset 0x00 and PC from 0x04 — nothing else. Every handler address has bit 0 set: Thumb.
The vector table: word 0 is data, every other word a handler
At reset the core loads SP from offset 0x00 and PC from 0x04 — nothing else. Every handler address has bit 0 set: Thumb.

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):

C++
__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:

C++
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:

C++
#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:

  1. Enable the FPU if the code uses floating point (Cortex-M4F/M7): set CP10 and CP11 to full access in SCB->CPACR, then dsb; isb. Until then any FP instruction is a UsageFault. With -mfloat-abi=hard the compiler may emit FP instructions in code you didn't expect to (saving s registers, memcpy), so this goes first.
  2. 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.
  3. 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.
  4. Run static constructors: everything in .preinit_array and .init_array (C++ globals with dynamic initialisation, __attribute__((constructor))).
  5. Call main.
  6. Decide what returning from main means. There's nobody to return to: typical startup code loops forever, resets, or (as here) calls exit, which runs destructors and atexit handlers.
From power-on to main
The hardware does the first box; everything else is ordinary code in the reset handler and the C runtime.
From power-on to main
The hardware does the first box; everything else is ordinary code in the reset handler and the C runtime.

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):

C++
[[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:

Text
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);
}
  • MEMORY names the regions; > SSRAM1 places a section in one.
  • . is the location counter; __bss_start__ = .; defines a symbol whose address is that location. In C++ you see it as extern "C" char __bss_start__[]; — it has no storage, only an address.
  • KEEP protects sections from --gc-sections (nothing references the vector table or the init array, but they must stay).
  • NOLOAD says .bss has no contents in the file.
  • ENTRY names 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:

Text
.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
Two addresses for .data: stored in flash, used in RAM
The image holds the initial values at the LMA (_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.
Two addresses for .data: stored in flash, used in RAM
The image holds the initial values at the LMA (_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).

C++
#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:

C++
#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/constinit objects — guaranteed to be initialised at compile time (so they live in .data/.bss and no code runs). constinit turns "this accidentally needs a constructor" into a compile error;
  • explicit init() calls from main, 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, calls main.
  • The linker script places sections in memory regions and defines the symbols (__bss_start__, __stack_top…) the startup code uses — symbols are addresses, declared as extern "C" char sym[];.
  • Weak aliases let a program override a handler just by defining it.
  • Keep work out of global constructors; use constinit and explicit init() calls.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →