软件分区与进程模型:任务划分、MPU隔离与IPC设计

嵌入式系统越做越复杂,单靠一个超级循环已经撑不住了。我记得早年做一款工业控制器,所有功能塞在一个大循环里,后来加了个OTA升级功能,整个系统就乱成一锅粥。从那以后,我坚定了一个信念:分区和进程模型,是嵌入式软件架构的基石。

这一章,咱们就聊聊怎么划分任务、怎么用MPU做内存隔离、怎么处理安全与非安全世界,以及进程间通信该怎么设计。说白了,就是让你的系统不乱、不崩、不泄密。

任务划分原则:别把鸡蛋放在一个篮子里

任务划分,我习惯从三个维度去思考:功能内聚、时间关键性、安全等级。

核心原则:高内聚、低耦合、关键任务独立运行。

具体怎么分?我总结了几个实战经验:

  • 按功能域划分:通信协议栈、传感器采集、控制算法、人机交互,各归各的任务。我在一个项目中把CAN协议栈和UI放在同一个任务里,结果CAN总线一忙,界面就卡死——血的教训。
  • 按时间关键性划分:1ms级的控制循环必须独占高优先级任务;100ms级的日志上报可以放低优先级。千万别把实时任务和非实时任务混在一起。
  • 按安全等级划分:安全关键功能(如刹车控制)和非安全功能(如娱乐系统)必须分到不同分区,甚至不同核。
划分维度 典型任务 优先级建议 周期/触发
时间关键型 电机FOC控制、传感器中断处理 最高(实时优先级) 1-10kHz
周期型 数据采集、状态机轮询 中高 10-100Hz
事件驱动型 按键响应、网络报文处理 中 外部触发
后台型 日志记录、OTA下载 低(空闲优先级) 空闲时运行

我的习惯:每个任务栈空间至少预留20%余量。曾经有个同事把栈算得刚刚好,结果加了个调试打印就栈溢出——系统随机死机,查了三天。

MPU分区:给每个任务画个圈

MPU(内存保护单元)是ARM Cortex-M3/M4/M7等内核标配的硬件模块。它不像MMU那么重,但足够做任务级内存隔离。

MPU的核心能力就两个:划定区域和设置权限。每个区域可以定义起始地址、大小、访问权限(读/写/执行)。

我一般这样配置MPU分区:

/* MPU区域配置示例 - 基于ARM CMSIS */
MPU_Region_InitTypeDef MPU_InitStruct;

/* 区域0:任务A的私有RAM - 只允许任务A读写 */
MPU_InitStruct.Enable = MPU_REGION_ENABLE;
MPU_InitStruct.BaseAddress = 0x20000000;
MPU_InitStruct.Size = MPU_REGION_SIZE_32KB;
MPU_InitStruct.AccessPermission = MPU_REGION_PRIV_RW;
MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL_0;
MPU_InitStruct.IsExecuteable = MPU_REGION_EXECUTE_NEVER;
HAL_MPU_ConfigRegion(&MPU_InitStruct);

/* 区域1:任务B的私有RAM - 只允许任务B读写 */
MPU_InitStruct.BaseAddress = 0x20008000;
MPU_InitStruct.Size = MPU_REGION_SIZE_16KB;
HAL_MPU_ConfigRegion(&MPU_InitStruct);

/* 区域2:共享内存区 - 所有任务可读,仅特权任务可写 */
MPU_InitStruct.BaseAddress = 0x2000C000;
MPU_InitStruct.Size = MPU_REGION_SIZE_4KB;
MPU_InitStruct.AccessPermission = MPU_REGION_PRIV_RW_UNPRIV_RO;
HAL_MPU_ConfigRegion(&MPU_InitStruct);

/* 使能MPU */
HAL_MPU_Enable(MPU_HFNMI_PRIVDEF);

我曾经踩过的坑:MPU区域不能重叠,否则行为未定义。有一次我配置了两个区域有1KB的重叠,结果任务A写数据时触发了MemManage Fault,系统直接hard fault。查了两天才发现是区域重叠的问题。

MPU分区的好处很明显:

  • 隔离故障:任务A野指针写飞了,最多崩自己,不会污染任务B的数据
  • 权限控制:关键数据区可以设为只读,防止误写
  • 调试友好:MPU fault可以精确定位到哪个任务、哪个地址越界

安全与非安全世界隔离:TrustZone的玩法

ARMv8-M架构引入了TrustZone技术,把系统分成安全世界(Secure World)和非安全世界(Non-Secure World)。这比MPU更狠——硬件级别的隔离。

我参与过一个金融支付终端项目,要求私钥和加密运算必须在安全世界执行,应用层代码不能碰。TrustZone正好解决这个问题。

安全世界和非安全世界的划分要点:

维度 安全世界 非安全世界
运行内容 安全启动、密钥管理、加密引擎 用户应用、协议栈、UI
内存访问 可访问所有内存(含安全区) 仅可访问非安全区内存
外设访问 安全外设(如TRNG、安全存储) 非安全外设(如UART、GPIO)
中断处理 安全中断(优先级更高) 非安全中断

安全世界和非安全世界通过安全监控调用(SMC)通信。非安全世界想调用加密服务,得通过SMC陷入安全世界,安全世界处理完再返回。

/* 非安全世界调用安全服务的示例 */
__attribute__((naked)) uint32_t secure_call(uint32_t func_id, uint32_t arg) {
    __asm volatile(
        "SMC #0\n"      /* 触发安全监控调用 */
        "BX lr\n"
    );
}

/* 安全世界服务处理 */
uint32_t Secure_Service_Handler(uint32_t func_id, uint32_t arg) {
    switch(func_id) {
        case SECURE_ENC:
            return aes_encrypt(arg);
        case SECURE_DEC:
            return aes_decrypt(arg);
        case SECURE_KEY_STORE:
            return store_key((void*)arg);
        default:
            return 0xFFFFFFFF; /* 非法服务ID */
    }
}

我的建议:安全世界的代码越少越好。只放真正需要保护的核心逻辑,其他都扔到非安全世界。代码少,漏洞面就小。

进程间通信(IPC)设计:别让任务变成孤岛

任务分好了,隔离做好了,但任务之间还得说话。IPC设计不好,系统性能直接腰斩。

嵌入式系统常用的IPC机制有几种:

  • 消息队列:最常用,适合异步通信。我习惯用固定长度的消息池,避免动态内存分配。
  • 共享内存 + 信号量:适合大数据量传输,但要注意互斥和缓存一致性。
  • 事件标志组:适合多任务同步,比如等待多个条件都满足再执行。
  • 管道/套接字:适合跨核或跨处理器通信,Linux上常用。

我画了一张IPC选型决策图,帮你快速判断:

IPC选型决策流程 需要IPC通信 数据量大吗?(>1KB) 是 共享内存 + 信号量 否 需要同步等待吗? 是 事件标志组 否 跨核或跨处理器? 是 管道/套接字 否 消息队列 提示 消息队列是通用方案 优先考虑

消息队列的设计有几个关键参数:

/* 消息队列配置结构体 */
typedef struct {
    uint32_t msg_pool[MSG_POOL_SIZE];  /* 固定消息池,避免动态分配 */
    uint32_t head;                     /* 读指针 */
    uint32_t tail;                     /* 写指针 */
    uint32_t count;                    /* 当前消息数 */
    SemaphoreHandle_t sem_send;        /* 发送信号量 */
    SemaphoreHandle_t sem_recv;        /* 接收信号量 */
} MessageQueue_t;

/* 发送消息 - 非阻塞版本 */
int32_t mq_send(MessageQueue_t *mq, uint32_t msg) {
    if (mq->count >= MSG_POOL_SIZE) {
        return -1;  /* 队列满,返回错误 */
    }
    mq->msg_pool[mq->tail] = msg;
    mq->tail = (mq->tail + 1) % MSG_POOL_SIZE;
    mq->count++;
    xSemaphoreGive(mq->sem_recv);  /* 通知接收任务 */
    return 0;
}

我曾经踩过的坑:消息队列的深度一定要根据最坏情况来算。有一次我按平均负载设了10个深度,结果突发数据一来,队列满了,消息直接丢弃。系统行为变得诡异——按键有时响应有时不响应。后来改成深度32,加上溢出告警,问题解决。

综合案例:一个安全关键系统的分区设计

最后,我分享一个实际项目的分区方案。这是一个车载T-Box,要求同时处理CAN总线数据、4G通信、安全认证和日志记录。

分区 任务 MPU区域 安全等级 IPC方式
安全分区 密钥管理、安全启动、加密通信 安全世界专用RAM(64KB) 最高 SMC调用
实时控制分区 CAN报文收发、车辆状态监控 私有RAM(32KB) 高 消息队列
通信分区 4G/LTE协议栈、TCP/IP 私有RAM(128KB) 中 共享内存+信号量
日志分区 数据记录、远程诊断 私有RAM(16KB) 低 事件标志组

这个方案跑下来,系统非常稳定。安全分区被TrustZone保护,即使通信分区被攻破,也拿不到密钥。实时控制分区有MPU保护,不会因为日志分区的内存泄漏而影响CAN通信。

我的总结:分区不是越细越好。分区太多,IPC开销会吃掉性能。一般4-6个分区是比较合理的范围。你想想看,每个分区之间的通信都要经过IPC,如果分区太多,系统大部分时间都在做IPC,反而得不偿失。