信号量(Semaphore):二值信号量与计数信号量的原理、创建、获取与释放
信号量,说白了就是 RTOS 里的「令牌」。谁拿到令牌,谁就能干活。拿不到的,乖乖排队等着。
我记得刚入行那会儿,总觉得信号量这玩意儿挺玄乎。后来在做一个多按键的工业控制器时,被中断服务程序和主任务之间的数据竞争坑了一把——按键明明按了,系统却偶尔没反应。查了两天,最后发现是共享变量没保护。从那以后,我对信号量就再也不敢马虎了。
二值信号量 vs 计数信号量
先搞清楚概念。二值信号量只有两个状态:0 和 1。就像一把锁,要么锁着(0),要么开着(1)。它最适合用来做「互斥」或者「通知」。
计数信号量就不一样了。它的值可以从 0 到你设定的最大值。比如你设最大值为 10,那它就能记录 10 个「资源可用」的信号。这玩意儿适合管理资源池——比如串口 DMA 缓冲区有 8 个槽位,每个槽位空闲时释放一个信号量,任务获取信号量就知道有槽位可用。
| 特性 | 二值信号量 | 计数信号量 |
|---|---|---|
| 取值范围 | 0 或 1 | 0 ~ 设定最大值 |
| 典型用途 | 互斥访问、任务同步 | 资源计数、事件计数 |
| 初始值 | 通常为 0 或 1 | 根据资源数量设定 |
| 释放操作 | 值变为 1 | 值加 1(不超过最大值) |
| 获取操作 | 值变为 0 | 值减 1(不小于 0) |
核心要点:二值信号量是计数信号量在最大值为 1 时的特例。很多 RTOS 底层实现其实共用同一套代码,只是初始化参数不同。
创建信号量
以 FreeRTOS 为例,创建信号量的 API 很直接。我个人习惯在系统初始化阶段就把所有信号量创建好,而不是在任务运行中动态创建——避免内存碎片和失败处理。
// 创建二值信号量
SemaphoreHandle_t xBinarySemaphore;
xBinarySemaphore = xSemaphoreCreateBinary();
if (xBinarySemaphore == NULL) {
// 创建失败,处理错误
}
// 创建计数信号量
SemaphoreHandle_t xCountingSemaphore;
xCountingSemaphore = xSemaphoreCreateCounting(
10, // 最大计数值
0 // 初始计数值
);
if (xCountingSemaphore == NULL) {
// 创建失败,处理错误
}
小经验:创建二值信号量后,它的初始值是不确定的!一定要手动调用一次 xSemaphoreGive() 把它置为 1,或者用 xSemaphoreCreateBinaryStatic() 配合初始化。我曾经因为这个踩过坑——信号量创建完直接拿去用,结果任务永远等不到信号量。
获取与释放
获取信号量用 xSemaphoreTake(),释放用 xSemaphoreGive()。这两个操作必须成对出现,否则资源就泄漏了。
// 获取信号量(带超时)
if (xSemaphoreTake(xBinarySemaphore, pdMS_TO_TICKS(100)) == pdTRUE) {
// 成功获取,执行临界区代码
// ...
// 释放信号量
xSemaphoreGive(xBinarySemaphore);
} else {
// 超时未获取到,做超时处理
}
// 中断中释放信号量(必须使用中断版本)
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xBinarySemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
警告:中断服务程序中绝对不要调用 xSemaphoreTake()!中断上下文不允许阻塞等待。释放信号量必须使用 xSemaphoreGiveFromISR() 版本,并且要根据返回值决定是否需要触发任务切换。
实战:按键触发任务
光说不练假把式。我们做一个经典场景:按键按下,触发一个任务去处理按键逻辑。这个场景在几乎所有嵌入式产品里都会出现。
为什么不用轮询?你想想看,如果主循环里不断读 GPIO,CPU 全浪费在空转了。用信号量,按键中断来了才唤醒任务,平时任务乖乖挂起,省电又高效。
设计思路
- 按键 GPIO 配置为下降沿中断
- 中断服务程序中释放二值信号量
- 按键处理任务阻塞等待信号量
- 获取到信号量后执行去抖、逻辑处理
// 全局信号量句柄
SemaphoreHandle_t xKeySemaphore;
// 按键中断服务程序
void EXTI0_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 清除中断标志
if (__HAL_GPIO_EXTI_GET_IT(KEY_PIN) != RESET) {
__HAL_GPIO_EXTI_CLEAR_IT(KEY_PIN);
// 释放信号量,通知任务
xSemaphoreGiveFromISR(xKeySemaphore, &xHigherPriorityTaskWoken);
// 如果唤醒的任务优先级更高,立即切换
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
// 按键处理任务
void KeyTask(void *pvParameters) {
// 初始化:创建二值信号量,初始为 0
xKeySemaphore = xSemaphoreCreateBinary();
// 配置 GPIO 中断(略)
while (1) {
// 等待信号量,超时设为最大值(一直等)
if (xSemaphoreTake(xKeySemaphore, portMAX_DELAY) == pdTRUE) {
// 按键被按下,执行去抖
vTaskDelay(pdMS_TO_TICKS(20));
// 再次确认按键状态
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
// 执行按键处理逻辑
HandleKeyPress();
}
}
}
}
关键点:中断中释放信号量后,如果唤醒的任务优先级比当前任务高,portYIELD_FROM_ISR 会立即触发上下文切换。这意味着按键处理任务几乎在中断退出后立刻执行,延迟只有几个 CPU 周期。
信号量使用中的常见陷阱
- 优先级反转:低优先级任务持有信号量,高优先级任务等待,中间优先级任务抢跑。解决办法是使用互斥量(Mutex)并开启优先级继承。
- 死锁:任务 A 等信号量 1,任务 B 等信号量 2,A 持有 2 等 1,B 持有 1 等 2。嗯,两个任务永远卡死。设计时一定要避免循环等待。
- 信号量泄漏:Take 了忘记 Give,或者 Give 了多次导致计数异常。我建议在代码审查时专门检查信号量的成对操作。
- 中断中调用阻塞 API:前面说过了,中断里绝对不能 Take。释放也只能用 FromISR 版本。
知识结构图
下面这张图把信号量的核心脉络梳理清楚了。从创建到使用,再到中断场景,一条线串下来。
小结
信号量是 RTOS 任务同步的基石。二值信号量解决「有没有」的问题,计数信号量解决「有多少」的问题。创建、获取、释放三个操作,加上中断安全版本,就能覆盖绝大多数场景。
我个人建议,新手先从二值信号量的按键触发任务练手。跑通了,再试试计数信号量管理串口缓冲区。一步一步来,信号量其实没那么神秘。