Skip to content

Part 2 · C Meets the Metal35 min read

The Cortex-M Architecture

Every STM32F407 peripheral is ST’s design, but the processor core that runs your code is Arm’s, and it behaves the same on every Cortex-M4 from every vendor. That is good news: what you learn here transfers to nRF52, Kinetis, SAM, and hundreds of other parts. This lesson covers the core itself: its registers and special registers, its execution modes, exactly what the hardware does when an interrupt arrives and when the handler returns, how the NVIC decides which interrupt wins, when memory barriers are required, and how the FPU fits in. These mechanisms explain every later topic, from interrupt latency to RTOS context switches to fault analysis, so expect to come back to this page.

You have already met the general-purpose registers in Reading what the compiler wrote. Here is the complete set, including the registers that only mrs and msr can reach:

RegisterWidthPurpose
r0-r1232General purpose (r0-r3, r12 are scratch across calls)
sp (r13)32Stack pointer: one of two physical registers, MSP or PSP
lr (r14)32Return address, or an EXC_RETURN value inside a handler
pc (r15)32Program counter
xPSR32Program status: APSR, IPSR, and EPSR combined
PRIMASK1When 1, masks every exception with configurable priority
FAULTMASK1When 1, masks everything except NMI
BASEPRI8Masks exceptions with priority value greater than or equal to it (0 = off)
CONTROL3Stack selection, privilege, FPU context active

xPSR is the combined view of three status registers that occupy different bits of the same word:

31 30 29 28 27 26 25 24 23 20 19 16 15 10 9 8 0
+--+--+--+--+--+-----+--+--------+------+--------+--+-----------+
|N |Z |C |V |Q |IT |T | |GE |IT | |ISR number |
+--+--+--+--+--+-----+--+--------+------+--------+--+-----------+
\____ APSR ___/ EPSR APSR EPSR IPSR
  • APSR (application): the condition flags N, Z, C, V, the saturation flag Q, and on the M4 the GE flags used by SIMD instructions.
  • IPSR (interrupt): the number of the exception currently being handled, 0 in Thread mode. You used it in Startup code and the vector table to identify an unexpected interrupt.
  • EPSR (execution): the Thumb bit T, always 1 on a running Cortex-M, and the IT/ICI bits that record the state of an IT block or an interrupted multi-register load. You cannot read EPSR directly; it reads as zero through mrs.

PRIMASK, FAULTMASK, and BASEPRI raise the execution priority of the core so that some exceptions cannot pre-empt it:

  • PRIMASK = 1 blocks every exception whose priority is configurable, which is everything except Reset, NMI, and HardFault. cpsid i sets it, cpsie i clears it. This is the classic “disable interrupts”.
  • FAULTMASK = 1 additionally blocks HardFault, leaving only NMI. The core clears it automatically on return from any exception except NMI. It is used in fault handlers and rarely elsewhere.
  • BASEPRI = n blocks only exceptions whose priority value is n or greater, that is, equally or less urgent. Writing 0 disables it. It lets you build critical sections that keep high-priority interrupts running, which is how FreeRTOS implements its critical sections. Like the NVIC priority registers, only the top 4 bits are implemented on the STM32F4, so priority level 5 is written as 0x50.
#include <stdint.h>
#include "stm32f407_regs.h"
/* Block interrupts with priority 5..15; priorities 0..4 still run. */
static inline void basepri_set(uint32_t prio)
{
uint32_t value = prio << (8u - NVIC_PRIO_BITS);
__asm volatile ("msr basepri, %0" : : "r" (value) : "memory");
}

Critical sections, and which mask to use when, are covered in Interrupts done right.

BitNameMeaning
0nPRIVThread mode privilege: 0 privileged, 1 unprivileged
1SPSELThread mode stack: 0 uses MSP, 1 uses PSP
2FPCASet by hardware when the current context has used the FPU

All three bits are 0 at reset. Handler mode ignores nPRIV and SPSEL: handlers always run privileged on MSP.

Classic Arm cores switch between the 32-bit Arm and 16-bit Thumb instruction sets. Cortex-M cores implement only Thumb, which is why every code address has bit 0 set and why the T bit must always be 1. The M3, M4, M7, and M33 implement Thumb-2, the mixed 16/32-bit encoding that covers nearly everything a 32-bit instruction set can do. The M0, M0+, and M23 implement a much smaller subset, mostly 16-bit encodings, which is why the same C code is larger and slower on them.

The core is always in one of two modes:

  • Thread mode: normal code, including main. Entered at reset.
  • Handler mode: exception handlers. Entered automatically on exception entry and left on exception return.

Independently, code runs privileged or unprivileged. Handler mode is always privileged. Thread mode is privileged unless CONTROL.nPRIV is set. Unprivileged code cannot change mask registers, cannot write most system registers (NVIC, SCB, SysTick), and is subject to MPU restrictions. Once Thread mode drops privilege, it cannot raise it again by itself; it must ask a handler, usually through the svc instruction, which is how an RTOS implements system calls.

There are two physical stack pointers. MSP (main stack pointer) is loaded from the vector table at reset and is always used in Handler mode. PSP (process stack pointer) is used in Thread mode when CONTROL.SPSEL is 1. Bare-metal firmware typically runs everything on MSP. An RTOS gives each task its own stack and points PSP at the current task’s stack, while all interrupt handlers share MSP. That split means a task’s stack only needs room for that task plus one exception frame, not for every possible nesting of handlers.

Switching Thread mode to PSP looks like this:

#include <stdint.h>
__attribute__((noreturn))
void start_on_psp(void (*entry)(void), uint32_t *stack_top)
{
__asm volatile (
"msr psp, %0 \n" /* PSP = top of the new stack */
"movs r0, #2 \n"
"msr control, r0 \n" /* SPSEL = 1: Thread mode now uses PSP */
"isb \n" /* later instructions see the new SP */
"bx %1 \n" /* jump to entry, never come back */
: : "r" (stack_top), "r" (entry) : "r0", "memory");
__builtin_unreachable();
}
static uint32_t app_stack[512] __attribute__((aligned(8)));
static void app_main(void)
{
for (;;) {
}
}
int main(void)
{
start_on_psp(app_main, &app_stack[512]);
}

The isb after writing CONTROL is required: it guarantees that every instruction after it uses the new stack pointer. The function never returns because its caller’s stack frame is on MSP, and after the switch every push and pop in Thread mode uses PSP; returning would pop garbage. That is also why real RTOSes start their first task from a carefully written assembly sequence, which RTOS fundamentals: tasks and the context switch builds.

An exception is anything that makes the core stop what it is doing and run a handler: a device interrupt, SysTick, a fault, an svc instruction. All of them go through the same mechanism.

Each exception has a number (its index in the vector table, from Startup code and the vector table) and a priority. Lower priority values are more urgent. Reset (-3), NMI (-2), and HardFault (-1) have fixed negative priorities that nothing else can match. All others are configurable from 0 to 255 in principle; the STM32F4 implements 4 bits, so there are 16 levels, 0 to 15, stored in the top nibble of each priority byte.

An exception pre-empts the running code if its priority is more urgent than the current execution priority. The current execution priority is the priority of the active handler (or “lowest possible” in Thread mode), raised further by PRIMASK, FAULTMASK, or BASEPRI if they are set. An exception that cannot pre-empt stays pending until it can.

When an exception is accepted, the hardware does the following with no software involvement:

  1. Stacking. It pushes eight registers onto the current stack (MSP or PSP, whichever was in use): r0, r1, r2, r3, r12, lr, the return address, and xPSR.
  2. Vector fetch. In parallel, it reads the handler address from the vector table at VTOR + 4 × exception number.
  3. State update. It switches to Handler mode and MSP, writes the exception number into IPSR, and loads lr with a special EXC_RETURN value.
  4. It starts executing the handler.

On a Cortex-M4 with zero-wait-state memory this takes 12 cycles from the interrupt being signalled to the first handler instruction. Flash wait states and bus contention add to it.

The stack frame after entry, without FPU context:

higher addresses
+--------------------+
| (alignment pad) | only if SP was not 8-byte aligned
+--------------------+
SP + 0x1C | xPSR | bit 9 = 1 if the pad word was added
SP + 0x18 | return address | where execution resumes
SP + 0x14 | LR | the interrupted code's LR
SP + 0x10 | R12 |
SP + 0x0C | R3 |
SP + 0x08 | R2 |
SP + 0x04 | R1 |
SP + 0x00 | R0 | <- SP inside the handler
+--------------------+
lower addresses

These are exactly the registers the calling convention allows a function to clobber. The handler is compiled as an ordinary C function, so it saves r4-r11 itself if it uses them, just like any function. That is the design decision that lets Cortex-M handlers be plain C functions with no special attribute: the hardware does the caller’s half of the calling convention, the compiler does the callee’s half.

Two details of the frame matter later. The stacked return address is the address of the instruction that will run when the handler returns; for a precise fault, it is the address of the faulting instruction, which is how Watchdogs, faults, and recovering from them finds the line that crashed. And the frame is always 8-byte aligned (CCR.STKALIGN is set by default on the M4): if sp was only 4-byte aligned, the hardware inserts a padding word first and records it in bit 9 of the stacked xPSR, so exception return can remove it.

Inside a handler, lr does not hold a return address. It holds a value of the form 0xFFFFFFxx that cannot be a valid code address, so when the handler executes bx lr (or pop {..., pc}), the core recognises it and performs an exception return instead of a branch. The low bits describe the frame to restore:

EXC_RETURNReturn toStackFrame
0xFFFFFFF1Handler mode (a nested exception)MSPBasic, 8 words
0xFFFFFFF9Thread modeMSPBasic
0xFFFFFFFDThread modePSPBasic
0xFFFFFFE1Handler modeMSPExtended, with FPU registers
0xFFFFFFE9Thread modeMSPExtended
0xFFFFFFEDThread modePSPExtended

Bit 2 selects the stack (1 = PSP), bit 3 the mode (1 = Thread), bit 4 the frame type (0 = extended FPU frame). A fault handler reads bit 2 to find out which stack the faulting code’s frame is on; an RTOS context switch reads bit 4 to know whether it must also save the upper FPU registers. In the blinky project, a breakpoint in SysTick_Handler shows lr = 0xfffffff9: Thread mode, main stack, no FPU context.

On exception return the core pops the frame from the stack EXC_RETURN names, restores xPSR (and with it the mode and IPSR), and resumes at the stacked return address. If the handler corrupted the stacked values, for example through a local buffer overflow that ran into the frame, the return goes somewhere random. Because return is driven by the frame, a handler can also change where execution resumes by editing the stacked pc, a trick fault handlers and RTOSes use deliberately.

The core avoids work it does not need:

Tail chaining: IRQ B becomes pending while IRQ A's handler runs (B cannot pre-empt A)
Thread |stack|----- A handler -----| 6 |----- B handler -----|unstack| Thread
^ no unstack/restack between A and B
Late arrival: IRQ H (urgent) arrives while IRQ L's frame is being stacked
Thread |stack...|-- H handler --| 6 |-- L handler --|unstack| Thread
^ L triggered stacking; the vector fetch switches to H
  • Tail chaining. When a handler returns and another exception is pending that can run, the core skips the unstack/re-stack pair and goes straight to the next handler, in about 6 cycles instead of 24. The frame on the stack is the same in both cases, so there is nothing to redo.
  • Late arrival. If a more urgent exception arrives while the core is still stacking for a less urgent one, it uses the same frame but fetches the urgent vector instead. The less urgent handler then runs by tail chaining afterwards.
  • Pop pre-emption. If an exception arrives while the core is unstacking, it abandons the unstacking and tail-chains into the new handler.
  • Nesting. If a more urgent exception arrives while a handler is running, it pre-empts the handler: a new frame is pushed on MSP and lr in the nested handler is 0xFFFFFFF1. Each nesting level costs at least 32 bytes of main stack (104 with FPU context), which is why stack budgets must account for the deepest possible nesting.

The Nested Vectored Interrupt Controller is the part of the core that manages device interrupts: it tracks which are enabled, pending, and active, holds their priorities, and feeds the most urgent one to the core. Its registers are at the same addresses on every Cortex-M4:

RegisterAddressOne bit (or byte) per IRQWrite semantics
ISER0-ISER70xE000E100EnableWrite 1 to enable, 0 has no effect
ICER0-ICER70xE000E180EnableWrite 1 to disable
ISPR0-ISPR70xE000E200PendingWrite 1 to set pending
ICPR0-ICPR70xE000E280PendingWrite 1 to clear pending
IABR0-IABR70xE000E300ActiveRead-only
IPR0-IPR590xE000E400Priority byteRead/write, byte accessible
STIR0xE000EF00Write an IRQ number to set it pending

IRQ n uses bit n % 32 of word n / 32 in the bit registers, so the STM32F407’s 82 IRQs use words 0 to 2. The set/clear register pairs mean you never need a read-modify-write: NVIC->ISER[1] = 1u << 6; enables IRQ 38 (USART2) without any risk of disturbing the others. The nvic_enable_irq() helper in stm32f407_regs.h is exactly that.

A device interrupt reaches your handler only if all of these hold: the peripheral has its own interrupt enable bit set and its event flag raised, the IRQ is enabled in the NVIC, its priority beats the current execution priority, and PRIMASK is clear. Pending state is separate from the peripheral flag: an IRQ stays pending in the NVIC until its handler starts, and a peripheral flag that is still set when the handler returns makes the IRQ pending again immediately.

Each IRQ has an 8-bit priority field, but the STM32F4 implements only bits 7:4. The unimplemented low bits read as zero and ignore writes:

NVIC->IPR[28] = 5u; /* bug: reads back as 0x00, the MOST urgent level */
NVIC->IPR[28] = 5u << 4; /* correct: priority level 5, byte value 0x50 */

The first line is a very common bug when porting code from a part with a different number of priority bits. nvic_set_priority() in the course header shifts for you, and CMSIS’s NVIC_SetPriority() does the same using __NVIC_PRIO_BITS.

The priority byte can be split into a group priority (also called pre-emption priority), which decides whether one exception can interrupt another, and a subpriority, which only breaks ties between pending exceptions with the same group priority. The split point is the 3-bit PRIGROUP field in SCB->AIRCR (bits 10:8). With 4 implemented bits:

PRIGROUPGroup bitsSubpriority bitsPre-emption levelsSub-levels
0 to 34 (bits 7:4)0161
43 (bits 7:5)1 (bit 4)82
52 (bits 7:6)244
61 (bit 7)328
704116

The reset value 0 already gives 16 pre-emption levels and no subpriority on the STM32F4, which is what most projects want. (ST’s HAL calls the equivalent setting NVIC_PRIORITYGROUP_4, which writes PRIGROUP = 3.) If two pending exceptions have equal group priority and equal subpriority, the lower exception number wins. Writes to AIRCR are ignored unless the top halfword is the key 0x05FA:

SCB->AIRCR = SCB_AIRCR_VECTKEY | (3u << SCB_AIRCR_PRIGROUP_Pos);

The configurable system exceptions take their priorities from the System Handler Priority Registers, one byte each, with the same 4 implemented bits:

RegisterByte 0 (bits 7:0)Byte 1Byte 2Byte 3 (bits 31:24)
SHPR1 (0xE000ED18)MemManageBusFaultUsageFault
SHPR2 (0xE000ED1C)SVCall
SHPR3 (0xE000ED20)Debug MonitorPendSVSysTick

Giving SysTick the least urgent level, for example:

SCB->SHPR3 = (SCB->SHPR3 & ~(0xFFu << 24)) | ((15u << 4) << 24);

MemManage, BusFault, and UsageFault are disabled at reset, and their faults escalate to HardFault. Setting the enable bits in SCB->SHCSR (SCB_SHCSR_MEMFAULTENA, BUSFAULTENA, USGFAULTENA) gives each its own handler, which makes faults much easier to diagnose.

You can watch the priority rules without any peripheral by borrowing two unused IRQs and triggering them from software through STIR. TIM6 and TIM7 are not used in this project, so their vectors (IRQ 54 and 55) are free:

preempt.c
#include <stdint.h>
#include "stm32f407_regs.h"
#define HIGH_IRQn 54u /* TIM6_DAC vector, borrowed */
#define LOW_IRQn 55u /* TIM7 vector, borrowed */
void TIM6_DAC_IRQHandler(void);
void TIM7_IRQHandler(void);
static volatile uint32_t g_trace[4];
static volatile uint32_t g_trace_len;
static void trace(uint32_t id)
{
if (g_trace_len < 4u) {
g_trace[g_trace_len] = id;
g_trace_len = g_trace_len + 1u;
}
}
void TIM6_DAC_IRQHandler(void) /* priority 4: more urgent */
{
trace(2u);
}
void TIM7_IRQHandler(void) /* priority 12: less urgent */
{
trace(1u);
NVIC->STIR = HIGH_IRQn; /* pend the urgent one */
__asm volatile ("dsb\n\tisb" : : : "memory");
trace(3u);
}
int main(void)
{
nvic_set_priority(HIGH_IRQn, 4u);
nvic_set_priority(LOW_IRQn, 12u);
nvic_enable_irq(HIGH_IRQn);
nvic_enable_irq(LOW_IRQn);
NVIC->STIR = LOW_IRQn; /* start the experiment */
__asm volatile ("dsb\n\tisb" : : : "memory");
for (;;) {
}
}

Run it and inspect g_trace in gdb: it holds 1, 2, 3. The low-priority handler records 1, pends the high-priority IRQ, and is pre-empted on the spot; the urgent handler records 2 and returns; the low-priority handler resumes and records 3. The dsb and isb after the STIR write make sure the pend has taken effect and the pipeline has been refilled before trace(3u) starts, so the pre-emption happens exactly at that point. Swap the two priorities and the trace becomes 1, 3, 2: the second IRQ cannot pre-empt, stays pending, and runs by tail chaining when the first returns.

SysTick is a 24-bit down-counter in the core, so every Cortex-M4 has the same system timer at 0xE000E010. You configured it in Blinky from nothing: LOAD holds the reload value (period minus one, at most 0xFFFFFF), VAL the current count (any write clears it), and CTRL the enable, interrupt enable, clock source, and COUNTFLAG bits. With CLKSOURCE = 1 it counts the processor clock; with 0 it counts an implementation-defined reference clock, which on the STM32F4 is the AHB clock divided by 8. Because it is part of the core, an RTOS can rely on it on any Cortex-M chip, which is exactly why they all use it for their tick.

The System Control Block at 0xE000ED00 holds the core’s configuration and status registers. You have now used most of them:

RegisterUse
CPUIDIdentifies the core: 0x410FC241 on the STM32F407 means Arm, Cortex-M4 (part 0xC24), revision r0p1
ICSRPend PendSV (PENDSVSET, bit 28) or SysTick; read the active and pending exception numbers
VTORVector table address
AIRCRPriority grouping, system reset request; writes need the 0x05FA key
SCRSleep behaviour (SLEEPDEEP, sleep-on-exit)
CCRTraps for unaligned access and divide by zero, stack alignment
SHPR1-SHPR3System exception priorities
SHCSREnable MemManage/BusFault/UsageFault handlers, see which are active
CFSR, HFSR, MMFAR, BFARFault status and fault addresses
CPACRFPU access control

ICSR.PENDSVSET deserves a mention: writing it pends PendSV, an exception that does nothing but run its handler when nothing more urgent is active. Given the least urgent priority, it is the standard way to defer work out of an interrupt, and it is the exception every Cortex-M RTOS uses to switch tasks.

The address map from Memory-mapped registers in C also defines how the core treats each region. Without an MPU configured, the default attributes are:

RegionRangeMemory typeExecutable
Code0x00000000-0x1FFFFFFFNormalYes
SRAM0x20000000-0x3FFFFFFFNormalYes
Peripheral0x40000000-0x5FFFFFFFDeviceNo (XN)
External RAM0x60000000-0x9FFFFFFFNormalYes
External device0xA0000000-0xDFFFFFFFDeviceNo
Private Peripheral Bus0xE0000000-0xE00FFFFFStrongly-orderedNo
Vendor system0xE0100000-0xFFFFFFFFDeviceNo

Normal memory may have accesses merged or buffered. Device memory accesses happen in program order relative to each other and are never speculative, but writes may be buffered. Strongly-ordered accesses are not buffered at all. Executing from a region marked XN (“execute never”) faults, so a function pointer that has wandered into peripheral space gives a MemManage or HardFault rather than running garbage.

The Cortex-M4 has no data or instruction cache. The STM32F4’s ART accelerator is a flash prefetch buffer and small cache built by ST into the flash interface, outside the core; it is invisible to software apart from the speed-up and is configured in FLASH_ACR. Cortex-M7 parts do have core caches, and that changes the rules for DMA, as DMA explains.

What the M4 does have is a write buffer: a store to a bufferable region can complete from the core’s point of view before it reaches the peripheral. Usually invisible, it explains two later surprises: a BusFault reported as “imprecise” with a stacked pc past the offending store, and an interrupt that fires twice because the store clearing its flag had not landed when the handler returned.

Barriers control ordering at the hardware level:

  • DMB (data memory barrier): memory accesses before it are observed before memory accesses after it. Ordering only; execution continues.
  • DSB (data synchronization barrier): no instruction after it executes until every memory access before it has completed. Stronger than DMB.
  • ISB (instruction synchronization barrier): flushes the pipeline, so instructions after it are fetched again and see the effect of everything before it, such as a new stack pointer, privilege level, or FPU access setting.

The canonical uses:

SituationBarrierWhy
After writing VTORDSB then ISBExceptions taken afterwards must use the new table
After enabling the FPU in CPACRDSB then ISBThe next instruction may be a floating-point instruction
After writing CONTROL (stack or privilege change)ISBFollowing instructions must use the new sp or privilege
Before WFIDSBPending writes (for example, clearing a flag) complete before sleeping
After enabling the MPUDSB then ISBNew memory permissions apply to the following code
At the end of an ISR that cleared a peripheral flagDSB, or read the register backThe clear reaches the peripheral before the exception returns
Between writing a buffer and starting DMA on itDMB (and cache maintenance on M7)The DMA engine must see the completed data

The Cortex-M3 and M4 have short, simple pipelines, and Arm’s own programming guide notes that several of these barriers are not strictly needed on them. Write them anyway: they cost a few cycles, they document intent, and the same code will run on an M7 or M33 where they are required. volatile, by contrast, constrains only the compiler; it emits no barrier instruction. The full story of compiler versus hardware ordering is in Concurrency, atomics, and memory ordering.

When you use vendor code you will see these operations through Arm’s CMSIS-Core functions rather than raw inline assembly. They are thin wrappers over exactly what this lesson described:

CMSIS-CoreDoes
__disable_irq() / __enable_irq()cpsid i / cpsie i
__get_PRIMASK() / __set_PRIMASK(x)mrs / msr PRIMASK
__get_BASEPRI() / __set_BASEPRI(x)mrs / msr BASEPRI
__get_MSP(), __set_MSP(x), __get_PSP(), __set_PSP(x)Read and write the two stack pointers
__get_CONTROL() / __set_CONTROL(x)CONTROL (recent CMSIS versions add the ISB inside __set_CONTROL)
__DSB(), __ISB(), __DMB()Barrier instructions
__WFI(), __WFE(), __NOP()Sleep and no-op instructions
NVIC_EnableIRQ(n) / NVIC_DisableIRQ(n)Write ISER / ICER (CMSIS 5 maps these to __NVIC_EnableIRQ)
NVIC_SetPriority(n, p)Priority byte in IPR, or SHPR for negative (system) numbers
NVIC_SetPriorityGrouping(g)AIRCR.PRIGROUP with the key
SysTick_Config(ticks)Sets LOAD, clears VAL, gives SysTick the least urgent priority, enables it
CoreArchitectureFPUMPUCachesTrustZoneBit-bandingPriority bits
M0Armv6-MNoNoNoNoNo2
M0+Armv6-MNoOptionalNoNoNo2
M3Armv7-MNoOptionalNoNoOptional3 to 8
M4Armv7E-MOptional, singleOptionalNoNoOptional3 to 8
M7Armv7E-MOptional, single or doubleOptionalOptional I and DNoNo3 to 8
M23Armv8-M BaselineNoOptionalNoOptionalNo2
M33Armv8-M MainlineOptional, singleOptionalNoOptionalNo3 to 8

Other differences you will trip over: the M0 and M0+ have no hardware divide, no BASEPRI, no FAULTMASK, and no exclusive load/store instructions, so their critical sections and atomics must disable interrupts entirely; the M0 has no VTOR (optional on the M0+); the M4, M7, and optionally M33 add DSP and SIMD instructions; the M7’s caches make DMA buffer handling a cache-maintenance problem; and Armv8-M adds stack limit registers (MSPLIM, PSPLIM) and a reworked MPU. “Optional” means the chip vendor decides: the STM32F407 has the FPU, the MPU, and bit-banding.

The STM32F407’s core is an M4F: it includes the FPv4-SP floating-point unit, single precision only.

  • Registers. 32 single-precision registers s0-s31 (also viewable as 16 double-word registers d0-d15 for loads, stores, and moves) and the status and control register FPSCR, which holds comparison flags, rounding mode, flush-to-zero and default-NaN controls, and sticky exception flags (invalid operation, divide by zero, overflow, underflow, inexact, input denormal).
  • Enabling. The FPU is off at reset. Grant access with CPACR bits 23:20 = 0xF, followed by dsb and isb, as the startup code in Startup code and the vector table does. A floating-point instruction executed before that raises a UsageFault with the NOCP flag.
  • ABI. -mfloat-abi=soft never uses the FPU and calls library routines for all float math. softfp uses FPU instructions but passes float arguments in core registers, so it can link with soft-float code. hard passes float arguments and results in s0-s15, the fastest option and the one this course uses. Objects built with hard cannot be linked with soft or softfp objects; the linker refuses with an error about VFP register arguments.
  • Double precision is software. Any double operation compiles to a call such as __aeabi_dmul, tens to hundreds of cycles instead of one. An unsuffixed literal like 0.5 is a double, so x * 0.5 with float x silently promotes to double. Write 0.5f, and use -Wdouble-promotion to catch the rest.

With the FPU in use, an exception must preserve s0-s15 and FPSCR as well, since the handler’s C code may use them. That makes the frame 26 words (104 bytes) instead of 8, the extended frame with EXC_RETURN bit 4 clear:

SP + 0x64 | reserved |
SP + 0x60 | FPSCR |
SP + 0x5C | S15 |
| ... |
SP + 0x20 | S0 |
SP + 0x1C | xPSR | the basic 8-word frame, unchanged,
| ... | sits at the bottom
SP + 0x00 | R0 |

Writing 18 extra words on every interrupt would hurt latency, so by default the M4 uses lazy stacking (FPCCR at 0xE000EF34, bits ASPEN and LSPEN, both set at reset). The hardware tracks whether the interrupted context has used the FPU (CONTROL.FPCA). If it has, the core reserves space for the FPU registers in the frame but does not write them, and records the reserved address. Only if the handler itself executes a floating-point instruction does the core pause and store them into the reserved space. Handlers that never touch floats pay for the extra stack space but not for the extra memory writes, and entry latency stays at 12 cycles. If the interrupted context has not used the FPU at all, the frame is the basic 8 words.

The practical consequences: keep floating-point math out of time-critical ISRs, budget 104 bytes per nesting level for stacks that may hold FPU frames, and remember that an RTOS context switch must save s16-s31 itself for tasks that use the FPU, which it knows from bit 4 of EXC_RETURN.

  • Writing a priority value without shifting it into the implemented bits, so priority 5 becomes 0, the most urgent. Fix: shift by 8 - NVIC_PRIO_BITS, or use a helper that does.
  • Writing AIRCR without the 0x05FA key, so the write is silently ignored (no reset, no priority grouping change). Fix: always OR in SCB_AIRCR_VECTKEY.
  • Disabling an IRQ with NVIC->ISER[n] &= ~bit. Writing 0 to ISER does nothing, and the read-modify-write can enable others by accident. Fix: write 1 to the bit in ICER.
  • Changing CONTROL to switch stacks inside a function that then returns. Fix: switch stacks only in code that never returns to a caller on the old stack, followed by isb.
  • Using double (or unsuffixed literals) on the M4F and getting slow software math. Fix: float with f suffixes, and -Wdouble-promotion.
  • Assuming same-priority interrupts can nest. They cannot; the second waits. Fix: give genuinely urgent work a more urgent group priority.
  • Forgetting that a fault in MemManage/BusFault/UsageFault escalates to HardFault when those handlers are disabled. Fix: enable them in SHCSR early in development.
  1. Read SCB->CPUID in gdb with p/x *(uint32_t *)0xE000ED00. Decode the implementer (bits 31:24), variant (23:20), part number (15:4), and revision (3:0). Expected on the STM32F407: 0x410fc241, Arm, Cortex-M4, r0p1.
  2. Build preempt.c and confirm g_trace is 1, 2, 3. Then predict and verify the trace for: priorities swapped; both priorities equal; and the high-priority IRQ disabled in the NVIC before it is pended (what is in g_trace, and what does NVIC->ISPR[1] show afterwards?).
  3. Set a breakpoint in TIM6_DAC_IRQHandler while running preempt.c. Inspect $lr, then x/8xw $sp. Explain why lr is 0xfffffff1 here, and find the stacked return address: it should point inside TIM7_IRQHandler, just after the barrier instructions. Use info symbol on it.
  4. Add a floating-point computation to main (with the FPU enabled), run it once, and then let SysTick fire. Compare lr in SysTick_Handler before and after the first floating-point instruction executes in main. Expected: 0xfffffff9 before, 0xffffffe9 after, because CONTROL.FPCA became 1.
  5. Use start_on_psp to run the blinky main loop on a 2 KB process stack. In SysTick_Handler, check that lr is 0xfffffffd, and compare p/x $msp and p/x $psp in gdb. Then fill the process stack with a pattern (0xDEADBEEF) before starting, let it run for a while, and count how many words were overwritten: that is your thread’s measured stack usage.

Question 1:Inside an interrupt handler, lr reads 0xFFFFFFFD. What does that tell you?

Question 2:On the STM32F407, BASEPRI is set to 0x60. Which interrupts can still pre-empt?

Question 3:A ported driver does NVIC->IPR[38] = 3; intending a low-urgency priority. What priority does USART2 actually get?

Question 4:IRQ A's handler is running when IRQ B (same group priority) becomes pending. What happens when A's handler returns?

Question 5:Code writes CONTROL to switch Thread mode onto PSP. Which barrier must follow, and why?

Question 6:A filter on the M4F runs far slower than expected. The disassembly shows calls to __aeabi_dmul. What is the most likely cause?

  • The Cortex-M4 has r0-r12, two stack pointers (MSP for handlers and by default for everything, PSP optionally for Thread mode), lr, pc, xPSR, and the mask registers PRIMASK, FAULTMASK, BASEPRI plus CONTROL.
  • Exception entry automatically stacks r0-r3, r12, lr, return address, and xPSR (plus s0-s15 and FPSCR when the FPU is in use), so handlers are plain C functions.
  • lr in a handler holds EXC_RETURN; its bits say which mode, stack, and frame type to return to.
  • Lower priority numbers are more urgent; the STM32F4 implements 4 bits, so priorities are written shifted left by 4. Equal group priorities never pre-empt each other.
  • Tail chaining and late arrival keep back-to-back interrupts cheap; nesting costs at least 32 bytes of main stack per level.
  • DSB waits for memory accesses to complete, ISB refetches instructions, DMB orders accesses. Use them after VTOR, CPACR, CONTROL, and MPU changes and before WFI, even where the M4 would tolerate their absence.
  • The M4F FPU is single precision only; double is software, and lazy stacking keeps FPU context from slowing interrupt entry.

Next: Clocks, reset, and timing, where the STM32F407 is taken from its 16 MHz reset clock to 168 MHz.