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
2
e2e6245aace07b545b8dbcef8fbcdb4469f55d9f
[arm][cortex-m4] Add hardware stack guard support

这使得 STM32F407 这类 Cortex-M4F MCU 可以直接使用 RT-Thread 的 MPU 抽象层和硬件线程栈保护。下面从硬件原理、RT-Thread 实现和实际运行过程三个层面把整条链路串起来。


1. 两个配置项分别解决什么问题

先看 Kconfig 里的依赖关系:

1
2
3
4
5
6
7
RT_USING_HW_STACK_GUARD
|
v
RT_USING_MEM_PROTECTION
|
v
RT_USING_HEAP

当前 components/mprotect/Kconfig 中:

1
2
3
4
5
6
7
8
9
config RT_USING_MEM_PROTECTION
bool "Enable memory protection"
default n
select RT_USING_HEAP

config RT_USING_HW_STACK_GUARD
bool "Enable hardware stack guard"
default n
select 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
2
3
4
5
6
typedef struct
{
void *start;
rt_size_t size;
rt_mem_attr_t attr;
} rt_mem_region_t;

应用层可以通过:

1
2
3
rt_mprotect_add_region();
rt_mprotect_delete_region();
rt_mprotect_update_region();

给某个线程增加、删除或修改 MPU region。

也可以通过:

1
2
rt_mprotect_add_exclusive_region();
rt_mprotect_delete_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
2
3
4
5
6
7
8
9
10
11
12
13
14
if (region->size < MPU_MIN_REGION_SIZE)
{
return RT_FALSE;
}

if ((region->size & (region->size - 1U)) != 0U)
{
return RT_FALSE;
}

if (((rt_uint32_t)region->start & (region->size - 1U)) != 0U)
{
return RT_FALSE;
}

其中:

1
#define MPU_MIN_REGION_SIZE 32U

对于 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
低地址

原始 stack buffer
+--------------------------------------------------+
| 对齐填充 |
+--------------------------------------------------+
| Bottom Guard:32 B,MPU No Access |
+--------------------------------------------------+
| |
| 可用线程栈 |
| |
| stack 向低地址增长 |
| ↓ |
+--------------------------------------------------+
| Top Guard:32 B,MPU No Access |
+--------------------------------------------------+
| 对齐填充 |
+--------------------------------------------------+

高地址

真正用于捕捉典型线程栈溢出的主要是低地址侧 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
2
3
4
5
6
7
8
9
10
11
stack_bottom = thread->stack_addr;
stack_top = thread->stack_addr + thread->stack_size;

bottom_guard_start = align_up(stack_bottom, 32);
top_guard_start = align_down(stack_top - 32, 32);

bottom_guard = [bottom_guard_start, bottom_guard_start + 32);
top_guard = [top_guard_start, top_guard_start + 32);

rt_mprotect_add_region(thread, &bottom_guard);
rt_mprotect_add_region(thread, &top_guard);

两个 region 的属性都是:

1
RT_MEM_REGION_P_NA_U_NA

也就是 Privileged No Access / Unprivileged No Access。

随后 RT-Thread 会修改线程控制块:

1
2
3
thread->stack_buf = thread->stack_addr;
thread->stack_addr = (void *)(bottom_guard_start + 32);
thread->stack_size = top_guard_start - bottom_guard_start - 32;

这里的语义变化非常重要:

1
2
3
4
5
stack_buf
-> 原始分配出来的完整 stack buffer

stack_addr / stack_size
-> 排除两个 Guard 后真正允许线程使用的栈

所以开启 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
2
3
#ifdef RT_USING_MEM_PROTECTION
void *mem_regions;
#endif

开启硬件栈保护时:

1
#define NUM_DYNAMIC_REGIONS (2 + NUM_CONFIGURABLE_REGIONS)

这里额外的 2 就是 Bottom Guard 和 Top Guard。

线程第一次执行 rt_mprotect_add_region() 时,RT-Thread 会为该线程分配自己的 region 表:

1
2
thread->mem_regions = RT_KERNEL_MALLOC(
NUM_DYNAMIC_REGIONS * sizeof(rt_mem_region_t));

因此整个关系实际上是:

1
2
3
4
5
6
7
flowchart LR
TCB["rt_thread / TCB"] --> Table["线程软件 mem_regions 表"]
Table --> Bottom["Bottom Guard 配置"]
Table --> Top["Top Guard 配置"]
Table --> Custom["可选 configurable regions"]
Table --> Switch["PendSV 上下文切换"]
Switch --> MPU["Cortex-M4 硬件 MPU region slots"]

图里最重要的是最后两步:线程自己的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
rt_thread_create()
|
+-- 分配 rt_thread 对象
|
+-- RT_KERNEL_MALLOC(stack_size)
|
+-- _thread_init()
|
+-- thread->stack_addr = stack_start
+-- thread->stack_size = stack_size
|
+-- memset(stack, '#', stack_size)
|
+-- rt_hw_stack_guard_init()
| |
| +-- 计算上下两个 32 B Guard
| +-- rt_mprotect_add_region(bottom)
| +-- rt_mprotect_add_region(top)
| +-- 保存 stack_buf
| +-- 重写 stack_addr / stack_size
|
+-- rt_hw_stack_init()
|
+-- 在“可用栈”顶部构造初始异常栈帧

注意顺序:

1
2
先调整 Guard 和有效 stack 边界
再建立线程初始上下文

这保证 rt_hw_stack_init() 不会把初始寄存器栈帧放进 Top Guard。

整个运行过程可以用下面的时序表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sequenceDiagram
participant APP as "线程创建者"
participant THR as "RT-Thread thread.c"
participant GUARD as "Cortex-M4 stack guard"
participant MPRO as "mprotect"
participant RAM as "线程 mem_regions 表"

APP->>THR: rt_thread_create(stack_size)
THR->>THR: 分配 TCB 和原始 stack buffer
THR->>GUARD: rt_hw_stack_guard_init(thread)
GUARD->>MPRO: add bottom No-Access region
MPRO->>RAM: 首次需要时分配并保存 region
GUARD->>MPRO: add top No-Access region
MPRO->>RAM: 保存 region
GUARD->>THR: 更新 stack_buf / stack_addr / stack_size
THR->>THR: rt_hw_stack_init()
THR-->>APP: 返回 thread handle

6. MPU 本身什么时候启用

通用 MPU 组件位于:

1
components/mprotect/

在 STM32 BSP 的启动路径中,这个初始化发生得比普通线程创建更早。当前调用关系是:

1
2
3
4
5
6
7
8
9
rtthread_startup()
-> rt_hw_board_init()
-> rt_system_heap_init()
-> rt_components_board_init()
-> INIT_BOARD_EXPORT(rt_mprotect_init)
-> rt_hw_mpu_init()
-> rt_system_scheduler_init()
-> rt_application_init()
-> rt_thread_create("main", ...)

这条顺序很关键:RT_USING_MEM_PROTECTION 会使用 heap 保存 per-thread region 表,因此 STM32 BSP 先完成 heap 初始化,再执行 Board Export 中的 MPU 初始化;随后 rt_application_init() 才创建 main 线程并建立它的 Guard。

初始化入口:

1
2
3
4
5
6
int rt_mprotect_init(void)
{
return (int)rt_hw_mpu_init();
}

INIT_BOARD_EXPORT(rt_mprotect_init);

在 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
2
3
4
5
NUM_STATIC_REGIONS
+ NUM_CONFIGURABLE_REGIONS
+ NUM_EXCLUSIVE_REGIONS
+ 2 个 Stack Guard
<= NUM_MEM_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
2
3
#ifdef RT_USING_MEM_PROTECTION
#define NUM_STATIC_REGIONS 1
#endif

board.c 中提供:

1
2
3
4
5
6
7
rt_mem_region_t static_regions[NUM_STATIC_REGIONS] = {
{
.start = (void *)STM32_FLASH_START_ADRESS,
.size = (rt_size_t)STM32_FLASH_SIZE,
.attr = RT_MEM_REGION_P_RX_U_RX,
},
};

含义是把整个 Flash 配置成:

1
2
Read + Execute
No Write

对于 STM32F407,如果只做线程 Guard,可以用这样的 region 预算:

1
2
3
4
5
6
7
硬件 MPU region 总数:        8
static Flash region: 1
per-thread configurable: 0
exclusive region: 0
stack guard: 2
---------------------------------
当前线程运行时使用: 3 / 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
2
rt_thread_self()
-> rt_hw_mpu_table_switch(current_thread)

rt_hw_mpu_table_switch() 会:

  1. 读取当前线程的 mem_regions
  2. 把 dynamic regions 写入 MPU;
  3. 根据 exclusive region owner 关系写入需要隔离的区域;
  4. 清空没有使用的 MPU slot。

运行链路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant PEND as "PendSV"
participant SCHED as "RT-Thread 调度上下文"
participant TCB as "新线程 TCB"
participant SW as "rt_hw_mpu_table_switch"
participant MPU as "Cortex-M4 MPU"

PEND->>SCHED: 完成线程上下文选择
SCHED->>TCB: current_thread 指向新线程
PEND->>SW: rt_hw_mpu_table_switch(current_thread)
SW->>TCB: 读取 mem_regions
SW->>MPU: 写入当前线程 dynamic regions
SW->>MPU: 写入适用的 exclusive regions
SW->>MPU: 清除剩余动态 slot
SW-->>PEND: MPU 已匹配新线程
PEND->>PEND: 恢复新线程寄存器并返回

这一步决定了硬件 Guard 能否随着线程真正“移动”。

也正因为如此,启用内存保护以后,线程切换路径会增加 MPU register programming 开销。这个开销能从源码确认存在,但具体增加多少 cycle 不能只靠代码静态推导;它取决于 CPU 主频、编译优化、动态 region 数量、Flash wait state、FPU 上下文等因素,需要在具体目标板上测量。


9. 栈真正越界时,CPU 和 RT-Thread 会发生什么

假设一个线程可用栈已经接近 Bottom Guard:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
高地址

Top Guard
+----------------------+
| |
| 可用 stack |
| |
| ↓ SP |
| ↓ |
| ↓ |
+----------------------+
Bottom Guard No Access

低地址

随着函数调用、局部变量分配或者寄存器压栈继续消耗 stack,SP 最终进入 Bottom Guard。

接下来 CPU 对 Guard 地址发起真实读写访问时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
线程执行
|
v
SP / 内存访问进入 Guard
|
v
Cortex-M4 MPU 权限检查
|
+-- 允许 --> 正常访问
|
+-- No Access
|
v
MemManage Fault
|
v
MemManage_Handler()
|
v
构造 rt_mem_exception_info_t
|
+-- thread
+-- addr
+-- region
+-- mmfsr
|
v
用户注册的 exception hook
|
v
while (1)

当前 Cortex-M4 MemManage_Handler() 会组织:

1
2
3
4
5
6
7
typedef struct
{
rt_thread_t thread;
void *addr;
rt_mem_region_t region;
rt_uint8_t mmfsr;
} rt_mem_exception_info_t;

应用可以通过:

1
rt_hw_mpu_exception_set_hook(...);

注册诊断 hook。

当前实现中,hook 返回后 handler 最终进入:

1
while (1);

所以这里的 hook 应理解为故障诊断入口,而不是通用 fault recovery 接口。

也就是说,当前主线不能简单理解成:

1
2
3
4
5
某个线程越界
-> MPU 捕获
-> 删除线程
-> 从异常返回
-> 系统继续运行

如果产品希望做这种“单线程故障恢复”,还要进一步处理 faulting instruction、异常堆栈、PSP/MSP、调度器状态和 MPU 状态,不能仅依赖当前 hook。


10. 为什么真实栈溢出不一定总有有效的 MMFAR

如果线程通过普通 C 语句访问 Guard,例如写入一个位于 Guard 内的地址,MemManage Status Register 常见表现是普通数据访问违规,同时 MMFAR 可能提供有效地址。

但真正的栈溢出可能发生在更多场景:

  • 函数 prologue 压栈;
  • 异常进入时硬件自动 stacking;
  • 异常返回时 unstacking;
  • FPU 上下文保存;
  • 嵌套异常。

如果故障发生在异常压栈或出栈过程,MMFSR 可能出现:

1
2
MSTKERR
MUNSTKERR

此时不能无条件使用 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
2
rt_align(32)
static rt_uint8_t worker_stack[1024 + 64];

在边界完全对齐时,两个 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
2
3
4
5
start pointer    4 B
size 4 B
attr.rasr 4 B
--------------------
约 12 B

两个 region 的纯结构体数据大约 24 Byte/线程。

TCB 还会额外保存:

1
2
mem_regions
stack_buf

两个指针通常再增加约 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
2
RT_USING_OVERFLOW_CHECK=y
RT_USING_HW_STACK_GUARD=y

软件检查继续提供栈使用情况和传统检测能力,MPU Guard 则提供硬件访问边界。


14. STM32F407 上一个最小而清晰的配置

如果目标只是先把硬件线程栈保护跑起来,而不做复杂的 per-thread 内存隔离,可以把动态 MPU 功能压到最小:

1
2
3
4
5
6
7
CONFIG_RT_USING_MEM_PROTECTION=y
CONFIG_RT_USING_HW_STACK_GUARD=y
CONFIG_USE_MEM_PROTECTION_EXAMPLES=n

CONFIG_NUM_MEM_REGIONS=8
CONFIG_NUM_EXCLUSIVE_REGIONS=0
CONFIG_NUM_CONFIGURABLE_REGIONS=0

对应的 rtconfig.h 概念上类似:

1
2
3
4
5
#define RT_USING_MEM_PROTECTION
#define RT_USING_HW_STACK_GUARD
#define NUM_MEM_REGIONS 8
#define NUM_EXCLUSIVE_REGIONS 0
#define NUM_CONFIGURABLE_REGIONS 0

BSP 再提供:

1
2
3
#ifdef RT_USING_MEM_PROTECTION
#define NUM_STATIC_REGIONS 1
#endif

以及一条 Flash RX static region。

这个配置下,一个正在运行的线程所需 MPU slot 为:

1
2
3
1 个 static Flash region
2 个当前线程 Stack Guard region
= 3 个 MPU 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
2
gcc
llvm-arm

其他工具链会直接抛出错误。

因此使用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
flowchart TD
Boot["系统启动"] --> MPUInit["rt_mprotect_init -> rt_hw_mpu_init"]
MPUInit --> Static["配置 static MPU regions 并启用 MPU"]
Static --> ThreadCreate["线程初始化"]
ThreadCreate --> GuardInit["rt_hw_stack_guard_init"]
GuardInit --> RegionTable["保存上下两个 Guard 到线程 mem_regions"]
RegionTable --> Run["线程进入调度"]
Run --> PendSV["PendSV 线程切换"]
PendSV --> TableSwitch["rt_hw_mpu_table_switch"]
TableSwitch --> ProgramMPU["把当前线程 Guard 写入硬件 MPU"]
ProgramMPU --> Execute["线程运行"]
Execute -->|"访问正常 stack"| Execute
Execute -->|"访问 Guard"| MemFault["MemManage Fault"]
MemFault --> Handler["MemManage_Handler"]
Handler --> Hook["用户 exception hook"]
Hook --> Stop["当前实现最终停在 while(1)"]

这张图可以概括成一句话:

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_PROTECTIONRT_USING_HW_STACK_GUARD 的核心并不复杂:前者建立 RT-Thread 的 MPU 抽象层,后者利用这层能力,为每个线程栈生成两个 No-Access region。

真正值得理解的是 RT-Thread 如何把有限的 Cortex-M4 MPU region 和多线程调度结合起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
线程创建
-> 建立 Guard 描述
-> 保存到 per-thread mem_regions

线程切换
-> 根据 current_thread 重装 MPU

线程运行
-> MPU 实时检查访问权限

栈越界
-> MemManage Fault
-> 收集线程、地址、region 和 MMFSR

它不是软件 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/Kconfig
  • components/mprotect/mprotect.c
  • components/mprotect/mprotect.h
  • components/mprotect/README.md
  • libcpu/arm/cortex-m4/mpu.c
  • libcpu/arm/cortex-m4/mpu.h
  • libcpu/arm/cortex-m4/mputype.h
  • libcpu/arm/cortex-m4/cpuport.c
  • libcpu/arm/cortex-m4/context_gcc.S
  • libcpu/arm/cortex-m4/SConscript
  • src/thread.c
  • bsp/stm32/stm32f407-fk407m2-zgt6/board/board.h
  • bsp/stm32/stm32f407-fk407m2-zgt6/board/board.c

相关提交:

硬件资料:

版本说明:文章依据截至 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()