CodeRigor

FREE 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 →

What startup code does between reset and main: vector table, initial SP, .data copy and .bss zeroing.

Replacing the vendor vector table with your own and the embedded build process (compile, link, map file).

Initialising the vector table in C with exception and interrupt handlers.

The hosted counterpart: everything that happens before main on Linux (static init, crt0, the linker).