第2章:实时性设计原则——任务优先级、中断延迟与Cache一致性
各位工程师朋友,咱们直接进入正题。实时性设计,说白了就是让系统在规定的deadline之前把活干完。STM32H7这种双核芯片,Cortex-M7和M4跑在一起,实时性要是没设计好,两个核互相抢资源,那画面太美我不敢看。
我个人习惯把实时性拆成三个维度来看:任务怎么排优先级、中断响应够不够快、Cache数据会不会乱掉。这三个点搞定了,实时性基本就稳了。
2.1 任务优先级划分策略
任务优先级划分,我见过太多人踩坑了。有人把所有任务都设成同一个优先级,有人把优先级设得密密麻麻跟梳子似的。这两种做法都不对。
先看一个典型的错误案例。我在项目中遇到过一位同事,把10个任务分别设了10个不同的优先级。结果高优先级任务一跑,低优先级任务全饿死了。你想想看,一个按键扫描任务,优先级比温度采集还高,这合理吗?
核心原则:优先级数量够用就行,别贪多。STM32 FreeRTOS支持32级优先级,但实际项目中我一般只用3-5级。
我建议按这个思路来划分:
- 紧急响应级(最高):电机堵转保护、过流保护、急停处理。这些任务必须在微秒级响应,优先级最高。
- 实时控制级:PID运算、PWM调整、传感器数据采集。毫秒级周期任务,优先级次之。
- 普通任务级:数据显示、日志记录、参数计算。几十毫秒到几百毫秒的周期,优先级放中间。
- 后台任务级(最低):固件升级、文件系统维护、网络重连。这些可以慢慢来,优先级最低。
| 优先级级别 | 典型任务 | 响应时间要求 | 优先级数值(数值越小优先级越高) |
|---|---|---|---|
| 紧急响应级 | 过流保护、急停 | < 10 μs | 0-1 |
| 实时控制级 | PID、PWM、传感器 | 100 μs - 1 ms | 2-3 |
| 普通任务级 | 显示、日志、计算 | 10 ms - 100 ms | 4-5 |
| 后台任务级 | 升级、文件系统 | > 100 ms | 6-7 |
小技巧:同优先级的任务用时间片轮转。我习惯把同级别的任务放在一个组里,用vTaskDelay()做时间片,而不是依赖FreeRTOS的时间片调度——后者开销更大。
2.2 中断延迟分析与优化
中断延迟,就是从中断信号触发到CPU开始执行ISR的时间。这个时间要是太长,实时性就崩了。
为什么会这样?因为CPU在执行其他代码时,得先把当前指令跑完,保存现场,查中断向量表,才能跳过去。这一套流程下来,少说几十个时钟周期。
我曾经在一个电机驱动项目里,中断延迟从2μs飙到了15μs,电机直接抖成了筛子。查了半天,发现是某个外设的中断优先级设得太高,把定时器中断给堵死了。
避坑指南:我曾经把DMA中断优先级设得比定时器还高,结果DMA频繁触发,定时器中断被延迟了上百微秒。记住:定时器中断通常应该比DMA中断优先级高,除非你的DMA在做实时性要求更高的操作。
优化中断延迟,我总结了几个实用方法:
- 中断嵌套要控制:不是所有中断都需要嵌套。把关键中断(定时器、外部触发)设成最高优先级,其他中断设成低优先级,避免高优先级中断被低优先级中断阻塞。
- ISR要短:ISR里只做最紧急的事——读寄存器、清标志、发信号量。真正的处理逻辑放到任务里做。我见过有人在ISR里做浮点运算,那延迟直接爆炸。
- 中断分组要合理:STM32H7支持8级抢占优先级和8级子优先级。我习惯把抢占优先级分成3组:紧急(0-1)、普通(2-3)、后台(4-7)。子优先级用来处理同组内的中断顺序。
// 中断优先级分组示例
HAL_NVIC_SetPriority(TIM1_UP_IRQn, 0, 0); // 定时器中断,最高优先级
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 1); // 外部中断,同组次高
HAL_NVIC_SetPriority(DMA1_Stream0_IRQn, 2, 0); // DMA中断,普通优先级
HAL_NVIC_SetPriority(UART1_IRQn, 4, 0); // 串口中断,后台优先级
实测数据:在STM32H743上,合理设置中断优先级后,定时器中断延迟从平均8μs降到了1.2μs。这个优化效果非常明显。
2.3 Cache一致性管理
Cache一致性,这是双核系统里最容易出问题的地方。M7核有L1 Cache,M4核没有。两个核共享SRAM,如果M7把数据缓存在Cache里,M4直接写SRAM,M7读到的就是旧数据。
我刚开始做双核项目时,就栽在这个坑里。两个核通过共享内存通信,M7发了一条指令,M4收到了但M7自己读状态寄存器时还是旧值。查了两天才发现是Cache没刷新。
核心问题:M7的Cache和M4直接访问的SRAM之间,数据不一致。M7写Cache,M4读SRAM,读到的是脏数据。反过来也一样。
解决Cache一致性问题,有三种主流方法:
- 方法一:关闭Cache。最简单粗暴,但性能损失很大。M7没有Cache,跑浮点运算慢30%以上。不推荐。
- 方法二:手动刷新Cache。在访问共享内存前后,手动调用SCB_CleanInvalidateDCache()。这是最常用的方法。
- 方法三:使用MPU配置非Cache区域。把共享内存区域配置成强序(Strongly Ordered)或设备(Device)类型,硬件自动绕过Cache。
// 方法二:手动刷新Cache示例
// M7核写共享内存
void M7_WriteToSharedMemory(uint32_t *addr, uint32_t data) {
*addr = data;
SCB_CleanDCache_by_Addr((uint32_t *)addr, 4); // 写完后刷Cache
}
// M7核读共享内存
uint32_t M7_ReadFromSharedMemory(uint32_t *addr) {
SCB_InvalidateDCache_by_Addr((uint32_t *)addr, 4); // 读之前无效化Cache
return *addr;
}
注意:SCB_CleanDCache_by_Addr()的参数地址必须32字节对齐,长度也必须是32字节的倍数。否则Cache操作会失败。我因为这个bug调试了整整一天。
方法三是我个人最推荐的。用MPU把共享内存区域配置成非Cache,一劳永逸。
// 方法三:MPU配置非Cache区域
void MPU_Config_SharedMemory(void) {
MPU_Region_InitTypeDef MPU_InitStruct = {0};
HAL_MPU_Disable();
MPU_InitStruct.Enable = MPU_REGION_ENABLE;
MPU_InitStruct.Number = MPU_REGION_NUMBER0;
MPU_InitStruct.BaseAddress = 0x30000000; // 共享SRAM基地址
MPU_InitStruct.Size = MPU_REGION_SIZE_64KB;
MPU_InitStruct.SubRegionDisable = 0x00;
MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0;
MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE;
MPU_InitStruct.IsShareable = MPU_ACCESS_SHAREABLE;
MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; // 非Cache
MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
HAL_MPU_ConfigRegion(&MPU_InitStruct);
HAL_MPU_Enable(MPU_CONTROL_PRIVILEGED_DEFAULT);
}
下面这张图展示了双核Cache一致性的核心逻辑:
嗯,这里要注意一点:MPU配置必须在系统启动早期就做好,最好在main()函数的第一行就调用。如果等到RTOS启动后再配,有些区域已经被Cache缓存了,再改就来不及了。
个人经验:在STM32H7双核项目中,我推荐用MPU把共享内存区域配成非Cache,其他区域正常使用Cache。这样既保证了数据一致性,又保留了Cache的性能优势。实测下来,性能损失不到5%。
最后总结一下:任务优先级划分要少而精,中断延迟优化要抓关键路径,Cache一致性管理要用MPU做区域隔离。这三个原则搞明白了,双核实时性设计就成功了一大半。