4、任务间通信(上):信号量(Binary/Counting/Mutex)的原理与使用、优先级反转问题与优先级继承协议

各位同学,咱们今天聊聊任务间通信。在VxWorks里,任务不是孤岛,它们需要互相配合。怎么配合?信号量就是最基础、最常用的一种手段。

我刚开始做嵌入式开发那会儿,对信号量的理解就是“加锁解锁”。后来踩的坑多了,才发现这东西远没那么简单。尤其是那个优先级反转,当年差点让我通宵加班。好,咱们一步步来。

4.1 信号量是什么?

说白了,信号量就是一个内核维护的计数器。任务想用某个资源,就得先拿到这个信号量。拿不到?那就等着。就这么简单。

VxWorks提供了三种信号量:Binary(二值)、Counting(计数)和Mutex(互斥)。每种都有自己的脾气。

4.2 Binary Semaphore

Binary信号量,取值只有0和1。0表示资源被占,1表示资源空闲。它最适合用来做“互斥访问”或者“事件通知”。

举个例子:两个任务共享一个串口。谁想发数据,谁就得先拿信号量。

/* 创建Binary信号量 */
SEM_ID semSerial = semBCreate(SEM_Q_FIFO, SEM_FULL);

/* 任务A:发送数据 */
void taskA(void)
{
    semTake(semSerial, WAIT_FOREVER);
    /* 使用串口... */
    semGive(semSerial);
}

/* 任务B:发送数据 */
void taskB(void)
{
    semTake(semSerial, WAIT_FOREVER);
    /* 使用串口... */
    semGive(semSerial);
}

这里有个细节:SEM_Q_FIFO表示等待队列是先进先出。如果你想让高优先级任务优先获得信号量,可以用SEM_Q_PRIORITY。我个人习惯用FIFO,因为行为更可预测。

小提示:Binary信号量创建时,SEM_FULL表示初始可用,SEM_EMPTY表示初始被占。做事件通知时,常用SEM_EMPTY

4.3 Counting Semaphore

Counting信号量,计数器可以大于1。它适合管理“多个同类资源”。比如一个缓冲池有10个缓冲区,每个任务取一个用。

/* 创建Counting信号量,初始10个资源 */
SEM_ID semBuf = semCCreate(SEM_Q_FIFO, 10);

/* 任务取缓冲区 */
void taskGetBuffer(void)
{
    semTake(semBuf, WAIT_FOREVER);
    /* 拿到一个缓冲区... */
}

/* 任务还缓冲区 */
void taskReturnBuffer(void)
{
    /* 用完缓冲区... */
    semGive(semBuf);
}

Counting信号量的值,就是当前可用的资源数量。每次semTake减1,semGive加1。减到0时,后来的任务就得排队等。

我在项目中遇到过一个问题:有个任务忘记semGive,结果资源越用越少,最后系统卡死。嗯,这种bug最难查。所以后来我写代码时,都会在semTakesemGive附近加上日志。

4.4 Mutex Semaphore

Mutex,全称Mutual Exclusion。它和Binary信号量有点像,但多了几个重要特性。

第一,Mutex有所有权概念。谁拿了谁才能放。别的任务不能强行释放。Binary信号量没这限制,A任务拿的,B任务也能放。这有时候会出乱子。

第二,Mutex支持优先级继承。这个咱们后面细说。

第三,Mutex支持递归获取。同一个任务可以多次semTake同一个Mutex,不会死锁。但记得要对应次数的semGive

/* 创建Mutex信号量 */
SEM_ID semMutex = semMCreate(SEM_Q_PRIORITY | SEM_INVERSION_SAFE);

/* 递归使用 */
void func1(void)
{
    semTake(semMutex, WAIT_FOREVER);
    func2();  /* 内部又拿一次 */
    semGive(semMutex);
}

void func2(void)
{
    semTake(semMutex, WAIT_FOREVER);
    /* 干活... */
    semGive(semMutex);
}
注意:Binary信号量不支持递归。同一个任务连续semTake两次,会把自己挂起。这就是死锁。我曾经因为这个bug排查了整整一个下午。

4.5 优先级反转问题

好,重点来了。优先级反转,这是实时系统里最头疼的问题之一。

什么叫优先级反转?你想想看:有三个任务,优先级从高到低分别是H、M、L。L先拿到了一个信号量。H也想拿,但被L占着,所以H阻塞。这时候M开始运行了,它不碰那个信号量,但因为它优先级比L高,所以一直抢CPU。L得不到CPU,就没办法释放信号量。H呢?只能干等着。

结果就是:高优先级的H,被中等优先级的M给“反转”了。H的实时性完全得不到保证。

我当年做的一个通信项目,就遇到过这个问题。一个高优先级的数据接收任务,时不时就超时。查了三天,最后发现是一个低优先级的日志任务占着互斥锁不放。那叫一个郁闷。

4.6 优先级继承协议

怎么解决优先级反转?VxWorks的Mutex提供了优先级继承协议。

原理很简单:当高优先级任务H被低优先级任务L阻塞时,L临时“继承”H的优先级。这样L就能尽快运行,尽快释放信号量。释放后,L的优先级恢复原样。

你想想看,这就像在说:“L,你现在身上背着高优先级任务的任务,赶紧把锁放了,别耽误事。”

使用方式也很简单:创建Mutex时加上SEM_INVERSION_SAFE标志。

SEM_ID semMutex = semMCreate(SEM_Q_PRIORITY | SEM_INVERSION_SAFE);

就这么一行,VxWorks内核会自动处理优先级继承。不需要你写额外代码。

核心要点:
  • Binary信号量:适合互斥和事件通知,无所有权,不支持递归
  • Counting信号量:适合管理多个同类资源
  • Mutex信号量:有所有权,支持递归,支持优先级继承
  • 优先级反转:高优先级被低优先级间接阻塞
  • 优先级继承:Mutex自带解决方案,创建时加标志即可

4.7 如何选择?

我个人的经验是这样的:

  • 如果只是简单的互斥,用Binary信号量就够了。但要注意递归问题。
  • 如果涉及多个同类资源,用Counting信号量。
  • 如果任务有优先级关系,或者可能递归获取,用Mutex。记得加上SEM_INVERSION_SAFE

还有一个建议:能用Mutex的地方,尽量用Mutex。它更安全。Binary信号量虽然轻量,但容易出一些隐蔽的bug。

好了,信号量的基础就讲到这里。下一节咱们聊聊消息队列和事件。这些东西组合起来,就能搭建出健壮的任务通信架构了。

避坑指南:我曾经在一个项目里,把所有信号量都设成了WAIT_FOREVER。结果某个外设挂了,所有任务都卡死在semTake上。后来我改成带超时的等待,至少能检测到异常。建议你也这么做。