一、场景引入
在嵌入式项目开发中,我们经常会遇到全局变量被多个任务、中断同时修改导致数据错乱的问题,比如共享的传感器采样计数器,在任务读取的同时被中断更新,最终读到非预期的脏数据;或者多个任务同时操作同一外设寄存器,引发外设状态异常。这类问题的根源就是没有对访问共享资源的临界代码段做正确的保护,是 RTOS 项目中最常见也最容易排查失误的 bug,考察这个点本质是看候选人能不能写出线程安全、中断安全的 RTOS 业务代码。
二、原理解析
临界段指的是访问共享资源时,不允许被其他任务或中断打断的代码段。RTOS 中临界段保护的核心实现逻辑是通过关闭内核调度 / 全局中断,禁止更高优先级的抢占行为,保证临界段代码执行的原子性:
- 基础实现分为两类:一是任务级临界段,仅关闭 RTOS 内核调度,禁止任务抢占,但允许中断响应,适合仅在任务间共享、不被中断访问的资源;二是中断级临界段,直接关闭全局中断(或关闭优先级阈值以上的中断),同时禁止任务和中断抢占,适合会被中断和任务同时访问的资源;
- 嵌套支持:为了避免多层临界段嵌套时内层退出直接打开中断 / 调度,实现时必须在进入临界段前保存当前的中断掩码 / 调度锁状态,退出时恢复原状态,而不是直接强制打开中断;
- 注意事项:临界段代码必须尽可能短,避免过长时间关中断导致中断响应延迟超标,甚至触发硬件看门狗复位。
三、三道递进追问及答案
追问 1:基础追问:临界段保护和互斥锁都可以保护共享资源,两者有什么区别?
临界段是通过关调度 / 关中断实现,保护时间极短,支持中断级场景,但不能阻塞,不能用于耗时操作;互斥锁是基于内核调度实现,保护过程允许任务调度和中断响应,适合保护耗时较长的资源,但只能用于任务间,不适用中断场景,且可能引发优先级翻转问题。
追问 2:进阶变形:为什么临界段实现要保存原中断状态,而不是进入关中断、退出直接开中断?
为了支持临界段嵌套:如果外层已经关闭了中断,内层临界段退出时直接开中断会破坏外层的临界段保护,导致外层临界段被意外打断。通过保存原状态、退出恢复的方式,可以保证嵌套场景下只有最外层临界段退出时才会恢复到最初的中断状态。
追问 3:实战综合:现在有一个共享队列,会被多个任务和串口接收中断同时读写,应该用什么临界段保护方式?如果临界段执行时间太长会有什么问题?
必须用中断级临界段,也就是进入队列操作前关全局中断(或关优先级高于串口的中断),操作完成后恢复原中断状态。如果临界段太长,会导致串口等低于关闭优先级阈值的中断响应延迟超标,出现数据丢包,如果超过看门狗超时时间还会触发系统复位。
四、最小验证实验
实验环境
Keil MDK 5、STM32F103 开发板、FreeRTOS V10.4.6
实验代码
#include "stm32f10x.h" #include "FreeRTOS.h" #include "task.h" #include <stdio.h> // 共享全局变量,会被任务和中断同时访问 volatile uint32_t g_shared_counter = 0; // 串口1初始化实现,用于打印输出 void USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // USART1_TX PA9 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); // USART1_RX PA10 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baudrate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } // 重定向printf到USART1 int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t) ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; } void HardFault_Handler(void) { while(1); } void SysTick_Handler(void) { if(xTaskGetSchedulerState() != taskSCHEDULER_NOT_STARTED) xPortSysTickHandler(); } // 定时器2中断,模拟中断里修改共享变量 void TIM2_IRQHandler(void) { if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_shared_counter++; } } // 测试任务1:无保护读取共享变量 void task1(void *arg) { uint32_t val; while(1) { // 模拟非原子读取,通过人为插入延时制造读取和校验的时间差,即使在32位机上也能复现并发问题 val = g_shared_counter; for(volatile int i=0; i<100; i++); if(val != g_shared_counter) { printf("无保护读取异常:val=%d, 实际=%d\r\n", val, g_shared_counter); } vTaskDelay(pdMS_TO_TICKS(100)); } } // 测试任务2:临界段保护读取共享变量 void task2(void *arg) { uint32_t val; while(1) { // 任务上下文调用任务级中断临界段API,保存原中断状态 taskENTER_CRITICAL(); val = g_shared_counter; for(volatile int i=0; i<100; i++); if(val != g_shared_counter) { printf("临界段读取异常:val=%d, 实际=%d\r\n", val, g_shared_counter); } // 退出临界段,恢复原中断状态 taskEXIT_CRITICAL(); vTaskDelay(pdMS_TO_TICKS(100)); } } // 定时器2初始化,1ms中断一次 void tim2_init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef tim_cfg = {0}; tim_cfg.TIM_Prescaler = 7200 - 1; tim_cfg.TIM_CounterMode = TIM_CounterMode_Up; tim_cfg.TIM_Period = 10 - 1; // 72M/7200/10 = 1kHz,1ms中断 TIM_TimeBaseInit(TIM2, &tim_cfg); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_EnableIRQ(TIM2_IRQn); TIM_Cmd(TIM2, ENABLE); } int main(void) { USART1_Init(115200); // 初始化串口1用于打印 tim2_init(); // 先创建任务1测试无保护场景,注释后创建任务2测试临界段场景 xTaskCreate(task1, "task1", 128, NULL, 2, NULL); // xTaskCreate(task2, "task2", 128, NULL, 2, NULL); vTaskStartScheduler(); while(1); }操作步骤
- 先编译运行创建 task1 的版本,观察串口打印;
- 注释掉 task1 创建代码,打开 task2 创建代码,重新编译下载运行,观察串口打印。
预期结果
- 运行 task1 时,会频繁打印「无保护读取异常」的日志,说明没有临界段保护时,读取与校验的间隙被 TIM2 中断打断修改了变量,导致数据不一致;
- 运行 task2 时,没有任何异常日志打印,说明临界段保护生效,读取与校验的过程中不会被中断打断,数据访问原子性得到保证。
五、实战总结
- 仅任务共享的资源用任务级临界段(关调度),中断和任务共享的资源用中断级临界段(关中断 + 状态保存);
- 临界段代码必须尽可能短,避免引发中断响应延迟、看门狗复位等问题;
- 嵌套临界段必须采用「保存原状态 - 退出恢复」的逻辑,禁止直接开中断 / 调度导致保护失效;
- 临界段与互斥锁适用场景不同:临界段适合短时间、中断级保护,互斥锁适合长时间、仅任务间的资源保护。