The puzzle
Vendor start-up files run to hundreds of lines, but most of that is a long list of handler names. Strip it down and a working start-up for a Cortex-M0+ is about thirty lines of C and a dozen lines of linker script. Writing it once shows exactly which lines are essential and how a board fails when one is missing. What is the smallest start-up that honestly gives C everything it promises?
STEP 1
The whole thing
Start with the part the hardware reads: a table whose first word is the stack top and whose second is the reset handler, with every other slot pointing at a default.
#include <stdint.h>
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack;
int main(void);
void Reset_Handler(void);
void Default_Handler(void) { for (;;) {} }
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));
__attribute__((section(".vectors"), used))
void (* const vectors[48])(void) = {
(void (*)(void))&_estack, /* 0: initial SP */
Reset_Handler, /* 1: reset */
NMI_Handler, /* 2 */
HardFault_Handler, /* 3 */
[11] = Default_Handler, /* SVCall; 4-10 stay 0 */
[14] = Default_Handler, /* PendSV; 12-13 stay 0 */
[15] = SysTick_Handler,
[16 ... 47] = Default_Handler, /* IRQ 0-31 (GNU C ranges) */
};
void Reset_Handler(void) {
uint32_t *src = &_sidata, *dst = &_sdata;
while (dst < &_edata) *dst++ = *src++; /* copy .data */
for (dst = &_sbss; dst < &_ebss; ) *dst++ = 0; /* zero .bss */
main();
for (;;) {} /* never return */
}
The linker script supplies every symbol the C code uses and puts the table first:
ENTRY(Reset_Handler)
MEMORY {
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 32K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS {
.text : { KEEP(*(.vectors)) *(.text*) *(.rodata*) . = ALIGN(4); } > FLASH
.data : { . = ALIGN(4); _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT> FLASH
_sidata = LOADADDR(.data);
.bss (NOLOAD) : { . = ALIGN(4); _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
}
Build it with the compiler’s own start-up files switched off and your script in their place: arm-none-eabi-gcc -mcpu=cortex-m0plus -mthumb -O2 -ffunction-sections -fdata-sections -nostartfiles -T min.ld -Wl,--gc-sections start.c main.c -o app.elf. The memory map is illustrative, for a chip that boots from flash at address 0; take the real origins and lengths from your chip’s reference manual. On the RP2040 this start-up could not boot on its own: flash must begin with the 256-byte boot2 and its checksum, with the table after it at 0x1000_0100 (lesson 2).
The linker script defines a handful of symbols and the startup code uses nothing else: where the .data image starts in flash (_sidata), where .data and .bss start and end in RAM, and the top of the stack. Change the section sizes and watch the values the startup loops will use. The memory map is an illustrative 32 KiB of RAM at 0x2000_0000 and flash at 0; the vector table is 48 words.
STEP 2
Why each line is there
usedandKEEP().usedstops the compiler from dropping the array when nothing references it, which matters if the table isstaticor the build uses link-time optimisation;KEEP()stops the linker’s--gc-sectionsfrom dropping its section. Neither tool sees a reference to the table, so it is safest with both.&_estackin word 0. Script symbols have an address, not a value (unit 4, lesson 4): the C code always takes&. The stack top here is 0x2000_8000, a multiple of 8 as the procedure-call standard expects at calls.- Function names, not numbers. The linker sets bit 0 of each Thumb function’s address, so every entry is a valid Thumb vector (lesson 3).
- The loops before
main(). They are the only thing that makes.dataand.bssmatch C (lesson 4)._sdata,_edata,_sbssand_ebssare word-aligned so that word loops cover them exactly. - The final loop.
main()should not return; if it does, the core parks instead of executing whatever follows in flash.
STEP 3
What this start-up leaves out, on purpose
It raises no clock, so everything runs on the boot clock; a real start-up raises it, after setting the flash wait states the faster clock needs (in SystemInit or at the top of main()). It runs no constructors: add .init_array to the script and lesson 5’s loop before main() if C++ objects or constructor functions are used. It sets up no heap: malloc needs an _sbrk and an end symbol. And at -O2 GCC may turn the two loops into calls to memcpy and memset; that is fine with a library whose versions need no initialised data, and -fno-tree-loop-distribute-patterns keeps them as loops when it is not.
STEP 4
Worked example: the symbols for one program
The program has 6000 bytes of code and constants after the 48-word (0xC0-byte) vector table, 256 bytes of .data and 1024 bytes of .bss:
.data runs from _sdata = 0x2000_0000 to _edata = 0x2000_0100 (64 words to copy); .bss from 0x2000_0100 to _ebss = 0x2000_0500 (256 words to zero). The stack grows down from 0x2000_8000 with 32 768 − 1280 = 31 488 bytes of room before it meets .bss, shared with any heap. The flash image is 6192 + 256 = 6448 bytes, because .data’s initial values are stored after the code.
MYTHS AND FACTS
Common misconceptions
used is enough to keep the table
It acts on the compiler only; with --gc-sections the linker needs KEEP().
A start-up must be written in assembly
On a Cortex-M the core loads SP before the first instruction, so C works from the first line.
The ELF entry point makes the chip start at Reset_Handler
The reset vector does; ENTRY() is for debuggers and loaders (lesson 3).
It works under the debugger, so the start-up is right
RAM survives a debugger reset and can hide a missing copy loop; test from a cold power-on.
-nostartfiles removes the C library
It removes only the start-up files; the libraries still link.
Check yourself
Answer in your head, then open the card.
The code grows to 20 000 bytes after the table and .data to 512 bytes. What are _sidata and _edata?
_sidata = 0xC0 + 20 000 = 20 192 = 0x4EE0. _edata = 0x2000_0000 + 512 = 0x2000_0200.
The board boots under the debugger but, from a cold power-on, a variable declared int mode = 2; reads as a random value. Which line is missing or wrong?
The .data copy. A debugger reset is not a power-on: RAM keeps what an earlier run (perhaps an earlier build whose loop worked) left there, so the variable happens to hold 2. Check the loop and that _sidata is LOADADDR(.data).
The linker script says *(.vectors) without KEEP and the build uses --gc-sections. What does the chip do at reset?
The table’s section is discarded because nothing references it. Words 0 and 1 of flash hold the start of some code, so SP and PC get meaningless values and the chip faults or runs garbage immediately.
Why is _estack defined outside SECTIONS as ORIGIN(RAM) + LENGTH(RAM) rather than inside .bss?
It is an address, not storage: the stack is the RAM above .bss, growing down from the top. Defining it from the region keeps the stack at the top however .data and .bss grow.
Sources (4)
- Arm, CMSIS-DFP, Device/ARMCM0plus/Source/startup_ARMCM0plus.c, and CMSIS 6, m-profile/cmsis_gcc_m.h — the pattern followed here: a const array of handler pointers in section .vectors with __attribute__((used)), the initial SP cast into the first entry, weak aliases to Default_Handler, and a Reset_Handler written in C
- STMicroelectronics, cmsis-device-f4, Source/Templates/gcc/startup_stm32f407xx.s — the symbol names used here (_sidata, _sdata, _edata, _sbss, _ebss, _estack) are those of ST’s GCC template and its linker scripts
- GNU ld manual, “Output Section LMA”, “Input Section and Garbage Collection” (KEEP) and “Source Code Reference” — AT> region and LOADADDR() for .data’s load image; KEEP protects a section from --gc-sections; a script symbol has an address but no storage (read from the ld texinfo source, unit 4)
- GCC manual, “Common Function Attributes” (alias, weak), “Common Variable Attributes” (section, used), “Link Options” (-nostartfiles) and “Optimize Options” (-ftree-loop-distribute-patterns) — -nostartfiles: “Do not use the standard system startup files when linking. The standard system libraries are used normally”; loop distribution into memset and memcpy calls is on at -O2 (read from gcc/doc/invoke.texi and extend.texi in the gcc-mirror repository)