Part 2 · C Meets the Metal29 min read
Startup Code and the Vector Table
Between the moment the reset pin is released and the first line of main, a short piece of code decides whether your C program can work at all. It is the code that makes initialized globals hold their values, makes zero-initialized globals zero, turns on the FPU, runs constructors, and routes every interrupt to the right function. Vendors ship it as an assembly file that most engineers never open, which is why bugs in this area (“my global is wrong at startup”, “an interrupt jumps into nowhere”) are so baffling when they happen. In Blinky from nothing you wrote a minimal version. This lesson expands it into the startup file you would ship: the full 98-entry STM32F407 vector table, a complete Reset_Handler, default handlers that help you debug, and vector table relocation for bootloaders.
What the core does at reset
Section titled “What the core does at reset”When reset is released, the Cortex-M4 does three things in hardware before executing a single instruction:
- Reads the 32-bit word at address
0x00000000and loads it into the main stack pointer,MSP. - Reads the word at
0x00000004, the reset vector. Bit 0 goes into the Thumb state bit (EPSR.T); the rest, with bit 0 cleared, goes intopc. - Starts executing at that
pc, in Thread mode, privileged, usingMSP.
address 0x00000000 (flash alias) core registers after reset+-----------------------------+| 0x20020000 | ------> MSP = 0x20020000+-----------------------------+| 0x08000255 (Reset_Handler) | ------> PC = 0x08000254+-----------------------------+ EPSR.T = 1 (bit 0 of the vector)| ... | LR = 0xFFFFFFFF xPSR = 0x01000000 CONTROL = 0, PRIMASK = 0, VTOR = 0The Thumb bit matters. Cortex-M cores can only execute Thumb instructions, so every vector must have bit 0 set. Function addresses produced by the compiler and linker for Thumb code already do. If bit 0 were clear, the core would try to enter Arm state, which does not exist on Cortex-M, and fault on the first instruction (an INVSTATE UsageFault, escalated to HardFault). If the HardFault vector is broken in the same way, the core cannot run its fault handler either and locks up.
The rest of the reset state that matters for startup code:
r0tor12hold unknown values.LRholds0xFFFFFFFF.PRIMASKis 0, so interrupts are not globally masked, but every interrupt is disabled in the NVIC and SysTick is off. Nothing can interrupt startup unless your code enables it.VTOR, the vector table offset register in the SCB, is 0. The core fetches every exception vector from address 0 plus an offset.- The FPU is disabled (
CPACRis 0). Any floating-point instruction faults. - The clock is the internal 16 MHz HSI oscillator, with zero flash wait states.
What lives at address 0
Section titled “What lives at address 0”On the STM32F407 address 0x00000000 is an alias. The BOOT pins, sampled at reset, choose which memory appears there:
| BOOT1 (PB2) | BOOT0 | Memory aliased at 0x00000000 |
|---|---|---|
| any | 0 | Main flash (0x08000000) |
| 0 | 1 | System memory (ST’s ROM bootloader) |
| 1 | 1 | Embedded SRAM (0x20000000) |
The Discovery board ties BOOT0 low, so the core reads its first two words from the start of flash. That is why the linker script places .isr_vector at 0x08000000, and why everything in this lesson assumes it is there. The table is linked at its real address, 0x08000000, and works through the alias because the two addresses read the same bytes.
The vector table layout
Section titled “The vector table layout”The vector table is an array of 32-bit words. Word 0 is the initial stack pointer. Word n (for n from 1) is the handler address for exception number n. Exceptions 1 to 15 are defined by Arm and are the same on every Cortex-M4. Exceptions 16 and up are the vendor’s device interrupts, which CMSIS numbers from 0 as IRQ numbers: IRQ k is exception 16 + k, at offset 4 × (16 + k).
| Exception | Name | Offset | Priority |
|---|---|---|---|
| (word 0) | Initial MSP value | 0x00 | |
| 1 | Reset | 0x04 | -3, fixed |
| 2 | NMI | 0x08 | -2, fixed |
| 3 | HardFault | 0x0C | -1, fixed |
| 4 | MemManage | 0x10 | configurable |
| 5 | BusFault | 0x14 | configurable |
| 6 | UsageFault | 0x18 | configurable |
| 7-10 | Reserved | 0x1C-0x28 | |
| 11 | SVCall | 0x2C | configurable |
| 12 | Debug Monitor | 0x30 | configurable |
| 13 | Reserved | 0x34 | |
| 14 | PendSV | 0x38 | configurable |
| 15 | SysTick | 0x3C | configurable |
| 16 + k | IRQ k | 0x40 + 4k | configurable |
The STM32F407 has 82 device interrupt vectors, IRQ 0 (WWDG) to IRQ 81 (FPU), so the table is 16 + 82 = 98 words, 392 bytes (0x188). A few you will use soon: EXTI0 is IRQ 6 at offset 0x58, TIM2 is IRQ 28 at 0xB0, USART2 is IRQ 38 at 0xD8. The order and the names come from the vector table in RM0090 and must match it exactly. A table that is shifted by one entry sends every interrupt to its neighbour’s handler, which is the kind of bug that takes a day to find. (CMSIS headers express the system exceptions as negative IRQ numbers, SysTick_IRQn is -1 and HardFault_IRQn is -13, so that exception number = IRQn + 16 holds everywhere.)
The complete startup.c
Section titled “The complete startup.c”This file replaces the minimal one from Blinky from nothing. It needs stm32f407_regs.h for SCB and the two small linker script additions shown after it.
/* startup.c: vector table and C runtime startup for the STM32F407. */#include <stdint.h>#include "stm32f407_regs.h"
/* ---- Symbols defined by link.ld (addresses only, no storage) ---- */extern uint32_t _estack[];extern uint32_t _sidata[], _sdata[], _edata[], _sbss[], _ebss[];
typedef void (*isr_t)(void);typedef void (*init_fn_t)(void);extern init_fn_t __preinit_array_start[], __preinit_array_end[];extern init_fn_t __init_array_start[], __init_array_end[];
int main(void);void Reset_Handler(void);void Default_Handler(void);void SystemInit(void);
/* Unexpected exception. In gdb: p/x $xpsr & 0x1ff gives its number. */void Default_Handler(void){ for (;;) { }}
#define WEAK_DEFAULT __attribute__((weak, alias("Default_Handler")))
/* ---- Cortex-M4 system exception handlers ---- */void NMI_Handler(void) WEAK_DEFAULT;void HardFault_Handler(void) WEAK_DEFAULT;void MemManage_Handler(void) WEAK_DEFAULT;void BusFault_Handler(void) WEAK_DEFAULT;void UsageFault_Handler(void) WEAK_DEFAULT;void SVC_Handler(void) WEAK_DEFAULT;void DebugMon_Handler(void) WEAK_DEFAULT;void PendSV_Handler(void) WEAK_DEFAULT;void SysTick_Handler(void) WEAK_DEFAULT;
/* ---- STM32F407 device interrupts, IRQ 0 to 81 in order (RM0090). * X(name) is a vector for name##_IRQHandler, R() is a reserved slot. ---- */#define STM32F407_IRQS(X, R) \ X(WWDG) X(PVD) X(TAMP_STAMP) X(RTC_WKUP) \ X(FLASH) X(RCC) X(EXTI0) X(EXTI1) \ X(EXTI2) X(EXTI3) X(EXTI4) X(DMA1_Stream0) \ X(DMA1_Stream1) X(DMA1_Stream2) X(DMA1_Stream3) X(DMA1_Stream4) \ X(DMA1_Stream5) X(DMA1_Stream6) X(ADC) X(CAN1_TX) \ X(CAN1_RX0) X(CAN1_RX1) X(CAN1_SCE) X(EXTI9_5) \ X(TIM1_BRK_TIM9) X(TIM1_UP_TIM10) X(TIM1_TRG_COM_TIM11) X(TIM1_CC) \ X(TIM2) X(TIM3) X(TIM4) X(I2C1_EV) \ X(I2C1_ER) X(I2C2_EV) X(I2C2_ER) X(SPI1) \ X(SPI2) X(USART1) X(USART2) X(USART3) \ X(EXTI15_10) X(RTC_Alarm) X(OTG_FS_WKUP) X(TIM8_BRK_TIM12) \ X(TIM8_UP_TIM13) X(TIM8_TRG_COM_TIM14) X(TIM8_CC) X(DMA1_Stream7) \ X(FSMC) X(SDIO) X(TIM5) X(SPI3) \ X(UART4) X(UART5) X(TIM6_DAC) X(TIM7) \ X(DMA2_Stream0) X(DMA2_Stream1) X(DMA2_Stream2) X(DMA2_Stream3) \ X(DMA2_Stream4) X(ETH) X(ETH_WKUP) X(CAN2_TX) \ X(CAN2_RX0) X(CAN2_RX1) X(CAN2_SCE) X(OTG_FS) \ X(DMA2_Stream5) X(DMA2_Stream6) X(DMA2_Stream7) X(USART6) \ X(I2C3_EV) X(I2C3_ER) X(OTG_HS_EP1_OUT) X(OTG_HS_EP1_IN) \ X(OTG_HS_WKUP) X(OTG_HS) X(DCMI) R() \ X(HASH_RNG) X(FPU)
#define IRQ_DECLARE(name) void name##_IRQHandler(void) WEAK_DEFAULT;#define IRQ_ENTRY(name) name##_IRQHandler,#define IRQ_ONE(name) + 1#define RSV_NONE()#define RSV_ENTRY() 0,#define RSV_ONE() + 1
STM32F407_IRQS(IRQ_DECLARE, RSV_NONE)
#define DEVICE_IRQ_COUNT (0 STM32F407_IRQS(IRQ_ONE, RSV_ONE))_Static_assert(DEVICE_IRQ_COUNT == 82, "STM32F407 has 82 device vectors");
typedef struct { uint32_t *initial_sp; /* 0x000 */ isr_t exceptions[15]; /* 0x004-0x03C: exceptions 1-15 */ isr_t irqs[DEVICE_IRQ_COUNT]; /* 0x040-0x184: IRQ 0-81 */} vector_table_t;
_Static_assert(sizeof(vector_table_t) == 98u * 4u, "vector table size");
__attribute__((section(".isr_vector"), used))const vector_table_t vector_table = { .initial_sp = _estack, .exceptions = { Reset_Handler, NMI_Handler, HardFault_Handler, MemManage_Handler, BusFault_Handler, UsageFault_Handler, 0, 0, 0, 0, /* 7-10 reserved */ SVC_Handler, DebugMon_Handler, 0, /* 13 reserved */ PendSV_Handler, SysTick_Handler, }, .irqs = { STM32F407_IRQS(IRQ_ENTRY, RSV_ENTRY) },};
/* ---- Startup ---- */__attribute__((weak)) void SystemInit(void){ /* Default: stay on the 16 MHz HSI. Override to configure the PLL. */}
void Reset_Handler(void){ /* 1. Enable the FPU: full access to coprocessors CP10 and CP11. */ SCB->CPACR |= SCB_CPACR_CP10_CP11_Msk; __asm volatile ("dsb\n\tisb" : : : "memory");
/* 2. Copy .data initial values from flash (LMA) to RAM (VMA). */ const uint32_t *src = _sidata; for (uint32_t *dst = _sdata; dst < _edata;) { *dst++ = *src++; }
/* 3. Zero .bss. */ for (uint32_t *dst = _sbss; dst < _ebss;) { *dst++ = 0u; }
/* 4. Clocks and other early hardware setup. Globals are valid now. */ SystemInit();
/* 5. Constructors (.preinit_array, then .init_array). */ for (init_fn_t *fn = __preinit_array_start; fn < __preinit_array_end; fn++) { (*fn)(); } for (init_fn_t *fn = __init_array_start; fn < __init_array_end; fn++) { (*fn)(); }
/* 6. The application. */ (void)main();
/* 7. main returned. There is nowhere to go: stop where a debugger can see. */ for (;;) { }}The rest of this lesson goes through it section by section.
The device interrupt list
Section titled “The device interrupt list”Writing 82 weak declarations and then 82 table entries in the same order is exactly the kind of duplication that drifts. The file uses the X-macro technique from The preprocessor, used well: one list, STM32F407_IRQS, expanded three ways. With IRQ_DECLARE it produces void WWDG_IRQHandler(void) WEAK_DEFAULT; and so on. With IRQ_ENTRY it produces the table initializer WWDG_IRQHandler, PVD_IRQHandler, .... With IRQ_ONE it produces 0 + 1 + 1 ..., which the _Static_assert checks against 82, so a deleted or duplicated entry fails the build.
The R() entry is IRQ 79. On the STM32F415 and F417 that slot is the CRYP crypto processor; the F407 has no crypto processor, so the slot is reserved and its entry is 0. Handler names follow ST’s convention (USART2_IRQHandler, EXTI0_IRQHandler) so that code and examples from anywhere in the STM32 world link against this table unchanged.
Placing the table
Section titled “Placing the table”__attribute__((section(".isr_vector"), used)) puts the object in its own input section, and the linker script places it first in flash:
.isr_vector : { KEEP(*(.isr_vector)) } > FLASHKEEP protects it from --gc-sections, because no code refers to vector_table. The two _Static_assert lines guarantee the size is exactly 98 words. After a build, arm-none-eabi-nm -n firmware.elf should show vector_table at 08000000 and the first function after it at 08000188.
Default handlers and weak aliases
Section titled “Default handlers and weak aliases”Every vector needs a valid address, but most programs implement only a handful of handlers. The weak alias pattern solves this. void TIM2_IRQHandler(void) WEAK_DEFAULT; declares TIM2_IRQHandler as a second name for Default_Handler, with weak binding. At link time:
- If another object file defines a normal (strong)
TIM2_IRQHandler, the linker uses it and the weak alias disappears. - If nothing defines it, the weak symbol resolves to
Default_Handler.
No registration, no function pointer tables at run time. Defining a function with the right name is all it takes, which is also the trap: a misspelled handler (TIM2_IRQhandler, USART2_IRQ_Handler) compiles and links without a word, and the real interrupt goes to Default_Handler. Check with nm: overridden handlers show type T, defaults show W.
Default_Handler is an infinite loop on purpose. An interrupt you enabled but forgot to handle will fire again the instant a handler returns without clearing its source, so returning would just lock the CPU in a storm of interrupts. Stopping in one place is much easier to diagnose. When you find the program stuck there, ask which exception brought it:
(gdb) p/x $xpsr & 0x1ff$1 = 0x2cThe low 9 bits of xPSR are the IPSR field, the number of the exception being handled. 0x2c is 44, so IRQ 28: TIM2. You enabled the TIM2 interrupt, and the handler’s name is wrong or missing. Some teams go further and give every vector its own default loop so the PC alone identifies the culprit; others make Default_Handler record the IPSR value in a .noinit crash record and reset. For production, Watchdogs, faults, and recovering from them builds a proper fault handler.
Reset_Handler, step by step
Section titled “Reset_Handler, step by step”Step 1: enable the FPU
Section titled “Step 1: enable the FPU”The Cortex-M4F’s floating-point unit is coprocessors 10 and 11 in Arm’s numbering. CPACR (0xE000ED88) has a 2-bit access field for each, in bits 23:20; writing 0b11 to both grants full access. Because the project is compiled with -mfloat-abi=hard, the compiler may use FPU registers in any function that touches a float, including inside library code, so this must happen before any such code runs. The dsb makes sure the register write has completed, and the isb flushes the pipeline so that instructions already fetched are re-fetched with the FPU enabled. The Cortex-M architecture explains both barriers.
Reset_Handler itself must be integer-only up to this point. In practice it is, because it has no floating-point code.
Steps 2 and 3: .data and .bss
Section titled “Steps 2 and 3: .data and .bss”These are the same two loops as in blinky. Here is what the linker symbols describe:
FLASH RAM0x08000000 +--------------------+ 0x20000000 +--------------------+ <- _sdata | .isr_vector | | .data | +--------------------+ copied by +--------------------+ <- _edata | .text, .rodata | Reset_Handler| .bss (zeroed) | <- _sbss | .init_array | ------------> +--------------------+ <- _ebss +--------------------+ | | _sidata ->| .data init values | | free RAM | +--------------------+ | ^ stack grows | | erased (0xFF) | | | downward | +--------------------+ 0x20020000 +--------------------+ <- _estack_sidata is the load address (LMA) of .data: where its initial values are stored. _sdata and _edata bound its run-time address range (VMA) in RAM. _sbss and _ebss bound .bss. The linker computes all of them from the script; Linker scripts shows how.
The symbols are declared as arrays of unknown size (extern uint32_t _sdata[];) rather than extern uint32_t _sdata;. With the second form, the compiler believes _sdata is a single 4-byte object, and recent GCC versions warn (-Warray-bounds) when the loop walks past it. The array form says “an address, size unknown”, which is the truth. Strictly speaking, comparing pointers derived from different objects (dst < _edata) is undefined in ISO C; every embedded toolchain defines it the obvious way, and it is the universal idiom, but it is one reason the startup file is treated as platform code rather than portable C.
The loops copy and clear whole words, which is why the linker script aligns the start and end of .data and .bss to 4 bytes. If they were not aligned, the last word written could spill into the next section.
The memcpy and memset you did not write
Section titled “The memcpy and memset you did not write”Compile startup.c at -O2 and look at what it needs from other files:
$ arm-none-eabi-nm startup.o | grep " U " U __init_array_end U __init_array_start U __preinit_array_end U __preinit_array_start U _ebss U _edata U _estack U _sbss U _sdata U _sidata U main U memcpy U memsetmemcpy and memset appear because GCC (version 10 and later, at -O2) recognises a word-copy loop and a word-fill loop and replaces them with calls to the library functions, which are usually faster. With newlib linked, as in our Makefile, this is harmless: newlib’s memcpy and memset use no global state and are safe to call before .data exists. It stops being harmless when:
- You link with
-nostdliband getundefined reference to 'memset'. - You write your own
memsetwith a loop, and the compiler turns the loop inside it into a call tomemset: infinite recursion.
-ffreestanding does not prevent this; GCC documents that even a freestanding program must provide memcpy, memmove, memset, and memcmp. To keep the loops as loops, compile the file with -fno-tree-loop-distribute-patterns, or put __attribute__((optimize("no-tree-loop-distribute-patterns"))) on the function. This is a good example of why you read the compiler’s output for startup code.
Step 4: SystemInit
Section titled “Step 4: SystemInit”Clock configuration traditionally lives in a function called SystemInit (the CMSIS name). Here it is a weak empty default, so the chip stays on the 16 MHz HSI until you provide a real one; Clocks, reset, and timing writes it.
Where to call it is a real design choice. ST’s current startup files call SystemInit before copying .data, because the copy then runs at full clock speed. The cost is that SystemInit must not touch any global. This startup calls it after, so it may freely set variables. The bug the first ordering invites is subtle: if an early SystemInit sets a global such as SystemCoreClock = 168000000; and that variable has an initializer, the .data copy that follows overwrites it with the initializer value. Every baud rate computed from it is then wrong, and nothing points at startup as the cause.
Step 5: constructors
Section titled “Step 5: constructors”C++ static constructors, and C functions marked __attribute__((constructor)), are run before main through tables of function pointers that the compiler emits into the .preinit_array and .init_array sections:
__attribute__((constructor))static void early_selftest(void){ /* runs before main, after .data/.bss and SystemInit */}Reset_Handler walks both tables. Newlib provides a function, __libc_init_array, that does the same thing and additionally calls a function named _init. That _init comes from the toolchain’s crti.o, which -nostartfiles removes, so calling __libc_init_array with our Makefile fails to link unless you add an empty _init. Walking the tables directly avoids the issue and shows what is really happening. Pure C projects with no constructors get two empty loops that cost a few bytes.
The table bounds come from the linker script. Add these two sections to link.ld from Blinky from nothing, after .text and before the line _sidata = LOADADDR(.data);:
.preinit_array : { __preinit_array_start = .; KEEP(*(.preinit_array*)) __preinit_array_end = .; } > FLASH
.init_array : { __init_array_start = .; KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array*)) __init_array_end = .; } > FLASHSORT(.init_array.*) orders constructors that have explicit priorities (constructor(101) goes into .init_array.00101). The full script in Linker scripts already contains these sections.
Steps 6 and 7: main, and what if it returns
Section titled “Steps 6 and 7: main, and what if it returns”main in firmware is an infinite loop. If it ever returns, there is no caller to go back to, so the final loop parks the CPU where a debugger shows it immediately. Production firmware often triggers a system reset here instead (write SCB_AIRCR_VECTKEY | SCB_AIRCR_SYSRESETREQ to SCB->AIRCR) after logging why.
What must not happen before main
Section titled “What must not happen before main”The C runtime is not fully alive until .data and .bss are initialized. Before that point:
- No reads or writes of globals or
staticvariables. Reads return garbage, and writes are overwritten by the copy or the zeroing. - No library calls that keep state.
printf,malloc, andrandall keep state in globals. - No floating-point code before the
CPACRwrite. It faults with a UsageFault (NOCP). - No compiler instrumentation that uses globals.
-fstack-protectorcompares a stack canary against the global__stack_chk_guard, and profiling options increment global counters. If such code runs in a function called before the copy, it sees a value that later changes under it. Buildstartup.cwithout these options. - No interrupts that use any of the above. After a power-on or a system reset this is automatic. If your code is started by a bootloader that left peripherals running, an interrupt can arrive the moment you enable it in the NVIC; Bootloaders and firmware update covers handing over cleanly.
C or assembly?
Section titled “C or assembly?”Vendor startup files are traditionally assembly. On Cortex-M, C is a legitimate choice, because Arm designed the architecture so that C works from the first instruction: the hardware loads sp before fetching anything, and exception entry saves exactly the registers the C calling convention says a function may clobber. Assembly is still the right tool when:
- The stack pointer cannot be trusted. When gdb loads an image and sets
pcto the entry point without a reset,spkeeps whatever value it had. ST’s startup begins withldr sp, =_estackfor this reason. A C function cannot safely set its own stack pointer. - RAM must be written before it is read. Some microcontrollers (several STM32H7 and safety-oriented parts) have ECC-protected SRAM that faults on reads of never-written locations. The stack is in that RAM, so even C’s first
pushcan fault. - You need guarantees about generated code. Startup compiled at
-O0keeps locals on the stack; at-O2it may call library functions. Assembly does exactly what you wrote.
For comparison, here is the core of Reset_Handler in assembly:
.syntax unified .thumb .section .text.Reset_Handler,"ax",%progbits .global Reset_Handler .thumb_func .type Reset_Handler, %functionReset_Handler: ldr sp, =_estack @ do not trust the incoming sp ldr r0, =_sdata ldr r1, =_edata ldr r2, =_sidata1: cmp r0, r1 @ copy .data bhs 2f ldr r3, [r2], #4 str r3, [r0], #4 b 1b2: ldr r0, =_sbss ldr r1, =_ebss movs r3, #03: cmp r0, r1 @ zero .bss bhs 4f str r3, [r0], #4 b 3b4: bl SystemInit bl main5: b 5b .size Reset_Handler, .-Reset_HandlerEvery line maps to a line of the C version. The numeric labels (1:, with 1b meaning “label 1, searching backward” and 2f meaning “label 2, searching forward”) are GNU assembler local labels. .thumb_func makes sure the symbol carries the Thumb bit when it is placed in the vector table. If you choose this route, keep the vector table in C and remove the C Reset_Handler, so that exactly one definition exists.
Relocating the vector table: VTOR
Section titled “Relocating the vector table: VTOR”The core finds the vector table through SCB->VTOR (0xE000ED08), which resets to 0. Writing another address moves the table. Two situations need this:
- Bootloaders. A bootloader occupies the start of flash with its own vector table; the application is linked at, say,
0x08008000with its table at that address. Before jumping to the application, the bootloader (or the application’s ownSystemInit) setsVTORto0x08008000. Bootloaders and firmware update covers the full handover. - Handlers installed at run time. A flash table is fixed at link time. Copying it to RAM and pointing
VTORat the copy lets code change vectors while running.
#include <stdint.h>#include "stm32f407_regs.h"
#define VECTOR_COUNT 98u /* 16 + 82 device IRQs */#define FLASH_VECTORS ((const uint32_t *)0x08000000u)
typedef void (*isr_t)(void);
/* 98 words = 392 bytes, so the table needs 512-byte alignment. */static uint32_t ram_vectors[VECTOR_COUNT] __attribute__((aligned(512)));
void vectors_relocate_to_ram(void){ for (uint32_t i = 0; i < VECTOR_COUNT; i++) { ram_vectors[i] = FLASH_VECTORS[i]; } SCB->VTOR = (uint32_t)ram_vectors; __asm volatile ("dsb\n\tisb" : : : "memory");}
void irq_set_handler(uint32_t irqn, isr_t handler){ ram_vectors[16u + irqn] = (uint32_t)handler; /* keeps the Thumb bit */ __asm volatile ("dsb" : : : "memory");}The details that matter:
- Alignment. The architecture requires the table to be aligned to a power of two at least as large as the table itself, with a minimum of 128 bytes. 98 entries need 392 bytes, so the next power of two is 512. A misaligned
VTORsilently ignores the low address bits, and the core fetches vectors from the wrong place. - Call it from
mainor later.ram_vectorsis in.bss, whichReset_Handlerzeroes; copying into it before that would be wiped. - No need to disable interrupts here. Until the
VTORwrite, exceptions use the complete flash table; after it, they use the complete RAM copy. Changing a single entry later is one aligned word store, which is atomic. - Barriers. The
dsbmakes sure theVTORwrite has completed before any later instruction, so an interrupt taken afterwards uses the new table; theisbmakes the following instructions observe it. This is the canonical barrier pattern after writingVTOR. - The Thumb bit. Converting a function pointer to
uint32_tkeeps bit 0 set, because the compiler’s function addresses for Thumb code already have it. Never construct a vector from a raw code address without setting bit 0.
Verifying with gdb
Section titled “Verifying with gdb”Check what the core will see before it runs a single instruction:
(gdb) target extended-remote :3333(gdb) load(gdb) monitor reset halttarget halted due to debug-request, current mode: ThreadxPSR: 0x01000000 pc: 0x08000254 msp: 0x20020000(gdb) x/8xw 0x080000000x8000000 <vector_table>: 0x20020000 0x08000255 0x08000251 0x080002510x8000010 <vector_table+16>: 0x08000251 0x08000251 0x08000251 0x00000000(gdb) x/4xw 0x000000000x0: 0x20020000 0x08000255 0x08000251 0x08000251(gdb) info registers sp pc xpsrsp 0x20020000 0x20020000pc 0x8000254 0x8000254 <Reset_Handler>xpsr 0x1000000 16777216(gdb) p/x *(uint32_t *)0xE000ED08$1 = 0x0Everything from the start of this lesson is visible here. Word 0 of the table, 0x20020000, is in msp. Word 1, 0x08000255, is odd (Thumb bit set), and pc holds the same address with bit 0 cleared, which gdb resolves to Reset_Handler. Words 2 to 6 (NMI, HardFault, MemManage, BusFault, UsageFault) all have the same value, because they are all weak aliases of Default_Handler, and word 7 is the first reserved slot, 0. Reading address 0 returns the same words as 0x08000000, the boot alias at work. xPSR is 0x01000000, only the Thumb bit set, IPSR 0 (Thread mode). VTOR is 0. Your exact addresses will differ; the relationships will not.
Common mistakes
Section titled “Common mistakes”- A misspelled handler name silently falling back to
Default_Handler. Fix: copy names from the vector table, and checknmoutput forWversusT. - Vector table entries shifted by one (a missing reserved slot). Every interrupt runs its neighbour’s handler. Fix: assert the table size, and generate declarations and entries from one list.
- Writing globals in early clock setup and having the
.datacopy overwrite them. Fix: callSystemInitafter the copy, or make it touch no globals. - Using
floatbefore the FPU is enabled. Fix: enableCPACRas the first action inReset_Handler, followed bydsbandisb. - Linking with
-nostdliband hittingundefined reference to 'memset'from loops the compiler converted. Fix: link libc, provide the functions, or use-fno-tree-loop-distribute-patternsfor that file. - Pointing
VTORat a RAM table that is not aligned to 512 bytes. Fix:__attribute__((aligned(512))), and check withnmthat the table’s address is a multiple of0x200. - Calling
__libc_init_arraywhile using-nostartfilesand gettingundefined reference to '_init'. Fix: walk.init_arrayyourself or provide an empty_init.
Exercises
Section titled “Exercises”- Build the project with the new
startup.cand runarm-none-eabi-objdump -h firmware.elf. Confirm that.isr_vectoris at08000000with size00000188. Then compute the table offset ofUSART2_IRQHandlerand verify withx/xw 0x080000D8in gdb that it holds the address ofDefault_Handlerplus 1. - Enable the SysTick interrupt from Blinky from nothing but delete your
SysTick_Handler. Run until the program hangs, halt it, and usep/x $xpsr & 0x1ffto identify the exception. Expected:0xf, SysTick. - Add a global
uint32_t g_boot_magic = 0xC0FFEEu;and a__attribute__((constructor))function that increments it. Inmain, check that it equals0xC0FFEFu. Then comment out the.datacopy loop inReset_Handler, rebuild, and observe in gdb what valueg_boot_magichas at the start ofmain(anything left in RAM, often a value from the previous run). - Disassemble
Reset_Handlerwitharm-none-eabi-objdump -d startup.oat-O2, then compile again with-fno-tree-loop-distribute-patternsand compare. Identify thebl memcpyandbl memsetin the first listing and the explicitldr/strloops in the second. - Use
vectors_relocate_to_ramto switch between two SysTick handlers at run time: one blinks the green LED, the other the red LED, and pressing the user button swaps them. SysTick is exception 15, not a device IRQ, soirq_set_handlerdoes not fit; write a variantexception_set_handler(uint32_t exc_num, isr_t handler)that indexes the table by exception number. Verify withp/x *(uint32_t *)0xE000ED08thatVTORpoints into RAM and that the value is a multiple of0x200.
Check yourself
Section titled “Check yourself”Question 1:At reset the word at address 0x00000004 is 0x08000254 (bit 0 clear). What happens?
Bit 0 of a vector sets EPSR.T. With it clear, the first instruction raises an INVSTATE UsageFault, which escalates to HardFault; if that vector is broken too, the core locks up.
Question 2:A program hangs and the debugger shows it in Default_Handler with $xpsr & 0x1ff equal to 0x36. Which interrupt fired?
0x36 is 54. Device interrupts are exception 16 + IRQ, so this is IRQ 38, which is USART2 on the STM32F407.
Question 3:Early startup code calls SystemInit() before the .data copy, and SystemInit sets SystemCoreClock = 168000000;. The variable is declared uint32_t SystemCoreClock = 16000000;. What value does main see?
Initialized globals live in .data. The copy from flash runs after SystemInit in this ordering and restores the initializer.
Question 4:Why must a RAM vector table for the STM32F407 be aligned to 512 bytes?
The architecture uses the low bits of the vector offset as an index into the table, so the base must be aligned to the next power of two above the table size.
Question 5:Your hand-written memset (a simple byte loop) crashes with a stack overflow at -O2. What is the most likely cause?
Loop distribution turns fill and copy loops into calls to memset and memcpy. Inside memset itself that becomes infinite recursion. Compile with -fno-tree-loop-distribute-patterns.
Key takeaways
Section titled “Key takeaways”- At reset the core loads
MSPfrom word 0 andpcfrom word 1 of the vector table at address 0, which aliases flash at0x08000000when BOOT0 is low. - Every vector must have bit 0 set (Thumb). Compiler-generated function addresses already do.
- The STM32F407 table has 16 system entries and 82 device IRQs: 98 words. IRQ k is exception 16 + k at offset 4 × (16 + k).
- Weak aliases to an infinite-loop
Default_Handlerlet you override any handler by name; read IPSR (xPSR & 0x1ff) to find which exception you missed. Reset_Handlerorder: enable FPU, copy.data, zero.bss, clock setup, constructors,main, trap. No globals, stateful library calls, or floats before their prerequisites.VTORrelocates the vector table for bootloaders and run-time handlers; align it to the table size rounded up to a power of two and follow the write withdsbandisb.
Next: Linker scripts, which explains every line of link.ld and how _sidata, _sdata, and the rest get their values.