Reset Sequence, Startup Code& Privilege Transitions-Embedded Systems Training Institute in Hyderabad

Cortex-M Reset Sequence and Privilege Modes | STM32 Embedded

Lecture 06 — ARM Cortex-M Programming

Reset Sequence, Startup Code
& Privilege Transitions

What actually happens between the moment you press the reset button and your first line of main() executing? This lecture traces every step — the vector table fetch, the startup file, section initialisation, and how the processor’s privilege level can shift during runtime.

Covers source pages 51–54  |  STM32F411  |  arm-none-eabi-gcc

1. What Happens Before main()?

Most programmers think of their embedded program as starting at main(). In reality, main() is called quite late — after the processor has gone through a carefully orchestrated boot sequence that sets up the runtime environment C code needs.

Understanding this sequence matters for three practical reasons:

  • Debugging early crashes — if your program crashes before main(), you need to know where to look.
  • Writing bare-metal projects from scratch — if you do not use an IDE template, you must supply a startup file yourself.
  • Optimising boot time — knowing what Reset_Handler does lets you decide what to skip in latency-critical applications.
PROGRAMMER VIEW

What you think happens

Power on → main() starts → global variables are initialised → your code runs.

REALITY

What actually happens

Power on → processor reads vector table → jumps to Reset_Handler → .data section copied from flash to SRAM → .bss section zeroed → C library constructors called → main() called.

2. The Vector Table and Reset Entry

Every Cortex-M processor starts in the same way after any reset (power-on, external pin, software, or watchdog). The processor does two reads from address 0x00000000 before executing a single instruction:

1
Word at 0x00000000 — loaded into the Main Stack Pointer (MSP). This is the initial top-of-stack value so the processor has a valid stack before calling any function.
2
Word at 0x00000004 — loaded into the Program Counter (PC). This is the address of the Reset_Handler function in flash.

This array of addresses starting at 0x00000000 is called the vector table. Entry 0 is the initial MSP value; entry 1 (at offset 4) is the reset handler address; entries 2 onward are addresses of exception and interrupt handlers.

/* Typical vector table definition in a startup file (C style) */

extern uint32_t _estack;      /* linker symbol: top of SRAM */
extern void Reset_Handler(void);
extern void NMI_Handler(void);
extern void HardFault_Handler(void);
/* ... more handlers ... */

/* Placed in .isr_vector section by the linker script */
__attribute__((section(".isr_vector")))
const uint32_t vector_table[] = {
    (uint32_t)&_estack,          /* 0x00: Initial MSP value          */
    (uint32_t)&Reset_Handler,    /* 0x04: Reset entry point           */
    (uint32_t)&NMI_Handler,      /* 0x08: Non-maskable interrupt      */
    (uint32_t)&HardFault_Handler,/* 0x0C: Hard fault                  */
    /* ... */
};
Why 0x00000000?
The Cortex-M memory map defines the CODE region starting at address 0x00000000. On most STM32 devices, the internal flash starts at 0x08000000, but the memory controller provides a memory remap so that 0x00000000 mirrors 0x08000000 at boot time. This is controlled by the BOOT0 pin. When BOOT0 is low (normal boot), reading 0x00000000 reads flash. When BOOT0 is high, it may mirror SRAM or the system bootloader.

Vector Table Layout at 0x08000000 (STM32F411)

AddressOffsetContent
0x08000000+0x00Initial MSP (top of SRAM)
0x08000004+0x04Reset_Handler address ← PC jumps here
0x08000008+0x08NMI_Handler
0x0800000C+0x0CHardFault_Handler
……Other exception/IRQ handlers

3. The Reset Sequence Step by Step

When reset is released, hardware performs these steps automatically — no code executes during this phase:

RESET released (power-on, NRST pin, or software)
↓
Hardware reads 0x00000000 → loads MSP register
↓
Hardware reads 0x00000004 → loads PC register
↓
Processor begins executing at Reset_Handler address
↓
Reset_Handler: .data section init
↓
Reset_Handler: .bss section zeroed
↓
Reset_Handler: __libc_init_array() — C++ constructors, libc init
↓
main() called — your application code starts

At the moment Reset_Handler begins executing, the processor is in Thread mode with Privileged access level. The MSP is valid (loaded by hardware), but .data and .bss have not been set up yet — so any C code that runs before the startup file completes cannot rely on global or static variables being initialised.

Common early-boot trap
If you add code to Reset_Handler that references a global variable before .data is initialised, you will read garbage. This is a real bug in hand-written startup files and is extremely hard to debug because it only manifests in specific compile configurations.

4. Reset Handler Responsibilities

The Reset_Handler function lives in the startup file (e.g. startup_stm32f411retx.s or startup_stm32f411.c). Its job is to bring the C runtime to a state where C code can execute correctly. It has exactly three responsibilities before calling main():

STEP 1

Initialise .data section

Copy initialised global/static variables from flash (where they are stored after programming) into SRAM (where the processor can read and write them at runtime).

STEP 2

Initialise .bss section

Zero-fill all uninitialised global/static variables. The C standard guarantees these are zero at program start — but SRAM power-up value is random, so we must do this explicitly.

STEP 3

Initialise C standard library

Call __libc_init_array() which runs C++ static constructors and any __attribute__((constructor)) functions. For pure C projects, this is often a no-op but must still be called.

After these three steps, the environment meets the requirements of the C standard and main() is called.

5. Initialising the .data Section

When you write a global variable like int x = 42;, the compiler places the initial value 42 in the .data section. But at link time, the linker places the initial values in flash (so they survive power-off) and reserves space for the variable in SRAM.

.data Section: Flash → SRAM Copy

FLASH (0x08000000+)
_sidata (start)
42 (x init val)
hello (y init)
_edata mirror
Initial values stored here
→
memcpy from _sidata
to _sdata, size =
_edata – _sdata
→
SRAM (0x20000000+)
_sdata (start)
x = 42
y = “hello”
_edata
Variables live here at runtime

The linker script exports three symbols that Reset_Handler uses:

Linker SymbolMeaning
_sidataStart of initial values in flash (Load Memory Address)
_sdataStart of .data in SRAM (Virtual Memory Address)
_edataEnd of .data in SRAM — copy bytes from _sdata to _edata
/* .data initialisation in C (equivalent to what startup .s file does) */
extern uint32_t _sidata;  /* initial values in flash */
extern uint32_t _sdata;   /* start of .data in SRAM  */
extern uint32_t _edata;   /* end of .data in SRAM    */

static void init_data_section(void)
{
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;

    while (dst < &_edata) {
        *dst++ = *src++;   /* copy word by word from flash to SRAM */
    }
}

6. Initialising the .bss Section

Uninitialised global and static variables — like int counter; or static uint8_t buffer[256]; — are placed in the .bss section. The C standard requires these to be zero at program start.

Flash stores only the size of .bss (no actual data, because it is all zeros). The startup code must zero-fill the SRAM region reserved for .bss.

extern uint32_t _sbss;   /* start of .bss in SRAM */
extern uint32_t _ebss;   /* end of .bss in SRAM   */

static void init_bss_section(void)
{
    uint32_t *dst = &_sbss;

    while (dst < &_ebss) {
        *dst++ = 0U;   /* zero every word in .bss */
    }
}
Why not rely on SRAM power-up state?
SRAM content after power-on is completely unpredictable. On a fresh device it might be all zeros. After a warm reset it will contain whatever was there before. Code that relies on .bss being zero without the startup explicitly zeroing it will work in debug builds (where the debugger loads the ELF and often zeroes SRAM) but fail in production (where the device boots from flash without a debugger).

.data vs .bss — what goes where

/* These variables go into .data (stored in flash, copied to SRAM) */
int     global_count = 100;           /* initialised global */
float   pi           = 3.14159f;      /* initialised global */
static  int s_limit  = 50;            /* initialised static */

/* These variables go into .bss (zeroed in SRAM, no flash storage) */
int     error_code;                   /* uninitialised global */
uint8_t tx_buffer[512];               /* uninitialised array  */
static  uint32_t s_timestamp;         /* uninitialised static */

/* Local variables go on the stack — NOT in .data or .bss */
void foo(void) {
    int local = 10;   /* stack — must be initialised explicitly */
}

7. C Standard Library Initialisation

The function __libc_init_array() is provided by newlib (the C library used by arm-none-eabi-gcc). It iterates through a list of function pointers stored in the .preinit_array, .init_array, and .fini_array sections of the ELF file and calls each one.

C projects

Usually a no-op

If your project is pure C with no __attribute__((constructor)) functions, __libc_init_array() does almost nothing and returns immediately. Still call it — the C standard technically requires it.

C++ projects

Runs static constructors

For C++ code, every class with a static instance (e.g. static Logger log;) has its constructor in .init_array. Skipping __libc_init_array() would leave static objects uninitialised — instant undefined behaviour.

/* The call in Reset_Handler */
extern void __libc_init_array(void);

void Reset_Handler(void)
{
    init_data_section();
    init_bss_section();
    __libc_init_array();   /* run constructors / C++ static init */
    main();                /* call user application              */

    /* If main() ever returns (it shouldn't in embedded code),
       loop forever to prevent the processor from fetching garbage */
    while (1);
}

8. Complete Startup File Example

Below is a minimal but complete startup file written in C, suitable for STM32F411. This is the kind of file that IDEs generate automatically — understanding it means you can debug it when things go wrong.

/* startup_stm32f411.c — minimal bare-metal startup for STM32F411 */
#include <stdint.h>

/* ----------------------------------------------------------------
   Linker-provided symbols (defined in the .ld linker script)
   ---------------------------------------------------------------- */
extern uint32_t _estack;     /* top of SRAM stack area          */
extern uint32_t _sidata;     /* .data initial values in flash    */
extern uint32_t _sdata;      /* .data start in SRAM              */
extern uint32_t _edata;      /* .data end in SRAM                */
extern uint32_t _sbss;       /* .bss start in SRAM               */
extern uint32_t _ebss;       /* .bss end in SRAM                 */

/* ----------------------------------------------------------------
   Forward declarations for handlers
   ---------------------------------------------------------------- */
void Reset_Handler(void);
void Default_Handler(void);

/* Weak aliases so every ISR has a safe default */
void NMI_Handler(void)      __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void)__attribute__((weak, alias("Default_Handler")));
void SVC_Handler(void)      __attribute__((weak, alias("Default_Handler")));
void PendSV_Handler(void)   __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void)  __attribute__((weak, alias("Default_Handler")));
/* ... add all 98 STM32F411 IRQ handlers here as weak aliases ... */

extern void main(void);
extern void __libc_init_array(void);

/* ----------------------------------------------------------------
   Vector table — placed at 0x08000000 by the linker script
   ---------------------------------------------------------------- */
__attribute__((section(".isr_vector")))
const uint32_t vector_table[] = {
    (uint32_t)&_estack,           /* 0: Initial MSP value      */
    (uint32_t)Reset_Handler,       /* 1: Reset                  */
    (uint32_t)NMI_Handler,         /* 2: NMI                    */
    (uint32_t)HardFault_Handler,   /* 3: HardFault              */
    /* ... remaining entries ... */
};

/* ----------------------------------------------------------------
   Reset Handler — first code that runs after reset
   ---------------------------------------------------------------- */
void Reset_Handler(void)
{
    /* 1. Copy .data initial values from flash to SRAM */
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }

    /* 2. Zero-fill the .bss section in SRAM */
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0U;
    }

    /* 3. Initialise C standard library (C++ constructors etc.) */
    __libc_init_array();

    /* 4. Call user application */
    main();

    /* Should never reach here in an embedded system */
    while (1);
}

/* ----------------------------------------------------------------
   Default handler — infinite loop for unhandled exceptions
   ---------------------------------------------------------------- */
void Default_Handler(void)
{
    while (1);   /* Breakpoint here to catch unexpected interrupts */
}
__attribute__((weak, alias(…)))
The weak attribute means any handler you define in your own code (e.g. void SysTick_Handler(void) { ... }) will override the default. If you do not define a handler, the linker uses Default_Handler as the safe fallback — which loops forever so you can catch it with a debugger.

9. Privilege Level Transitions at Runtime

After reset, the processor runs in Thread mode, Privileged access level. This is the most permissive state — the code can access all registers including NVIC, SCB, MPU, and system control registers. The CONTROL register’s nPRIV bit (bit 0) is 0 (privileged).

A well-designed RTOS or secure application may want to drop application tasks to unprivileged level to prevent them from corrupting the OS or hardware. Here is how the transitions work:

Privilege Level State Machine — Thread Mode

Thread Mode
PRIVILEGED
CONTROL[0] = 0
Can access ALL registers
Can switch to unprivileged
Thread Mode
UNPRIVILEGED
CONTROL[0] = 1
No access to NVIC, SCB
Cannot switch back directly
→ Set CONTROL[0]=1 (software)
Drop to unprivileged
← Must use SVC (syscall)
ISR returns to privileged
Handler Mode (ANY Exception/IRQ)
ALWAYS PRIVILEGED
CONTROL[0] forced to 0 on entry
Returns to Thread mode on exit
/* ---- Dropping from privileged to unprivileged in Thread mode ---- */

static inline void drop_privileges(void)
{
    uint32_t ctrl;
    __asm volatile ("MRS %0, CONTROL" : "=r"(ctrl));
    ctrl |= 1U;   /* set nPRIV bit 0 */
    __asm volatile ("MSR CONTROL, %0\n\t" "ISB\n\t" : : "r"(ctrl) : "memory");
}

/* ---- Regaining privileges from unprivileged Thread mode ----
   ONLY possible via a supervisor call (SVC) — the SVC handler
   runs in Handler mode (always privileged) and can clear nPRIV  */

void SVC_Handler(void)
{
    uint32_t ctrl;
    __asm volatile ("MRS %0, CONTROL" : "=r"(ctrl));
    ctrl &= ~1U;   /* clear nPRIV bit */
    __asm volatile ("MSR CONTROL, %0\n\t" "ISB\n\t" : : "r"(ctrl) : "memory");
}

/* Application calls: */
void task_A(void) {
    drop_privileges();   /* now unprivileged — safe for user tasks */
    /* ... user code ... */
    __asm volatile ("SVC #0");  /* request OS service — goes to SVC_Handler */
}
Why can’t unprivileged code just clear CONTROL[0] itself?
The MSR instruction that writes CONTROL is a privileged instruction. If unprivileged code attempts to execute it, the processor raises a UsageFault exception. This is the whole point of the privilege separation — hardware enforces it, not software.

10. ISR Entry Forces Privileged Mode

One very important property of the Cortex-M exception model is that entering any exception or interrupt automatically switches the processor to Handler mode with Privileged access level, regardless of what Thread mode it was in.

Mode and Privilege Across an Interrupt

Timeline ──────────────────────────────────────────────────────────>

Thread/Unprivileged │<─── CONTROL[0]=1 ───>│
│ │
Interrupt arrives │ ─────────────────┼──> Handler/Privileged (ISR runs)
│ │ │
ISR returns │ │ <─────────────────────┘
Thread/Unprivileged │ │<── returns to pre-ISR state ──>

CONTROL register: │ nPRIV=1 │ forced 0 in ISR │ nPRIV=1 restored

When the ISR returns (via the special EXC_RETURN value in LR), the processor restores the previous Thread mode state including the CONTROL register value — so if Thread mode was unprivileged before the interrupt, it returns to unprivileged after.

The ISR bridge trick
An RTOS like FreeRTOS uses this property to let unprivileged tasks call OS functions safely. The task executes SVC #N which triggers the SVC exception (Handler mode, privileged). The SVC handler reads the syscall number N, performs the OS operation with full hardware access, and returns to Thread mode. The task never has permanent privilege elevation — it only gets it for the duration of the syscall.

11. FAQ

Q: What if my project doesn’t have a startup file? Will it still work?
No. Without a startup file, there is no valid vector table at 0x08000000 so the processor has no reset entry point. Even if you somehow set PC manually, .data will not be initialised and .bss will not be zeroed, causing undefined behaviour in any code that uses global variables. STM32CubeIDE always generates a startup file automatically. If you create a project from scratch, you must supply one.
Q: Does Reset_Handler need to run out of flash or can it run from SRAM?
It runs from flash initially because the PC is set to its flash address by the vector table. However, on STM32 you can relocate functions to SRAM using __attribute__((section(".RamFunc"))). The startup code copies those functions from flash to SRAM during the .data init phase, and then they execute from SRAM for higher speed (no flash latency). Reset_Handler itself must start in flash.
Q: If main() returns on an embedded system, what happens?
In the startup file shown, there is a while(1) after main() which loops forever. In other implementations, returning from main() may call _exit() which on embedded targets typically does the same — loop forever or trigger a reset. Never design an embedded system where main() is expected to return; it is considered a fatal programming error.
Q: What exactly does ISB do after writing CONTROL?
ISB (Instruction Synchronisation Barrier) flushes the processor’s instruction pipeline. The ARM architecture spec requires ISB after writing to CONTROL because the privilege level change takes effect on the next instruction fetch — without ISB, the pipeline may already have fetched subsequent instructions at the old privilege level, leading to unpredictable behaviour. Always pair CONTROL writes with ISB.
Q: What is the difference between a warm reset and a cold reset for .bss?
Both should zero .bss because the startup file always runs. A cold reset (power cycle) gives you random SRAM content; a warm reset (software reset via SCB_AIRCR or external pin without power cycle) keeps the previous SRAM content. In both cases, the startup file zeroes .bss before main(), so global variables are guaranteed to be zero when your code first accesses them — assuming your startup file is correct.
Q: Why is Handler mode always privileged? Can that be changed?
Handler mode is architecturally always privileged — this is a hardware-enforced property of Cortex-M and cannot be changed. The rationale is that exception handlers need to access the NVIC, SCB, and stack pointer registers to do their job; restricting them would make exception handling impossible. This is why secure RTOS designs use the privilege separation at the Thread mode level rather than trying to restrict Handler mode.

Next: Cortex-M Memory Map and Bus Architecture

With reset and modes covered, the next lecture maps out the complete 4 GB Cortex-M address space — code, SRAM, peripherals, private peripheral bus — and explains how the AHB and APB buses determine where you can reach peripherals in C.

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *