在上一篇文章中,我们学习了队列,实现了按键中断与任务之间的数据传递。但在很多场景下,我们只需要传递"某个事件发生了"的信号,而不需要附带具体数据。此时,更轻量、更直接的机制------信号量便派上了用场。本篇将详细讲解二值信号量与计数信号量,并通过实验展示任务同步与中断通知的实际用法。

一、信号量与队列的区别
队列是"有数据"的通信:发送方把数据拷贝到队列中,接收方再从队列中取出数据。而信号量更像一个"计数器",它只负责维护一个计数值,用来表示某个事件发生了多少次,或者某个资源还有多少个可用。信号量不存储数据,因此更轻量。
常见的信号量有两类:
- 二值信号量 :计数值只能为 0 或 1,常用于任务与中断之间的同步(例如"按键按下了,请立即处理")。
- 计数信号量 :计数值可大于 1,常用于资源管理(例如"还有 3 个缓冲区可用")。
二、信号量的关键 API
| 功能 | API 名称 | 说明 |
|---|---|---|
| 创建二值信号量 | xSemaphoreCreateBinary |
初始计数值为 0 |
| 创建计数信号量 | xSemaphoreCreateCounting |
需指定最大计数值和初始计数值 |
| 给出信号量(任务) | xSemaphoreGive |
计数值加 1 |
| 给出信号量(中断) | xSemaphoreGiveFromISR |
中断中使用的版本 |
| 获取信号量(任务) | xSemaphoreTake |
若计数值 > 0 则减 1 并返回 pdTRUE;否则阻塞等待 |
| 获取信号量(中断) | xSemaphoreTakeFromISR |
中断中使用的版本(极少使用) |
| 删除信号量 | vSemaphoreDelete |
释放信号量占用的内存 |
注意:互斥量(Mutex)也是通过信号量 API 创建的,但它的行为更复杂,我们将在下一篇文章中专门讨论。
三、硬件准备与优先级配置
本章实验仍使用 PA0 按键 和 PC13 LED 。硬件连接与前文完全相同,BSP 文件(bsp_led.c、bsp_key.c、bsp_exti.c)也沿用之前的实现。请确保在 main() 函数开头调用:
c
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
以设置 4 位抢占优先级,与我们的 FreeRTOS 配置严格匹配。
四、二值信号量:实现中断到任务的精确同步
4.1 实验目标
每按一次按键,PA0 触发外部中断,在中断服务函数中给出一个二值信号量;一个专门的任务阻塞等待该信号量,获取后翻转一次 LED。这样即可实现"按一下、闪一下"的精确同步,比用队列传递空消息更加简洁。
4.2 中断服务函数
在 stm32f10x_it.c 中实现 EXTI0_IRQHandler,使用 xSemaphoreGiveFromISR。
c
#include "stm32f10x_it.h"
#include "FreeRTOS.h"
#include "semphr.h"
/* 外部变量:在 main.c 中定义的信号量句柄 */
extern SemaphoreHandle_t xBinarySemaphore;
void EXTI0_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
{
/* 给出二值信号量 */
xSemaphoreGiveFromISR(xBinarySemaphore, &xHigherPriorityTaskWoken);
EXTI_ClearITPendingBit(EXTI_Line0);
}
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
4.3 任务代码与 main 函数
c
#include "stm32f10x.h"
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
#include "bsp_led.h"
#include "bsp_exti.h"
/* 二值信号量句柄 */
SemaphoreHandle_t xBinarySemaphore = NULL;
/* LED 控制任务:等待信号量 */
void vLedTask(void *pvParameters)
{
while (1)
{
/* 阻塞直到信号量可用(无限等待) */
if (xSemaphoreTake(xBinarySemaphore, portMAX_DELAY) == pdTRUE)
{
LED3_Toggle(); // 翻转一次 LED
}
}
}
int main(void)
{
/* 必须首先设置优先级分组 */
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
LED_InitAll(); // 初始化 LED (PC13)
EXTI0_Init(); // 初始化 PA0 按键中断
/* 创建二值信号量,初始计数值为 0 */
xBinarySemaphore = xSemaphoreCreateBinary();
if (xBinarySemaphore == NULL)
{
while (1); // 创建失败则停止
}
xTaskCreate(vLedTask, "Led", 128, NULL, 1, NULL);
vTaskStartScheduler();
while (1);
}
4.4 实验现象
上电后 LED 保持熄灭。每按一次 PA0 按键,LED 翻转一次状态(灭→亮或亮→灭)。不按键时,LED 任务因获取不到信号量而一直阻塞,完全不占用 CPU。这种"中断通知任务"的模式是实际项目中最常用的同步手段之一。
五、计数信号量:管理有限资源
5.1 使用场景
设想系统中有 3 个相同的通信缓冲区,任务使用一个缓冲区发送数据,用完后归还。计数信号量可以记录当前还有多少个缓冲区可用:初始计数值为 3,获取一个减少 1,归还一个增加 1。当计数值为 0 时,尝试获取的任务将被阻塞,直到有任务归还资源。
本章我们用按键事件来模拟资源的申请与释放,直观展示计数信号量的作用。
5.2 代码实现
实验中仍通过按键中断发送队列消息(记录按键事件),由一个任务接收队列消息后尝试获取计数信号量。若获取成功,LED 翻转一次(模拟使用资源),延时一小段时间后归还信号量;若获取失败,说明暂时无可用资源,直接忽略。
c
#include "stm32f10x.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
#include "bsp_led.h"
#include "bsp_exti.h"
/* 队列句柄 */
QueueHandle_t xKeyQueue = NULL;
/* 计数信号量句柄 */
SemaphoreHandle_t xCountingSemaphore = NULL;
/* 按键处理任务 */
void vKeyTask(void *pvParameters)
{
uint8_t key_val;
while (1)
{
/* 等待按键事件 */
if (xQueueReceive(xKeyQueue, &key_val, portMAX_DELAY) == pdTRUE)
{
/* 尝试获取一个资源(非阻塞) */
if (xSemaphoreTake(xCountingSemaphore, 0) == pdTRUE)
{
LED3_Toggle(); // 模拟使用资源
vTaskDelay(pdMS_TO_TICKS(300)); // 模拟占用 300ms
xSemaphoreGive(xCountingSemaphore); // 归还资源
}
else
{
/* 无可用资源,本实验中不做处理 */
}
}
}
}
/* 中断服务函数:按键按下时发送队列消息 */
void EXTI0_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
{
uint8_t event = 1;
xQueueSendFromISR(xKeyQueue, &event, &xHigherPriorityTaskWoken);
EXTI_ClearITPendingBit(EXTI_Line0);
}
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
int main(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
LED_InitAll();
EXTI0_Init();
/* 创建队列,用于接收按键事件 */
xKeyQueue = xQueueCreate(5, sizeof(uint8_t));
if (xKeyQueue == NULL) while (1);
/* 创建计数信号量:最大计数值 3,初始计数值 3 */
xCountingSemaphore = xSemaphoreCreateCounting(3, 3);
if (xCountingSemaphore == NULL) while (1);
xTaskCreate(vKeyTask, "KeyTask", 128, NULL, 1, NULL);
vTaskStartScheduler();
while (1);
}
5.3 实验现象与解释
- 上电后,LED 初始熄灭。
- 快速连续按下按键,LED 会翻转 最多 3 次(因为只有 3 个资源可用)。
- 继续按键,由于资源已被用尽(计数值为 0),
xSemaphoreTake(sem, 0)返回pdFALSE,LED 不再翻转。 - 等待约 300ms 后,第一个资源被释放,此时再按键又可以翻转一次。
这个实验清楚地展示了计数信号量对有限资源的保护作用。在实际项目中,可以将"翻转 LED"替换为操作实际外设(如 DMA 通道、串口发送缓冲等),实现安全的多任务资源共享。
六、信号量使用注意事项
-
二值信号量不能用于互斥
二值信号量可能导致优先级反转,且不提供优先级继承。如果是为保护一个共享变量,请使用下一篇文章将要介绍的互斥量(Mutex)。
-
中断中必须使用 FromISR 版本
在中断服务函数中给出信号量时,必须使用
xSemaphoreGiveFromISR,并使用portYIELD_FROM_ISR触发可能需要的上下文切换。 -
初始计数值的意义
二值信号量创建后计数值为 0,适合"等待事件发生"的模式。如果希望任务一开始就能运行一次,可在创建后立即调用
xSemaphoreGive。 -
计数信号量的最大值
创建计数信号量时指定的最大值是一个"软上限",用来检测编程错误。如果你在计数值达到最大值时再次调用
xSemaphoreGive,操作会返回pdFAIL(在调试中可借此发现资源归还次数过多的 bug)。 -
启用计数信号量的宏
要使用计数信号量,必须在
FreeRTOSConfig.h中将configUSE_COUNTING_SEMAPHORES设置为 1,这在第一篇的配置文件中已经完成。
七、总结
本篇介绍了 FreeRTOS 中最常用的两种信号量:
- 二值信号量:轻量的事件标志,是中断通知任务的首选方式;
- 计数信号量:管理有限资源的计数器,确保系统资源不会被超额分配。
信号量与队列互为补充,是构建复杂 RTOS 应用的基础构件。在下一篇文章中,我们将直面多任务共享资源的挑战,学习互斥量以及它如何通过优先级继承机制优雅地解决优先级反转问题。
下一篇:FreeRTOS 互斥量 ------ 保护共享资源与优先级继承。