Nothing worse than a firmware crash caused by a stack overflow silently corrupting a critical configuration structure โ or a rogue pointer in a DMA callback overwriting the task control block of a running FreeRTOS task. The Cortex-M Memory Protection Unit (MPU) exists precisely to catch these violations at the moment they happen, before data corruption spreads.
On most STM32 projects, the MPU sits disabled by default. Many developers treat it as an optional safety feature for high-integrity or automotive code. In reality, any Cortex-M3/M4/M7/M33 part from STM32 can run the MPU, and configuring it takes fewer than twenty lines of C once you understand the register model. This article walks through region configuration, subregion masking, privilege separation, and the gotchas I have encountered on client projects.
The MPU Model in One Paragraph
The Cortex-M MPU divides the 4 GB address space into up to eight (or sixteen on Cortex-M33/M55) independently configurable regions. Each region has a base address, a size from 32 bytes to 4 GB, and a set of access permissions: read/write/execute for privileged versus unprivileged software. When code running in unprivileged mode accesses an address not covered by any enabled region, or violates the region's permissions, the MPU raises a MemManage fault โ caught by MemManage_Handler in your vector table.
That is the entire abstraction. There are no page tables, no TLB maintenance, and no OS dependency. The MPU is a pure hardware gatekeeper that operates on physical addresses.
Region Configuration โ Register by Register
The MPU control registers live in the System Control Block (SCB) address space, accessed through the CMSIS-Core macros or by direct pointer dereference.
Step 1 โ Enable the MPU and define the background region.
The MPU_CTRL register (0xE000ED94) has three relevant bits:
- ENABLE (bit 0): enables the MPU.
- PRIVDEFENA (bit 2): when set, the privileged code sees the entire memory map as a default region with full access. Clearing this bit means unprivileged code also needs explicit regions for any access.
- HFNMIENA (bit 1): controls whether the MPU is active during NMI and HardFault. Keep it set (0) to let exception handlers reach anything; clear it (1) for extra safety during fault handling.
/* Enable MPU, no default privileged region, MPU active in HardFault/NMI */
MPU->CTRL = MPU_CTRL_ENABLE_Msk;
/* OR: Enable with default privileged region */
MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
Step 2 โ Configure a region with RNR, RBAR, and RASR.
Each region is programmed through three memory-mapped registers:
MPU_RNR(Region Number Register) โ selects the region index (0โ7).MPU_RBAR(Region Base Address Register) โ holds the base address, address type (normal/device/strongly-ordered), and whether the region is valid.MPU_RASR(Region Attribute and Size Register) โ encodes size, subregions, access permissions, shareability, cache attributes (TEX/S/C/B for normal memory), and execute-never (XN) flag.
void MPU_ConfigRegion(uint8_t region, uint32_t base, uint32_t size, uint32_t rasr) {
/* Ensure MPU is disabled while programming regions */
MPU->CTRL = 0;
/* Select region */
MPU->RNR = region;
MPU->RBAR = base | MPU_RBAR_VALID_Msk | (region << MPU_RBAR_REGION_Pos);
MPU->RASR = rasr;
/* Re-enable */
MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
__DSB();
__ISB();
}
The size parameter in RASR is encoded as Size = log2(region_size) - 1. A 32 KB region gets Size = 14 (2^(14+1) = 32768).
Encoding the RASR Attribute Field
The RASR layout is the most error-prone part of the configuration. Here is the field breakdown:
RASR bits:
[31:29] TEX โ Type Extension, combined with C/B/S for cache behaviour
[28] S โ Shareable
[27] C โ Cacheable
[26] B โ Bufferable
[24] SRD โ SubRegion Disable (8 bits, one per subregion)
[23:16] Reserved
[15:8] SRD โ actually bits [15:8] are for the legacy mapping
[7:6] Reserved
[5:3] AP โ Access Permission
[2] Reserved
[1] XN โ Execute Never
[0] EN โ Region Enable
The key combinations for typical embedded memory types:
| Memory type | TEX | C | B | S | RASR mask |
|---|---|---|---|---|---|
| Flash (Normal, WT) | 0 | 1 | 0 | 0 | 0x03000000 |
| SRAM (Normal, WBWA) | 1 | 1 | 1 | 1 | 0x07000000 |
| Peripherals (Device) | 0 | 0 | 0 | 1 | 0x01000000 |
| Strongly-Ordered | 0 | 0 | 0 | 0 | 0x00000000 |
The AP field (bits [5:3]) controls who can do what:
| AP | Privileged | Unprivileged |
|---|---|---|
| 000 | No access | No access |
| 001 | RW | No access |
| 010 | RW | RO |
| 011 | RW | RW |
| 101 | RO | No access |
| 110 | RO | RO |
Practical Example: Guarding SRAM With Three Regions
Consider a typical FreeRTOS project on STM32G474. The SRAM layout is:
0x20000000โ 32 KB: .data + .bss + heap (privileged RW, no unprivileged)0x20008000โ 16 KB: FreeRTOS heap + TCBs (privileged RW)0x2000C000โ 8 KB: unprivileged task stacks (unprivileged RW, XN)
#define MPU_REGION_FLASH 0
#define MPU_REGION_SRAM_DATA 1
#define MPU_REGION_SRAM_RTOS 2
#define MPU_REGION_SRAM_TASK 3
#define MPU_REGION_PERIPH 4
void MPU_Init_Production(void) {
/* Disable during config */
MPU->CTRL = 0;
__DSB(); __ISB();
/* Region 0: Flash (512 KB) โ privileged RO, unprivileged RO, XN cleared */
MPU->RNR = MPU_REGION_FLASH;
MPU->RBAR = 0x08000000 | MPU_RBAR_VALID_Msk | (MPU_REGION_FLASH << MPU_RBAR_REGION_Pos);
MPU->RASR = (18 << 1) /* SIZE: log2(512KB)-1 = 18 */
| (0x03 << 3) /* AP: RW/RW */
| (0x01 << 5) /* TEX=0, C=1 (Write-Through) */
| (1 << MPU_RASR_C_Pos) /* Cacheable */
| (1 << MPU_RASR_ENABLE_Pos);
/* Region 1: .data + .bss (32 KB) โ privileged RW, unprivileged no access */
MPU->RNR = MPU_REGION_SRAM_DATA;
MPU->RBAR = 0x20000000 | MPU_RBAR_VALID_Msk | (MPU_REGION_SRAM_DATA << MPU_RBAR_REGION_Pos);
MPU->RASR = (14 << 1) /* SIZE: log2(32KB)-1 = 14 */
| (0x01 << 3) /* AP: privileged RW, unprivileged no access */
| (1 << MPU_RASR_TEX_Pos) /* TEX=1, C=1, B=1, S=1 (WBWA) */
| (1 << MPU_RASR_C_Pos)
| (1 << MPU_RASR_B_Pos)
| (1 << MPU_RASR_S_Pos)
| (1 << MPU_RASR_ENABLE_Pos);
/* Region 2: RTOS kernel structures (16 KB) โ privileged RW only */
MPU->RNR = MPU_REGION_SRAM_RTOS;
MPU->RBAR = 0x20008000 | MPU_RBAR_VALID_Msk | (MPU_REGION_SRAM_RTOS << MPU_RBAR_REGION_Pos);
MPU->RASR = (13 << 1) /* SIZE: log2(16KB)-1 = 13 */
| (0x01 << 3) /* AP: privileged RW only */
| (1 << MPU_RASR_TEX_Pos)
| (1 << MPU_RASR_C_Pos)
| (1 << MPU_RASR_B_Pos)
| (1 << MPU_RASR_S_Pos)
| (1 << MPU_RASR_ENABLE_Pos);
/* Region 3: Task stacks (8 KB) โ unprivileged RW, XN enabled */
MPU->RNR = MPU_REGION_SRAM_TASK;
MPU->RBAR = 0x2000C000 | MPU_RBAR_VALID_Msk | (MPU_REGION_SRAM_TASK << MPU_RBAR_REGION_Pos);
MPU->RASR = (12 << 1) /* SIZE: log2(8KB)-1 = 12 */
| (0x03 << 3) /* AP: RW/RW */
| (1 << MPU_RASR_TEX_Pos)
| (1 << MPU_RASR_C_Pos)
| (1 << MPU_RASR_B_Pos)
| (1 << MPU_RASR_S_Pos)
| (1 << MPU_RASR_ENABLE_Pos)
| (1 << 0); /* XN โ no code execution from stack */
/* Region 4: Peripherals (0x40000000โ0x5FFFFFFF) โ privileged RW, Device, XN */
MPU->RNR = MPU_REGION_PERIPH;
MPU->RBAR = 0x40000000 | MPU_RBAR_VALID_Msk | (MPU_REGION_PERIPH << MPU_RBAR_REGION_Pos);
MPU->RASR = (29 << 1) /* SIZE: log2(512MB)-1 = 29 */
| (0x01 << 3) /* AP: privileged RW only */
| (0 << MPU_RASR_TEX_Pos)
| (1 << MPU_RASR_S_Pos) /* Device, Shareable */
| (1 << MPU_RASR_ENABLE_Pos)
| (1 << 0); /* XN */
/* Enable MPU with privileged background region */
MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
__DSB(); __ISB();
}
With this configuration, an unprivileged task that accidentally writes through a wild pointer to 0x20000004 (in the .data region) immediately triggers MemManage_Handler. The same task trying to execute code from its own stack region is also caught โ the XN bit prevents execution from the stack area.
Subregion Disable โ Finer Granularity Without Extra Regions
Each MPU region is divided into eight equal subregions. By setting bits in the SubRegion Disable (SRD) field of RASR, you can carve out holes within a region without consuming another region slot. This is invaluable when the linker places a read-only data table inside a writable .bss area, or when a memory-mapped FPGA region overlaps with a peripheral aperture.
/* Example: 16 KB region at 0x20000000, disable subregions 4 and 5
(bytes 0x20008000โ0x2000BFFF) so they become no-access */
uint32_t srd_mask = (1 << 4) | (1 << 5); /* subregions 4 and 5 disabled */
uint32_t rasr = (13 << 1) /* SIZE = 16 KB */
| (0x01 << 3) /* AP: privileged RW */
| (srd_mask << 8) /* SRD field at bits [15:8] */
| (1 << MPU_RASR_TEX_Pos)
| (1 << MPU_RASR_C_Pos)
| (1 << MPU_RASR_B_Pos)
| (1 << MPU_RASR_S_Pos)
| (1 << MPU_RASR_ENABLE_Pos);
Each subregion covers region_size / 8 bytes. For a 16 KB region, each subregion is 2 KB.
Practical Checklist
- Set
PRIVDEFENA = 1during development โ the background privileged region avoids surprise faults from library code that was not written with MPU awareness. - Always pair region writes with
__DSB()and__ISB()โ the MPU configuration registers are buffered writes. Without a barrier, the next instruction may execute with stale region state. - Disable the MPU before changing regions โ writing to RASR while the MPU is enabled produces UNPREDICTABLE behaviour on Cortex-M3/M4. Disable, configure, re-enable.
- Set XN on SRAM regions unless you explicitly need to execute code from RAM (which is rare and usually a sign of a bootloader or self-modifying pattern that should be an explicit exception).
- Verify with a deliberate fault โ write a short test that triggers a MemManage fault and confirm
MemManage_Handleris called before trusting the configuration in the field. - Remember the MPU resets to disabled โ any code that runs before
MPU_Init_Production()(startup code, SystemInit, board-level init) executes without protection. If you need coverage from the first instruction, configure the MPU at the very top ofReset_Handler. - Watch out for MPU regions on Cortex-M7 with D-Cache โ regions covering the same physical memory must share the same cache attributes (TEX/C/B/S) or the cache may exhibit coherency issues. This is a common source of "works without MPU, fails with MPU" bugs on STM32H7.
How I Would Approach This on a Client Project
On a recent STM32H723 industrial controller project, I divided the MPU regions into three categories: core-kernel (privileged RW only), application-data (privileged RW, unprivileged RO), and stack (unprivileged RW, XN). The linker script was adjusted to place each category at a known base address with an alignment equal to the region size โ this is critical because the MPU enforces alignment of base % size == 0.
The biggest time-saver was keeping PRIVDEFENA enabled during development. It let peripheral HAL libraries (which typically run in privileged mode) access the entire memory map without explicit region coverage, while catching precisely the application-level violations I cared about. Only at production release did I tighten the regions to cover every memory type explicitly โ including a dedicated Device region for the peripheral bus.
I also added a #define MPU_ENABLE 1 compile-time switch so the same binary can be debugged on a bench unit (MPU disabled) and run in production (MPU enabled) without changing source code. The only difference is whether the MemManage fault handler halts or performs a safe reset.
Sources
- ARM Cortex-M4 TRM, Chapter 8 โ Memory Protection Unit (arm.com)
- ST Application Note AN4838 โ Introduction to MPU on STM32 MCUs
- ST Application Note AN4839 โ MPU tips and usage guidelines
- CMSIS-Core (v5/v6) โ
MPU_ConfigRegion()andARM_MPU_SetRegion()API reference - STM32G4 and STM32H7 Reference Manuals โ MPU register descriptions in the System Control Block chapter
๐ฌ Comments / discussion
Prefer email: [email protected] โ include the article URL so I can follow up. For corrections or deeper questions, I typically reply within 48 hours.