Part 3 · Firmware Systems31 min read
Memory Protection, Stack Safety, and Robustness
A microcontroller has no operating system watching over memory. A stack that grows one word too far, a memcpy with a bad length, or a wild pointer silently overwrites whatever happens to live next door, and the symptom appears minutes later in unrelated code. This lesson is about turning that silence into noise. You will configure the Cortex-M4’s Memory Protection Unit so that a stack overflow faults at the exact instruction that caused it, make RAM non-executable, see how privileged and unprivileged code are separated, measure stack usage properly, choose a heap policy that cannot fail at 3 a.m., and use canaries, checksums and watchpoints to find the code that wrote where it should not.
The MPU on the Cortex-M4
Section titled “The MPU on the Cortex-M4”The Memory Protection Unit (MPU) checks every memory access the core makes against a small table of regions. Each region has a base address, a size, access permissions for privileged and unprivileged code, an execute-never bit, and memory type attributes. An access that violates its region’s rules is blocked and raises a MemManage fault before the write lands. It is not an MMU: there is no address translation and no virtual memory, only checks.
The STM32F407’s Cortex-M4 implements the Armv7-M MPU (called PMSAv7) with 8 regions. Its registers sit in the System Control Space:
| Register | Address | Purpose |
|---|---|---|
MPU_TYPE | 0xE000ED90 | read-only; DREGION (bits 15:8) = number of regions, 8 here |
MPU_CTRL | 0xE000ED94 | bit 0 ENABLE, bit 1 HFNMIENA, bit 2 PRIVDEFENA |
MPU_RNR | 0xE000ED98 | selects which region RBAR and RASR refer to |
MPU_RBAR | 0xE000ED9C | region base address (bits 31:5), plus VALID (bit 4) and REGION (bits 3:0) |
MPU_RASR | 0xE000EDA0 | region attributes, size and enable |
CMSIS exposes them as MPU->TYPE, MPU->CTRL, MPU->RNR, MPU->RBAR and MPU->RASR. The attribute register packs everything else:
MPU_RASR 31 29 28 27 26 24 23 22 21 19 18 17 16 15 8 7 6 5 1 0+-------+---+--+------+-----+------+---+---+---+-----------+-----+--------+---+| res. |XN | | AP | res.| TEX | S | C | B | SRD | res.| SIZE | EN|+-------+---+--+------+-----+------+---+---+---+-----------+-----+--------+---+ XN 1 = never execute from this region AP access permissions (table below) TEX/S/C/B memory type and cacheability SRD subregion disable, one bit per eighth of the region SIZE region size = 2^(SIZE+1) bytes, minimum SIZE = 4 (32 bytes) EN region enableAP | Privileged | Unprivileged |
|---|---|---|
000 | no access | no access |
001 | read/write | no access |
010 | read/write | read-only |
011 | read/write | read/write |
100 | reserved | reserved |
101 | read-only | no access |
110 | read-only | read-only |
111 | read-only | read-only |
The rules that trip everyone up
Section titled “The rules that trip everyone up”- Size is a power of two, from 32 bytes to 4 GB.
SIZEholds log2(size) minus 1. You cannot make a 48 KB region. - The base address must be aligned to the size. A 64 KB region must start on a 64 KB boundary. The low bits of
RBARbelow the size are ignored, so a misaligned base silently protects the wrong range. - Overlaps are allowed, and the highest-numbered region wins. This is what makes guard regions easy: one large region for all of SRAM, and a small region with a higher number carved out of it.
- Subregions. A region of 256 bytes or more is split into eight equal subregions, and each bit of
SRDdisables one. An access to a disabled subregion behaves as if that region did not cover it, and falls through to lower-numbered regions or the background. For example, a 128 KB region over the STM32F407’s SRAM has 16 KB subregions, and the top one is exactly SRAM2 (0x2001C000); settingSRD = 0x80leaves SRAM2 to a different region with different rules. - The background region. With
PRIVDEFENA = 1, privileged accesses that hit no region use the default memory map as if the MPU were off. Unprivileged accesses that hit no region always fault. WithPRIVDEFENA = 0, every access must hit a region or it faults. HFNMIENA. When 0 (the usual choice), the MPU is disabled while running the HardFault and NMI handlers, so a fault handler can still access memory to report what happened.
Memory types on the STM32F4
Section titled “Memory types on the STM32F4”TEX, C, B and S describe the memory type. The Cortex-M4 in the STM32F4 has no data cache, so these bits change little in practice, but they should still describe the memory honestly, and code that moves to a Cortex-M7 depends on them. Common choices:
| Memory | TEX | C | B | S | Type |
|---|---|---|---|---|---|
| Internal flash | 000 | 1 | 0 | 0 | normal, write-through |
| Internal SRAM | 000 | 1 | 1 | 1 | normal, write-back, shareable |
| Peripherals | 000 | 0 | 1 | 1 | shareable device |
Configuring regions
Section titled “Configuring regions”Here is an MPU setup for the STM32F407 that makes flash read-only, makes SRAM non-executable, and reserves the highest-priority region for a stack guard. It uses the CMSIS register definitions and bit masks from core_cm4.h, included through stm32f4xx.h.
#include <stdint.h>#include "stm32f4xx.h" /* MPU, SCB, MPU_RASR_*, MPU_CTRL_*, __DMB, __DSB, __ISB */#include "mpu.h"
/* RASR.AP values */#define AP_NONE 0u /* privileged: none, unprivileged: none */#define AP_PRIV_RW 1u /* privileged: RW, unprivileged: none */#define AP_FULL_RW 3u /* privileged: RW, unprivileged: RW */#define AP_RO 6u /* privileged: RO, unprivileged: RO */
/* Memory types for STM32F4 internal memories */#define ATTR_FLASH (MPU_RASR_C_Msk)#define ATTR_SRAM (MPU_RASR_C_Msk | MPU_RASR_B_Msk | MPU_RASR_S_Msk)
#define REGION_FLASH 0u#define REGION_SRAM 1u#define REGION_GUARD 7u /* highest number: overrides REGION_SRAM where they overlap */
static uint32_t rasr(uint32_t size_log2, uint32_t ap, uint32_t xn, uint32_t attr){ return (xn << MPU_RASR_XN_Pos) | (ap << MPU_RASR_AP_Pos) | attr | ((size_log2 - 1u) << MPU_RASR_SIZE_Pos) | MPU_RASR_ENABLE_Msk;}
static void region_set(uint32_t region, uint32_t base, uint32_t rasr_value){ MPU->RNR = region; MPU->RBAR = base; /* VALID = 0, so RNR selects the region */ MPU->RASR = rasr_value;}
void mpu_init(void){ __DMB(); /* finish accesses made under the old map */ MPU->CTRL = 0u; /* disable while reprogramming */
for (uint32_t r = 0; r < 8u; r++) { /* clear anything a bootloader left */ MPU->RNR = r; MPU->RASR = 0u; }
/* Region 0: 1 MB flash at 0x08000000, read-only for all, executable. */ region_set(REGION_FLASH, 0x08000000u, rasr(20u, AP_RO, 0u, ATTR_FLASH));
/* Region 1: 128 KB SRAM at 0x20000000, read/write for all, never executable. */ region_set(REGION_SRAM, 0x20000000u, rasr(17u, AP_FULL_RW, 1u, ATTR_SRAM));
SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk; /* MemManage_Handler instead of HardFault */ MPU->CTRL = MPU_CTRL_PRIVDEFENA_Msk | MPU_CTRL_ENABLE_Msk; __DSB(); /* MPU enable has completed... */ __ISB(); /* ...and later instructions see it */}
void mpu_set_stack_guard(uint32_t guard_base) /* must be 32-byte aligned */{ region_set(REGION_GUARD, guard_base, rasr(5u, AP_NONE, 1u, ATTR_SRAM)); __DSB(); __ISB();}Line by line, the important parts:
rasr()builds the attribute word. Sizes are passed as log2:20for 1 MB,17for 128 KB,5for 32 bytes, and the function storessize_log2 - 1in theSIZEfield, as the hardware expects.- The MPU is disabled while regions change. Otherwise a half-configured region could fault the code doing the configuring. The
DMBbefore ensures earlier accesses complete under the old rules. - All eight regions are cleared so that nothing left over from a bootloader stays active.
- Region 0, flash, is read-only with execution allowed. A stray write to flash (a bad pointer into code) now faults immediately instead of being ignored by the flash controller.
- Region 1, SRAM, is read/write but
XN. If a corrupted function pointer or return address sends the PC into RAM, the first instruction fetch faults with an instruction access violation instead of executing data as code. That is the single cheapest defense against code-injection attacks on an MCU. MEMFAULTENAroutes MPU violations toMemManage_Handlerrather than escalating to HardFault, which keeps the cause clear.PRIVDEFENA = 1. Everything not covered (peripherals, the system ROM, CCM RAM at0x10000000, the core’s own registers at0xE0000000) stays accessible to privileged code through the default map.DSBthenISBafter enabling. TheDSBwaits for theMPU_CTRLwrite to complete; theISBflushes instructions already fetched under the old configuration.
A stack guard
Section titled “A stack guard”Stacks grow downward. A stack guard is a small no-access region placed directly below the lowest address of a stack. The first push, call or local variable that goes past the bottom of the stack touches the guard, and the MPU raises a MemManage fault on that instruction, before a single byte of the neighbor is corrupted.
higher addresses +---------------------------+ <- top of task 2's stack | task 2 stack, grows down | | | | | v | +---------------------------+ | GUARD: 32 bytes, AP=000 | <- MPU region 7; any access faults +---------------------------+ <- top of task 1's stack | task 1 stack | | (saved context lives | | near the top) | lower addressesThe natural place for guards is per-task stacks in an RTOS. Tasks run on PSP, so when a task overflows, the MemManage exception is taken on MSP, which is healthy, and the handler can report the fault cleanly. With only eight regions you cannot keep a guard for every task permanently, but you do not need to: only the running task can overflow its stack. Reprogram one region on every context switch to guard the incoming task’s stack.
Applying this to the scheduler from RTOS fundamentals: tasks and the context switch takes three changes:
/* 1. Align every stack to 32 bytes so its lowest 32 bytes can be an MPU region. */static uint32_t stacks[MAX_TASKS][STACK_WORDS] __attribute__((aligned(32)));
/* 2. In os_switch_context(), after choosing the next task (interrupts are disabled there): */mpu_set_stack_guard((uint32_t)(uintptr_t)&stacks[current_index][0]);
/* 3. In main(), before os_start(): */mpu_init();Each row of stacks is 1024 bytes, so with 32-byte alignment of the array every row starts on a 32-byte boundary, and the bottom 32 bytes of each row become that task’s guard when it runs. The usable stack shrinks to 992 bytes. Region 7 overlaps region 1 and wins, so the guard is inaccessible even though the rest of SRAM is read/write.
What the fault tells you
Section titled “What the fault tells you”#include <stdint.h>#include "stm32f4xx.h"#include "crash.h" /* crash_record_save(), from Watchdogs, faults, and recovering from them */
void MemManage_Handler(void){ const uint32_t cfsr = SCB->CFSR; const uint32_t mmfar = SCB->MMFAR; /* valid only if MMARVALID is set */ crash_record_save(CRASH_MEMMANAGE, cfsr, mmfar); NVIC_SystemReset();}The MemManage status bits are the low byte of SCB->CFSR. For a stack guard they read like this:
| Bits set | Meaning |
|---|---|
DACCVIOL (bit 1) and MMARVALID (bit 7), MMFAR inside the guard | the task’s own code pushed, called or wrote a local into the guard: a clean catch, the stacked PC is the guilty instruction |
MSTKERR (bit 4) | the overflow happened while the hardware was stacking an exception frame for an interrupt; the task’s frame is incomplete and it cannot be resumed |
MLSPERR (bit 5) | lazy floating-point state preservation hit the guard |
IACCVIOL (bit 0) | an instruction fetch from an XN or no-access region, typically a corrupted return address or function pointer jumping into RAM |
In an RTOS, the handler can also record current_tcb->name, which tells you which task overflowed. Watchdogs, faults, and recovering from them shows how to dump the full stacked frame. Compare this with the unguarded version: task 2 overwrites task 1’s saved context, and the fault happens much later when task 1 is resumed with a garbage PC.
The guard has to be wider than any single frame
Section titled “The guard has to be wider than any single frame”A guard only catches accesses that land on it. A function with a large local array moves the stack pointer by the whole frame size in one instruction:
void parse_packet(void){ uint8_t scratch[512]; /* sub sp, sp, #512: SP jumps far past a 32-byte guard */ scratch[400] = 0; /* this write lands below the guard: not caught */ /* ... */}If the task’s stack is nearly full, SP can jump straight over a 32-byte guard, and the first writes go into the next task’s stack without touching the guard at all. Defenses: make the guard larger than the largest frame in any task (check the .su files from -fstack-usage), keep big buffers out of stack frames entirely (static or pool-allocated), or use a core with hardware stack limit registers, described below, which check the SP value itself.
Guarding the main stack
Section titled “Guarding the main stack”The main stack can get a guard the same way, by reserving an aligned guard area below it in the linker script:
_Min_Stack_Size = 0x1000;
.stack (NOLOAD) :{ . = ALIGN(32); _stack_guard = .; /* 32 bytes, no access once the MPU is on */ . = . + 32; _sstack = .; /* lowest usable main stack address */ . = . + _Min_Stack_Size; . = ALIGN(8); _estack = .; /* initial MSP, vector table entry 0 */} > RAMThis replaces the _estack = ORIGIN(RAM) + LENGTH(RAM) definition from Linker scripts, and in a bare-metal application you declare extern uint32_t _stack_guard[]; and call mpu_set_stack_guard((uint32_t)(uintptr_t)_stack_guard) after mpu_init(). There is a catch: when MSP itself overflows, the fault handler also needs MSP to run, and its exception frame would be pushed into the guard too. The stacking fault escalates and the handler may not get a usable stack. Make the main stack guard larger than an exception frame plus what the handler needs, or have the handler switch to a small dedicated emergency stack before doing anything else. A useful alternative on many STM32 parts is to place the main stack at the very bottom of SRAM, so that an overflow walks below 0x20000000 into reserved address space and causes a bus fault without corrupting anything.
Other jobs for the MPU
Section titled “Other jobs for the MPU”Beyond stack guards, firmware uses the MPU to:
- Make RAM non-executable (
XNon SRAM), as above. It stops a whole class of exploits and turns “jumped to garbage” bugs into an immediate, precise fault. - Make flash read-only, catching writes through pointers that should have pointed to RAM.
- Fence off peripherals from unprivileged code. With
PRIVDEFENA = 1and no region covering0x40000000, unprivileged code cannot touch any peripheral; you then grant exactly the ranges each component needs. A sensor task gets I2C1 and nothing else. - Isolate tasks. FreeRTOS-MPU, the MPU-enabled FreeRTOS ports, runs tasks created with
xTaskCreateRestrictedin unprivileged mode, each with its own stack region and a few configurable regions for the buffers and peripherals it may touch. Kernel code and data live in privileged sections, and API calls from unprivileged tasks go through wrappers that switch to privileged mode for the duration of the call. Zephyr offers the same idea as user-mode threads with memory domains.
Privileged and unprivileged execution
Section titled “Privileged and unprivileged execution”Cortex-M code runs either privileged (full access, including system registers such as SCB, MPU and NVIC) or unprivileged. Handler mode is always privileged. Thread mode is privileged after reset and becomes unprivileged when CONTROL.nPRIV is set:
__set_CONTROL(__get_CONTROL() | CONTROL_nPRIV_Msk);__ISB(); /* following instructions run unprivileged */This is a one-way door. Unprivileged code cannot clear nPRIV; only an exception handler can. Unprivileged code also cannot write PRIMASK, BASEPRI or FAULTMASK (the writes are ignored) and cannot access the System Control Space (those accesses fault). So how does an unprivileged task ask for something it is not allowed to do, like reprogramming a clock? It makes a system call with SVC, which enters a privileged handler that validates the request and performs it.
#include <stdint.h>#include "stm32f4xx.h"
int32_t sys_led_set(uint32_t on); /* privileged implementation, defined elsewhere */
__attribute__((naked)) void SVC_Handler(void){ __asm volatile ( " tst lr, #4 \n" /* EXC_RETURN bit 2: frame on MSP or PSP? */ " ite eq \n" " mrseq r0, msp \n" " mrsne r0, psp \n" " b svc_dispatch \n" /* tail call; its return performs the exception return */ );}
__attribute__((used)) void svc_dispatch(uint32_t *frame){ const uint16_t *ret = (const uint16_t *)(uintptr_t)frame[6]; /* stacked PC: after the SVC */ const uint8_t number = (uint8_t)(ret[-1] & 0xFFu); /* SVC #n is encoded 0xDFnn */ switch (number) { case 1: frame[0] = (uint32_t)sys_led_set(frame[0]); /* r0 in, result back in r0 */ break; default: frame[0] = (uint32_t)-1; /* unknown call */ break; }}
/* The unprivileged side: */static inline int32_t led_set(uint32_t on){ register uint32_t r0 __asm__("r0") = on; __asm volatile ("svc 1" : "+r"(r0) : : "memory"); return (int32_t)r0;}The assembly picks whichever stack holds the caller’s exception frame, then branches (not calls) to the C dispatcher, leaving EXC_RETURN in lr so that when svc_dispatch returns, the return is the exception return. The call number is the 8-bit immediate inside the 16-bit SVC instruction, found just before the stacked return address. Arguments arrive in the stacked r0 to r3, and writing frame[0] sets the value the caller sees in r0.
A real system-call layer must treat every argument as hostile: a pointer passed by an unprivileged task must be checked against the regions that task is allowed to access before the privileged handler dereferences it, or the syscall becomes a way around the MPU. This is a sketch of the mechanism; RTOS kernels with MPU support implement the full version. The SVC that starts the first task in the scheduler lesson is the same exception used for a different purpose. TrustZone-M on the Cortex-M33 adds a second, hardware-enforced split between secure and non-secure worlds; Firmware security covers it.
The Armv8-M MPU and stack limit registers
Section titled “The Armv8-M MPU and stack limit registers”Cortex-M23 and M33 cores implement Armv8-M, and their MPU (PMSAv8) fixes most of PMSAv7’s annoyances:
- Regions are defined by a base and a limit (
RBARandRLAR) with 32-byte granularity. No power-of-two sizes, no alignment to the size, no subregions. - Regions must not overlap. An access that matches two regions faults, so the “big region plus higher-numbered guard” trick is replaced by laying regions out side by side.
- Memory types are indices into two attribute registers (
MPU_MAIR0andMPU_MAIR1) instead ofTEX/C/B/Sper region. - With TrustZone, the secure and non-secure worlds each have their own MPU.
Armv8-M Mainline cores such as the M33 also add stack limit registers, MSPLIM and PSPLIM. Any instruction that would move SP below the limit raises a UsageFault with the STKOF flag set, before the access happens. Because the check is on the stack pointer value rather than on the memory touched, it catches the large-frame case that a guard region misses, and it costs no MPU region. RTOS ports for the M33 load PSPLIM on every context switch. (On the M23, Armv8-M Baseline, the limit registers exist only in the secure state.)
Stack painting and high-water marks
Section titled “Stack painting and high-water marks”The MPU catches overflows; it does not tell you how close you are to one. For that, fill the stack with a known pattern at boot and later count how much is still untouched, as the scheduler lesson did for task stacks. For the main stack:
#include <stdint.h>#include "stm32f4xx.h" /* __get_MSP */
#define STACK_PAINT 0xA5A5A5A5u
extern uint32_t _sstack[]; /* lowest usable main stack address, from the linker script */extern uint32_t _estack[]; /* top of the main stack */
void stack_paint(void) /* call first thing in main, with interrupts still disabled */{ uint32_t *p = _sstack; const uintptr_t limit = __get_MSP() - 64u; /* stay clear of this function's own frame */ while ((uintptr_t)p < limit) { *p++ = STACK_PAINT; }}
uint32_t stack_unused_bytes(void){ const uint32_t *p = _sstack; while ((uintptr_t)p < (uintptr_t)_estack && *p == STACK_PAINT) { p++; } return (uint32_t)((uintptr_t)p - (uintptr_t)_sstack);}stack_paint fills from the bottom of the stack up to a little below the current stack pointer, never touching live frames. The best place for it is the startup code, before main has used much stack. stack_unused_bytes scans upward from the bottom and stops at the first overwritten word; everything below that point has never been used.
The measurement is only as good as the test: the high-water mark records the deepest point reached during this run, so exercise every feature, every error path and the worst interrupt nesting before trusting it. Keep at least 25 percent margin. FreeRTOS gives the same number per task from uxTaskGetStackHighWaterMark(), and configCHECK_FOR_STACK_OVERFLOW = 2 makes the kernel check the last 16 bytes of the pattern at every context switch and call vApplicationStackOverflowHook. That check is cheap and useful, but it runs only at switches, so it detects an overflow after the damage. The MPU guard detects it at the instruction.
Heap strategies
Section titled “Heap strategies”Memory: where everything lives explained why malloc is risky in firmware: fragmentation, non-deterministic timing, and failure at unpredictable moments. The practical policies, from safest:
| Strategy | Frees memory? | Fragmentation | Timing | Use it for |
|---|---|---|---|---|
| No heap at all | n/a | none | deterministic | the default for most firmware |
| Allocate at boot only | no | none | deterministic after init | buffers sized from configuration read at startup |
| Fixed-block pools | yes | none within a pool | O(1) | messages, packets, events |
FreeRTOS heap_1 | no | none | deterministic | systems that create every task and queue at startup |
FreeRTOS heap_2 | yes, no coalescing | grows over time | not O(1) | legacy; superseded by heap_4 |
FreeRTOS heap_3 | yes | the C library’s | the C library’s | wrapping libc malloc with the scheduler suspended |
FreeRTOS heap_4 | yes, merges adjacent free blocks | limited | not O(1) | general use when objects are created and deleted at runtime |
FreeRTOS heap_5 | as heap_4 | limited | not O(1) | heaps spread over several RAM areas (vPortDefineHeapRegions) |
Two practical notes. On the STM32F407, heap_5 can span SRAM and the 64 KB CCM RAM, but DMA cannot reach CCM, so never let a DMA buffer come from that part of the heap. And FreeRTOS’s static creation functions (xTaskCreateStatic, xQueueCreateStatic, enabled with configSUPPORT_STATIC_ALLOCATION) let you build a complete system with no heap at all.
Whatever the strategy, decide the failure policy before the first allocation fails: every allocation’s return value is checked; the system either degrades gracefully (drop the packet, count it) or records a crash and resets; it never continues with a null pointer. FreeRTOS calls vApplicationMallocFailedHook() when configUSE_MALLOC_FAILED_HOOK is 1, a good place to log and stop in development builds.
If the application must never use the C library heap, make that a link-time fact rather than a convention:
#include <errno.h>#include <stddef.h>
void *_sbrk(ptrdiff_t incr) /* newlib's heap growth hook */{ (void)incr; errno = ENOMEM; return (void *)-1; /* every malloc now returns NULL */}Any hidden malloc (some printf implementations allocate stdio buffers on first use) now fails visibly in testing instead of slowly eating RAM in the field.
Detecting and debugging memory corruption
Section titled “Detecting and debugging memory corruption”When something overwrites memory it does not own, you want to know two things: that it happened, as early as possible, and who did it.
Canaries. Put a known value at both ends of important buffers and structures and check it periodically, before use, and in the fault handler:
#include <stdbool.h>#include <stdint.h>
#define CANARY 0xC0FFEE11u
typedef struct { uint32_t head; uint8_t data[128]; uint32_t tail;} guarded_buf_t;
static guarded_buf_t rx = { .head = CANARY, .tail = CANARY };
bool rx_intact(void){ return rx.head == CANARY && rx.tail == CANARY;}An overflow of data by even one word changes tail. Canaries do not prevent corruption, but they shorten the distance between cause and detection from “somewhere, sometime” to “this buffer, since the last check”. GCC’s -fstack-protector applies the same idea to stack frames automatically.
Checksums over important RAM. Configuration copied from flash into RAM at boot can carry a CRC computed when it was loaded. Verify it before using the configuration and periodically; on mismatch, reload from flash and log the event. A corrupted config is a symptom; the log entry with a timestamp helps find the cause.
Hardware watchpoints. Once you know which address gets corrupted, let the CPU catch the writer. The Cortex-M4’s DWT unit has four comparators, and gdb uses them for watch:
(gdb) watch *(uint32_t *)0x20001a40Hardware watchpoint 2: *(uint32_t *)0x20001a40(gdb) continueHardware watchpoint 2: *(uint32_t *)0x20001a40Old value = 3New value = 1953066601(gdb) btThe core halts on the write, and the backtrace shows who did it. This one technique finds most “who wrote here” bugs in minutes.
Watchpoints without a debugger. In the field there is no gdb, but the DWT can still fire. With monitor-mode debugging enabled, a watchpoint match raises the DebugMonitor exception instead of halting the core:
#include <stdint.h>#include "stm32f4xx.h"
void watch_writes(const volatile void *addr) /* addr must be 4-byte aligned */{ CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk | CoreDebug_DEMCR_MON_EN_Msk; DWT->COMP0 = (uint32_t)(uintptr_t)addr; DWT->MASK0 = 2u; /* ignore the low 2 address bits: watch one word */ DWT->FUNCTION0 = 6u; /* data address match on write */}DebugMon_Handler (vector offset 0x30) then reads the stacked PC, exactly as the HardFault handler does, and records it in the crash log. The PC points at or shortly after the instruction that wrote. Monitor mode only operates when no halting debugger is attached, so it does not interfere with gdb sessions.
Defensive coding
Section titled “Defensive coding”The MPU and watchpoints catch mistakes. Coding habits prevent them:
- Bound every buffer write. Every array index from outside data is checked against the array size before use.
- Length-checked copies. Pass the destination size to every copy function and refuse oversize input:
#include <stddef.h>#include <stdint.h>#include <string.h>#include "err.h"
err_t copy_payload(uint8_t *dst, size_t dst_size, const uint8_t *src, size_t len){ if (dst == NULL || src == NULL || len > dst_size) { return ERR_INVALID_ARG; /* never trust a length that came from outside */ } memcpy(dst, src, len); return ERR_OK;}- Assert invariants. If a state variable can only be 0 to 3, assert it. An assertion that records the file and line and resets is far better than continuing with corrupted state.
- Check every return code. Mark functions whose result must not be ignored with
__attribute__((warn_unused_result)), and the compiler enforces it. - Avoid big stack frames and recursion. They make stack depth hard to bound and defeat small guard regions.
Design patterns for embedded C and Safety, standards, and coding guidelines turn these habits into project-wide policy.
Common mistakes
Section titled “Common mistakes”- A region base not aligned to its size. The low address bits are ignored and the wrong range is protected; align the base to the region size, and assert it at runtime.
- Forgetting
DSBandISBafter enabling or changing the MPU. The next few instructions run under the old rules; always follow MPU changes with both. - Making SRAM
XNwhile code runs from RAM. Flash programming routines in.ramfuncfault on their first instruction; give them their own executable region. - A guard smaller than the biggest stack frame. A large local array jumps the guard and corrupts the neighbor anyway; size the guard from
-fstack-usageor move big buffers off the stack. - Not enabling
MEMFAULTENA. Every MPU violation arrives as a HardFault and the cause is harder to see; enable the MemManage handler. - Trusting a high-water mark from a light test. The deepest path never ran; measure under worst-case load with every feature active.
- Ignoring allocation failures. A null pointer is dereferenced much later; check every allocation and define the failure policy up front.
- A system call that dereferences unchecked pointers. Unprivileged code uses it to bypass the MPU; validate every pointer argument against the caller’s regions.
Exercises
Section titled “Exercises”- Add
mpu_init()to your Part 2 blinky and confirm it still runs. Then add a deliberate write to*(volatile uint32_t *)0x08004000and confirm you land inMemManage_HandlerwithDACCVIOLandMMARVALIDset andMMFAR = 0x08004000. - Make SRAM executable for a moment to prove the point: copy a two-instruction Thumb function (
movs r0, #42thenbx lr, encoded0x202A 0x4770) into a RAM array and call it through a function pointer with bit 0 set. With region 1 markedXN, observeIACCVIOL; withXNcleared, observe the call returning 42. - Add per-task guards to the scheduler from RTOS fundamentals: tasks and the context switch. Give one task a recursive function with a depth controlled from a button press, and confirm that the fault happens at the recursion, with
MMFARinside that task’s guard, rather than later in a different task. Then repeat with the MPU disabled and describe the difference. - Compile a module containing a function with a 512-byte local array using
-fstack-usage, and read the.suoutput. Explain why a 32-byte guard does not protect a task that calls it, and change the code so it would. - Use
watch_writes()on a canary at the end of a buffer and deliberately overflow the buffer from a function called through a few layers. InDebugMon_Handler, record the stacked PC, then usearm-none-eabi-addr2line -e firmware.elfon it to find the guilty line, all without a debugger attached.
Check yourself
Section titled “Check yourself”Question 1:You configure a 64 KB MPU region with base 0x20004000. What range is actually protected?
PMSAv7 regions must be aligned to their size. The base's low 16 bits are ignored for a 64 KB region, so the region starts at the aligned address below.
Question 2:Region 1 covers all SRAM as read/write. Region 7 is a 32-byte no-access guard inside it. A task writes to the guard. What happens?
In PMSAv7 overlapping regions are allowed and the highest-numbered region wins, which is exactly what makes a small guard inside a large region work.
Question 3:A task with a 32-byte stack guard calls a function whose frame has a 1 KB local buffer while its stack is nearly full. Why might the overflow go undetected?
A guard only catches accesses that land on it. Guards must be larger than the largest frame, or big buffers kept off the stack; stack limit registers on Armv8-M avoid the problem.
Question 4:After mpu_init() marks SRAM as XN, a corrupted return address points into a RAM buffer. What does the CPU do on return?
Execute-never makes any instruction fetch from the region fault. It turns a code-execution bug or exploit into an immediate, diagnosable fault.
Question 5:Unprivileged thread code executes __set_PRIMASK(1) to disable interrupts. What happens?
Unprivileged code cannot change PRIMASK, BASEPRI or FAULTMASK, and cannot clear CONTROL.nPRIV. Privileged services are reached through an SVC handler.
Key takeaways
Section titled “Key takeaways”- The Cortex-M4 MPU has 8 regions of power-of-two size, aligned to their size; overlaps are allowed and the highest-numbered region wins.
- Enable it with
PRIVDEFENAso privileged code keeps the default map, enableMEMFAULTENAfor a dedicated handler, and follow every change withDSBandISB. - A no-access guard below each task stack, reprogrammed on every context switch, turns silent stack overflow into a fault at the guilty instruction; it must be wider than the largest frame.
- Mark SRAM execute-never and flash read-only; give RAM-resident code and flash programming explicit exceptions.
- Unprivileged code cannot touch system registers or masks and reaches privileged services through
SVC, whose handler must validate every argument. - Armv8-M replaces power-of-two regions with base and limit, forbids overlaps, and adds
MSPLIMandPSPLIMstack limit checks. - Measure stacks with painting under worst-case load, choose the simplest heap policy that works, and use canaries, checksums and DWT watchpoints to catch whoever corrupts memory.
Next: Bootloaders and firmware update, where you design the code that runs before your application and replaces it safely in the field.