第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会丢失,注意频率匹配
下一章我们会讲互斥锁。互斥锁和二进制信号量长得像,但脾气完全不同。到时候我会告诉你为什么互斥锁能解决优先级反转,而二进制信号量不行。
嗯,今天就到这里。代码写起来,有问题咱们下节课聊。