FreeRTOS任务通信详解:队列、信号量、互斥量怎么选
上一篇学会了创建任务,但多个任务怎么传数据、怎么同步?
传感器任务采到的数据怎么给上报任务?中断来了怎么通知任务处理?
这篇讲透FreeRTOS的5种通信机制,附选择决策树和智慧农业项目的真实架构。
一、为什么需要任务通信?
1.1 裸机思维的问题
新手第一反应是用全局变量传数据:
/* 错误做法:全局变量 */
volatile uint8_t sensor_data;
volatile uint8_t data_ready;
void sensor_task(void *arg)
{
for (;;) {
sensor_data = read_sensor();
data_ready = 1;
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void upload_task(void *arg)
{
for (;;) {
if (data_ready) {
upload(sensor_data);
data_ready = 0;
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
问题:
upload_task每10ms轮询一次data_ready,99%是空转,浪费CPU- 如果传感器采集很快,
data_ready还没被清零就又写了新数据——数据覆盖 - 多个任务读写同一个变量,可能读到"半新半旧"的数据——数据撕裂(所谓数据撕裂,就是一个32位变量,高16位被改了、低16位还没来得及改,你就读走了,读到一个不存在的中间值)
1.2 RTOS的正确做法
FreeRTOS提供专门的通信机制,解决三个问题:
| 问题 | 解决方案 |
|---|---|
| 任务等待事件,不想空转 | 信号量(阻塞等待,不占CPU) |
| 任务间传数据,不丢失 | 队列(FIFO缓存) |
| 保护共享资源,防止冲突 | 互斥量(加锁解锁) |
| 等待多个事件组合 | 事件组(位运算) |
二、队列(Queue):任务间的数据管道
2.1 队列是什么
队列是一个先进先出(FIFO)的数据结构,任务A往里放数据,任务B从里取数据。
任务A(生产者) 任务B(消费者)
│ │
▼ ▼
[发] → [数据1][数据2][数据3] → [收]
←─── FIFO队列 ───→
(先放的数据先被取走)
特点:
- 有容量限制(创建时指定)
- 满了再发会阻塞或超时返回
- 空了再收会阻塞或超时返回
- 线程安全(线程安全=多任务同时读写不会出问题,队列内部已经做了保护),多任务读写不需要额外加锁
2.2 创建和使用队列
#include "FreeRTOS.h"
#include "queue.h"
/* 定义数据结构 */
typedef struct {
float temperature;
float humidity;
} sensor_data_t;
QueueHandle_t sensor_queue;
/* 创建队列:容量8,每个元素是sensor_data_t大小 */
void queue_init(void)
{
sensor_queue = xQueueCreate(8, sizeof(sensor_data_t));
}
/* 生产者任务:采集数据,发送到队列 */
void sensor_task(void *arg)
{
sensor_data_t data;
for (;;) {
data.temperature = read_temp();
data.humidity = read_humi();
/* 发送到队列,超时10ms */
xQueueSend(sensor_queue, &data, pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(100));
}
}
/* 消费者任务:从队列取数据,上报 */
void upload_task(void *arg)
{
sensor_data_t data;
for (;;) {
/* 阻塞等待队列有数据,一直等 */
if (xQueueReceive(sensor_queue, &data, portMAX_DELAY) == pdTRUE) {
upload_to_cloud(&data);
}
/* 不需要vTaskDelay!没数据时xQueueReceive会阻塞,不占CPU */
}
}
2.3 队列的关键参数
xQueueSend(queue, &data, timeout);
/* │ │ │ */
/* │ │ └─ 超时:pdMS_TO_TICKS(10)=等10ms,portMAX_DELAY=死等 */
/* │ └─ 数据指针(注意传地址!) */
/* └─ 队列句柄 */
坑点:
xQueueSend传的是数据指针,函数内部会拷贝一份数据到队列。所以传局部变量地址是安全的(拷贝完成后局部变量就可以释放了)。
2.4 队列满了怎么办
队列容量8,已经放了8个数据:
[1][2][3][4][5][6][7][8]
生产者再发第9个 → 取决于超时设置:
portMAX_DELAY → 阻塞等待,直到消费者取走一个
pdMS_TO_TICKS(10) → 等10ms,超时返回失败
0 → 立即返回失败(不阻塞)
策略:生产速度 > 消费速度时,队列会满。要么加大队列容量,要么丢弃旧数据(用
xQueueOverwrite)。
三、信号量(Semaphore):事件通知
3.1 信号量是什么
信号量像是一个"计数器",用于通知事件发生。
二值信号量:只有0和1,像一个"标志位"
任务A give → 计数变1
任务B take → 计数变0,同时任务B被唤醒
计数信号量:可以大于1,像"停车场车位"
多个生产者give → 计数累加
消费者take → 计数减1
3.2 二值信号量:中断通知任务
最经典用法——ISR通知任务处理事件:
#include "semphr.h"
SemaphoreHandle_t uart_sem;
/* 创建二值信号量 */
void sem_init(void)
{
uart_sem = xSemaphoreCreateBinary();
}
/* ISR:UART收到数据,释放信号量 */
void USART1_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
/* 释放信号量,通知任务 */
xSemaphoreGiveFromISR(uart_sem, &xHigherPriorityTaskWoken);
}
/* 必要时触发任务切换 */
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
/* 任务:阻塞等待信号量 */
void uart_task(void *arg)
{
for (;;) {
/* 阻塞等待信号量,不占CPU */
xSemaphoreTake(uart_sem, portMAX_DELAY);
/* 收到信号量,处理UART数据 */
process_uart_data();
}
}
优势:任务平时阻塞睡眠,不占CPU。中断来了一给信号量,任务立即唤醒处理。比轮询高效100倍。
3.3 计数信号量:计事件次数
计数信号量可以累计事件次数,适合"多个事件可能同时发生"的场景:
SemaphoreHandle_t event_sem;
/* 创建计数信号量:最大计数10,初始0 */
event_sem = xSemaphoreCreateCounting(10, 0);
/* 多个中断源都能give */
void ISR_button(void) { xSemaphoreGiveFromISR(event_sem, &woken); }
void ISR_timer(void) { xSemaphoreGiveFromISR(event_sem, &woken); }
/* 任务每次take处理一个事件 */
void event_task(void *arg)
{
for (;;) {
xSemaphoreTake(event_sem, portMAX_DELAY);
handle_event(); /* 处理一个事件 */
/* 如果give了3次,这里会连续执行3次 */
}
}
二值vs计数:二值只能记"有没有",计数能记"有几次"。如果事件可能堆积,用计数信号量。
四、互斥量(Mutex):保护共享资源
4.1 共享资源冲突
两个任务同时操作同一个硬件(如UART),会数据错乱:
/* 错误:两个任务同时用UART打印 */
void task_a(void *arg)
{
for (;;) {
printf("任务A:温度=%d\r\n", temp); /* 可能打印到一半被切走 */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void task_b(void *arg)
{
for (;;) {
printf("任务B:湿度=%d\r\n", humi); /* 插进来打印,输出交错 */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
/* 实际输出可能变成:
任务任务BA::温度湿== 30
55
*/
4.2 互斥量解决
SemaphoreHandle_t uart_mutex;
/* 创建互斥量 */
uart_mutex = xSemaphoreCreateMutex();
void task_a(void *arg)
{
for (;;) {
xSemaphoreTake(uart_mutex, portMAX_DELAY); /* 加锁 */
printf("任务A:温度=%d\r\n", temp);
xSemaphoreGive(uart_mutex); /* 解锁 */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void task_b(void *arg)
{
for (;;) {
xSemaphoreTake(uart_mutex, portMAX_DELAY); /* 加锁(等A解锁) */
printf("任务B:湿度=%d\r\n", humi);
xSemaphoreGive(uart_mutex); /* 解锁 */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
4.3 互斥量 vs 二值信号量
| 对比 | 二值信号量 | 互斥量 |
|---|---|---|
| 优先级继承 | ❌ 无 | ✅ 有 |
| 谁都能give | ✅ 是 | ❌ 只有take者能give |
| 适用 | 中断通知任务 | 保护共享资源 |
优先级继承是互斥量的核心特性,下面详细讲。
五、优先级翻转与优先级继承(面试必考)
5.1 什么是优先级翻转
高优先级任务被低优先级任务间接阻塞的现象:
时间轴 →
低优先级任务C:███持有Mutex███ ███继续███
中优先级任务B: ████运行████████████
高优先级任务A: ███等Mutex(被C阻塞)███
1. C先运行,拿了Mutex
2. A就绪,抢占C,但A也要Mutex → A阻塞,等C释放
3. B就绪,优先级比C高,抢占C
4. B跑完,C才能继续,C释放Mutex,A才恢复
结果:A(最高)被B(中等)间接阻塞了!违反优先级原则!
5.2 优先级继承解决
互斥量支持优先级继承:当高优先级任务等Mutex时,持有Mutex的低优先级任务会临时继承高优先级,尽快执行完释放资源:
低优先级任务C:███持Mutex(继承A的优先级)███
中优先级任务B: ███运行███ ← 被C压制
高优先级任务A: ███等Mutex███恢复运行███
1. C持有Mutex,A等Mutex
2. C继承A的高优先级 → B无法抢占C
3. C快速执行完,释放Mutex,恢复原优先级
4. A获得Mutex,恢复运行
结论:保护共享资源必须用Mutex,不能用二值信号量。Mutex有优先级继承,能避免翻转。
六、事件组(Event Group):等多个事件
6.1 场景
需要"传感器就绪 且 WiFi连接成功"才上报数据。用信号量要等两个,事件组可以一次等多个:
#include "event_groups.h"
#define BIT_SENSOR_READY (1 << 0)
#define BIT_WIFI_READY (1 << 1)
EventGroupHandle_t system_events;
/* 创建事件组 */
system_events = xEventGroupCreate();
/* 传感器任务:采集完置位 */
void sensor_task(void *arg)
{
for (;;) {
read_sensor();
xEventGroupSetBits(system_events, BIT_SENSOR_READY);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
/* WiFi任务:连接成功置位 */
void wifi_task(void *arg)
{
for (;;) {
if (wifi_connect() == OK) {
xEventGroupSetBits(system_events, BIT_WIFI_READY);
break;
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
/* 上报任务:等两个事件都置位 */
void upload_task(void *arg)
{
for (;;) {
/* 等待两个bit都为1,参数1=要等全部 */
xEventGroupWaitBits(system_events,
BIT_SENSOR_READY | BIT_WIFI_READY,
pdTRUE, /* 退出后清除bit */
pdTRUE, /* pdTRUE=等全部(AND), pdFALSE=等任一(OR) */
portMAX_DELAY);
upload_to_cloud();
}
}
优势:一个API等多个事件,不用创建多个信号量。适合"多条件满足才执行"的场景。
七、5种机制对比与选择
| 机制 | 本质 | 典型场景 |
|---|---|---|
| 队列 | 数据管道(FIFO) | 任务间传数据 |
| 二值信号量 | 0/1标志 | ISR通知任务 |
| 计数信号量 | 计数器 | 计事件次数、资源池 |
| 互斥量 | 带优先级继承的锁 | 保护共享资源 |
| 事件组 | 24位标志位 | 等多个事件组合 |
| 任务通知 | 轻量级通知 | 一对一通知(最省内存) |
选择决策树
要传数据?
├─ 是 → 数据量大吗?
│ ├─ 单个变量/结构体 → 队列
│ └─ 大块数据 → 队列传指针(注意内存管理)
│
└─ 否 → 中断通知任务?
├─ 是 → 二值信号量 或 任务通知
│
└─ 否 → 保护共享资源?
├─ 是 → 互斥量
│
└─ 否 → 等多个事件?
├─ 是 → 事件组
└─ 否 → 计数信号量
任务通知(Task Notification):FreeRTOS V8.2+新增,比信号量更轻量(不需要单独分配对象),适合一对一通知。ESP8266中断→解析任务这种场景,用任务通知比信号量更好。
八、智慧农业项目的通信架构
这是我实际项目里用到的,展示完整的多任务通信设计:
┌─────────────────┐
│ 按键中断ISR │
└────────┬────────┘
│ Give
▼
┌──────────┐ Send ┌──────────────┐ Receive ┌──────────┐
│ 传感器 │────────►│ 传感器队列 │─────────►│ 上报任务 │
│ 采集任务 │ │ (容量8) │ │ (MQTT) │
└──────────┘ └──────────────┘ └──────────┘
│
│ Mutex
▼
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ 按键任务 │◄────────│ 按键信号量 │ │ UART │
│ │ Take │ (二值) │ │ (共享) │
└──────────┘ └──────────────┘ └──────────┘
▲
│ Mutex
┌──────────┐ ┌──────────────┐ │
│ MQTT任务 │◄────────│ 事件组 │ │
│ │ │ (网络就绪位) │─────────────┘
└──────────┘ └──────────────┘
| 通信机制 | 用途 |
|---|---|
| 传感器队列 | 采集任务→上报任务传数据 |
| 按键信号量 | 按键ISR→按键任务通知 |
| UART互斥量 | 上报任务和调试任务共享UART |
| 事件组 | 等WiFi就绪+MQTT连接成功才上报 |
九、ISR里的FromISR规则(铁律)
中断里调用FreeRTOS API必须用FromISR版本,且要处理任务切换:
void ISR(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
/* 必须用FromISR版本 */
xQueueSendFromISR(queue, &data, &xHigherPriorityTaskWoken);
xSemaphoreGiveFromISR(sem, &xHigherPriorityTaskWoken);
/* 检查是否唤醒了更高优先级任务,如果是则触发切换 */
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
为什么不能用普通版本?
- 普通版本会调用调度器,ISR里不能调度
- 普通版本可能阻塞,ISR里不能阻塞
- 内部临界区实现不同,用错会导致未定义行为
铁律:ISR里只用带FromISR的API,普通API在ISR里一律禁用。
十、常见踩坑
坑1:队列传数据成了传指针
/* 错误:传指针,数据可能被覆盖 */
sensor_data_t *ptr = &data;
xQueueSend(queue, &ptr, 0); /* 传的是指针的指针 */
/* 正确:传数据本身 */
xQueueSend(queue, &data, 0); /* 队列内部拷贝数据 */
队列是值拷贝,传
&data会把data的内容拷进队列。如果传指针,多个任务访问同一块内存,还是会冲突。
坑2:互斥量在中断里使用
/* 错误:Mutex不能在ISR里用! */
void ISR(void)
{
xSemaphoreTake(mutex, 0); /* 崩溃 */
}
/* 正确:中断里只能用二值信号量 */
xSemaphoreGiveFromISR(sem, &woken);
Mutex有优先级继承机制,依赖任务调度,不能在ISR里用。ISR里只能用FromISR版本的信号量。
坑3:信号量创建后忘记give,任务永远阻塞
sem = xSemaphoreCreateBinary();
/* 二值信号量创建后初始值是0!必须先give才能take */
void task(void *arg)
{
xSemaphoreTake(sem, portMAX_DELAY); /* 永远阻塞! */
}
解决:创建后手动give一次,或用xSemaphoreCreateMutex()(初始可用)。
坑4:死锁
/* 任务A */
Take(Mutex1);
Take(Mutex2); /* 等任务B释放Mutex2 */
/* 任务B */
Take(Mutex2);
Take(Mutex1); /* 等任务A释放Mutex1 */
/* A等B,B等A,死锁! */
解决:多个互斥量时,所有任务按相同顺序获取(如都先锁Mutex1再锁Mutex2)。
坑5:队列内存不足创建失败
queue = xQueueCreate(100, sizeof(big_struct_t)); /* 可能返回NULL */
if (queue == NULL) {
/* 创建失败!堆内存不够 */
}
解决:检查返回值;调大configTOTAL_HEAP_SIZE;或减少队列容量。
十一、总结
| 要点 | 内容 |
|---|---|
| 队列 | 任务间传数据,FIFO,值拷贝 |
| 二值信号量 | ISR→任务通知,不占CPU |
| 计数信号量 | 累计事件次数 |
| 互斥量 | 保护共享资源,带优先级继承 |
| 事件组 | 等多个事件组合 |
| FromISR | 中断里必须用FromISR版本 |
| 优先级翻转 | 用Mutex的优先级继承解决 |
一句话总结:任务通信的核心是"传数据用队列,等事件用信号量,护资源用互斥量",记住这句口诀,90%的场景都能搞定。
下一篇预告:《FreeRTOS进阶:任务通知与内存管理》
如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。作者:嵌入式阿蔡
更多推荐
所有评论(0)