Zephyr Userspace: Hardware-Enforced Protection for Embedded Firmware
1. Introduction
Consider a firmware application with four threads: a sensor driver, a BLE stack, a bootloader update handler, and a logging component. Each thread has its own stack and its own data. In a typical embedded RTOS with no memory protection, every thread runs with full access to the entire address space. A single pointer arithmetic bug in the sensor driver can silently overwrite the BLE stack's buffers. A misconfigured DMA in the bootloader handler can corrupt the logging state. The system continues to run corrupted and the bug surfaces hours later in a completely unrelated subsystem.
This is not a hypothetical. It is one of the most common and hardest-to-debug failure modes in embedded firmware.
Hardware isolation changes the model entirely. The Cortex-M Memory Protection Unit (MPU) enforces access permissions at the hardware level: each thread can only read and write the memory it has been explicitly granted access to. Any attempt to access memory outside the permitted regions triggers a hardware fault immediately, at the point of the violation, with a precise fault address. The bug is caught in the thread that caused it, not in an unrelated thread an hour later.
Zephyr is the only mainstream embedded RTOS that exposes this hardware capability as a first-class feature through its userspace subsystem. With CONFIG_USERSPACE=y , Zephyr gives you unprivileged threads, per-thread memory domains, and a validated syscall interface a complete privilege separation model on bare metal.
This article covers the full stack: the Cortex-M MPU, Zephyr's userspace model, memory domains and partitions, and the syscall gate.
2.
The Cortex-M MPU and Zephyr's Userspace Model
2.1 The MPU on Cortex-M
Any Cortex-M device with an MPU exposes a set of independently configurable memory regions. Each region defines:
- A base address and size (on ARMv7-M such as Cortex-M4, size must be a power of two and at least 32 bytes; ARMv8-M such as Cortex-M33 relaxes this to any 32-byte-aligned size)
- Access permissions : read/write/execute for privileged mode and for unprivileged mode, set independently
- Memory type attributes : normal, device, or strongly-ordered
The number of regions varies by implementation: 8 on most Cortex-M3/M4/M7 parts, 16 on many Cortex-M33/M55 parts. Zephyr's MPU driver abstracts this you configure partitions and domains; the kernel manages the hardware regions.
When the CPU accesses an address, the MPU checks whether the active privilege level has permission for that access type. If not, a MemManage fault fires immediately. On Cortex-M4, the processor saves the faulting address in the MMFAR register, giving you a precise diagnosis at the exact point of the violation.
2.2 Privileged vs Unprivileged Mode
Cortex-M defines two execution privilege levels in Thread Mode:
- Privileged Thread Mode full access to system registers ( CONTROL , MSP , PRIMASK , etc.), unrestricted memory access subject only to MPU rules. The default for all Zephyr threads when userspace is not enabled.
- Unprivileged Thread Mode cannot access system registers, cannot execute privileged instructions ( MSR / MRS on restricted registers), and is fully subject to MPU restrictions.
When a Zephyr thread is marked for userspace ( K_USER flag or k_thread_user_mode_enter ), the kernel switches it to unprivileged mode before handing control over. From that point, the thread operates entirely under the MPU rules of its assigned memory domain. Any kernel call must go through the syscall gate.
2.3 Enabling Userspace
In prj.conf :
CONFIG_USERSPACE=y CONFIG_MPU=y CONFIG_ARM_MPU=y CONFIG_THREAD_STACK_INFO=y CONFIG_THREAD_USERSPACE_INFO=y |
Enabling userspace activates the MPU driver, the syscall dispatch table, and the memory domain infrastructure. No other embedded RTOS not FreeRTOS, not ThreadX, not embOS provides this as a built-in, configuration-driven feature on bare metal.2.4 Stack Sentinels and Thread Isolation
Even without full userspace, Zephyr supports CONFIG_MPU_STACK_GUARD=y , which places a write-protected MPU region immediately below each thread's stack. A stack overflow faults immediately at the overflow point rather than silently corrupting adjacent memory. With full userspace enabled, each thread's stack is mapped as a dedicated MPU region accessible only to the owning thread a complete isolation boundary between stacks.
Struggling to implement this for a professional project?
If you need to master full-scale firmware architecture, security, and build automation, join our 1-D or 4-Day Live Implemnetation Workshops. I'll show you the exact direct path to production-ready firmware without the trial and error.
3.
Memory Domains and Partitions
3.1 Concepts
A k_mem_partition is a named memory region with explicit access permissions: read-write, read-only, or execute. It maps directly to one MPU hardware region.
A k_mem_domain is a collection of partitions assigned to a thread. When the kernel context-switches to a thread, it reprograms the MPU with the partitions of that thread's domain. No manual MPU register manipulation is needed the domain assignment drives everything.
The default domain ( k_mem_domain_default ) is assigned to threads that are not explicitly given a domain. It contains the kernel's shared read-only data region, accessible to all threads.
3.2 Defining Partitions and Domains
#include <zephyr/kernel.h> #include <zephyr/app_memory/app_memdomain.h>
/* Define a named partition for sensor driver data. * Variables placed in this section get this partition's R/W permissions. */ K_APPMEM_PARTITION_DEFINE(sensor_partition);
K_APP_DMEM(sensor_partition) static uint8_t sensor_buf[256]; K_APP_DMEM(sensor_partition) static int32_t last_temperature;
/* A read-only partition: config the sensor thread can read but not write */ K_APPMEM_PARTITION_DEFINE(config_partition); K_APP_RODATA(config_partition) static const uint32_t sample_interval_ms = 100;
/* Build the domain */ struct k_mem_domain sensor_domain;
void init_sensor_domain(void) { struct k_mem_partition *parts[] = { &sensor_partition, &config_partition, }; k_mem_domain_init(&sensor_domain, ARRAY_SIZE(parts), parts); } |
K_APP_DMEM places a variable in the linker section owned by the partition. K_APP_RODATA does the same but marks the section read-only. The permissions are enforced by the MPU at runtime no runtime checks in software needed.
3.3 Real Example: Isolated Sensor Thread
This example creates a userspace thread that can only access its own data buffer and stack. Any access to kernel RAM or another thread's memory triggers a MemManage fault immediately.
K_APPMEM_PARTITION_DEFINE(sensor_partition);
K_APP_DMEM(sensor_partition) static struct { int32_t temperature; /* milli-degrees C */ int32_t humidity; /* milli-percent */ uint32_t timestamp_ms; } s_data;
K_APP_DMEM(sensor_partition) static bool data_ready; #define SENSOR_STACK_SIZE 1024 K_THREAD_STACK_DEFINE(sensor_stack, SENSOR_STACK_SIZE); static struct k_thread sensor_thread_data; struct k_mem_domain sensor_domain;
static void sensor_thread_entry(void *p1, void *p2, void *p3) { const struct device *dev = DEVICE_DT_GET(DT_NODELABEL(temp_sensor));
while (1) { sensor_sample_fetch(dev); struct sensor_value val; sensor_channel_get(dev, SENSOR_CHAN_AMBIENT_TEMP, &val); s_data.temperature = val.val1 * 1000 + val.val2 / 1000;
sensor_channel_get(dev, SENSOR_CHAN_HUMIDITY, &val); s_data.humidity = val.val1 * 1000 + val.val2 / 1000;
s_data.timestamp_ms = k_uptime_get_32(); data_ready = true;
k_msleep(100); } }
void launch_sensor_thread(void) { struct k_mem_partition *parts[] = { &sensor_partition }; k_mem_domain_init(&sensor_domain, ARRAY_SIZE(parts), parts);
k_thread_create(&sensor_thread_data, sensor_stack, SENSOR_STACK_SIZE, sensor_thread_entry, NULL, NULL, NULL, 5, K_USER, /* unprivileged mode */ K_NO_WAIT);
k_mem_domain_add_thread(&sensor_domain, &sensor_thread_data); } |
The
K_USER flag causes the kernel to drop into unprivileged mode before
calling sensor_thread_entry. The MPU is programmed with exactly two
regions: sensor_partition (read-write) and sensor_stack (read-write,
owning thread only). Any pointer that escapes those regions faults at
the Cortex-M hardware level, with the faulting address captured in
MMFAR. Fig
1. Memory isolation in action
4.
The Syscall Interface: Crossing the Privilege Boundary
4.1 The Problem
A thread running in unprivileged mode cannot directly call kernel functions or touch peripheral registers. Calling k_msleep() writes to kernel-managed scheduler state. Toggling a GPIO touches a memory-mapped peripheral register. Both are blocked by the MPU for unprivileged threads.
The solution is the syscall gate: a single, controlled, validated entry point from unprivileged to privileged mode.
4.2 What Happens at the Hardware Level
The Cortex-M SVC (Supervisor Call) instruction is the hardware primitive that makes this work. When a userspace thread calls a kernel function:
1. The userspace stub executes SVC #id the syscall number is encoded in the immediate.
2. The SVC exception fires. The Cortex-M hardware automatically elevates to privileged Handler Mode and saves the thread's registers on the stack.
3. Zephyr's SVC handler reads the syscall number from the exception frame and dispatches to the registered handler.
4. The handler ( z_vrfy_* ) validates every argument: pointer bounds are checked against the calling thread's domain, integers are range-checked, kernel object ownership is verified.
5. If all checks pass, the implementation ( z_impl_* ) executes the privileged operation.
6. Exception return restores the thread's registers and drops back to unprivileged Thread Mode .
This design means a buggy or malicious userspace thread cannot pass a crafted pointer to the kernel and corrupt kernel state every pointer is validated against the thread's own domain before the kernel acts on it.
4.3 Pointer Validation in Syscall Handlers
For syscalls accepting pointer arguments, Zephyr provides macros that check
the pointer against the calling thread's memory domain:
static inline int z_vrfy_write_sensor_result(struct sensor_msg *msg) { /* Verify the entire struct is within the caller's writable memory */ K_OOPS(K_SYSCALL_MEMORY_WRITE(msg, sizeof(struct sensor_msg))); return z_impl_write_sensor_result(msg); } |
K_SYSCALL_MEMORY_WRITE walks the calling thread's domain partitions and verifies that every byte of the struct lies within a writable region. A forged or out-of-bounds pointer is rejected before the kernel implementation ever sees it.
Fig
2. Syscall gate.
Zephyr
also supports custom syscalls : developers can define their own
validated privilege boundary crossings using the __syscall keyword
and the z_impl_ / z_vrfy_ naming convention
5.
Conclusion
Zephyr's userspace subsystem brings OS-grade memory isolation to bare-metal Cortex-M firmware any Cortex-M with an MPU. The four components work as a complete system:
- The MPU enforces access permissions at the hardware level on every memory access. A violation faults immediately at the point of the bad access, not silently downstream.
- Memory partitions ( k_mem_partition ) define named regions with explicit permissions. Variables are placed in partitions at link time using K_APP_DMEM and K_APP_RODATA .
- Memory domains ( k_mem_domain ) group partitions and are assigned to threads. The MPU is reprogrammed automatically on every context switch.
- The syscall gate gives unprivileged threads a controlled, validated path to kernel services. Every argument is checked before the kernel acts on it.
Together, these four components give you a complete, hardware-enforced isolation model.
Fig
3. Full isolation model
