Cortex-M3 MPU 实战:从链接脚本到用户态隔离
Cortex-M3 MPU 实战:从链接脚本到用户态隔离
在 Cortex-M3 裸机工程里,MPU 的难点通常不在寄存器,而在链接布局。
MPU 只能保护按规则对齐的连续内存区域。如果用户代码、用户数据、共享数据和特权数据在链接阶段混在一起,运行时再配置 MPU,也无法做到细粒度隔离。
这个工程用三个步骤解决问题:
- 用 scatter 链接脚本把不同代码和数据放到独立区域。
- 用链接器生成的
Image$$xxx$$Base符号作为 MPU 基地址。 - 用 3 个 MPU 区域给用户态开放必要权限,再通过 SVC 进入特权服务。
整体流程如下:
main,特权态
-> mpu_set()
-> svc #0
-> SVC 处理器切换到 user_entry
user_entry,用户态
-> svc #1
-> SVC 处理器执行 printf
-> 返回用户态MPU 负责限制用户态访问范围,SVC 负责提供受控的特权入口。
一、链接脚本先划出保护边界
工程使用的 scatter 文件是:
RTE/Device/ARMCM3/ARMCM3_ac6.sct核心内容:

USER_CODE AlignExpr(+0, 0x1000) FIXED {
user.o (+RO +XO)
}
MAIN_CODE AlignExpr(+0, 0x1000) FIXED {
.ANY (+RO)
.ANY (+XO)
}
USER_DATA __RW_BASE {
user.o (+RW +ZI)
}
SHARE_DATA AlignExpr(+0, 0x1000) {
share_data.o (+RW +ZI)
}
MAIN_DATA AlignExpr(+0, 0x1000) {
.ANY (+RW +ZI)
}几个关键点:
AlignExpr(+0, 0x1000):把地址向上对齐到 4KB 边界。user.o、share_data.o:只选择指定目标文件中的段。.ANY:选择前面还没有被分配的所有段。+RO、+XO:只读段和只执行段。+RW、+ZI:可读写段和零初始化段。
最终布局为:
USER_CODE 0x00001000 user.c 的代码和只读数据 非特权级程序
MAIN_CODE 0x00002000 main.c、SVC 处理器、启动代码和 C 库 特权级程序
USER_DATA 0x20000000 user.c 的数据,psp_stack 非特权级数据
SHARE_DATA 0x20001000 share_data.c 的数据,share_count 共享数据
MAIN_DATA 0x20002000 main.c 和 C 库的数据 特权级数据这些地址可以在 Listings/cortexm.map 中确认。
链接脚本的作用不是自动保护,而是先让这些区域地址明确、互不重叠,为 MPU 提供稳定的区域边界。
二、链接符号把地址交给 MPU
scatter 中的执行域名字会自动生成链接符号:
extern uint32_t Image$$USER_CODE$$Base;
extern uint32_t Image$$USER_DATA$$Base;
extern uint32_t Image$$SHARE_DATA$$Base;实际值为:
Image$$USER_CODE$$Base = 0x00001000
Image$$USER_DATA$$Base = 0x20000000
Image$$SHARE_DATA$$Base = 0x20001000配置 MPU 时直接取这些符号的地址:
ARM_MPU_SetRegion(
ARM_MPU_RBAR(0, (uint32_t)&Image$$USER_CODE$$Base),
ARM_MPU_RASR(
0, // 允许取指
ARM_MPU_AP_RO, // 非特权态只读
0, 0, 0, 0, 0,
ARM_MPU_REGION_SIZE_4KB
)
);这样 MPU 地址不需要手工写死。以后调整 scatter 布局,只要执行域名字不变,MPU 地址会自动跟着链接结果变化。
三、三个 MPU 区域分别保护什么
Cortex-M3 的每个 MPU 区域主要由两个寄存器描述:
RBAR:区域基地址。RASR:区域权限和大小。
本工程使用了 3 个区域对非特权级和共享数据进行保护,特权级区域的话,开启ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk)即可
| MPU 区域 | 基地址 | 大小 | 用户态权限 |
|---|---|---|---|
| Region 0 | 0x00001000 | 4KB | 读、执行,不可写 |
| Region 1 | 0x20000000 | 4KB | 读写,不可执行 |
| Region 2 | 0x20001000 | 4KB | 读写,不可执行 |
Region 0:非特权级程序
ARM_MPU_SetRegion(
ARM_MPU_RBAR(0, (uint32_t)&Image$$USER_CODE$$Base),
ARM_MPU_RASR(
0, // XN = 0,允许取指
ARM_MPU_AP_RO,
0, 0, 0, 0, 0,
ARM_MPU_REGION_SIZE_4KB
)
);保护范围是 0x00001000 - 0x00001FFF。用户态可以执行 user_entry 和 sys_print_num,也可以读取其中的只读字符串,但不能写入。
ARM_MPU_AP_RO 的准确含义是:
特权态:可读、可写
非特权态:只读代码注释里写“特权程序只读”并不准确。
Region 1:非特权级数据
ARM_MPU_SetRegion(
ARM_MPU_RBAR(1, (uint32_t)&Image$$USER_DATA$$Base),
ARM_MPU_RASR(
1, // XN = 1,禁止取指
ARM_MPU_AP_FULL,
0, 0, 0, 0, 0,
ARM_MPU_REGION_SIZE_4KB
)
);保护范围是 0x20000000 - 0x20000FFF。psp_stack 位于 0x20000000,用户程序运行时的 PSP 栈就在这里。
虽然实际只使用前 1KB,但 MPU 保护的最小单位是整个 4KB 区域。
Region 2:共享数据
ARM_MPU_SetRegion(
ARM_MPU_RBAR(2, (uint32_t)&Image$$SHARE_DATA$$Base),
ARM_MPU_RASR(
1, // XN = 1,禁止取指
ARM_MPU_AP_FULL,
0, 0, 0, 0, 0,
ARM_MPU_REGION_SIZE_4KB
)
);保护范围是 0x20001000 - 0x20001FFF。share_count 位于 0x20001000。
Region 2 使用 ARM_MPU_AP_FULL,所以用户态可以执行:
share_count++;注释写“共享可读”不完整,实际是共享区对用户态可读可写。
四、PRIVDEFENA 如何隔离特权区
配置完成后执行:
ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk);PRIVDEFENA 表示启用“特权态默认内存映射”:
- 特权态访问没有被 MPU 区域覆盖的地址时,可以继续使用默认内存映射。
- 非特权态访问没有被 MPU 区域覆盖的地址时,会触发保护异常。
因此用户态不能访问:
MAIN_CODE 0x00002000 开始
MAIN_DATA 0x20002000 开始例如,把 user.c 中的注释恢复:
extern uint32_t main_counter;
main_counter++;main_counter 属于 MAIN_DATA,用户态没有访问权限,会触发 MPU 保护异常。
直接调用 SVC_Handler() 也不允许,因为它位于 MAIN_CODE。用户只能执行 svc 指令,由异常机制进入特权态。
这就是代码隔离和系统调用的区别:
直接跳转到特权函数:用户态取指,被 MPU 阻止
执行 svc 指令:CPU 进入 Handler 模式,获得特权五、如何进入用户态并执行 SVC
main 配置完 MPU 后执行:
__ASM("svc #0");SVC 处理器识别系统调用号。编号 0 表示进入用户程序:
stack_frame_t *frame =
(stack_frame_t *)(&psp_stack[1024] - sizeof(stack_frame_t));
frame->pc = (uint32_t)user_entry;
frame->xpsr = 1 << 24;
__set_PSP((uint32_t)frame);然后通过 0xFFFFFFFD 返回 Thread 模式,并使用 PSP。
进入 user_entry 后:
__set_CONTROL(__get_CONTROL() | 0x1);CONTROL.nPRIV = 1 后,当前 Thread 模式切换到非特权态。之后:
- 取指只能从
USER_CODE进行。 - 数据访问只能覆盖
USER_DATA和SHARE_DATA。 - 访问
MAIN_CODE或MAIN_DATA会触发异常。
用户程序打印时执行:
svc #1SVC 异常会把 CPU 切换到 Handler 模式。Handler 模式始终是特权态,因此可以执行 MAIN_CODE 中的:
do_sys_print_num(...);
printf(...);处理完成后,异常返回用户态。用户程序始终没有直接执行 printf 的权限。
六、触发保护后为什么程序会卡住
工程没有使能 MemManage 异常。Cortex-M3 复位后,SHCSR.MEMFAULTENA 默认为 0,此时 MPU 访问违规会升级为 HardFault。
工程中的 HardFault 处理器是:
void HardFault_Handler(void)
{
while (1);
}所以发生违规后,程序通常直接停在死循环里。
调试时可以在配置 MPU 前增加:
SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;这样可以单独触发 MemManage 异常,并查看:
MMFAR:触发保护的地址。CFSR:具体的 MemManage fault 类型。HFSR:是否发生异常升级。
七、几个容易误解的点
1. 4KB 区域只需要 4KB 对齐
代码注释写“地址要对齐到 16KB”,但实际配置是:
ARM_MPU_REGION_SIZE_4KB4KB 区域的基地址按 4KB 对齐即可。scatter 中的 AlignExpr(+0, 0x1000) 正好满足要求。
2. MPU 保护区域,不保护单个变量
share_count 只有 4 字节,但 MPU 保护整个 4KB:
0x20001000 - 0x20001FFF因此不能把私有数据放在共享区域的填充空间里。
3. 用户态入口的切换时机很重要
工程在 user_entry 内部才设置 CONTROL.nPRIV。进入函数的第一条指令是否已经处于用户态,取决于异常返回前的 CONTROL 状态。更严格的设计应在 SVC 返回前完成用户态设置。
4. SVC 必须校验用户指针
处理器把用户传入的 fmt 指针直接交给特权态 printf。真实系统中,SVC 处理器必须检查用户指针是否位于允许的用户地址范围内。
八、总结
这个工程的 MPU 保护可以概括为:
先用链接脚本把内存按所有权分区,再用 MPU 给用户态只开放必要区域,最后用 SVC 提供受控的特权服务。
完整链路:
scatter
-> 划分 USER_CODE / USER_DATA / SHARE_DATA / MAIN_CODE / MAIN_DATA
-> 生成 Image$$xxx$$Base
-> mpu_set() 写入 RBAR/RASR
-> 用户态只允许访问 USER_CODE、USER_DATA、SHARE_DATA
-> 访问 MAIN_CODE、MAIN_DATA 触发保护异常
-> 用户执行 svc
-> SVC Handler 在特权态完成操作并返回真正实现 MPU 隔离,需要同时做好三件事:链接布局、权限配置、特权态与用户态切换。
代码
main.c
#include <stdio.h>
#include <string.h>
#include "ARMCM3.h"
uint32_t main_counter;
typedef struct _stack_frame_t {
uint32_t r0;
uint32_t r1;
uint32_t r2;
uint32_t r3;
uint32_t r12;
uint32_t lr;
uint32_t pc;
uint32_t xpsr;
}stack_frame_t;
#define SYSCALL_ENTER_USER 0
#define SYSCALL_PRINT_NUM 1
int do_sys_print_num (char * fmt, int num) {
printf(fmt, num);
return 0;
}
extern uint32_t __INITIAL_SP;
extern unsigned char psp_stack[1024];
void user_entry (void);
void SVC_C_Handler (stack_frame_t * frame) {
uint8_t imm = (*(uint16_t *)(frame->pc - 2)) & 0xFF;
switch (imm) {
case 0: {
stack_frame_t * frame = (stack_frame_t *)(&psp_stack[1024] - sizeof(stack_frame_t));
frame->pc = (uint32_t)user_entry;
frame->xpsr = 1 << 24;
frame->r0 = 0;
frame->r1 = 1;
__set_PSP((uint32_t)frame);
// MSP的值可以不重新加载,即使用当前的MSP值,不过由于前面已经压了些栈,然后下面又没有从MSP弹出
// 所以MSP指向的这块栈空间有一些浪费,所以最好是按下面的方法重新设置MSP的值
// 注意,由于frame在当前msp指向的栈中,所以需要在前面将frame等使用完毕之后,再更改MSP的值
__ASM("msr msp, %[stack_top]\n"
"ldr lr, =0xFFFFFFFD\n"
"bx lr"::[stack_top]"r"(&__INITIAL_SP));
}
case SYSCALL_PRINT_NUM:
frame->r0 = do_sys_print_num((char *)frame->r0, frame->r1);
break;
default:
frame->r0 = -1;
break;
}
}
__attribute__((naked)) void SVC_Handler (void) {
__asm(
"tst lr, #0x4\n"
"ite eq\n"
"mrseq r0, msp\n"
"mrsne r0, psp\n"
"b SVC_C_Handler"
);
}
extern uint32_t Image$$USER_CODE$$Base;
extern uint32_t Image$$USER_DATA$$Base;
extern uint32_t Image$$SHARE_DATA$$Base;
// 存储保护单元设置
void mpu_set (void) {
__disable_irq();
// 设置用户可以执行的代码区域
ARM_MPU_SetRegion(
ARM_MPU_RBAR(0, (uint32_t)&Image$$USER_CODE$$Base), // 注意这个地址要对齐到16KB
ARM_MPU_RASR(
0, // DisableExec,是否允许取指
ARM_MPU_AP_RO, // AccessPermission,特权程序只读
0, // TypeExtField
0, // IsShareable
0, // IsCacheable
0, // IsBufferable
0, // SubRegionDisable
ARM_MPU_REGION_SIZE_4KB // 区域大小为4KB
)
);
// 设置用户可以访问的数据区域
ARM_MPU_SetRegion(
ARM_MPU_RBAR(1, (uint32_t)&Image$$USER_DATA$$Base), // 注意这个地址要对齐到16KB
ARM_MPU_RASR(
1, // DisableExec,是否允许取指,禁止取指
ARM_MPU_AP_FULL, // AccessPermission,用户程序可读写
0, // TypeExtField
0, // IsShareable
0, // IsCacheable
0, // IsBufferable
0, // SubRegionDisable
ARM_MPU_REGION_SIZE_4KB // 区域大小为4KB
)
);
// 两者共享的一部分区域,都可以读写
ARM_MPU_SetRegion(
ARM_MPU_RBAR(2, (uint32_t)&Image$$SHARE_DATA$$Base), // 注意这个地址要对齐到16KB
ARM_MPU_RASR(
1, // DisableExec,是否允许取指
ARM_MPU_AP_FULL, // AccessPermission,共享可读
0, // TypeExtField
0, // IsShareable
0, // IsCacheable
0, // IsBufferable
0, // SubRegionDisable
ARM_MPU_REGION_SIZE_4KB // 区域大小为4KB
)
);
ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk);
__enable_irq();
}
int main (void) {
mpu_set();
__ASM("svc #0");
for (;;) {
}
return 0;
}user.c
#include <stdio.h>
#include "ARMCM3.h" // Device header
// 此处的用户程序只能访问自己的数据和代码
// 不能访问特权级的代码和数据
// 这样用户程序和特权级程序的数据和代码完全分离
unsigned char psp_stack[1024];
int sys_print_num (char * fmt, int num) {
register int res __ASM("r0");
__ASM("mov r0, %[fmt]\n"
"mov r1, %[num]\n"
"svc #1"
:"+r"(res):[fmt]"r"(fmt), [num]"r"(num));
return res;
}
void user_entry (void) {
int cnt = 0;
__set_CONTROL(__get_CONTROL() | 0x1);
// 没有权限访问特权级代码的数据
//extern uint32_t main_counter;
//main_counter++;
// 没有权限执行code中的代码
//void SVC_Handler (void);
//SVC_Handler();
for (;;) {
sys_print_num("Hello, world: %d\n", cnt++);
extern int share_count;
sys_print_num("Hello, sharecount: %d\n", share_count++);
}
}share_data.c
unsigned int share_count = 0;
