2. 信号量基础
信号量,说白了就是一个内核维护的计数器,加上两个操作:P(获取)和V(释放)。我刚开始接触 RTOS 时,总觉得这玩意儿不就是个全局变量加锁吗?后来踩了坑才明白——信号量的精髓在于“谁等待,谁唤醒”的机制,而不是简单的计数值。
你想想看,裸机编程里我们经常用全局标志位来做同步:
volatile uint8_t data_ready = 0;
// ISR 中
void UART_IRQHandler(void) {
rx_buffer[0] = USART->DR;
data_ready = 1;
}
// 主循环中
while(1) {
if(data_ready) {
process_data();
data_ready = 0;
}
}
这段代码在简单场景下能用,但一旦任务多了,问题就来了:轮询浪费 CPU、优先级反转、临界区保护缺失。信号量正是为了解决这些问题而生的。
2.1 信号量的核心概念
信号量本质上是一个非负整数计数器,配合一个等待队列。它的工作逻辑其实很简单:
- P 操作(pend/take/acquire):如果计数值 > 0,减 1 并立即返回;否则将当前任务挂起到等待队列。
- V 操作(post/give/release):如果等待队列中有任务,唤醒一个;否则计数值加 1。
关键区别:信号量与互斥量不同。互斥量有所有权概念——谁上锁谁解锁;信号量没有所有权,任何任务或 ISR 都可以释放信号量。这个特性在驱动开发中非常有用。
我记得有一次做 SPI 驱动,DMA 传输完成后在中断中释放信号量,等待的任务立刻被唤醒。如果用互斥量,中断里是不能释放的——因为中断不属于任何任务上下文。这就是信号量的典型应用场景。
2.2 二值信号量
二值信号量的计数值只有 0 和 1 两种状态。它本质上就是一个“通知旗”:
- 计数值 = 0:资源不可用,任务等待
- 计数值 = 1:资源可用,任务可获取
二值信号量最常见的用途是中断与任务之间的同步。我画了一张图来说明这个流程:
实战经验:二值信号量在 ISR 中释放时,一定要使用带 FromISR 后缀的 API(如 xSemaphoreGiveFromISR),并且检查是否需要触发上下文切换。我曾经见过同事在中断里直接调用 xSemaphoreGive,结果系统死锁——因为那个 API 内部会尝试获取调度器锁。
2.3 计数信号量
计数信号量的计数值可以大于 1,用于管理多个相同类型的资源。比如一个串口 DMA 有 4 个描述符,或者一个内存池有 8 个缓冲区。
它的行为逻辑是:
- 创建时:指定最大计数值和初始计数值
- P 操作:计数值减 1,如果为 0 则阻塞
- V 操作:计数值加 1,如果超过最大值则保持最大值
我举个实际例子。之前做一个多通道 ADC 采集系统,有 6 个通道共用同一个 DMA 控制器。每个通道采集完成后,ISR 释放一个信号量,处理任务获取信号量后处理数据。如果使用二值信号量,通道 1 采集完通知了任务,但任务还没处理完,通道 2 又来了——通知就丢了。用计数信号量,初始值设为 0,最大值设为 6,每个通道完成时 give 一次,任务循环 take,完美解决。
| 特性 | 二值信号量 | 计数信号量 |
|---|---|---|
| 计数值范围 | 0 或 1 | 0 ~ N(N 由用户指定) |
| 典型用途 | 中断同步、单资源互斥 | 多资源管理、事件计数 |
| 释放多次的效果 | 计数值保持 1,后续释放无效 | 计数值累加,最多到 N |
| 等待队列行为 | 单任务等待,或按优先级排队 | 多任务等待,按优先级/时间片唤醒 |
| 典型 API(FreeRTOS) | xSemaphoreCreateBinary() |
xSemaphoreCreateCounting() |
2.4 信号量的典型应用模式
在驱动开发中,信号量主要有三种应用模式。我根据自己的项目经验总结了一下:
- 中断同步模式:ISR 中释放信号量,任务中等待。这是最常用的模式,适合事件驱动型外设(UART 接收、GPIO 中断、定时器超时)。
- 资源池管理:创建时初始值等于资源数量,任务获取资源前 take,使用完后 give。适合有限资源场景(DMA 通道、硬件定时器、共享缓冲区)。
- 任务间单向通知:一个任务完成某阶段工作后 give,另一个任务 take 后继续。适合流水线型处理(数据采集→处理→输出)。
避坑指南:我曾经在一个项目中,用二值信号量做中断同步,但 ISR 触发频率很高(每 100μs 一次),而任务处理一次需要 500μs。结果信号量被多次 give,但任务只 take 一次,中间的事件全部丢失。后来改成计数信号量,最大值设为 10,才解决了事件丢失问题。
所以,选择二值还是计数信号量,关键看事件是否可能“堆积”。如果 ISR 触发频率高于任务处理速度,必须用计数信号量。
2.5 信号量的实现原理(简化版)
理解信号量的内部实现,有助于你调试驱动时快速定位问题。我以 FreeRTOS 为例,简化一下核心逻辑:
// 伪代码:信号量 P 操作
void xSemaphoreTake(Semaphore_t *sem, TickType_t timeout) {
taskENTER_CRITICAL();
if(sem->count > 0) {
sem->count--;
taskEXIT_CRITICAL();
return; // 立即获取成功
}
// 计数值为 0,当前任务加入等待队列
vTaskPlaceOnEventList(&sem->waitList, timeout);
taskEXIT_CRITICAL();
vTaskSwitchContext(); // 触发任务切换
}
// 伪代码:信号量 V 操作
void xSemaphoreGive(Semaphore_t *sem) {
taskENTER_CRITICAL();
if(listCURRENT_LIST_LENGTH(&sem->waitList) > 0) {
// 有任务在等待,直接唤醒一个
xTaskRemoveFromEventList(&sem->waitList);
} else {
// 没有等待任务,计数值加 1
if(sem->count < sem->maxCount) {
sem->count++;
}
}
taskEXIT_CRITICAL();
}
注意看,P 操作和 V 操作内部都使用了临界区保护。这是因为信号量的计数值和等待队列是全局资源,多个任务或 ISR 可能同时访问。临界区保证了操作的原子性。
个人习惯:我在写驱动时,会先画一个信号量状态机:
- 初始状态:计数值 = 初始值,等待队列为空
- P 操作成功:计数值减 1,任务继续运行
- P 操作阻塞:任务加入等待队列,调度其他任务
- V 操作唤醒:从等待队列移除一个任务,计数值不变
- V 操作计数:计数值加 1,等待队列为空
这个状态机帮我避免了很多逻辑错误,比如“信号量被多次 give 但任务只 take 一次”这类问题。
2.6 信号量使用中的常见陷阱
最后,我列几个实际项目中踩过的坑,希望能帮你少走弯路:
- 陷阱一:ISR 中调用非 FromISR 版本 API——后果是死锁或系统崩溃。记住,中断上下文只能调用带
FromISR后缀的函数。 - 陷阱二:忘记设置超时时间——
portMAX_DELAY会导致任务永久阻塞。如果信号量永远不来,这个任务就“死”了。我建议在驱动初始化阶段使用有限超时,调试通过后再改为永久等待。 - 陷阱三:信号量创建后忘记初始化——二值信号量创建时计数值为 0,需要先 give 一次才能被 take。计数信号量创建时可以指定初始值。这个细节很容易被忽略。
- 陷阱四:多个任务等待同一个信号量——如果多个任务 take 同一个二值信号量,只有一个能获取到,其他任务继续阻塞。这种场景下,考虑使用事件组或消息队列可能更合适。
嗯,信号量的基础就讲到这里。记住一句话:二值信号量是“通知”,计数信号量是“计数”。选对类型,你的驱动代码会清晰很多。