上一篇学会了创建任务,但多个任务怎么传数据、怎么同步?
传感器任务采到的数据怎么给上报任务?中断来了怎么通知任务处理?
这篇讲透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));
    }
}

问题

  1. upload_task每10ms轮询一次data_ready,99%是空转,浪费CPU
  2. 如果传感器采集很快,data_ready还没被清零就又写了新数据——数据覆盖
  3. 多个任务读写同一个变量,可能读到"半新半旧"的数据——数据撕裂(所谓数据撕裂,就是一个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);
}

为什么不能用普通版本?

  1. 普通版本会调用调度器,ISR里不能调度
  2. 普通版本可能阻塞,ISR里不能阻塞
  3. 内部临界区实现不同,用错会导致未定义行为

铁律: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进阶:任务通知与内存管理》

如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。

作者:嵌入式阿蔡

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐