4、二值信号量详解:创建、获取、释放、典型应用(任务同步)

好,咱们今天来聊聊二值信号量。说实话,这是VxWorks里最基础、也最常用的同步机制之一。很多刚入行的朋友容易把它跟互斥信号量搞混,其实它们俩的用途完全不同。二值信号量,说白了就是一个“开关”,要么是满的(可用),要么是空的(不可用)。它不像计数信号量那样能数数,也不像互斥信号量那样有优先级继承。

我个人习惯把二值信号量想象成一个“令牌”。谁拿到了令牌,谁就能干活。干完了,把令牌还回来。就这么简单。

4.1 二值信号量的本质

二值信号量在VxWorks内部其实是一个结构体,包含了一个计数值和一个等待任务队列。计数值只有两种状态:0和1。0表示信号量被占用,1表示信号量可用。

我在项目中遇到过一个问题:一个任务在等待信号量时,另一个高优先级任务突然插进来,导致低优先级任务一直拿不到信号量。这就是典型的优先级反转。不过二值信号量本身不解决这个问题,那是互斥信号量的活儿。

核心要点:二值信号量只用于同步,不用于保护共享资源。如果你需要保护临界区,请用互斥信号量。

4.2 创建二值信号量

创建二值信号量用 semBCreate() 函数。它的原型长这样:

SEM_ID semBCreate(
    int options,      // 信号量选项
    SEM_B_STATE initialState  // 初始状态:SEM_FULL 或 SEM_EMPTY
);

options 参数有两个常用值:

  • SEM_Q_FIFO:等待任务按先进先出顺序排队
  • SEM_Q_PRIORITY:等待任务按优先级排队

嗯,这里要注意。如果你用 SEM_Q_PRIORITY,高优先级任务会插队。这在某些实时场景下是必要的,但也会带来不确定性。我建议除非你有明确的优先级需求,否则用 SEM_Q_FIFO 更稳妥。

initialState 参数:

  • SEM_FULL:初始可用(计数值为1)
  • SEM_EMPTY:初始不可用(计数值为0)

举个例子:

SEM_ID mySem;

/* 创建一个初始可用的二值信号量,FIFO排队 */
mySem = semBCreate(SEM_Q_FIFO, SEM_FULL);

if (mySem == NULL) {
    printf("创建信号量失败!\n");
    return ERROR;
}

我曾经犯过一个低级错误:创建信号量时忘了检查返回值。结果程序跑起来后,任务一直卡在等待信号量的地方,排查了半天才发现是信号量没创建成功。所以,创建完一定要检查返回值,这是血的教训。

4.3 获取二值信号量

获取信号量用 semTake() 函数:

STATUS semTake(
    SEM_ID semId,      // 信号量ID
    int timeout        // 超时时间,单位:tick
);

timeout 参数:

  • WAIT_FOREVER:一直等,直到拿到信号量
  • NO_WAIT:不等待,立即返回
  • 其他正数:等待指定的 tick 数

看个实际用法:

/* 等待信号量,最多等100个tick */
if (semTake(mySem, 100) == OK) {
    /* 拿到信号量了,开始干活 */
    printf("任务A开始执行...\n");
    taskDelay(10);
    /* 干完活,释放信号量 */
    semGive(mySem);
} else {
    printf("等待信号量超时!\n");
}

小技巧:在中断服务程序中,绝对不能调用 semTake() 并设置 WAIT_FOREVER。中断上下文不能阻塞。如果你需要在ISR中获取信号量,只能用 NO_WAIT

4.4 释放二值信号量

释放信号量用 semGive() 函数:

STATUS semGive(
    SEM_ID semId       // 信号量ID
);

这个函数很简单,就是把信号量的计数值从0变成1。如果有任务在等待这个信号量,VxWorks会唤醒等待队列中的第一个任务。

释放信号量可以在任务中调用,也可以在中断服务程序中调用。这是二值信号量的一大优势——它可以在ISR和任务之间传递同步信息。

举个例子:

/* 中断服务程序 */
void isrHandler(void)
{
    /* 做一些紧急处理 */
    ...
    
    /* 释放信号量,通知任务来处理剩余工作 */
    semGive(mySem);
}

/* 任务 */
void taskEntry(void)
{
    while (1) {
        /* 等待中断通知 */
        semTake(mySem, WAIT_FOREVER);
        
        /* 处理中断遗留的工作 */
        printf("处理中断数据...\n");
    }
}

这种模式在嵌入式系统中非常常见。中断只做最紧急的事,然后把繁重的处理工作交给任务。你想想看,如果中断里做太多事,会影响系统的实时性。

4.5 典型应用:任务同步

二值信号量最经典的应用就是任务同步。我给大家画个场景:

假设有两个任务:任务A负责采集数据,任务B负责处理数据。任务A采集完数据后,必须通知任务B开始处理。这就是典型的“生产者-消费者”模型。

SEM_ID dataSem;

/* 任务A:数据采集 */
void taskA(void)
{
    while (1) {
        /* 采集数据 */
        collectData();
        
        /* 通知任务B:数据准备好了 */
        semGive(dataSem);
        
        /* 继续采集下一批数据 */
        taskDelay(100);
    }
}

/* 任务B:数据处理 */
void taskB(void)
{
    while (1) {
        /* 等待任务A的通知 */
        semTake(dataSem, WAIT_FOREVER);
        
        /* 处理数据 */
        processData();
    }
}

void main(void)
{
    /* 创建信号量,初始为不可用 */
    dataSem = semBCreate(SEM_Q_FIFO, SEM_EMPTY);
    
    /* 创建任务 */
    taskSpawn("tA", 100, 0, 2000, (FUNCPTR)taskA, 0,0,0,0,0,0,0,0,0,0);
    taskSpawn("tB", 100, 0, 2000, (FUNCPTR)taskB, 0,0,0,0,0,0,0,0,0,0);
}

警告:千万不要在多个任务之间用二值信号量来保护共享资源。比如两个任务同时访问一个全局变量,用二值信号量来“加锁解锁”。这会导致优先级反转、死锁等问题。保护共享资源请用互斥信号量。

4.6 避坑指南

我总结几个常见的坑,都是我曾经踩过的:

  1. 忘记初始化信号量状态:创建时 SEM_FULLSEM_EMPTY 选错了,导致任务一开始就卡住或者意外执行。
  2. 在ISR中调用 semTake() 并阻塞:中断上下文不能阻塞,否则系统会崩溃。
  3. 多次释放同一个信号量:二值信号量释放后计数值变成1,再释放一次还是1,不会变成2。但如果你不小心在多个地方调用了 semGive(),可能会导致逻辑错误。
  4. 忘记检查返回值:创建、获取、释放都要检查返回值,尤其是 semTake() 超时的情况。

我记得有一次,一个同事在ISR里调用了 semGive(),然后在任务里用 semTake(WAIT_FOREVER) 等待。看起来没问题,但他忘了考虑一种情况:如果ISR连续触发了两次,而任务只处理了一次,第二次的 semGive() 就白给了。因为二值信号量只能记住一次状态。这就是为什么有些场景需要用计数信号量。

4.7 小结

二值信号量,说白了就是一个同步开关。它最适合的场景是:一个任务等待另一个任务或中断的“信号”。记住三点:

  • 创建时选对初始状态
  • 获取时注意超时设置
  • 释放时别在ISR里阻塞

掌握了这些,二值信号量这块基本就稳了。下一节咱们聊聊计数信号量,它跟二值信号量很像,但能处理更复杂的场景。