RT-Thread 如何用 MPU 做线程栈保护
摘要:结合 RT-Thread 当前 Cortex-M4 实现,拆解 MPU 内存保护、线程双 Guard 区、上下文切换重装载以及 MemManage Fault 的完整运行链路。
在 Cortex-M 系列 MCU 上,线程栈溢出一直是最难排查的一类故障之一。它往往不是在“栈刚好用完”的那一刻表现为明确错误,而是先覆盖相邻内存,再以随机变量异常、链表损坏、HardFault、跑飞甚至数分钟后的非确定性故障出现。
RT-Thread 传统的 RT_USING_OVERFLOW_CHECK 可以对线程栈做软件检查,但软件检查本质上依赖检查时机:如果栈已经越界,在下一次检查发生之前,相邻内存仍可能先被破坏。
另一条路线是利用 Cortex-M 自带的 MPU(Memory Protection Unit)。RT-Thread 的 RT_USING_MEM_PROTECTION 提供了 MPU 抽象层,而 RT_USING_HW_STACK_GUARD 则进一步利用 MPU,在每个线程栈的上下边界放置不可访问区域。一旦 CPU 真正访问到 Guard 区,就会触发 MemManage Fault。
截至 2026-08-27,RT-Thread 主线已经加入 Cortex-M4 对这套机制的支持。相关提交为:
1 | e2e6245aace07b545b8dbcef8fbcdb4469f55d9f |
这使得 STM32F407 这类 Cortex-M4F MCU 可以直接使用 RT-Thread 的 MPU 抽象层和硬件线程栈保护。下面从硬件原理、RT-Thread 实现和实际运行过程三个层面把整条链路串起来。
1. 两个配置项分别解决什么问题
先看 Kconfig 里的依赖关系:
1 | RT_USING_HW_STACK_GUARD |
当前 components/mprotect/Kconfig 中:
1 | config RT_USING_MEM_PROTECTION |
因此,RT_USING_HW_STACK_GUARD 不是一个独立实现,它是 RT_USING_MEM_PROTECTION 之上的一种具体应用。
1.1 RT_USING_MEM_PROTECTION
它提供的是一层通用的内存保护抽象。
在 ARMv7-M Cortex-M4 上,底层对应硬件 MPU。RT-Thread 用统一的 rt_mem_region_t 描述一个受保护区域:
1 | typedef struct |
应用层可以通过:
1 | rt_mprotect_add_region(); |
给某个线程增加、删除或修改 MPU region。
也可以通过:
1 | rt_mprotect_add_exclusive_region(); |
建立“只有 owner 线程能够访问”的区域。
它可以用来完成:
- Flash 只读、可执行;
- RAM 可读写、不可执行;
- 指定线程只能访问指定数据;
- 某段内存仅由一个线程拥有;
- 线程栈边界保护。
但要注意,普通 RT-Thread Cortex-M4 系统中的线程和内核仍然共享同一套物理地址空间。这里的 MPU 更准确地说是在共享地址空间上增加访问约束,而不是像 MMU + 虚拟内存那样建立进程级地址空间隔离。
1.2 RT_USING_HW_STACK_GUARD
这个选项专门解决线程栈越界检测。
它不会改变 C 编译器生成函数栈帧的方式,也不是 GCC 的 -fstack-protector。它的工作方式更接近硬件“防撞墙”:RT-Thread 在每个线程的栈两端各放置一个 MPU No-Access 区域,一旦 CPU 对这块区域发起访问,就由 MPU 拦截。
所以它检测的是:
1 | CPU 是否真正访问了线程栈边界之外的受保护区域 |
而不是:
1 | 某个软件标记是否已经被改写 |
2. Cortex-M4 MPU 为什么能实现这种保护
Cortex-M4 属于 ARMv7E-M 架构,MPU region 具有几个直接影响 RT-Thread 实现的约束:
- region 数量有限;
- 最小 region 大小为 32 Byte;
- region size 必须是 2 的幂;
- region base 必须按 region size 自然对齐;
- 可以配置读、写、执行权限;
- 访问违反权限的地址可以产生 MemManage Fault。
RT-Thread Cortex-M4 实现中的 rt_hw_mpu_region_valid() 也直接检查这三个条件:
1 | if (region->size < MPU_MIN_REGION_SIZE) |
其中:
1 |
对于 STM32F407,MPU 提供 8 个 region。因此当前使用这套功能时,典型配置为:
1 | NUM_MEM_REGIONS=8 |
RT-Thread 不会只相信配置值。rt_hw_mpu_init() 启动时还会读取:
1 | MPU->TYPE |
获取硬件真实支持的 region 数量,然后与 NUM_MEM_REGIONS 比较。配置和硬件不一致时初始化会失败。
这个设计很重要,因为 NUM_MEM_REGIONS 不是“想要几个就配置几个”,而是对目标处理器硬件能力的描述。
3. 一个线程的栈实际上被改造成什么样
在 Cortex-M4 上,RT-Thread 线程栈向低地址方向增长。
打开硬件栈保护以后,原始 stack buffer 不再全部作为可用线程栈,而会被切成三个主要部分:
1 | 低地址 |
真正用于捕捉典型线程栈溢出的主要是低地址侧 Bottom Guard,因为 Cortex-M4 的线程栈通常从高地址向低地址增长。
高地址侧 Top Guard 仍然有意义,它可以覆盖错误 SP、越界访问或者其他破坏栈上边界的情况。
对应的 Cortex-M4 代码位于:
1 | libcpu/arm/cortex-m4/cpuport.c |
核心函数是:
1 | void rt_hw_stack_guard_init(rt_thread_t thread) |
其主要逻辑可以概括为:
1 | stack_bottom = thread->stack_addr; |
两个 region 的属性都是:
1 | RT_MEM_REGION_P_NA_U_NA |
也就是 Privileged No Access / Unprivileged No Access。
随后 RT-Thread 会修改线程控制块:
1 | thread->stack_buf = thread->stack_addr; |
这里的语义变化非常重要:
1 | stack_buf |
所以开启 Guard 以后,thread->stack_size 已经不是最初传给 rt_thread_create() 或 rt_thread_init() 的原始大小。
4. 为什么线程创建时还要分配一张 MPU region 表
很多人第一次看到 MPU 栈保护时,会自然认为:
1 | 每个线程直接占用两个硬件 MPU region |
实际上不是。
Cortex-M4 的硬件 MPU 只有固定数量的 region。例如 STM32F407 只有 8 个,不可能 20 个线程就需要 40 个硬件 region。
RT-Thread 使用的是“软件表 + 上下文切换时重装载”的方式。
每个线程的 rt_thread 中都有:
1 |
|
开启硬件栈保护时:
1 |
这里额外的 2 就是 Bottom Guard 和 Top Guard。
线程第一次执行 rt_mprotect_add_region() 时,RT-Thread 会为该线程分配自己的 region 表:
1 | thread->mem_regions = RT_KERNEL_MALLOC( |
因此整个关系实际上是:
1 | flowchart LR |
图里最重要的是最后两步:线程自己的 region 配置长期保存在 RAM 中,真正的硬件 MPU slot 在调度时被当前线程反复复用。
这也解释了两个现象。
第一,线程数量增加不会线性消耗硬件 MPU region 数量,但会增加 RAM 占用。
第二,只要启用 per-thread MPU region,线程切换就需要重新编程 MPU,因此上下文切换路径会增加额外工作。
还有一个容易忽略的工程语义:即使线程本身通过 rt_thread_init() 使用静态 TCB 和静态 stack,当前硬件 Guard 的 mem_regions 仍由 RT_KERNEL_MALLOC() 分配。因此开启该功能以后,“静态线程”不再意味着整个线程相关数据都完全不使用 heap。
这也正是 RT_USING_MEM_PROTECTION 在 Kconfig 中主动 select RT_USING_HEAP 的原因之一。
5. 从 rt_thread_create() 到 Guard 建立的完整调用链
线程初始化核心逻辑位于:
1 | src/thread.c |
简化后的调用关系如下:
1 | rt_thread_create() |
注意顺序:
1 | 先调整 Guard 和有效 stack 边界 |
这保证 rt_hw_stack_init() 不会把初始寄存器栈帧放进 Top Guard。
整个运行过程可以用下面的时序表示:
1 | sequenceDiagram |
6. MPU 本身什么时候启用
通用 MPU 组件位于:
1 | components/mprotect/ |
在 STM32 BSP 的启动路径中,这个初始化发生得比普通线程创建更早。当前调用关系是:
1 | rtthread_startup() |
这条顺序很关键:RT_USING_MEM_PROTECTION 会使用 heap 保存 per-thread region 表,因此 STM32 BSP 先完成 heap 初始化,再执行 Board Export 中的 MPU 初始化;随后 rt_application_init() 才创建 main 线程并建立它的 Guard。
初始化入口:
1 | int rt_mprotect_init(void) |
在 Cortex-M4 上,最终进入:
1 | libcpu/arm/cortex-m4/mpu.c |
的:
1 | rt_hw_mpu_init() |
这个函数主要做四件事。
第一,读取 MPU 硬件能力:
1 | MPU->TYPE.DREGION |
确认处理器确实有 MPU,并校验它与 NUM_MEM_REGIONS 一致。
第二,检查 region 预算:
1 | NUM_STATIC_REGIONS |
第三,关闭 MPU 并写入 BSP 定义的 static regions。
第四,重新启用 MPU:
1 | ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk); |
PRIVDEFENA 表示 privileged code 对未被 MPU region 显式覆盖的地址仍采用默认内存映射。这样 BSP 不需要一开始就把整个 4 GB 地址空间全部拆成 MPU region。
CMSIS 的 ARM_MPU_Enable() 同时会打开 MemManage Fault:
1 | SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk; |
因此 MPU 权限违规能够进入 MemManage_Handler(),而不是所有问题都只表现为 HardFault。
7. BSP 为什么还需要 static_regions[]
NUM_MEM_REGIONS 是处理器总共有多少 MPU slot,而 NUM_STATIC_REGIONS 则由 BSP 决定要永久占用多少个 MPU region。
当前 STM32F407 参考 BSP:
1 | bsp/stm32/stm32f407-fk407m2-zgt6 |
在 board.h 中定义:
1 |
在 board.c 中提供:
1 | rt_mem_region_t static_regions[NUM_STATIC_REGIONS] = { |
含义是把整个 Flash 配置成:
1 | Read + Execute |
对于 STM32F407,如果只做线程 Guard,可以用这样的 region 预算:
1 | 硬件 MPU region 总数: 8 |
关键是“当前线程运行时”。
两个 Guard 并不是每个线程永久占用硬件 slot,而是每个线程保留自己的配置,调度时装进相同的硬件动态 slot。
8. PendSV 为什么必须参与 MPU 保护
如果只在线程创建时写一次 MPU,会出现一个根本问题:不同线程需要不同的栈 Guard 地址。
线程 A 的栈可能位于:
1 | 0x20001000 ~ 0x20001400 |
线程 B 的栈可能位于:
1 | 0x20002000 ~ 0x20002800 |
CPU 从 A 切到 B 以后,如果 MPU 仍然保留 A 的 Guard,那么 B 的栈根本没有受到正确保护。
因此 MPU region 必须成为“线程上下文”的一部分。
Cortex-M4 当前 GCC 上下文切换代码在 PendSV 切换路径中加入:
1 | rt_thread_self() |
rt_hw_mpu_table_switch() 会:
- 读取当前线程的
mem_regions; - 把 dynamic regions 写入 MPU;
- 根据 exclusive region owner 关系写入需要隔离的区域;
- 清空没有使用的 MPU slot。
运行链路如下:
1 | sequenceDiagram |
这一步决定了硬件 Guard 能否随着线程真正“移动”。
也正因为如此,启用内存保护以后,线程切换路径会增加 MPU register programming 开销。这个开销能从源码确认存在,但具体增加多少 cycle 不能只靠代码静态推导;它取决于 CPU 主频、编译优化、动态 region 数量、Flash wait state、FPU 上下文等因素,需要在具体目标板上测量。
9. 栈真正越界时,CPU 和 RT-Thread 会发生什么
假设一个线程可用栈已经接近 Bottom Guard:
1 | 高地址 |
随着函数调用、局部变量分配或者寄存器压栈继续消耗 stack,SP 最终进入 Bottom Guard。
接下来 CPU 对 Guard 地址发起真实读写访问时:
1 | 线程执行 |
当前 Cortex-M4 MemManage_Handler() 会组织:
1 | typedef struct |
应用可以通过:
1 | rt_hw_mpu_exception_set_hook(...); |
注册诊断 hook。
当前实现中,hook 返回后 handler 最终进入:
1 | while (1); |
所以这里的 hook 应理解为故障诊断入口,而不是通用 fault recovery 接口。
也就是说,当前主线不能简单理解成:
1 | 某个线程越界 |
如果产品希望做这种“单线程故障恢复”,还要进一步处理 faulting instruction、异常堆栈、PSP/MSP、调度器状态和 MPU 状态,不能仅依赖当前 hook。
10. 为什么真实栈溢出不一定总有有效的 MMFAR
如果线程通过普通 C 语句访问 Guard,例如写入一个位于 Guard 内的地址,MemManage Status Register 常见表现是普通数据访问违规,同时 MMFAR 可能提供有效地址。
但真正的栈溢出可能发生在更多场景:
- 函数 prologue 压栈;
- 异常进入时硬件自动 stacking;
- 异常返回时 unstacking;
- FPU 上下文保存;
- 嵌套异常。
如果故障发生在异常压栈或出栈过程,MMFSR 可能出现:
1 | MSTKERR |
此时不能无条件使用 MMFAR。
正确的 Fault 分析规则仍然是:只有当 CFSR 中对应的 MMARVALID 置位时,MMFAR 才能作为有效 fault address 使用。
这属于 ARMv7-M Fault 语义,而不是 RT-Thread 自己定义的规则。
11. 开启以后,原来的线程栈会少多少
两个固定 Guard 一共占:
1 | 32 B + 32 B = 64 B |
但当前实现还会把上下边界按 32 Byte 对齐。
因此实际损失不仅是固定 64 Byte,还要考虑原始 stack buffer 地址和大小带来的 alignment padding。
按当前算法分析,最坏情况下有效 stack 损失可以接近:
1 | 64 ~ 126 Byte |
这里的范围来自源码中的 32 Byte Guard 与上下边界对齐关系,是实现层面的推导,不是官方规定“所有线程固定损失 126 Byte”。
如果是静态线程,比较理想的做法是让 stack 本身 32 Byte 对齐,并把总大小设计成 32 Byte 的整数倍。例如:
1 | rt_align(32) |
在边界完全对齐时,两个 32 Byte Guard 消耗 64 Byte,可以给线程保留约 1024 Byte 有效 stack。
对于 rt_thread_create() 动态 stack,因为实际 heap 返回地址未必正好 32 Byte 对齐,如果希望原来 1024 Byte 的有效空间大致保持不变,工程上可以先保守额外预留约 128 Byte,再结合实际 stack watermark 调整。
这个“增加约 128 Byte”是基于当前实现的工程估算,而不是 RT-Thread API 的硬性要求。
12. RAM 开销不仅来自两个 Guard
Guard region 本身只是权限描述,不会额外分配两块独立 32 Byte RAM;它们实际上占用了原始 stack buffer 的两段空间。
额外 RAM 开销主要来自每线程 MPU region table 和 TCB 新字段。
只启用硬件 Guard、且:
1 | NUM_CONFIGURABLE_REGIONS = 0 |
时:
1 | NUM_DYNAMIC_REGIONS = 2 |
在 32-bit Cortex-M4 上,rt_mem_region_t 通常包含:
1 | start pointer 4 B |
两个 region 的纯结构体数据大约 24 Byte/线程。
TCB 还会额外保存:
1 | mem_regions |
两个指针通常再增加约 8 Byte。
最终实际 heap 增量还要叠加 allocator metadata 和 alignment,所以应以目标工程的 sizeof()、map 文件和 heap 统计为准,而不能把 32 Byte 左右当成绝对值。
13. RT_USING_OVERFLOW_CHECK 和硬件 Guard 是替代关系吗
不是完全替代。
软件 overflow check 和 MPU Guard 解决的是同一个大问题,但检测机制不同。
| 维度 | RT_USING_OVERFLOW_CHECK |
RT_USING_HW_STACK_GUARD |
|---|---|---|
| 检测方式 | 软件检查栈边界/填充值 | MPU 访问权限 |
| 触发时机 | 到达软件检查点时 | CPU 第一次访问 Guard 时 |
| 是否占 MPU region | 否 | 是,每线程配置中占 2 个动态 region |
| 是否减少有效 stack | 通常没有 Guard 固定损失 | 有,至少两个 32 B Guard |
| 上下文切换额外工作 | 很小或无 MPU 重装载 | 需要切换 MPU dynamic regions |
| 故障表现 | 软件检测逻辑 | MemManage Fault |
| 适合用途 | watermark、常规溢出检测 | 快速阻断越界继续扩散 |
所以在调试版本或高可靠 MCU 系统中,两者可以同时开启:
1 | RT_USING_OVERFLOW_CHECK=y |
软件检查继续提供栈使用情况和传统检测能力,MPU Guard 则提供硬件访问边界。
14. STM32F407 上一个最小而清晰的配置
如果目标只是先把硬件线程栈保护跑起来,而不做复杂的 per-thread 内存隔离,可以把动态 MPU 功能压到最小:
1 | CONFIG_RT_USING_MEM_PROTECTION=y |
对应的 rtconfig.h 概念上类似:
1 |
BSP 再提供:
1 |
以及一条 Flash RX static region。
这个配置下,一个正在运行的线程所需 MPU slot 为:
1 | 1 个 static Flash region |
对于 STM32F407 的 8-region MPU 来说空间充足,也让第一次引入这项功能时的变量尽可能少。
15. 当前 Cortex-M4 实现还有哪些明确边界
这套支持已经进入主线,但它仍是非常新的 Cortex-M4 port 功能。理解下面几个边界,有助于避免把“功能已存在”误解成“所有工程配置都已成熟覆盖”。
15.1 当前构建脚本限制 GCC / llvm-arm
libcpu/arm/cortex-m4/SConscript 当前逻辑为:
1 | using_mpu = RT_USING_MEM_PROTECTION || RT_USING_HW_STACK_GUARD |
启用后只允许:
1 | gcc |
其他工具链会直接抛出错误。
因此使用 Keil ARMCC、ArmClang 或 IAR 的工程不能只打开 Kconfig 就认为当前实现可以直接工作。
15.2 rt_hw_stack_guard_init() 当前不向上处理 region 添加失败
当前函数会连续调用两个:
1 | rt_mprotect_add_region() |
然后修改 stack_addr/stack_size。
从当前源码看,它没有把 rt_mprotect_add_region() 的错误状态返回给 _thread_init()。
因此如果内部 mem_regions heap 分配失败,或者添加 region 失败,错误传播链仍值得产品工程进一步审查。
这是根据当前源码得到的实现事实,不表示已确认存在某个线上故障。
15.3 通用 MPU 配置 peripheral 时要继续核对 memory type
当前 Cortex-M4 rt_hw_mpu_region_default_attr() 对大部分低于 0xE0000000 的地址使用 NORMAL_NON_CACHEABLE_SHAREABLE 作为默认 memory type。
对于线程栈所在 SRAM,这条路径没有明显冲突。
但如果未来把通用 rt_mprotect_add_region() 用到 0x40000000 一类 peripheral/MMIO 地址,只看 AP/XN 权限还不够,还需要结合具体 MCU memory map 复核 Device/Normal memory type 是否正确。
该函数被声明为 rt_weak,因此 BSP 可以根据芯片地址空间覆盖默认策略。
这也是使用 MPU 时一个很重要的原则:
1 | 访问权限正确,不等于 memory type 一定正确。 |
16. 从启动到 Fault 的整条运行链路
把前面的实现合并起来,RT-Thread Cortex-M4 硬件栈保护可以理解成四个阶段。
1 | flowchart TD |
这张图可以概括成一句话:
Guard 是在线程创建时计算和保存的,在上下文切换时装入硬件,在真正越界访问时由 MPU 触发 Fault。
理解这三个时间点,就基本理解了 RT-Thread 这套设计。
17. 为什么这种设计适合 MCU RTOS
在没有 MMU 的 Cortex-M4 上,RTOS 很难像 Linux 那样给每个线程建立独立虚拟地址空间,但 MPU 仍然能提供一种非常实用的“有限硬件边界”。
RT-Thread 当前设计没有试图让每个线程永久独占一套硬件 region,而是把:
1 | 线程自己的保护策略 |
保存在软件结构中,再把:
1 | 当前正在运行线程的保护策略 |
映射进有限的 MPU hardware slots。
这是典型的 MCU 资源复用思路:用少量硬件 region 换取任意数量线程的时分复用保护。
代价也很明确:
- TCB 和 heap 增加少量 RAM;
- 线程有效 stack 减少;
- PendSV 增加 MPU register programming;
- Fault 默认是终止性诊断路径,而不是自动恢复机制。
因此它更适合被理解成“提高故障发现确定性和系统内存边界强度”的机制,而不是零成本的安全隔离方案。
对于 STM32F407 这类具有 MPU、但没有 MMU 的 Cortex-M4 MCU,这种取舍是合理且实用的。
18. 结语
RT_USING_MEM_PROTECTION 和 RT_USING_HW_STACK_GUARD 的核心并不复杂:前者建立 RT-Thread 的 MPU 抽象层,后者利用这层能力,为每个线程栈生成两个 No-Access region。
真正值得理解的是 RT-Thread 如何把有限的 Cortex-M4 MPU region 和多线程调度结合起来:
1 | 线程创建 |
它不是软件 canary,也不是完整的进程内存隔离,而是一套与 RTOS 上下文切换绑定的硬件访问边界机制。
如果目标只是在线程栈失控时尽可能早地阻止内存继续被覆盖,那么 Cortex-M4 MPU Guard 相比单纯依赖软件检查提供了更直接的硬件故障边界;同时,软件 overflow check 仍可以继续承担 watermark 和传统检测职责,两者并不冲突。
对于使用 STM32F407、GCC 和当前 RT-Thread 主线的项目,先从 static Flash RX + 双 Stack Guard 这一最小组合开始,是目前最容易理解和控制的使用方式。等这一层稳定以后,再扩展到 per-thread RAM、exclusive region 或 MMIO 权限,会更容易区分权限设计、内存属性和调度开销各自带来的影响。
参考源码与资料
RT-Thread 主线源码:
components/mprotect/Kconfigcomponents/mprotect/mprotect.ccomponents/mprotect/mprotect.hcomponents/mprotect/README.mdlibcpu/arm/cortex-m4/mpu.clibcpu/arm/cortex-m4/mpu.hlibcpu/arm/cortex-m4/mputype.hlibcpu/arm/cortex-m4/cpuport.clibcpu/arm/cortex-m4/context_gcc.Slibcpu/arm/cortex-m4/SConscriptsrc/thread.cbsp/stm32/stm32f407-fk407m2-zgt6/board/board.hbsp/stm32/stm32f407-fk407m2-zgt6/board/board.c
相关提交:
- RT-Thread commit
e2e6245aace07b545b8dbcef8fbcdb4469f55d9f:[arm][cortex-m4] Add hardware stack guard support - RT-Thread
components/mprotect - RT-Thread
libcpu/arm/cortex-m4 - STM32 公共 BSP 初始化路径
drv_common.c
硬件资料:
- ST AN4838:Introduction to memory protection unit management on STM32 MCUs
- ST PM0214:STM32 Cortex-M4 MCUs and MPUs programming manual
- Arm Cortex-M4 Processor Technical Reference / Architecture documentation
版本说明:文章依据截至 2026-08-27 的 RT-Thread
master实现整理。Cortex-M4 MPU/Stack Guard 支持属于近期新增功能,旧版 RT-Thread 即使存在同名 Kconfig,也应检查libcpu/arm/cortex-m4/是否已经包含完整 MPU port、PendSV MPU table switch 和rt_hw_stack_guard_init()。










