Skip to content

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.

When reset is released, the Cortex-M4 does three things in hardware before executing a single instruction:

  1. Reads the 32-bit word at address 0x00000000 and loads it into the main stack pointer, MSP.
  2. 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 into pc.
  3. Starts executing at that pc, in Thread mode, privileged, using MSP.
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 = 0

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

  • r0 to r12 hold unknown values. LR holds 0xFFFFFFFF.
  • PRIMASK is 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 (CPACR is 0). Any floating-point instruction faults.
  • The clock is the internal 16 MHz HSI oscillator, with zero flash wait states.

On the STM32F407 address 0x00000000 is an alias. The BOOT pins, sampled at reset, choose which memory appears there:

BOOT1 (PB2)BOOT0Memory aliased at 0x00000000
any0Main flash (0x08000000)
01System memory (ST’s ROM bootloader)
11Embedded 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 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).

ExceptionNameOffsetPriority
(word 0)Initial MSP value0x00
1Reset0x04-3, fixed
2NMI0x08-2, fixed
3HardFault0x0C-1, fixed
4MemManage0x10configurable
5BusFault0x14configurable
6UsageFault0x18configurable
7-10Reserved0x1C-0x28
11SVCall0x2Cconfigurable
12Debug Monitor0x30configurable
13Reserved0x34
14PendSV0x38configurable
15SysTick0x3Cconfigurable
16 + kIRQ k0x40 + 4kconfigurable

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

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
/* 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.

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.

__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))
} > FLASH

KEEP 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.

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 = 0x2c

The 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.

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.

These are the same two loops as in blinky. Here is what the linker symbols describe:

FLASH RAM
0x08000000 +--------------------+ 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.

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 memset

memcpy 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 -nostdlib and get undefined reference to 'memset'.
  • You write your own memset with a loop, and the compiler turns the loop inside it into a call to memset: 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.

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.

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 = .;
} > FLASH

SORT(.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.

The C runtime is not fully alive until .data and .bss are initialized. Before that point:

  • No reads or writes of globals or static variables. Reads return garbage, and writes are overwritten by the copy or the zeroing.
  • No library calls that keep state. printf, malloc, and rand all keep state in globals.
  • No floating-point code before the CPACR write. It faults with a UsageFault (NOCP).
  • No compiler instrumentation that uses globals. -fstack-protector compares 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. Build startup.c without 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.

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 pc to the entry point without a reset, sp keeps whatever value it had. ST’s startup begins with ldr sp, =_estack for 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 push can fault.
  • You need guarantees about generated code. Startup compiled at -O0 keeps locals on the stack; at -O2 it 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, %function
Reset_Handler:
ldr sp, =_estack @ do not trust the incoming sp
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
1: cmp r0, r1 @ copy .data
bhs 2f
ldr r3, [r2], #4
str r3, [r0], #4
b 1b
2: ldr r0, =_sbss
ldr r1, =_ebss
movs r3, #0
3: cmp r0, r1 @ zero .bss
bhs 4f
str r3, [r0], #4
b 3b
4: bl SystemInit
bl main
5: b 5b
.size Reset_Handler, .-Reset_Handler

Every 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.

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, 0x08008000 with its table at that address. Before jumping to the application, the bootloader (or the application’s own SystemInit) sets VTOR to 0x08008000. 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 VTOR at the copy lets code change vectors while running.
vectors.c
#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 VTOR silently ignores the low address bits, and the core fetches vectors from the wrong place.
  • Call it from main or later. ram_vectors is in .bss, which Reset_Handler zeroes; copying into it before that would be wiped.
  • No need to disable interrupts here. Until the VTOR write, 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 dsb makes sure the VTOR write has completed before any later instruction, so an interrupt taken afterwards uses the new table; the isb makes the following instructions observe it. This is the canonical barrier pattern after writing VTOR.
  • The Thumb bit. Converting a function pointer to uint32_t keeps 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.

Check what the core will see before it runs a single instruction:

(gdb) target extended-remote :3333
(gdb) load
(gdb) monitor reset halt
target halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x08000254 msp: 0x20020000
(gdb) x/8xw 0x08000000
0x8000000 <vector_table>: 0x20020000 0x08000255 0x08000251 0x08000251
0x8000010 <vector_table+16>: 0x08000251 0x08000251 0x08000251 0x00000000
(gdb) x/4xw 0x00000000
0x0: 0x20020000 0x08000255 0x08000251 0x08000251
(gdb) info registers sp pc xpsr
sp 0x20020000 0x20020000
pc 0x8000254 0x8000254 <Reset_Handler>
xpsr 0x1000000 16777216
(gdb) p/x *(uint32_t *)0xE000ED08
$1 = 0x0

Everything 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.

  • A misspelled handler name silently falling back to Default_Handler. Fix: copy names from the vector table, and check nm output for W versus T.
  • 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 .data copy overwrite them. Fix: call SystemInit after the copy, or make it touch no globals.
  • Using float before the FPU is enabled. Fix: enable CPACR as the first action in Reset_Handler, followed by dsb and isb.
  • Linking with -nostdlib and hitting undefined reference to 'memset' from loops the compiler converted. Fix: link libc, provide the functions, or use -fno-tree-loop-distribute-patterns for that file.
  • Pointing VTOR at a RAM table that is not aligned to 512 bytes. Fix: __attribute__((aligned(512))), and check with nm that the table’s address is a multiple of 0x200.
  • Calling __libc_init_array while using -nostartfiles and getting undefined reference to '_init'. Fix: walk .init_array yourself or provide an empty _init.
  1. Build the project with the new startup.c and run arm-none-eabi-objdump -h firmware.elf. Confirm that .isr_vector is at 08000000 with size 00000188. Then compute the table offset of USART2_IRQHandler and verify with x/xw 0x080000D8 in gdb that it holds the address of Default_Handler plus 1.
  2. Enable the SysTick interrupt from Blinky from nothing but delete your SysTick_Handler. Run until the program hangs, halt it, and use p/x $xpsr & 0x1ff to identify the exception. Expected: 0xf, SysTick.
  3. Add a global uint32_t g_boot_magic = 0xC0FFEEu; and a __attribute__((constructor)) function that increments it. In main, check that it equals 0xC0FFEFu. Then comment out the .data copy loop in Reset_Handler, rebuild, and observe in gdb what value g_boot_magic has at the start of main (anything left in RAM, often a value from the previous run).
  4. Disassemble Reset_Handler with arm-none-eabi-objdump -d startup.o at -O2, then compile again with -fno-tree-loop-distribute-patterns and compare. Identify the bl memcpy and bl memset in the first listing and the explicit ldr/str loops in the second.
  5. Use vectors_relocate_to_ram to 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, so irq_set_handler does not fit; write a variant exception_set_handler(uint32_t exc_num, isr_t handler) that indexes the table by exception number. Verify with p/x *(uint32_t *)0xE000ED08 that VTOR points into RAM and that the value is a multiple of 0x200.

Question 1:At reset the word at address 0x00000004 is 0x08000254 (bit 0 clear). What happens?

Question 2:A program hangs and the debugger shows it in Default_Handler with $xpsr & 0x1ff equal to 0x36. Which interrupt fired?

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?

Question 4:Why must a RAM vector table for the STM32F407 be aligned to 512 bytes?

Question 5:Your hand-written memset (a simple byte loop) crashes with a stack overflow at -O2. What is the most likely cause?

  • At reset the core loads MSP from word 0 and pc from word 1 of the vector table at address 0, which aliases flash at 0x08000000 when 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_Handler let you override any handler by name; read IPSR (xPSR & 0x1ff) to find which exception you missed.
  • Reset_Handler order: enable FPU, copy .data, zero .bss, clock setup, constructors, main, trap. No globals, stateful library calls, or floats before their prerequisites.
  • VTOR relocates 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 with dsb and isb.

Next: Linker scripts, which explains every line of link.ld and how _sidata, _sdata, and the rest get their values.