4. 临界区保护与任务同步机制(信号量/队列)
大家好,我是老李。今天咱们聊聊多任务系统里最核心、也最容易出问题的地方——临界区保护和任务同步。说实话,我见过太多项目因为这两个问题翻车了。有的跑着跑着数据就乱了,有的任务卡死不动了,查半天都找不到原因。
其实说白了,临界区保护和任务同步,就是解决「多个任务同时访问共享资源」的问题。你想想看,两个任务同时往一个全局变量里写数据,会发生什么?嗯,结果就是数据被覆盖,系统行为完全不可预测。
4.1 临界区保护:别让任务互相踩脚
临界区,就是那些不能被多个任务同时执行的代码段。比如操作共享变量、读写外设寄存器、更新链表结构等等。我个人的习惯是,只要涉及全局数据,先问自己一句:「这个会不会被多个任务同时访问?」
核心原则:临界区要短、要快、要明确。能不用就不用,非用不可就保护好。
4.1.1 关中断保护
最粗暴也最有效的方式,就是关中断。FreeRTOS 提供了 taskENTER_CRITICAL() 和 taskEXIT_CRITICAL() 这对宏。进入临界区时关掉所有可屏蔽中断,退出时恢复。
// 保护共享变量
taskENTER_CRITICAL();
g_shared_counter++;
taskEXIT_CRITICAL();
我在项目中遇到过一个问题:一个任务在临界区里调用了 vTaskDelay(),结果系统直接死机了。为什么?因为临界区里关掉了中断,而 vTaskDelay() 依赖 SysTick 中断来计时。中断都不工作了,任务自然永远等不到唤醒。
警告:临界区内绝对不要调用任何可能阻塞的函数!包括 vTaskDelay()、xQueueReceive() 等。否则系统必死无疑。
4.1.2 调度器锁保护
有时候我们不想关中断,只想阻止任务切换。这时候可以用 vTaskSuspendAll() 和 xTaskResumeAll()。它不会关中断,只是把调度器暂停了。
vTaskSuspendAll();
// 这里可以安全操作共享数据
// 中断仍然可以响应
g_shared_buffer[index++] = data;
xTaskResumeAll();
我个人更推荐用调度器锁,因为它不影响中断响应。但要注意,调度器锁里也不能调用阻塞函数,道理和关中断一样。
4.2 信号量:任务间的交通信号灯
信号量,说白了就是个计数器。任务 A 释放信号量(give),任务 B 获取信号量(take)。如果信号量计数为 0,take 的任务就会阻塞等待。
我刚开始用 FreeRTOS 时,总觉得信号量很神秘。后来发现,它就是个「资源计数」的机制。有多少个资源可用,信号量就计多少数。
4.2.1 二值信号量 vs 计数信号量
| 类型 | 最大计数值 | 典型用途 |
|---|---|---|
| 二值信号量 | 1 | 任务同步、中断与任务通信 |
| 计数信号量 | 可配置(如 10) | 管理有限资源(如缓冲池) |
二值信号量就像一把钥匙。谁拿到谁干活,干完再还回来。计数信号量就像停车场,有 N 个车位,来一辆车就占一个,满了就排队等。
// 创建二值信号量
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();
// 任务A:等待信号量
void TaskA(void *pvParameters) {
while(1) {
if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
// 收到信号量,开始处理
process_data();
}
}
}
// 中断服务函数:释放信号量
void USART_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
经验之谈:在中断中释放信号量,一定要用 FromISR 版本的函数。而且别忘了检查 xHigherPriorityTaskWoken,如果为 pdTRUE,记得调用 portYIELD_FROM_ISR 触发一次调度。
4.2.2 互斥信号量(Mutex)
互斥信号量是二值信号量的「升级版」。它多了两个特性:优先级继承和递归获取。优先级继承能解决经典的「优先级反转」问题。
什么是优先级反转?我举个例子:低优先级任务拿了锁,中优先级任务抢占了 CPU,高优先级任务等着锁却拿不到。结果高优先级任务被中优先级任务「反转」了。有了优先级继承,低优先级任务在持有锁期间会临时提升到高优先级,避免被中优先级打断。
// 创建互斥信号量
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
// 使用互斥信号量保护共享资源
void write_to_uart(const char *data) {
xSemaphoreTake(xMutex, portMAX_DELAY);
// 独占串口输出
uart_send(data);
xSemaphoreGive(xMutex);
}
注意:互斥信号量不能在中断中使用!因为优先级继承机制需要任务调度器的支持,而中断上下文没有任务的概念。
4.3 队列:任务间的数据管道
信号量只能传递「信号」,不能传递数据。如果你想让任务 A 把数据发给任务 B,就得用队列。队列本质上是个 FIFO 缓冲区,任务 A 往里面放数据,任务 B 从里面取数据。
我记得有个项目,需要把传感器数据从采集任务传给处理任务。一开始我用全局变量加信号量,结果各种同步问题。后来换成队列,代码瞬间清爽了,bug 也少了。
4.3.1 队列的创建与使用
// 创建队列:每个元素 4 字节(int),最多 10 个元素
QueueHandle_t xQueue = xQueueCreate(10, sizeof(int));
// 任务A:发送数据
void Task_Sender(void *pvParameters) {
int value = 0;
while(1) {
value = read_sensor();
xQueueSend(xQueue, &value, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// 任务B:接收数据
void Task_Receiver(void *pvParameters) {
int received;
while(1) {
if(xQueueReceive(xQueue, &received, portMAX_DELAY) == pdTRUE) {
process_sensor_data(received);
}
}
}
队列的每个元素是「拷贝」进去的,不是传指针。这意味着如果你传一个结构体,整个结构体都会被复制。对于大数据,建议传指针,但要注意指针指向的内存必须一直有效。
4.3.2 队列在中断中的使用
中断中往队列发数据,要用 xQueueSendFromISR()。和信号量一样,也要检查是否需要触发任务切换。
void TIM_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
uint32_t timestamp = get_timestamp();
xQueueSendFromISR(xTimestampQueue, ×tamp, &xHigherPriorityTaskWoken);
if(xHigherPriorityTaskWoken) {
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
4.4 知识体系总览
下面这张图是我自己整理的,把临界区保护、信号量、队列的关系画清楚了。你看一眼就能明白整个体系。
4.5 避坑指南
做了这么多年嵌入式,我踩过的坑不少。下面这几个是高频问题,你遇到了可以少走弯路。
- 信号量泄漏:创建了信号量但忘记释放,导致任务永远阻塞。我曾经在调试一个串口驱动时,中断里给了信号量,但处理任务里有个条件判断提前 return 了,没调用 give。结果信号量计数越来越少,最后死锁。
- 队列溢出:发送速度大于接收速度,队列满了。可以用
xQueueSend()的超时机制,或者用xQueueOverwrite()覆盖旧数据。我个人习惯在创建队列时留够余量,比如最大数据量的 1.5 倍。 - 在中断中使用 Mutex:这个前面说过,Mutex 不能在中断中用。如果你需要在中断中做互斥,用二值信号量或者关中断。
- 临界区嵌套:FreeRTOS 支持临界区嵌套,但要注意每个 enter 必须对应一个 exit。我曾经在代码里 enter 了两次但只 exit 了一次,结果中断一直关着,系统时钟都停了。
调试小技巧:用 uxSemaphoreGetCount() 查看信号量当前计数值,用 uxQueueMessagesWaiting() 查看队列中待处理的消息数。这些函数在调试时非常有用,能帮你快速定位问题。
好了,临界区保护和任务同步就聊到这里。记住一句话:共享资源要保护,任务通信用队列,同步用信号量。把这些基础打牢了,多任务系统才能跑得稳。
公众号:蓝海资料掘金营,微信deep3321