第3章:计数型信号量——资源管理的利器

计数型信号量,说白了就是一个可以计数的信号量。它不像二值信号量那样只有0和1两个状态,而是可以记录一个数值。这个数值代表当前可用的资源数量。

我刚开始接触FreeRTOS时,总觉得计数型信号量有点多余。二值信号量不是已经够用了吗?后来在一个项目中,我需要管理5个相同的串口缓冲区,才真正体会到计数型信号量的价值。

3.1 工作原理

计数型信号量的核心机制其实很简单。它维护一个计数器,每次获取信号量时计数器减1,每次释放信号量时计数器加1。当计数器为0时,任务获取信号量就会阻塞。

嗯,这里要注意一个关键点:计数型信号量有一个最大值限制。这个最大值在创建信号量时设定,计数器永远不会超过这个值。

核心机制:

  • 创建时设定初始计数值和最大值
  • xSemaphoreTake():计数值减1,如果为0则阻塞
  • xSemaphoreGive():计数值加1,如果达到最大值则无效

来看一个典型的创建和使用示例:

// 创建一个计数型信号量,初始有3个资源,最多5个
SemaphoreHandle_t xSemaphore = xSemaphoreCreateCounting(3, 5);

// 任务A:获取一个资源
void TaskA(void *pvParameters) {
    while(1) {
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 成功获取资源,开始使用
            processResource();
            // 使用完毕后释放
            xSemaphoreGive(xSemaphore);
        }
    }
}

我个人习惯把计数型信号量想象成一个停车场。初始有3个空车位,最多能停5辆车。每来一辆车,空车位减1;每走一辆车,空车位加1。当空车位为0时,后来的车就得排队等待。

3.2 与二值信号量的区别

很多初学者会混淆这两种信号量。其实它们的区别非常明显:

特性 二值信号量 计数型信号量
取值范围 0 或 1 0 到 N(N为最大值)
典型用途 任务同步、互斥 资源管理、事件计数
创建函数 xSemaphoreCreateBinary() xSemaphoreCreateCounting()
释放行为 只能释放一次 可多次释放,直到最大值

你想想看,二值信号量就像一把钥匙,只能给一个人用。而计数型信号量就像一盒钥匙,可以分给多个人。这就是本质区别。

我的经验:如果你只需要控制一个资源的独占访问,用二值信号量就够了。但如果你要管理多个相同的资源,计数型信号量才是正确选择。我在一个项目中用二值信号量管理3个UART,结果代码写得乱七八糟,后来换成计数型信号量,代码瞬间清爽了。

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

计数型信号量最经典的应用就是资源管理。我把它分为三类场景:

3.3.1 有限资源池管理

假设你有5个硬件定时器,多个任务需要定时服务。用计数型信号量来管理再合适不过:

// 初始化:5个定时器全部可用
SemaphoreHandle_t xTimerSem = xSemaphoreCreateCounting(5, 5);

void RequestTimer(void) {
    // 获取一个定时器
    if(xSemaphoreTake(xTimerSem, pdMS_TO_TICKS(100)) == pdTRUE) {
        // 成功获取,分配定时器编号
        uint8_t timerId = 5 - uxSemaphoreGetCount(xTimerSem);
        configureTimer(timerId);
    } else {
        // 超时,没有可用定时器
        printf("所有定时器都在使用中\n");
    }
}

void ReleaseTimer(uint8_t timerId) {
    // 释放定时器
    xSemaphoreGive(xTimerSem);
}

我曾经在一个工业控制项目中用过这个模式。当时有8个PWM通道,多个控制算法需要动态分配。用计数型信号量管理后,资源分配变得非常清晰,再也没有出现资源冲突的问题。

3.3.2 生产者-消费者模式

计数型信号量也常用于生产者-消费者模式。生产者生产资源,消费者消费资源:

// 初始有0个资源,最多10个
SemaphoreHandle_t xItems = xSemaphoreCreateCounting(0, 10);

// 生产者任务
void Producer(void *pvParameters) {
    while(1) {
        // 生产一个资源
        produceItem();
        // 通知消费者
        xSemaphoreGive(xItems);
    }
}

// 消费者任务
void Consumer(void *pvParameters) {
    while(1) {
        // 等待资源
        if(xSemaphoreTake(xItems, portMAX_DELAY) == pdTRUE) {
            // 消费资源
            consumeItem();
        }
    }
}

避坑指南:我曾经在生产者-消费者模式中犯过一个错误。生产者在中断中释放信号量,但忘记使用xSemaphoreGiveFromISR()。结果系统时不时崩溃,排查了两天才找到原因。记住:在中断中一定要用FromISR版本的API!

3.3.3 事件计数

计数型信号量还可以用来统计事件发生的次数。比如统计按键按下次数:

// 按键中断处理
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // 记录按键事件
    xSemaphoreGiveFromISR(xKeyPressSem, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 按键处理任务
void KeyProcessTask(void *pvParameters) {
    while(1) {
        // 等待按键事件
        xSemaphoreTake(xKeyPressSem, portMAX_DELAY);
        // 处理按键
        handleKeyPress();
    }
}

这个用法特别适合处理快速连续的事件。如果按键按得很快,信号量会累积计数,不会丢失任何事件。我做过一个手持设备,用户快速连按按键时,系统能准确响应每一次按键,就是靠这个机制。

3.4 使用注意事项

最后分享几个我踩过的坑:

  • 不要滥用计数型信号量:如果资源数量很少(比如2-3个),用二值信号量加标志位可能更简单
  • 注意最大值设置:最大值设置太小会导致资源浪费,太大则浪费内存。根据实际需求合理设置
  • 中断中释放要小心:一定要用FromISR版本,并且检查是否需要任务切换
  • 获取超时处理:不要用portMAX_DELAY死等,设置一个合理的超时时间,避免系统卡死

我的建议:刚开始用计数型信号量时,可以在调试时打印当前计数值。这样能直观地看到资源的使用情况,对理解工作原理很有帮助。我至今还保留着这个习惯,排查问题时特别有用。

计数型信号量是FreeRTOS中非常实用的工具。掌握了它,你就能轻松应对各种资源管理场景。下一章我们来聊聊互斥信号量,那又是另一个有趣的话题。