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重配置等。这些我们后面章节再聊。