信号量(Semaphores)、互斥量(Mutex)与优先级继承
在上一课我们重点聊了任务与队列,解决的是"数据怎么在任务之间流动"的问题。这一课要讨论的是另一类更容易被忽视、但在真实工程里同样致命的问题:谁该等谁,以及谁能同时访问共享资源。也就是同步与互斥。
如果你已经写过稍微复杂一点的 RTOS 程序,大概率已经遇到过这类场景:一个中断告诉你"某件事发生了",一个任务需要等这个事件;或者多个任务要访问同一块硬件、同一个全局结构体,却偶尔出现数据错乱、卡死,甚至时序异常。这些问题,基本都落在信号量和互斥量的职责范围内。
信号量、互斥量和队列各自解决什么问题
在 FreeRTOS 里,很多同步原语的底层实现其实是相通的,但语义完全不同。队列更偏向"搬运数据",信号量更像"事件发生了"的通知,而互斥量则是专门用来保护共享资源的"钥匙"。
简单理解可以是这样:如果你关心的是"把什么东西传过去",优先考虑队列;如果你只关心"这件事发生没发生",用信号量;如果你要保证同一时间只有一个任务能访问某个资源,那就一定要用互斥量,而不是偷懒用二值信号量。
FreeRTOS 中的信号量 API 是怎么一回事
FreeRTOS 把信号量和互斥量都统一抽象成 SemaphoreHandle_t,这点一开始会让人有点困惑:明明用途不同,为什么类型一样?原因在于它们的底层内核对象相似,但行为策略不同,尤其是在优先级处理上。
常见的创建接口包括二值信号量、计数信号量和互斥量:
c
SemaphoreHandle_t xSemaphoreCreateBinary(void);
SemaphoreHandle_t xSemaphoreCreateCounting(UBaseType_t uxMaxCount, UBaseType_t uxInitialCount);
SemaphoreHandle_t xSemaphoreCreateMutex(void);
配合使用的核心操作无非是 Take 和 Give,以及在中断里用的 GiveFromISR。真正的差异,不在 API 长什么样,而在"你该在什么语义下用它"。
二值信号量:最常见的事件通知工具
二值信号量只有两种状态:可用或不可用。它最典型的用法不是互斥,而是事件通知 。例如,按键中断发生了、ADC 转换完成了、DMA 结束了------ISR 不做复杂处理,只是简单地"给"一下信号量,某个任务正阻塞在 xSemaphoreTake 上,立刻被唤醒。
c
SemaphoreHandle_t xSem = xSemaphoreCreateBinary();
一个细节需要注意:二值信号量刚创建出来时,通常是"不可用"的。如果你希望任务一启动就能直接通过一次 Take,需要在创建后先 Give 一次。
二值信号量非常适合 ISR → 任务 的通知模型,但不适合用来长期保护共享资源,这一点后面讲优先级反转时会非常关键。
计数信号量:有限资源或事件计数
当你面对的不是"有没有发生",而是"发生了几次"或者"有多少个资源槽位"时,计数信号量就派上用场了。它内部维护一个计数器,每次 Give 增加计数,每次 Take 减少计数,上限和下限都受到约束。
c
SemaphoreHandle_t xCount = xSemaphoreCreateCounting(10, 0);
这种信号量常见于连接池、缓冲池、任务配额控制等场景。只要计数大于 0,任务就能继续执行;计数为 0 时,任务自然阻塞等待。
互斥量:它和信号量最本质的区别
在 FreeRTOS 里,互斥量看起来像一个"带特殊属性的二值信号量",但它们在语义上完全不是一回事。互斥量的核心目的只有一个:保护共享资源。
c
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
xSemaphoreTake(xMutex, portMAX_DELAY);
// 访问共享资源
xSemaphoreGive(xMutex);
互斥量和二值信号量最大的区别,在于互斥量支持优先级继承。这不是一个锦上添花的特性,而是在多优先级系统中保证实时性的关键机制。
什么是优先级反转,为什么它很危险
想象这样一个经典场景:低优先级任务 L 先拿到了某个共享资源,高优先级任务 H 随后也需要这个资源,于是被阻塞。此时,一个与资源无关的中优先级任务 M 开始运行,并持续抢占 CPU。结果就是:L 因为优先级低,迟迟得不到运行机会释放资源;H 又因为等资源被卡住;最终,一个"无关紧要"的 M 间接拖慢了 H,这就是优先级反转。
在实时系统中,这种情况非常致命,因为它破坏了"高优先级任务应该尽快响应"的基本假设。
优先级继承是怎么解决这个问题的
当高优先级任务阻塞在互斥量上时,FreeRTOS 会临时把持有互斥量的低优先级任务提升到高优先级任务的级别。这样一来,中优先级任务就无法再抢占 CPU,低优先级任务可以尽快跑完临界区并释放资源,高优先级任务也就能及时恢复执行。
需要强调的是:只有通过 xSemaphoreCreateMutex() 创建的互斥量,才具备这一机制。用二值信号量"假装"互斥,是不会触发优先级继承的。
一个完整实验:复现优先级反转,再解决它
下面这段代码非常适合在上位机 FreeRTOS 模拟环境中跑一遍。它构造了三个任务:低优先级任务占用资源,高优先级任务等待资源,中优先级任务不断抢占 CPU。通过切换锁的类型,你可以清楚看到是否发生了优先级反转。
c
#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
static SemaphoreHandle_t xLock = NULL;
#define USE_MUTEX 1
void vLowTask(void *pv) {
for (;;) {
xSemaphoreTake(xLock, portMAX_DELAY);
printf("[Low] acquired lock, doing long work...\n");
vTaskDelay(pdMS_TO_TICKS(300));
printf("[Low] releasing lock\n");
xSemaphoreGive(xLock);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void vMidTask(void *pv) {
for (;;) {
printf("[Mid] running heavy work\n");
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void vHighTask(void *pv) {
for (;;) {
vTaskDelay(pdMS_TO_TICKS(50));
printf("[High] trying to take lock...\n");
xSemaphoreTake(xLock, portMAX_DELAY);
printf("[High] got lock\n");
xSemaphoreGive(xLock);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
int main(void) {
#if USE_MUTEX
xLock = xSemaphoreCreateMutex();
printf("Using MUTEX (with priority inheritance)\n");
#else
xLock = xSemaphoreCreateBinary();
xSemaphoreGive(xLock);
printf("Using BINARY SEM (no priority inheritance)\n");
#endif
xTaskCreate(vLowTask, "Low", 256, NULL, tskIDLE_PRIORITY + 1, NULL);
xTaskCreate(vMidTask, "Mid", 256, NULL, tskIDLE_PRIORITY + 2, NULL);
xTaskCreate(vHighTask, "High", 256, NULL, tskIDLE_PRIORITY + 3, NULL);
vTaskStartScheduler();
for(;;);
}
把 USE_MUTEX 设为 0,你会看到高优先级任务明显被拖慢;改为 1 之后,延迟会大幅降低。这正是优先级继承在起作用。
在 ISR 中使用信号量的正确姿势
和队列类似,信号量在中断里也必须使用 FromISR 版本。ISR 的职责仍然是:尽快通知、尽快返回。
c
void EXTI_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xBinarySem, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
任务侧只需要正常 Take,就可以把中断事件转化为可控、可阻塞的任务逻辑。