3. 计数信号量:创建与配置、资源计数管理、典型应用场景(资源池管理)

好,咱们接着聊信号量。前面讲了二值信号量,说白了就是个开关——要么开、要么关。但实际项目中,资源往往不止一个。比如一个串口缓冲池有10个缓冲区,或者一个内存池有20个块。这时候二值信号量就不够用了。

计数信号量就是干这个的。它维护一个计数器,记录当前可用资源的数量。我刚开始用VxWorks时,总觉得计数信号量就是二值信号量的升级版,后来踩了坑才发现——没那么简单。

3.1 计数信号量的创建与配置

创建计数信号量,核心函数就一个:semCCreate()。它的原型长这样:

SEM_ID semCCreate(
    int options,      // 选项:优先级队列或FIFO队列
    int initialCount  // 初始计数值
);

嗯,这里要注意两个参数。

第一个参数 options

  • SEM_Q_PRIORITY:等待任务按优先级排队。高优先级的先拿到信号量。
  • SEM_Q_FIFO:先来后到,谁先等谁先拿。

我个人习惯用 SEM_Q_PRIORITY。为什么?因为实时系统里,高优先级任务往往更紧急。你想想看,一个中断处理任务在等资源,结果被一个后台打印任务插队了,这合理吗?

第二个参数 initialCount

这个值代表初始有多少资源可用。比如你有一个10个槽位的缓冲池,初始全部空闲,那就传10。如果一开始有3个被占用了,那就传7。

小提示: 我见过有人把 initialCount 设成0,然后靠任务释放来增加计数。这种做法不是不行,但容易让代码逻辑变得混乱。除非你有特殊需求,否则建议初始值就设成实际可用资源数。

3.2 资源计数管理

计数信号量的核心操作就两个:semTake()semGive()

  • semTake():计数器减1。如果计数器为0,任务阻塞等待。
  • semGive():计数器加1。如果有任务在等,唤醒一个。

说白了,这就是一个「借」和「还」的过程。你借一个资源,计数器减1;你还回来,计数器加1。简单吧?

但坑往往藏在细节里。我曾经在一个项目中,用计数信号量管理DMA描述符池。每个描述符对应一个DMA传输任务。代码大概长这样:

/* 创建计数信号量,初始10个描述符可用 */
SEM_ID semDmaPool = semCCreate(SEM_Q_PRIORITY, 10);

/* 任务A:申请描述符 */
void taskA(void) {
    semTake(semDmaPool, WAIT_FOREVER);
    /* 拿到描述符,开始DMA传输 */
    ...
    /* 传输完成,释放描述符 */
    semGive(semDmaPool);
}

/* 任务B:同样申请描述符 */
void taskB(void) {
    semTake(semDmaPool, WAIT_FOREVER);
    /* 拿到描述符,开始DMA传输 */
    ...
    semGive(semDmaPool);
}

看起来没问题对吧?但实际跑起来,系统偶尔会死锁。排查了很久才发现——有个任务在拿到描述符后,因为DMA传输超时,直接返回了,忘了调用 semGive()。结果计数器少了一个,资源池慢慢枯竭。

避坑指南: 我曾经因为一个异常分支忘记释放信号量,导致系统运行3小时后资源耗尽。从那以后,我养成了一个习惯——所有 semTake()semGive() 必须成对出现,而且尽量放在同一个函数内。如果跨函数传递,一定要在注释里标明。

3.3 典型应用场景:资源池管理

计数信号量最经典的应用,就是管理资源池。比如网络协议栈里的mbuf池、文件系统里的缓存块池、驱动里的DMA描述符池。

我给大家画个图(嗯,用文字描述):

资源池 = 一组相同类型的资源 + 一个计数信号量

每个资源有一个状态标志:空闲或占用。计数信号量的值,就是空闲资源的数量。

来看一个完整的例子:管理一个10个缓冲区的串口发送池。

/* 缓冲区结构 */
typedef struct {
    BOOL    inUse;
    UINT8   data[256];
} BUF_NODE;

BUF_NODE bufPool[10];
SEM_ID   semBufPool;

/* 初始化 */
void bufPoolInit(void) {
    int i;
    for (i = 0; i < 10; i++) {
        bufPool[i].inUse = FALSE;
    }
    semBufPool = semCCreate(SEM_Q_PRIORITY, 10);
}

/* 申请缓冲区 */
BUF_NODE* bufAlloc(void) {
    BUF_NODE* pBuf = NULL;
    int i;

    /* 等待可用资源 */
    semTake(semBufPool, WAIT_FOREVER);

    /* 查找空闲缓冲区 */
    for (i = 0; i < 10; i++) {
        if (!bufPool[i].inUse) {
            bufPool[i].inUse = TRUE;
            pBuf = &bufPool[i];
            break;
        }
    }

    return pBuf;
}

/* 释放缓冲区 */
void bufFree(BUF_NODE* pBuf) {
    pBuf->inUse = FALSE;
    semGive(semBufPool);
}

这个模式很常见。你想想看,如果没有计数信号量,多个任务同时申请缓冲区,就会出现竞争——两个任务可能拿到同一个缓冲区。有了计数信号量,semTake() 保证了「只有空闲资源时才放行」。

核心要点: 计数信号量的本质是「资源计数 + 互斥访问」。计数器保证不会超额分配,而信号量内部的锁机制保证了对资源池的互斥操作。

3.4 几个实用技巧

最后分享几个我实际项目中用到的技巧:

  1. 超时机制semTake() 不要总用 WAIT_FOREVER。我习惯给个超时时间,比如100ms。如果超时了,说明资源紧张,可以打印日志或者尝试降级处理。
  2. 资源泄漏检测:在调试阶段,可以用 semShow() 查看当前信号量的计数值。如果发现计数值一直在减少,说明有任务拿了没还。
  3. 优先级反转:当使用 SEM_Q_PRIORITY 时,要注意优先级反转问题。低优先级任务拿着资源,高优先级任务在等,中间优先级的任务插队运行——高优先级任务就被「反转」了。解决办法是使用优先级继承协议,但VxWorks的计数信号量默认不支持。需要自己实现或者用其他机制。

嗯,计数信号量就讲这么多。说白了,它就是管理「多个同类资源」的利器。你只要记住:创建时设好初始值,使用时成对Take/ Give,异常分支别忘了释放。做到这三点,基本不会出大问题。

下一节我们讲互斥信号量,那个更复杂一些,涉及到优先级继承和死锁预防。到时候我会分享一个我当年在航天项目中踩过的坑——嗯,那个故事挺有意思的。