第4章 二进制信号量实战:任务间事件通知经典模型

各位同学,咱们今天聊点实在的。

二进制信号量,说白了就是一把只有0和1的锁。很多初学者觉得它简单,不就是个开关嘛。但我在项目中见过太多人用错——要么死锁,要么信号量丢失,要么任务永远等不到通知。

这一章,我就带你把这个「开关」玩明白。

4.1 什么是二进制信号量?

二进制信号量,英文叫Binary Semaphore。它只有两个状态:

  • 满(1):表示有资源可用,或者事件已发生
  • 空(0):表示资源被占用,或者事件未发生

你想想看,这和计数型信号量有什么区别?计数型可以累加,比如有5个缓冲区可用。二进制只有0和1,它更适合做「事件通知」。

我个人习惯把二进制信号量比作一个「旗子」:

  • 旗子举起来(give),表示「有情况!」
  • 旗子放下去(take),表示「收到,已处理」

核心区别:二进制信号量用于同步,计数型信号量用于资源管理。

4.2 经典模型:生产者-消费者事件通知

这是嵌入式系统里最常见的场景。一个任务负责采集数据(生产者),另一个任务负责处理数据(消费者)。

我在项目中遇到过这样一个案例:一个传感器采集任务每10ms产生一次数据,处理任务需要及时响应。如果用轮询,CPU浪费太大。用二进制信号量,完美解决。

来看代码:

/* 声明二进制信号量 */
SEM_ID g_semEvent;

/* 生产者任务:采集数据 */
void taskProducer(void)
{
    while (1)
    {
        /* 采集数据... */
        collectData();

        /* 通知消费者:数据准备好了 */
        semGive(g_semEvent);

        /* 等10ms再采下一次 */
        taskDelay(sysClkRateGet() / 100);
    }
}

/* 消费者任务:处理数据 */
void taskConsumer(void)
{
    while (1)
    {
        /* 等待事件通知 */
        semTake(g_semEvent, WAIT_FOREVER);

        /* 收到通知,处理数据 */
        processData();
    }
}

/* 初始化 */
void init(void)
{
    /* 创建二进制信号量,初始为空 */
    g_semEvent = semBCreate(SEM_Q_FIFO, SEM_EMPTY);

    /* 创建任务 */
    taskSpawn("tProducer", 100, 0, 4096, (FUNCPTR)taskProducer, 0,0,0,0,0,0,0,0,0,0);
    taskSpawn("tConsumer", 100, 0, 4096, (FUNCPTR)taskConsumer, 0,0,0,0,0,0,0,0,0,0);
}

嗯,这里要注意:semBCreate的第二个参数决定了初始状态。

  • SEM_EMPTY:初始为0,消费者会先等待
  • SEM_FULL:初始为1,消费者会立即拿到

在事件通知模型里,我们通常用SEM_EMPTY。为什么?因为事件还没发生,消费者不应该先跑。

4.3 避坑指南:信号量丢失问题

我曾经在一个项目中踩过一个坑。生产者连续给了两次信号量,但消费者只处理了一次。怎么回事?

二进制信号量的特性是:多次give只相当于一次。因为它的值最大就是1。

看这个场景:

/* 生产者连续给两次 */
semGive(g_semEvent);  /* 信号量变成1 */
semGive(g_semEvent);  /* 还是1,第二次丢失! */

消费者只取了一次,第二次事件就丢了。这在高速数据采集时特别危险。

警告:如果生产者比消费者快,二进制信号量会导致事件丢失。这时应该考虑用计数型信号量,或者用消息队列。

那什么时候用二进制信号量合适?

  • 事件频率低,消费者处理速度足够快
  • 只关心「有没有事件」,不关心「发生了多少次」
  • 比如按键按下、中断触发、状态变化

4.4 中断与任务同步:最经典用法

二进制信号量在中断处理中特别常用。我做过一个电机控制项目,编码器中断每来一次,就需要通知控制任务更新位置。

中断服务程序里不能调用semTake(会阻塞),但可以调用semGive。这是VxWorks允许的。

/* 中断服务程序 */
void isrEncoder(void)
{
    /* 记录编码器值 */
    g_encoderCount++;

    /* 通知控制任务 */
    semGive(g_semEvent);

    /* 清除中断标志 */
    *pRegIntClear = 1;
}

/* 控制任务 */
void taskControl(void)
{
    while (1)
    {
        /* 等待中断通知 */
        semTake(g_semEvent, WAIT_FOREVER);

        /* 更新位置 */
        updatePosition(g_encoderCount);
    }
}

这里有个细节:semGive在中断里是安全的,但要注意优先级反转问题。如果控制任务优先级比中断低,中断频繁触发时,控制任务可能被饿死。

小技巧:在中断里调用semGive后,可以立即返回。VxWorks会在中断退出时检查是否有更高优先级的任务就绪,如果有,会直接切换过去。这叫「中断延迟任务调度」。

4.5 超时处理:别让任务死等

很多新手写代码时喜欢用WAIT_FOREVER。但万一信号量永远不来呢?任务就卡死了。

我建议:能用超时就用超时。哪怕设一个很长的超时时间,也比死等强。

/* 带超时的等待 */
STATUS status = semTake(g_semEvent, sysClkRateGet() * 5);  /* 等5秒 */

if (status == OK)
{
    /* 正常收到事件 */
    processData();
}
else
{
    /* 超时了,做异常处理 */
    logMsg("Event timeout! Reset sensor...\n", 0,0,0,0,0,0);
    resetSensor();
}

你看,加了超时后,系统就有了容错能力。即使传感器挂了,任务也能恢复。

4.6 实战建议:什么时候用二进制信号量?

总结一下我的经验:

场景 推荐方案 原因
中断通知任务 二进制信号量 轻量、快速、ISR安全
任务间单次事件 二进制信号量 简单、直观
多次事件累积 计数型信号量 避免事件丢失
数据传递 消息队列 信号量不携带数据
互斥访问 互斥锁 支持优先级继承

说白了,二进制信号量最适合做「事件通知」,不适合做「数据传递」或「资源计数」。

4.7 本章小结

这一章我们聊了:

  • 二进制信号量的本质:0和1的开关
  • 生产者-消费者模型:give和take的配合
  • 中断同步:ISR里give,任务里take
  • 超时处理:别死等,给系统留条后路
  • 避坑:多次give会丢失,注意频率匹配

下一章我们会讲互斥锁。互斥锁和二进制信号量长得像,但脾气完全不同。到时候我会告诉你为什么互斥锁能解决优先级反转,而二进制信号量不行。

嗯,今天就到这里。代码写起来,有问题咱们下节课聊。