FreeRTOS临界区详解:原理、任务优先级区别及STM32使用注意事项
前言
在学习 FreeRTOS 时,很容易把“临界区”和“提高任务优先级”混为一谈。
例如:Task0负责创建 Task1、Task2、Task3。为了避免创建 Task1 后立即切换到高优先级任务,是否应该使用临界区?
答案是:
临界区确实可以暂时阻止任务切换,但它的主要用途是保护短小的共享数据操作,而不是控制多个任务的创建顺序。
本文介绍临界区的作用、实现机制、适用场景,以及它与任务优先级、互斥锁之间的区别。
一、什么是临界区
临界区(Critical Section)是一段不能被其他并发执行单元干扰的代码。
在 FreeRTOS 任务中,使用下面两个宏进入和退出临界区:
taskENTER_CRITICAL();/* 需要保护的代码 */taskEXIT_CRITICAL();进入临界区后,当前任务不会因为普通的抢占调度而切换到其他任务。退出临界区后,调度器重新选择当前最高优先级的就绪任务运行。
需要注意,临界区不是:
- 等待其他任务执行完成;
- 控制任务启动顺序;
- 让其他任务进入阻塞状态;
- 可以长时间独占CPU的代码区域。
可以简单理解为:
临界区的目标是保证一小段代码连续执行,而不是保证整个任务连续执行。
二、为什么需要临界区
1. 保护“读—修改—写”操作
假设任务和中断都会修改同一个变量:
sharedCount++;虽然只有一行C代码,但底层可能需要执行三个步骤:
读取 sharedCount ↓ 数值加1 ↓ 写回 sharedCount如果程序执行到中间时被打断,另一个任务或中断也修改了该变量,就可能发生数据更新丢失。
可以使用临界区保护:
taskENTER_CRITICAL();sharedCount++;taskEXIT_CRITICAL();2. 保持多个变量的一致性
假设传感器数据由多个成员组成:
typedefstruct{inttemperature;inthumidity;TickType_t timestamp;}SensorData_t;更新数据时,如果被其他任务或中断打断,读取方可能看到一组不完整的数据。
推荐先在临界区外完成耗时操作,然后进入临界区快速更新:
TickType_t now=xTaskGetTickCount();taskENTER_CRITICAL();sensorData.temperature=newTemperature;sensorData.humidity=newHumidity;sensorData.timestamp=now;taskEXIT_CRITICAL();这样可以保证其他代码不会读取到只更新了一部分的传感器数据。
三、volatile不能代替临界区
下面的写法不能保证并发安全:
volatileuint32_tsharedCount;volatile的作用是告诉编译器:
每次访问这个变量时,都应真正读取或写入内存,不要擅自省略或缓存这个操作。
但是,volatile不能保证下面的操作不可被打断:
sharedCount++;因此需要区分:
| 机制 | 作用 |
|---|---|
volatile | 防止编译器省略或缓存内存访问 |
| 临界区 | 防止一段代码被相关任务或中断并发干扰 |
| 互斥锁 | 协调多个任务对共享资源的访问 |
| 原子操作 | 保证特定操作不可分割 |
四、临界区与任务优先级的区别
假设系统中存在以下任务:
Task0:优先级4,负责系统初始化 Task1:优先级1 Task2:优先级2 Task3:优先级3Task0创建其他任务:
staticvoidTask0(void*argument){configASSERT(xTaskCreate(Task1,"Task1",128,NULL,1,NULL)==pdPASS);configASSERT(xTaskCreate(Task2,"Task2",128,NULL,2,NULL)==pdPASS);configASSERT(xTaskCreate(Task3,"Task3",128,NULL,3,NULL)==pdPASS);/* 初始化任务完成使命后删除自己 */vTaskDelete(NULL);}由于 Task0 的优先级高于 Task1~3,新创建的任务虽然进入就绪状态,但不能抢占 Task0。
执行过程大致如下:
Task0开始运行 ↓ 创建Task1,Task1进入就绪状态 ↓ 创建Task2,Task2进入就绪状态 ↓ 创建Task3,Task3进入就绪状态 ↓ Task0删除自己 ↓ Task3作为最高优先级就绪任务开始运行这属于任务调度设计,不属于临界区保护。
两种方式的区别如下:
| 对比项 | 提高Task0优先级 | 使用临界区 |
|---|---|---|
| 低优先级任务能否抢占 | 不能 | 不能 |
| 更高优先级任务能否抢占 | 能 | 通常不能 |
| 是否影响中断 | 不影响 | 会屏蔽全部或部分中断 |
| 主要用途 | 安排任务执行顺序 | 保护极短的共享操作 |
| 能否长时间使用 | 任务阻塞合理时可以 | 不可以 |
因此,如果需求只是:
创建完 Task1~3后再让它们正常运行。
优先考虑以下方法:
- 在启动调度器之前创建全部任务;
- 使用高优先级初始化任务,完成后删除自己;
- 使用事件组、信号量或任务通知作为启动同步机制。
一般不应该为了创建多个任务而设置一个较长的临界区。
五、为什么临界区必须尽可能短
临界区会屏蔽全部或部分中断,并阻止正常的任务抢占。
如果临界区运行时间过长,会造成:
- 串口接收数据丢失;
- 定时器中断响应延迟;
- ADC、DMA等外设处理不及时;
- 高优先级任务无法按时运行;
- 系统实时性下降;
- 严重时触发看门狗复位。
下面的代码不适合放在临界区中:
taskENTER_CRITICAL();HAL_UART_Transmit(&huart1,data,length,1000);printf("processing...\r\n");for(uint32_ti=0;i<1000000;i++){/* 长时间循环 */}taskEXIT_CRITICAL();串口发送、格式化输出和长循环的执行时间都可能很长。
六、临界区中禁止或应避免的操作
临界区内部不应进行可能阻塞、等待或主动触发调度的操作,例如:
vTaskDelay(...);xQueueReceive(queue,&data,portMAX_DELAY);xSemaphoreTake(mutex,portMAX_DELAY);ulTaskNotifyTake(pdTRUE,portMAX_DELAY);也应避免放入:
printf();- 长时间串口发送;
- 动态内存分配;
xTaskCreate();- 文件或Flash写入;
- 复杂计算;
- 长循环;
- 执行时间不确定的函数。
特别不能在临界区中等待中断改变标志:
taskENTER_CRITICAL();while(uartFinished==0){/* 错误:相关中断可能已经被临界区屏蔽 */}taskEXIT_CRITICAL();这可能形成死锁:任务在等待中断,但负责修改标志的中断无法执行。
七、任务与中断使用不同的临界区接口
1. 在任务中使用
taskENTER_CRITICAL();sharedValue++;taskEXIT_CRITICAL();2. 在中断服务函数中使用
中断中不能直接使用普通任务版本,应使用带有_FROM_ISR的接口:
voidUSART_IRQHandler(void){UBaseType_t savedInterruptStatus;savedInterruptStatus=taskENTER_CRITICAL_FROM_ISR();sharedValue++;taskEXIT_CRITICAL_FROM_ISR(savedInterruptStatus);}taskENTER_CRITICAL_FROM_ISR()会返回进入临界区前的中断屏蔽状态。
这个返回值必须传递给对应的:
taskEXIT_CRITICAL_FROM_ISR(savedInterruptStatus);不能随意填写,也不能丢弃。
八、STM32 Cortex-M中的临界区机制
在常见的 Cortex-M3、Cortex-M4和Cortex-M7端口中,FreeRTOS通常使用BASEPRI寄存器和下面的配置屏蔽一定范围的中断:
configMAX_SYSCALL_INTERRUPT_PRIORITY因此,进入临界区不一定意味着所有硬件中断都被关闭。
一般情况下:
- 受 FreeRTOS 管理的较低紧迫度中断会被屏蔽;
- 高于阈值的高紧迫度中断仍然可以运行;
- 不受屏蔽的高紧迫度中断不能调用 FreeRTOS API。
特别注意优先级数字方向
FreeRTOS任务优先级:
数字越大,任务优先级越高Cortex-M的NVIC中断优先级通常是:
数值越小,中断逻辑优先级越高例如:
任务优先级3高于任务优先级1 但是: 中断优先级1高于中断优先级3任务优先级和中断优先级属于两个不同的系统,不能直接比较。
九、临界区支持嵌套
FreeRTOS临界区通常支持嵌套:
taskENTER_CRITICAL();/* 嵌套深度:1 */taskENTER_CRITICAL();/* 嵌套深度:2 *//* 仍然处于临界区 */taskEXIT_CRITICAL();/* 嵌套深度:1,尚未真正退出 */taskEXIT_CRITICAL();/* 嵌套深度:0,真正退出 */进入和退出必须严格配对。
如果少调用一次taskEXIT_CRITICAL(),系统可能长期无法恢复正常中断响应和任务调度。
推荐保持结构清晰:
taskENTER_CRITICAL();/* 简短的受保护操作 */taskEXIT_CRITICAL();尽量不要在中间直接return:
taskENTER_CRITICAL();if(error){return;/* 错误:没有退出临界区 */}taskEXIT_CRITICAL();十、临界区、互斥锁和队列如何选择
可以按照下面的思路选择:
是否存在并发访问? │ ├── 否:不需要保护 │ └── 是 │ ├── 只有任务访问,操作时间较长 │ └── 使用互斥锁 │ ├── 任务和ISR访问,操作非常短 │ └── 考虑临界区或原子操作 │ ├── 需要传递数据 │ └── 使用队列 │ ├── 只需要通知事件发生 │ └── 使用任务通知或信号量 │ └── 需要控制任务启动顺序 └── 使用初始化任务或启动同步机制简单对比如下:
| 需求 | 推荐机制 |
|---|---|
| 修改几个任务与ISR共享的变量 | 临界区或原子操作 |
| 多个任务共享串口、I2C、SPI | 互斥锁 |
| ISR向任务传递数据 | 队列或任务通知 |
| 等待某个事件发生 | 信号量、事件组或任务通知 |
| 控制多个任务何时开始工作 | 事件组或启动信号 |
| 创建完所有任务后再运行 | 调度器启动前创建,或高优先级初始化任务 |
十一、使用临界区前的检查清单
使用临界区前,可以检查以下问题:
- 是否确实存在共享数据?
- 哪些任务或中断会访问该数据?
- 是否可以改用队列、任务通知或互斥锁?
- 临界区中的代码是否足够短?
- 执行时间是否固定且可预测?
- 是否包含延时、等待、串口发送或动态分配?
- 进入和退出是否严格配对?
- ISR是否使用了正确的
_FROM_ISR版本? - STM32中断优先级是否配置正确?
- 是否启用了
configASSERT()帮助发现配置错误?
总结
临界区可以概括为一句话:
临界区用于保护极短且不能被并发打断的操作,而不是保护整个任务,也不是控制任务的创建和启动顺序。
对于 Task0 创建 Task1~3的场景,优先使用高优先级初始化任务,并在初始化完成后删除 Task0:
staticvoidTask0(void*argument){/* 创建任务和完成初始化 */vTaskDelete(NULL);}只有在保护共享变量、状态更新或极短硬件操作时,才应考虑使用临界区:
taskENTER_CRITICAL();sharedData++;taskEXIT_CRITICAL();临界区使用原则是:
能不用就不用,必须使用时尽可能短。
参考资料
- FreeRTOS:taskENTER_CRITICAL()与taskEXIT_CRITICAL()
- FreeRTOS:ISR临界区接口
- FreeRTOS:Cortex-M中断优先级说明