Embedded Systems · Microcontrollers

Memory Protection Units on Microcontrollers and Stack Overflow Detection

How an MPU partitions a small address space into regions with access attributes, why guard regions catch a stack overflow at the fault rather than later, and what the unit cannot protect.

A stack that grows past its allocation does not stop. It writes over whatever occupies the memory below it, and the corruption surfaces later as a variable that changed value, a return address that points somewhere implausible, or a fault in a function that was never called. The delay between the cause and the symptom is what makes stack overflow expensive to debug, and closing that delay is the main practical reason to enable a memory protection unit on a microcontroller.

Regions, not pages

An MPU in the ARMv7-M profile is a set of region descriptors, each defining a base address, a size, and access permissions. The number of regions is small — commonly eight, with sixteen on some implementations, and the unit reports its own count in a type register — and the size of each must be a power of two, from 32 bytes up to the whole 4 GiB space.

The power-of-two constraint is the first thing that shapes a real configuration. A region cannot be sized to match an object; it can only be sized to the next power of two at or above the object, aligned to that size. A 6 KiB buffer needs an 8 KiB region, and the extra 2 KiB of address space is granted the buffer’s permissions unless it is covered by a higher-numbered region with different attributes.

Overlap is resolved by priority. When two regions overlap, the one with the higher region number wins for the overlapping bytes. This is the mechanism that makes a configuration possible at all: a large background region grants broad permissions, and small high-numbered regions carve out exceptions. A design typically starts with a region covering all of SRAM as read-write for privileged code, then adds a smaller region over a stack guard with no access, and a still smaller one over a read-only table with execute-never set.

The granularity of the size field is a minimum of 32 bytes, and the alignment requirement is that the base is a multiple of the size. A 32-byte region is therefore 32-byte aligned, and a 4 KiB region is 4 KiB aligned. The alignment is not a suggestion: a descriptor whose base is not aligned to its size has unpredictable behaviour, and the architecture says so rather than defining a fault.

The guard region that catches the overflow

The standard construction is a small no-access region immediately below the stack’s lowest expected address.

The stack on ARMv7-M grows downward, so the stack pointer decreases with each push. A stack allocated from address S to S + N has its lowest valid address at S, and the guard region sits below S, covering the 32 bytes from S - 32 to S. The main stack pointer starts at S + N and moves down; when a push would write below S, it writes into the guard and the MPU raises a fault.

The fault is precise and immediate. It is a MemManage fault: the fault status register reports a data access violation, the fault address register holds the address that was refused, and the stacked program counter points at the instruction that performed the push. That is the whole value of the mechanism: the fault occurs at the offending instruction rather than after the corruption has propagated.

For a stack that is checked by filling it with a pattern and measuring the high-water mark, the difference is diagnostic versus statistical. The fill method reports how deep the stack went in the runs that were observed; the guard region reports the first run that went too deep, at the moment it happened.

The guard’s size is a compromise. Thirty-two bytes is the minimum region size and catches a single push that crosses the boundary, but a function that allocates a large frame in one instruction — a local array of a few hundred bytes — decrements the stack pointer by the whole frame before writing anything. The write lands well below the guard, past it, and the fault may not occur at all if the frame is large enough to skip the region entirely. A guard of one region is therefore a detector for gradual overflow, not for a single large frame.

The remedy is either a larger guard or a stack limit check in the function prologue. A 256-byte or 1 KiB guard catches most single frames, and the cost is the address space plus the region, which is scarce. Compiler support for stack probing — a call that touches each page of a large frame before using it — is the general solution on larger systems and is rarely available on a microcontroller toolchain.

Privilege levels and what they buy

ARMv7-M has two privilege levels, and the MPU’s permissions are expressed per region for privileged and unprivileged access separately. A region can be read-write for privileged code and read-only or inaccessible for unprivileged code.

The pattern this enables is a supervisor that owns the peripherals and a task that owns only its own data. The task runs unprivileged, its code and data regions are readable and writable, the peripheral region is not accessible at all, and every hardware interaction goes through a supervisor call. A task that dereferences a null pointer or writes past its buffer faults instead of corrupting the supervisor’s state.

The cost is the call path. Every peripheral access from a task becomes an exception, with the entry and return overhead described in the interrupt controller article, plus the argument marshalling. On a part running at tens of megahertz, a supervisor call is a substantial fraction of a microsecond, and a driver that performs one call per register write is unusable. Real designs batch: a call that configures a whole peripheral in one request, or a shared memory region that the task writes and the supervisor reads, with the call only signalling that the request is ready.

The privilege boundary is also where the MPU’s limits become visible. The unit protects against accidental access, not against a malicious one. A task that can read the supervisor’s code region can find the addresses of the supervisor’s data, and a task that can execute arbitrary code in its own region can construct a call to the supervisor with any arguments. On a part with no memory management unit there is no address translation, so the physical addresses are the only addresses and nothing is hidden.

Execute-never and the data-as-code problem

A region can be marked execute-never, which means an instruction fetch from it faults. Marking SRAM and the peripheral space execute-never removes the largest class of control-flow attacks: a buffer overflow that overwrites a return address can redirect execution only to code that already exists in an executable region.

The protection is meaningful only if the executable region is small and does not contain useful gadgets. On a microcontroller the code region is flash, which is where the application already lives, so execute-never on SRAM prevents injected code but not return-oriented reuse of existing functions. That is still a large reduction in exposure, and it costs nothing at run time.

The setting interacts with anything that legitimately executes from SRAM. A flash-programming routine copied to SRAM for the reason described in the flash timing article needs an executable region over its own buffer, and that region must be as small as the routine. A region sized to the next power of two above a few hundred bytes of routine grants execute permission to a kilobyte or more of SRAM, and anything that can write into that kilobyte can execute.

Fault handling and the information available

A MemManage fault stacks the same eight-word frame as any other exception, and the fault status register reports whether the access was a read, a write, or an instruction fetch. The fault address register holds the address that was refused when the fault is precise, and that address is what identifies the region involved; the status register itself carries no region number.

The distinction between precise and imprecise matters here for the same reason it does for bus faults, but it resolves differently. The protection check is made when the core issues the access, so a refused read or a refused write is reported on the instruction that made it and the fault address register is valid. The two status bits that are not tied to a single instruction are the ones for a violation during hardware stacking on exception entry and during unstacking on return: in those cases the stack pointer is still adjusted, the frame on the stack may be incomplete, and the fault address register is not written.

A handler that must distinguish a stack overflow from a null-pointer write reads the fault address and compares it against the guard region’s base. If the address falls inside the guard, the stack overflowed, and the deepest address the stack still legitimately used is the guard’s base — the exception frame saves eight registers but no stack pointer, so there is nothing in the frame to read that value from.

The handler’s own stack requirement is the trap. A fault handler runs on the same stack that just overflowed, so a handler that pushes a large frame before reading anything faults again, this time in handler mode, and the second fault escalates to a hard fault with no useful state. Fault handlers on MPU-equipped parts should be minimal, should read the fault registers first, and should record the information somewhere that survives a reset rather than attempting to recover in place.

Where the unit is not enough

An MPU provides no address translation, so a region’s identity is its physical address and nothing can be remapped. Two tasks cannot share one address with different contents, which rules out the virtual-memory tricks that a full MMU enables: demand paging, copy-on-write, and per-task address spaces.

It also provides no protection against a DMA engine. A DMA transfer writes to physical memory with no regard for the MPU’s regions, because the MPU is a property of the core’s accesses and the DMA is a separate bus master. A misconfigured DMA channel can overwrite the very guard region that is meant to detect corruption, and the corruption will not fault because the write never passed through the core. Parts that implement a peripheral-side protection unit — a region check on the DMA’s own path — close this hole, and parts that do not leave it open.

The region count is the practical limit on how fine-grained a partition can be. Eight regions must cover the code, the data, the stack, the guard, the peripheral space, and whatever shared buffers the design has. The background map that grants default permissions to privileged code is enabled by a control bit rather than by a region descriptor, so it does not cost one of the eight, but eight is still a small number once the fixed obligations are met. A design that needs per-task data isolation with more than a handful of tasks cannot express it with eight regions, and the usual response is to partition by privilege level rather than by task, accepting that unprivileged tasks share a region and are not isolated from each other.

References

  1. ARMv7-M Architecture Reference Manual (ARM DDI 0403) — Arm Limited
  2. Cortex-M4 Technical Reference Manual (ARM DDI 0439) — Arm Limited
  3. The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors — Joseph Yiu, Newnes
  4. ARMv8-M Architecture Reference Manual (ARM DDI 0553) — Arm Limited