第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一致性的核心逻辑:

双核Cache一致性管理架构 Cortex-M7 带L1 Cache Cortex-M4 无Cache L1 Cache (32KB) 共享SRAM (D3域) 0x30000000 - 0x3000FFFF ⚠ 一致性问题 M7写Cache → M4读SRAM → 读到脏数据 写回策略 直接访问

嗯,这里要注意一点:MPU配置必须在系统启动早期就做好,最好在main()函数的第一行就调用。如果等到RTOS启动后再配,有些区域已经被Cache缓存了,再改就来不及了。

个人经验:在STM32H7双核项目中,我推荐用MPU把共享内存区域配成非Cache,其他区域正常使用Cache。这样既保证了数据一致性,又保留了Cache的性能优势。实测下来,性能损失不到5%。

最后总结一下:任务优先级划分要少而精,中断延迟优化要抓关键路径,Cache一致性管理要用MPU做区域隔离。这三个原则搞明白了,双核实时性设计就成功了一大半。