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 硬件事件触发 二值信号量 计数值 0→1 处理任务 等待→就绪→运行 xSemaphoreGiveFromISR 任务被唤醒 等待队列行为 任务调用 xSemaphoreTake() 时,若计数值=0,任务进入阻塞态 ISR 调用 xSemaphoreGiveFromISR() 后,任务从阻塞→就绪 // 任务代码 while(1) { xSemaphoreTake(binSem, portMAX_DELAY); // 阻塞等待 process_data(); // 收到通知后处理 }

实战经验:二值信号量在 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 信号量的典型应用模式

在驱动开发中,信号量主要有三种应用模式。我根据自己的项目经验总结了一下:

  1. 中断同步模式:ISR 中释放信号量,任务中等待。这是最常用的模式,适合事件驱动型外设(UART 接收、GPIO 中断、定时器超时)。
  2. 资源池管理:创建时初始值等于资源数量,任务获取资源前 take,使用完后 give。适合有限资源场景(DMA 通道、硬件定时器、共享缓冲区)。
  3. 任务间单向通知:一个任务完成某阶段工作后 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 同一个二值信号量,只有一个能获取到,其他任务继续阻塞。这种场景下,考虑使用事件组或消息队列可能更合适。

嗯,信号量的基础就讲到这里。记住一句话:二值信号量是“通知”,计数信号量是“计数”。选对类型,你的驱动代码会清晰很多。