4、RTOS中的MPU应用:任务栈保护、内核空间与用户空间隔离、系统调用入口保护
好,咱们接着聊MPU在RTOS里的实战。说实话,裸机编程时你基本不用操心内存保护——整个地址空间都是你的,想怎么写就怎么写。但一旦上了RTOS,情况就完全不一样了。多个任务共享CPU,一个任务写飞了,可能把整个系统拖垮。
我最早接触MPU是在一个工业控制项目上。当时设备偶尔会死机,查了两个月,最后发现是一个低优先级任务栈溢出,把内核的关键数据给覆盖了。从那以后,我对任务栈保护这件事就特别上心。
任务栈保护:别让一个任务毁了全家
每个RTOS任务都有自己的栈空间。任务栈用来保存局部变量、函数调用上下文、中断现场等。如果任务栈不够大,或者任务里有个递归函数没控制好深度,栈就会溢出。
栈溢出有多可怕?它会往低地址方向写,覆盖相邻的内存区域。如果相邻的是另一个任务的栈,那另一个任务就会莫名其妙地崩溃。如果相邻的是内核数据区,那整个系统都可能挂掉。
MPU怎么保护?很简单——给每个任务的栈区域配置一个MPU区域,设置读写权限,但不允许访问相邻区域。一旦任务试图访问栈边界之外,MPU立刻触发异常。
核心思路:每个任务栈独占一个MPU区域,区域大小对齐到32字节(MPU的最小粒度)。栈底设置一个“哨兵”区域,只读不可写,用来检测栈溢出。
我习惯在任务创建时动态分配栈空间,然后立即配置MPU。举个例子:
// 任务栈保护配置示例
void task_stack_protect_init(TaskHandle_t task) {
uint32_t stack_base = (uint32_t)task->stack_addr;
uint32_t stack_size = task->stack_size;
// 配置MPU区域:任务栈可读写
MPU->RBAR = stack_base | REGION_NUM(0) | REGION_ENABLE;
MPU->RASR = (MPU_AP_RW_RW) | // 特权级和用户级都可读写
(stack_size << 1) | // 区域大小
MPU_REGION_ENABLE;
// 配置哨兵区域:只读,用于检测溢出
MPU->RBAR = (stack_base - 32) | REGION_NUM(1) | REGION_ENABLE;
MPU->RASR = (MPU_AP_RO_RO) | // 只读
(MPU_SIZE_32B) |
MPU_REGION_ENABLE;
}
嗯,这里要注意:哨兵区域的大小我一般设32字节,刚好是MPU的最小粒度。如果任务写到了栈底以下32字节内,就会触发MemManage Fault。我在调试时会在异常处理函数里打印出当前任务的栈指针,一眼就能看出是哪个任务出了问题。
个人经验:我曾经在一个项目里把哨兵区域设成了64字节,结果发现内存浪费太多。后来改成32字节,配合栈使用率统计,效果很好。建议你在任务栈尾部留10%~20%的余量,别卡得太死。
内核空间与用户空间隔离:特权级的分水岭
RTOS里,内核代码运行在特权级(Privileged Mode),用户任务运行在非特权级(Unprivileged Mode)。MPU的作用就是确保用户任务不能随便访问内核的内存区域。
说白了,内核空间放的是调度器、中断向量表、系统时钟等核心数据。用户任务如果误写这些区域,系统会立刻崩溃。MPU把这两块区域隔开,用户任务只能看到自己的栈和全局变量,内核区域对它来说就是“禁区”。
我建议这样划分内存布局:
| 区域 | 起始地址 | 大小 | 权限 | 说明 |
|---|---|---|---|---|
| 内核代码区 | 0x08000000 | 64KB | 特权级可执行 | RTOS内核、启动代码 |
| 内核数据区 | 0x20000000 | 32KB | 特权级读写 | 任务控制块、调度器变量 |
| 用户代码区 | 0x08010000 | 128KB | 用户级可执行 | 任务函数、库函数 |
| 用户数据区 | 0x20008000 | 64KB | 用户级读写 | 任务栈、全局变量 |
| 外设区 | 0x40000000 | 1MB | 特权级读写 | GPIO、UART、定时器等 |
你想想看,如果用户任务能直接操作外设寄存器,那它就能绕过内核的控制,直接修改硬件状态。这在安全关键系统里是绝对不允许的。所以外设区也要放在特权级下,用户任务只能通过系统调用来间接操作外设。
避坑指南:我曾经在一个项目里把用户数据区设得太大,结果MPU区域数量不够用了。ARM Cortex-M3/M4最多支持8个MPU区域,你得精打细算。我的做法是:区域0给内核数据,区域1给用户代码,区域2给用户数据,区域3给外设,剩下的留给任务栈保护。这样刚好够用。
系统调用入口保护:唯一的合法通道
用户任务不能直接调用内核函数,否则就破坏了隔离。那用户任务怎么请求内核服务?答案是系统调用(System Call)。
系统调用的典型流程是:用户任务触发一个SVC(Supervisor Call)异常,CPU切换到特权模式,然后执行内核提供的服务函数。服务执行完后,再切换回用户模式。
MPU在这里的作用是:确保只有SVC异常处理函数能访问内核数据,其他任何方式都不行。如果用户任务试图直接跳转到内核函数地址,MPU会阻止它,因为内核代码区对用户级是不可执行的。
我习惯这样实现系统调用入口:
// 系统调用入口保护
__attribute__((naked)) void SVC_Handler(void) {
__asm volatile(
"TST LR, #4\n" // 检查是从用户模式还是特权模式进入
"ITE EQ\n"
"MRSEQ R0, MSP\n" // 特权模式:使用主栈指针
"MRSNE R0, PSP\n" // 用户模式:使用进程栈指针
"B svc_handler_main\n" // 跳转到C处理函数
);
}
void svc_handler_main(uint32_t *stack_frame) {
uint8_t svc_num = ((uint8_t*)stack_frame[6])[-2]; // 获取SVC编号
uint32_t *args = &stack_frame[0]; // 参数从栈帧中获取
// 根据SVC编号分发到不同的内核服务
switch(svc_num) {
case SVC_TASK_CREATE:
// 创建任务(需要访问内核数据区)
break;
case SVC_SEMAPHORE_GIVE:
// 释放信号量
break;
// ... 其他系统调用
}
}
为什么这里要检查LR寄存器?因为从用户模式进入SVC时,LR的bit2是1,表示使用进程栈指针(PSP)。从特权模式进入时,LR的bit2是0,使用主栈指针(MSP)。这个细节我当年踩过坑——如果搞混了栈指针,取到的参数全是错的。
关键点:系统调用是用户任务访问内核的唯一合法通道。所有内核服务都必须通过SVC异常来触发,不能有别的入口。我在代码审查时,会特别检查有没有用户任务直接调用内核函数的情况——一旦发现,必须改成系统调用。
嗯,最后总结一下。MPU在RTOS里的这三个应用场景,说白了就是三件事:
- 任务栈保护:给每个任务栈加个围栏,防止栈溢出祸害邻居
- 内核空间与用户空间隔离:把内核和用户任务隔开,用户任务不能碰内核数据
- 系统调用入口保护:只留一个合法的门(SVC异常),其他路径全部封死
这三个措施配合起来,你的RTOS系统就基本具备了内存安全防护能力。当然,实际项目中还会有更多细节,比如中断处理时的MPU切换、任务切换时的MPU重配置等。这些我们后面章节再聊。