FreeRTOS学习(10)——消息队列

文章目录

任务间为什么需要通信?

在 FreeRTOS 多任务系统中,本质上就是很多个并行干活的工人

比如:

  1. A 任务采集传感器数据
  2. B 任务负责 OLED 刷屏
  3. C 任务负责串口打印
  4. D 任务负责报警/蜂鸣器
  5. ...

但工人多了就一定会遇到一个现实问题:

大多数时候你干你的,我干我的,但总有需要协同工作的时候。

比如:

  1. A 任务采集到温度数据以后,B 才能刷新 OLED
  2. A 任务采集到异常值以后,D 才需要鸣叫报警
  3. C 任务打印数据之前,必须等 A 任务把数据准备好
  4. ...

它们之间必然要传数据、发通知、同步工作节奏、互相"喊一声"。

在基于 FreeRTOS 实时操作系统的多任务环境中,各任务运行在相互独立的执行上下文中。

为了完成协同工作,例如:

  1. 任务A采集数据
  2. 任务B处理数据
  3. 任务C显示结果

就必须通过某种机制完成:

  1. 数据传递
  2. 执行同步
  3. 资源协调

这些机制统称为:任务间通信(Inter-Task Communication, ITC)

所以,我们可以看到,任务间具有通信的需求,就像"多个工人"完成工作需要沟通协作一样。


除此之外,任务与中断之间也有通信的需求,而且多是中断向中断传输数据、或者发送通知。

虽然标题叫做任务间通信,但任务与中断间的通信,也一并也在这里讲解。

如何实现任务间通信

现在我们已经知道,任务间确实存在通信的需求。

任务之间不仅要各自运行,还必须互相交换数据、协调工作节奏。

那问题来了:

既然任务之间需要通信,那么如何实现任务间的通信呢?

一个最简单,最容易想到的方式是:

使用全局变量。

比如定义一个全局变量:

复制代码
int16_t Temperature;

A 任务负责采集传感器数据:

复制代码
Temperature = ReadSensor();

B 任务负责在 OLED 上显示数据:

复制代码
OLED_ShowNum(Temperature);

乍一看,好像也没什么毛病。

但实际上问题还是挺多的,在多个任务并行的系统中:

  1. 如果 A 任务数据写到一半,B 任务刚好被调度运行去读,怎么办?
  2. 读写过程中被抢占怎么办?
  3. 多个任务同时访问这个变量怎么办?
  4. B 任务如果想"只在有新数据时再刷新OLED",怎么办?
  5. ...

你会发现:

事情开始变得复杂了。

为了弥补这些问题,往往就需要:

  1. 增加各种全局标志位
  2. 增加状态变量
  3. 手动加锁
  4. 手动判断数据是否更新
  5. ...

慢慢地,你的程序里就会出现一堆xxxFlag:

复制代码
IsDataReady
DataUpdatedFlag
BusyFlag
UpdateFinishedFlag

你的大量时间,不再用来做功能开发,而是花在维护这些全局变量和状态逻辑上。

这显然是非常不可取的。

因为:

搞的太复杂,既容易出错,又难以维护,还严重影响系统的可扩展性。

那怎么办呢?

FreeRTOS 官方早就替我们思考过这个问题。

在 FreeRTOS 中,已经为我们提供了一套标准、安全、可控的任务间通信机制。

一共有哪些手段实现任务间通信呢?

FreeRTOS 为我们提供了以下常见的任务间通信手段:

  1. 消息队列(Queue)
  2. 二值信号量(Binary Semaphore)
  3. 计数信号量(Counting Semaphore)
  4. 事件标志组(Event Group)
  5. 任务通知(Task Notification)
  6. 互斥信号量(Mutex)
  7. 队列集(Queue Set)

这些通信机制看起来很多,但并不是每一种都同等重要、同等常用。

从工程实践角度来说,可以抓住三条主线:

  1. 如果是两个任务/中断之间收发数据,优先考虑使用消息队列。
    1. 它是最基础、最常用、最核心的任务间通信手段。
    2. 绝大多数"任务间传数据"的场景,都可以直接使用消息队列去解决。
  2. 如果是一个任务/中断给另一个任务发"通知"而不是传数据,优先考虑任务通知。
    1. 它是 FreeRTOS 官方最推荐的,轻量级、高性能的任务间实现通知的通信手段。
    2. 任务通知实际上类似二值信号量,但它性能更好,某种程度上可以把它视为二值信号量的升级版。
  3. 如果需要多个任务/中断互斥访问共享资源,实现类似临界区的对共享资源保护,应使用互斥信号量。
    1. 互斥信号量是基于任务间通信的方式,来实现临界区,本质是一个互斥锁。
    2. 它带有优先级继承机制,专门用于解决优先级反转问题,是共享资源保护的标准做法。

当然,总的来说:

消息队列是基础,是核心。

不管怎么样,消息队列都是这些手段中的基础。

其余通信手段,其实大部分也都涉及消息队列的使用。

所以接下来,接下来,我们就从最核心的通信手段开始------

看看消息队列是如何实现任务间通信的。

FreeRTOS的消息队列

在前面的课程内容中,我们已经学习过诸如:

  1. FreeRTOS任务
  2. FreeRTOS阻塞列表
  3. FreeRTOS临界区
  4. ...

等概念。

有了这些概念理论做支撑,我们直接看消息队列的源码,直接搞懂FreeRTOS消息队列的机制原理,也并不困难。

所以,我们学习FreeRTOS的消息队列,第一件事情就是直接看源码。

这既是对前面学习知识点的巩固和实战,也是你了解FreeRTOS消息队列最本质的第一步。

消息队列的机制原理

首先回答一个核心问题:

FreeRTOS 的消息队列到底是什么结构?

在源码queue.c文件中,所有消息队列相关实现都在这里。

当然,我们想要使用这个消息队列,也需要包含头文件queue.h

消息队列的核心数据结构,如下图所示:

消息队列本质上是一个结构体类型,其中最核心的成员如下(简化版):

c 复制代码
typedef struct QueueDefinition{
    // 第一部分四个成员
    int8_t *pcHead;
    int8_t *pcTail;
    int8_t *pcWriteTo;
    int8_t *pcReadFrom;

    // 第二部分两个成员
    List_t xTasksWaitingToSend;
    List_t xTasksWaitingToReceive;

    // 第三部分三个成员
    volatile UBaseType_t uxMessagesWaiting;
    UBaseType_t uxLength;
    UBaseType_t uxItemSize;

} xQUEUE;

这些最核心的成员,我们分为三个部分来进行讲解。

我们首先讲解一下第三部分,因为它是消息队列的核心。

Tips:

在上面贴出的源码注释中,可以看到频繁出现**"Items"**单词,它是FreeRTOS消息队列的基本组成单元。

在下面的文档中,统一将FreeRTOS消息队列的基本组成单元,翻译成**"队列元素"**。

使用元素这个翻译,更符合数据结构中的术语使用习惯。

数组循环队列

首先我们先来看一下下面三个成员:

c 复制代码
// 第三部分三个成员
volatile UBaseType_t uxMessagesWaiting;
UBaseType_t uxLength;
UBaseType_t uxItemSize;

一个一个来介绍一下。

先看第一个:

c 复制代码
volatile UBaseType_t uxMessagesWaiting;

这个变量表示:

当前消息队列里已经存放了多少个元素。

注意是"队列元素的个数",不是字节的个数。

这个成员的作用非常简单直接:就是用于判断队列是否空或满。

可以理解为:这是队列的"元素库存计数器"。

再看第二个成员:

c 复制代码
UBaseType_t uxLength;

这个成员表示:

此消息队列中,最多可以存放多少个元素。

强调一点:

它表示的是"元素数量",不是字节数。

如果uxLength = 5,说明这个队列最多放 5 个元素。

那么一个元素多大呢?

这就要看第三个成员:

c 复制代码
UBaseType_t uxItemSize;

这个成员表示:

每个元素占多少字节。

当你创建队列时:会调用函数xQueueCreate

实际上就是给此结构体的这两个成员传参,比如调用方式如下:

c 复制代码
// 参数1:uxLength, 参数2:uxItemSize 
xQueueCreate( 5, 4 );

那么:

uxLength = 5

uxItemSize = 4

最终,你所创建的消息队列实际上是一个总长度20个字节,存储了5个大小为4个字节元素的数组,而且它是一个数组循环队列。

上面描述的内容,可以参考以下源码:

通常这些源码,我们能够得到以下结论:

  1. FreeRTOS消息队列的控制结构体,和对应的数据区域(数组循环队列区域),是直接一次性分配的连续空间。
  2. 数组循环队列,消息队列的数据区就紧跟在控制结构体后面。
  3. 之所以这样设计,是因为都使用连续内存性能更好,且分配和释放都只需要一次,更快捷。

四个操作指针

以下四个成员:

c 复制代码
int8_t *pcHead;
int8_t *pcTail;
int8_t *pcWriteTo;
int8_t *pcReadFrom;

本质上是:

用来管理一块连续数组内存的"边界指针 + 读写位置指针"。

我们已经知道,消息队列的数据区域本质上是一个数组。

那么这四个指针的作用,就是对这块数组内存区域进行"循环队列式"的管理。

其中:

pcHead指针:指向数组的起始地址,它是整个循环队列的"起点"。

pcTail指针,指向数组末尾的越界地址。

注意:

它并不是指向"最后一个元素",而是指向"最后一个元素的下一个元素"。

也就是说:

pcTail 本质上是循环队列数组的"终止边界标记"。

以上两个指针是固定不动的。

它们只是用来标识这块内存区域的边界,在队列运行过程中不会发生移动。

关于这两点,可以通过以下源码获悉:

在队列进行读写操作时,真正会移动的是下面两个指针:

pcWriteTo 指针:指向"下一次写入元素"的位置。

FreeRTOS 的设计是:

  1. 写队列操作时,先在 pcWriteTo 指向的位置写一个元素
  2. 然后再移动pcWriteTo 指针
  3. 如果该指针移动到pcTail位置,说明越界了,那么pcWriteTo 指针就会回绕到pcHead位置。
  4. pcWriteTo 指针一开始指向pcHead,表示队列为空,从数组起点开始写入数据。

这就是循环队列的由来。

pcReadFrom 指针:指向"上一次读取元素的位置"。

注意这里非常关键:

它并不是指向"当前可读元素"。

FreeRTOS 的设计是:

  1. 读队列操作时,先移动 pcReadFrom 到下一个元素
  2. 如果移动后 pcReadFrom == pcTail,则回绕到 pcHead
  3. 如果没到末尾或已经回绕,则从移动后的 pcReadFrom 位置读一个元素

把读指针设计为"指向上一次读的位置",可以让读操作也采用"先移动(含回绕处理)→ 再读取"的统一流程,减少边界的特殊判定。

关于这两个读写位置指针,其源码如下图所示:

所以,这四个指针本质就是:

两个边界指针(Head / Tail)

两个移动指针(Write / Read)

它们配合在一起,让一个普通数组具备了:循环队列的能力。

最后还是需要强调一点的就是:

队列满不满,不靠指针判断,而是靠成员uxMessagesWaiting来进行判断。

指针只负责移动,这个计数器用来记录队列状态。

FreeRTOS消息队列的结构示意图

依照上面的内容,我们可以画出以下 FreeRTOS 消息队列的结构示意图

这张图主要展示了:

  1. FreeRTOS 在创建消息队列时,会通过 pvPortMalloc() 一次性申请一块连续内存,其中包含 队列控制结构体(Queue_t) 和 队列数据存储区。
  2. 在内存布局上,队列控制结构体位于低地址 ,而 队列数据数组位于高地址
  3. 用户获得的队列句柄 pxNewQueue,本质上是 指向 Queue_t/ xQUEUE / QueueDefinition 队列控制结构体类型 的指针。
  4. pcHead 指向 队列数据存储区的起始地址,也就是数组的第一个元素位置。
  5. pcTail 指向 队列存储区的末尾地址(最后一个元素之后的位置),用于判断数组是否越界,并配合实现循环队列。
  6. 队列的数据元素以 数组形式连续存储 ,FreeRTOS 通过移动 读写指针 来实现 FIFO(先进先出)的循环数组队列

在队列 初始创建完成时,四个核心控制指针的位置如下图所示:

现在向队列 新增一个元素(入队列)

  1. 首先将新元素 复制到 pcWriteTo 指针当前指向的位置
  2. 随后将 pcWriteTo 移动到下一个元素位置,表示下一次写入的位置。

如下图所示

接下来 从队列读取一个元素(出队列)

  1. 首先将 pcReadFrom 移动到下一个元素位置
  2. 如果发现 pcReadFrom == pcTail,说明已经到达数组末尾,此时发生 回绕pcReadFrom 指针回到 pcHead 位置。
  3. 随后 读取 pcReadFrom 指向位置的元素数据,并将该数据返回给调用者。

如下图所示:

在这种情况下,再 向队列连续插入三个元素

  1. 每插入一个元素时,都先将数据 复制到 pcWriteTo 指向的位置 ,然后再将该指针 移动到下一个元素位置
  2. 当插入第三个元素后,pcWriteTo 指针移动到了 pcTail 位置。
  3. 此时 pcWriteTo == pcTail,说明已经到达数组末尾,因此发生 回绕pcWriteTo 指针重新回到 pcHead 位置。
  4. 此时三个指针的关系变成:pcReadFrom == pcHead == pcWriteTo
  5. 也就是说,如果此时 再次入队列一个元素 ,新的数据就会写入 pcHead 所指向的位置。

如下图所示:

可以看到:

正是基于两个固定的边界指针(pcHeadpcTail),以及两个可以回绕移动的读写位置指针(pcWriteTopcReadFrom

FreeRTOS 的消息队列最终实现了一个 循环数组队列(Circular Queue) 的数据结构。

阻塞队列的核心(重点)

FreeRTOS的消息队列是一个阻塞队列,那么它为什么可以阻塞呢?

本质原因就是下面两个成员:

c 复制代码
List_t xTasksWaitingToSend;
List_t xTasksWaitingToReceive;

这个List_t类型,我们并不陌生。

因为我们前面讲过的:就绪列表、阻塞列表等列表,它们底层用的,都是基于List_t类型的一条链表结构。

所以现在你应该意识到一件事:

FreeRTOS的消息队列之所以能"阻塞",是一个阻塞队列

必然不是因为其数组的结构,也跟四个管理指针没有关系

而是队列控制块内部维护了两个"事件等待列表",用于管理因等待队列条件而进入阻塞态的任务。

一旦某个消息队列满了,那么负责往队列发送元素的任务,如果还想继续发送元素,就会:

  1. 被从就绪列表中移除
  2. 被插入到 xTasksWaitingToSend 列表,也就是进入了"等待发送阻塞列表"
  3. 进入阻塞态
  4. 主动放弃CPU执行权,让出CPU

那等待什么条件被唤醒呢?

当有其他任务从队列接收数据,从队列中取走一个元素,腾出一个空位之后。

调度器会唤醒此任务,任务从阻塞态回到就绪态,随后根据优先级进行任务调度。

一旦某个消息队列是空的,那么负责从队列接收元素的任务,如果还想继续接收元素,就会:

  1. 被从就绪列表中移除
  2. 被插入到 xTasksWaitingToReceive 列表,也就是进入了"等待接收阻塞列表"
  3. 进入阻塞态
  4. 主动放弃CPU执行权,让出CPU

等什么时候被唤醒呢?

当有其他任务往队列发送1个元素,消息队列不再为空之后。

调度器会唤醒此任务,任务从阻塞态回到就绪态,随后根据优先级进行任务调度。

所以,我们可以得出一个非常关键的结论:

FreeRTOS的消息队列是一个阻塞队列

但这个阻塞指的是"执行收发队列元素的任务"被阻塞,而不是队列本身是阻塞式的。

被阻塞的是任务,而不是队列本身

消息队列本身,就只是一个普通的数组循环队列。

消息队列的线程安全(重点)

如果你没深入的看上述文档,但看到这里,你也应该记住:

  1. FreeRTOS的消息队列,其存储元素的队列部分,底层实现是一个数组循环队列
  2. FreeRTOS的消息队列,之所以是一个阻塞队列,是因为内核维护了两个等待阻塞列表

到这里,FreeRTOS消息队列的大部分原理,你就搞懂了。

但还有一个非常核心的问题:

两个任务,乃至于多个任务,都需要操作同一个消息队列。

在FreeRTOS的多任务环境下,这很显然属于,访问了共享资源。

这势必产生竞态条件,导致线程安全问题。

那消息队列是如何解决这个问题,保持线程安全,保证多个任务同时访问它,而不会乱呢?

原因也很简单:

FreeRTOS消息队列的读写操作,都是在临界区内完成的,某个任务在执行读写队列操作时,执行的都是一个原子操作。

比如下列源码:

需要注意的是,消息队列使用的临界区是:

c 复制代码
taskENTER_CRITICAL();
...
taskEXIT_CRITICAL();

这意味着:

  1. 在任务进行读写操作消息队列时,在临界区内,不会发生任务切换。
  2. 在默认配置下,此临界区不会被优先级小于等于11的中断打断。优先级高于11的中断(比如10)则可以打断临界区的执行。
  3. 如果是在自定义中断中调用相关任务间通信API,一定要确保此中断的优先级是11或更低优先级的中断!
  4. 队列结构体中的关键成员(如 pcWriteTopcReadFromuxMessagesWaiting)的修改是原子操作,不会被打断。

所以,通过加临界区的方式,使得队列的数据修改阶段,其操作是一个原子操作。

从而保障了消息队列的线程安全。

最后做一个总结:

消息队列的阻塞机制解决了"任务调度问题"

而临界区机制解决了"并发线程安全问题"

两套机制配合,构成了完整的、安全的、消息队列实现。

消息队列核心操作函数

了解了 FreeRTOS 消息队列的核心机制和工作原理之后,下面我们正式进入 消息队列的核心操作函数 部分。

在 FreeRTOS 中,消息队列的核心 API 实际上只有三个,也是我们在实际开发中最常用、最重要的三个函数:

  1. xQueueCreate函数,创建消息队列
  2. xQueueSend函数,向消息队列发送数据
  3. xQueueReceive函数,从消息队列接收数据

可以说,只要掌握了这三个函数,消息队列的基本使用就已经完全没有问题了。

下面我们逐一介绍这三个函数。

首先,这三个函数在FreeRTOS都是默认开启的,前提是启用了 动态内存分配机制,即开启下面的宏:

c 复制代码
#define configSUPPORT_DYNAMIC_ALLOCATION 1 

也就是依靠动态分配内存宏,它是默认开启的。

这是因为 xQueueCreate() 本质上是通过动态分配内存来创建结构体和队列数据存储区的。

xQueueCreate:创建消息队列

其函数原型如下:

c 复制代码
QueueHandle_t xQueueCreate(
    // UBaseType_t也就是BaseType_t的无符号版本,其实就是unsigned long,通常就是无符号4字节
    const UBaseType_t uxQueueLength,    // 数组循环队列的元素数量,也就是循环队列最多装几个元素
    const UBaseType_t uxItemSize,       // 数组循环队列中每个元素的大小,以字节为单位
)

两个参数非常关键:

  1. uxQueueLength:队列能存放多少个元素
  2. uxItemSize:每个元素的大小(单位:字节)

这两个参数共同决定了消息队列的数据区域------数组循环队列的长度大小,以及可以存储几个元素。

举例:

c 复制代码
QueueHandle_t Queue = xQueueCreate(5, 4);

这表示:

  1. 队列最多存 5 个元素
  2. 每个元素 4 字节
  3. 底层会分配 5 × 4 = 20 字节的连续内存,也就是一个数组

返回值,得到一个QueueHandle_t类型的返回值。

这种情况我们太熟悉的。

此函数的返回值,就是FreeRTOS消息队列的操作句柄,也就是一个操作消息队列的凭证。

当然,这个消息队列句柄本质上是一个消息队列结构体对象的指针。

由于结构体并没有定义在头文件中,你并不能直接利用这个指针操作结构体。

函数的返回值如果是 NULL, 则说明内存不足,消息队列创建失败。

xQueueSend:向队列发送数据

其函数原型如下:

c 复制代码
BaseType_t xQueueSend(
    QueueHandle_t xQueue,   // 消息队列句柄,也就是"队列对象的指针/引用"
    const void * pvItemToQueue, // 要发送数据的指针
    TickType_t xTicksToWait // 消息队列满时,最多愿意等多久(单位:tick)
);

该函数用于,在任务中向消息队列发送数据,也就是向队列尾部写入一个元素。

xQueue参数:

表示要操作的队列句柄。或者说,表示往哪个队列发送数据。

它是 xQueueCreate() 返回的值,本质可以理解为队列控制结构的引用。

pvItemToQueue参数:

表示要发送数据的地址。

类型为 const void *,说明可以发送任意类型的数据。

需要特别强调:

队列发送的是元素内容的拷贝,而不是指针本身。

执行时会从 pvItemToQueue 指向的地址开始,拷贝 uxItemSize 个字节,存入数组循环队列的一个元素位置。

整个过程实际上就是执行了一个memcpy函数。如下源码:

例如:

复制代码
uint32_t Value = 100;
xQueueSend(QueueHandle, &Value, 0);

进入队列的是数值 100 的副本,即使 Value是局部变量也不会出问题。

注意:发送数据的格式要和创建队列时指定元素的长度保持一致。

比如队列创建时一个元素是4个字节,pvItemToQueue指针指向的数据也应该是4字节的。

xTicksToWait参数:

表示当消息队列已满时,当前任务最多等待多少个 tick(1个tick默认设置是1ms)。

它的取值规则如下:

  1. 0 表示不等待,立即返回发送的结果(成功或失败)。
  2. 大于 0 表示最多等待指定 tick 数。
  3. portMAX_DELAY 表示一直阻塞等待(前提是允许无限期阻塞,FreeRTOS默认允许无限期阻塞)。

关于宏portMAX_DELAY ,这里要多说两句:

第一,从宏定义上来说,它其实就是4字节无符号整型的最大值:

这么看,使用这个宏填在xTicksToWait位置,表示最大阻塞等待大约49天多。

这个时间对于嵌入式系统而言,确实已经非常"大"了。几乎可以说是无限期阻塞等待。

但事实真的是这样吗?

当然不是。

实际上:

在使用宏portMAX_DELAY作为参数,配合默认允许无限期阻塞的宏配置

熟悉不,这不就是之前学挂起状态的相关宏。

相关的源码如下:

所以内核实际是这么做的:

使用 portMAX_DELAY 且队列操作的条件不满足时,任务实际上被内核移入挂起列表,进入挂起态。

但这个挂起态比较特殊:

  1. 本质上任务处于"无限期等待"的延时阻塞状态,只不过为了方便,内核将它移入了挂起列表。
  2. 当事件满足(比如队列腾出空位)时,内核会把任务从挂起列表中移出,重新放回 就绪列表,随后参与调度继续完成发送。

关于函数的返回值也要着重说一下:

  1. 如果函数返回 pdPASS 表示发送成功。
  2. 如果函数返回 errQUEUE_FULL 则表示队列已满,无法发送数据到队列。errQUEUE_FULL 等价于 pdFALSE/pdFAIL。

此函数的行为逻辑可以这样描述:

  1. 如果队列有空位,立刻把数据拷贝到队列,数据发送成功,返回 pdPASS
  2. 如果队列没有空位,当 xTicksToWait = 0,立即返回 errQUEUE_FULL 表示失败。
  3. 如果队列没有空位,当 xTicksToWait > 0 时:
    1. 当前任务进入阻塞态,主动放弃CPU执行权
    2. 随后任务被延时最多 xTicksToWait 个Tick
    3. 若在等待时间内队列腾出空间,任务被唤醒回到就绪态,随后获取CPU执行权,完成数据发送。
    4. 若超时队列仍无空间,返回 errQUEUE_FULL 发送失败。
  4. 如果队列没有空位,当 xTicksToWait = portMAX_DELAY时:
    1. 任务进入 无限期等待(实际由内核操作,进入挂起列表)
    2. 如果队列一直没有空位,那么当前任务就会一直挂起,那么函数就会一直阻塞等待,函数始终不会返回
    3. 一旦空位出现,内核就会让任务恢复挂起,回到就绪态,随后获取CPU执行权发送数据。

理解了往队列发送数据,那么从队列接收数据也是差不多的,只不过数据方向改变了。

xQueueReceive:从队列接收数据

函数原型如下:

复制代码
BaseType_t xQueueReceive(
    QueueHandle_t xQueue,   // 表示要操作的队列句柄。
    void * pvBuffer,        // 表示用于接收数据的缓冲区地址。
    TickType_t xTicksToWait // 消息队列空时,最多愿意等多久(单位:tick)
);

该函数用于,在任务中从消息队列接收数据,也就是从队列头部移除一个元素,并返回元素的取值。

xQueueReceive 的本质是:

从队列中取出一个元素,并拷贝到用户提供的缓冲区,若队列为空,则根据等待时间决定是否阻塞。

**xQueue参数:**表示要操作的队列句柄。

pvBuffer参数:

表示用于接收数据的缓冲区地址。

当接收成功时,队列会将内部存储的一个元素,按 uxItemSize 字节拷贝到 pvBuffer 指向的内存空间。

队列创建时的元素大小、发送时的数据大小,接收时的数据大小应当保持一致。

最简单的设定就是,直接让它们的类型都保持一致。

需要注意的是:

xQueueReceive接收队列一个元素后,该元素会被从队列中移除,也就是所谓的"出队"。

xTicksToWait参数:

使用方式和原理与xQueueSend函数的xTicksToWait参数没有任何区别。

这里不再赘述。

返回值有两种:

  1. pdPASS 表示接收成功。
  2. errQUEUE_EMPTY 表示队列为空且等待失败。

其余原理和xQueueSend函数一致,这里不再赘述。

消息队列的使用举例(任务与任务通信)

现在我们已经搞懂了,FreeRTOS 消息队列的核心机制以及工作原理。

那么接下来我们就来"实战演练"一下。

下面举一些实际的场景,实际的代码,分析一下最终的行为。

场景一:一个任务发送不接收

示例代码如下:

c 复制代码
#include "stm32f10x.h"

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"

#include "DebugUSART1.h"

static QueueHandle_t QueueHandle = NULL;

/* -------------------- 任务1:发送任务 -------------------- */
void TaskSend(void *Argument) {
    uint32_t Count = 0;

    while (1) {
        Count++;

        // 如果队列满了,最多等待3s
        BaseType_t Ret = xQueueSend(QueueHandle, &Count, 3000);

        if (Ret == pdPASS) {
            /* 发送成功:说明队列里还有空位 */
            printf1("[Send OK ] Count = %lu \n", Count);
        } else {
            /* 发送失败:最典型原因就是队列满了(errQUEUE_FULL) */
            printf1("[Send FAIL] queue full \n", Count);
        }

        vTaskDelay(1000);
    }
}

int main(void) {
    DebugUSART1_Init();

    // 创建消息队列,一个元素是4个字节,一共可以存放5个数据
    QueueHandle = xQueueCreate(5, sizeof(uint32_t));

    xTaskCreate(TaskSend,
                "TaskSend",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    vTaskStartScheduler();

    while (1) {}
}

程序的行为是:

  1. 队列容量是 5,且没有接收者(只有生产者,没有消费者)
  2. 所以前 5 次发送必定成功(返回 pdPASS
  3. 第 6 次开始队列满,xQueueSend(..., 3000) 会让任务进入 阻塞等待
  4. 因为没人接收,等待永远等不到空位。所以 3000 tick 后超时返回 errQUEUE_FULL
  5. 从第 6 次开始,打印Error,任务的循环周期会变长,约等于:等待超时 + 自己的 vTaskDelay

试想一下,如果把Send函数等待时长改为portMAX_DELAY,会出现什么场景?

任务在发送5个数据后,会始终等待队列空位置,会持续阻塞。

场景二:优先级一致的两个任务通信

示例代码如下:

c 复制代码
#include "stm32f10x.h"

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"

#include "DebugUSART1.h"

static QueueHandle_t QueueHandle = NULL;

/* -------------------- 任务1:发送任务 -------------------- */
void TaskSend(void *Argument) {

    uint32_t Count = 0;
    BaseType_t xRet = pdFAIL;

    while (1) {

        Count++;

        /* 向队列发送数据
        参数说明:
           1. 队列句柄
           2. 数据地址
           3. 等待时间(这里为0,表示不等待)
        */
        xRet = xQueueSend(QueueHandle, &Count, 0);

        if (xRet == pdPASS) {
            /* 发送成功 */
            printf1("[Send] %lu\r\n", Count);
        } else {
            /* 队列已满,发送失败 */
            printf1("[Send] Queue Full!\r\n");
        }

        /* 延时 1s */
        vTaskDelay(1000);
    }
}

/* -------------------- 任务2:接收任务 -------------------- */
void TaskRecv(void *Argument) {

    uint32_t Value = 0;
    BaseType_t xRet = pdFAIL;

    while (1) {

        /* 从队列接收数据
           portMAX_DELAY 表示无限等待
           在默认配置下会进入"无限期阻塞模式"
        */
        xRet = xQueueReceive(QueueHandle, &Value, portMAX_DELAY);

        if (xRet == pdPASS) {
            /* 接收成功 */
            printf1("[Recv] %lu\r\n", Value);
        } else {
            /* 正常情况下不会走到这里 */
            printf1("[Recv] Error!\r\n");
        }
    }
}

int main(void) {

    DebugUSART1_Init();

    /* 创建消息队列
       1. 最多存放 5 个元素
       2. 每个元素大小为 4 字节(uint32_t)
    */
    QueueHandle = xQueueCreate(5, sizeof(uint32_t));

    if (QueueHandle == NULL) {
        /* 创建失败通常是堆空间不足 */
        printf1("Queue Create Failed!\r\n");
        while (1);
    }

    /* 创建发送任务 */
    xTaskCreate(TaskSend,
                "TaskSend",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 创建接收任务 */
    xTaskCreate(TaskRecv,
                "TaskRecv",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 启动调度器 */
    vTaskStartScheduler();

    /* 如果进入这里,说明堆空间不足,
       Idle任务无法创建 */
    while (1);
}

最终程序的行为:

  1. Send 每 1000ms 发一次,发完立刻进入阻塞状态
  2. Recv 用 portMAX_DELAY 无限期等消息
  3. Recv 优先级虽然没有更高,但 Send 发完后就会阻塞放弃CPU执行权,所以只要队列有数据,立刻被唤醒,接收打印数据。

扩展了解:利用结构体校验数据边界

在之前学习任务列表时,我们知道列表的基本类型是一个 List_t 类型。如下图所示:

可以看到:这个结构体的首尾会放置 边界校验成员宏,用于检测内存是否存在非法覆盖。

这种机制本质上就是一种 结构体边界保护Guard 保护思想

这是怎么实现的呢?

恰好在学习消息队列时,有同学提出了这样的疑问:

如果我们在使用消息队列时,

队列的元素大小 和 实际收发元素的大小 不一致,会发生什么?

比如:

我们创建消息队列时是这样写的:

复制代码
pxQueue = xQueueCreate(5, 20);

这意味着:

  1. 队列中每个元素大小是 20 字节
  2. 每次发送 / 接收时,FreeRTOS 内部都会执行 memcpy
  3. 并且从指定指针位置开始,固定拷贝 20 个字节

注意这里的关键点队列拷贝是"按创建时的 uxItemSize 固定长度拷贝"它不会管你实际数据有多大

与此同时,我们发送的数据却是:

c 复制代码
uint32_t Value = 1;
xQueueSend(pxQueue, &Value, portMAX_DELAY);

一个 uint32_t 只有 4 字节

队列却按 20 字节进行拷贝,这 20 字节从哪里来?

&Value 开始往后连续读取 20 字节。

也就是说:

Value 后面的栈空间也会被一起复制进队列。这显然是不正确的。

但问题更严重的是接收端。

如果接收端仍然用 uint32_t 去接收数据,那么 FreeRTOS 仍然会从目标指针开始,连续写入 20 个字节的数据。

这就会导致:任务栈空间被非法覆盖,并产生更加严重的问题与后果。

为了把这种"越界覆盖"的现象演示得更直观,我们可以构建这样一个结构体类型:

c 复制代码
/* 结构体成员布局是连续的,确保RecvValue前后都被校验成员包围 */
typedef struct {
    volatile uint32_t CheckValue1;
    uint32_t RecvValue;
    volatile uint32_t CheckValue2;
} QueueItemOverflow_t;

其中:

  1. RecvValue 用来"接收"数据(注意:这里故意用错)
  2. CheckValue1 / CheckValue2 用作边界校验
  3. 如果 CheckValue2 的值发生变化,就说明发生了越界覆盖

为什么这个方法有效?

因为结构体成员在内存中是连续排列的。

当我们从 &RecvValue 开始写入 20 字节时:

  1. 前 4 字节写入 RecvValue
  2. 后续字节会继续写入 CheckValue2 所在位置
  3. 导致 CheckValue2 被破坏

因此:

CheckValue2 是否被改写就是最直观的"越界检测信号"

这也正是 FreeRTOS 在一些数据结构中使用边界校验成员的核心思想。

整体演示代码如下(故意写错,用于实验):

c 复制代码
#include "stm32f10x.h"

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"

#include "DebugUSART1.h"

static QueueHandle_t pxQueue = NULL;

/* -------------------- 任务1:发送任务 -------------------- */
void TaskSend(void *Argument) {
    (void)Argument;

    uint32_t Value = 1;

    while (1) {
        printf1("[Send] Value = %d\r\n", Value);

        /* 故意错误:队列元素是 20 字节,但这里传的指针指向的内存块实际只有4字节可用 */
        xQueueSend(pxQueue, &Value, portMAX_DELAY);

        Value++;
        vTaskDelay(3000);
    }
}

/* -------------------- 任务2:接收任务 -------------------- */
void TaskRecv(void *Argument) {
    (void)Argument;

    /* 结构体成员布局是连续的,确保RecvValue前后都被校验成员包围 */
    typedef struct {
        volatile uint32_t CheckValue1;
        uint32_t RecvValue;
        volatile uint32_t CheckValue2;
    } QueueItemOverflow_t;

    QueueItemOverflow_t Struct;

    Struct.CheckValue1 = 0xAAAAAAAA;
    Struct.RecvValue = 0;
    Struct.CheckValue2 = 0xBBBBBBBB;

    while (1) {
        /*
         * 故意错误:
         * 队列元素大小 = 20 字节
         * 但是RecvValue实际只有4个字节,强行接收20个字节的数据,势必导致越界
         * FreeRTOS 会 memcpy 20 字节,从而覆盖 RecvValue 后面的 CheckValue2
         */
        xQueueReceive(pxQueue, (void *)&(Struct.RecvValue), portMAX_DELAY);

        printf1("[Recv] CheckValue1    = 0x%08X\r\n", (unsigned int)Struct.CheckValue1);
        printf1("[Recv] RecvValue      = %d\r\n", Struct.RecvValue);
        printf1("[Recv] CheckValue2    = 0x%08X\r\n", (unsigned int)Struct.CheckValue2);

        printf1("------------------------------\r\n");
        vTaskDelay(1000);
    }
}

int main(void) {
    DebugUSART1_Init();
    printf1("system start. \n");

    /* 1) 创建消息队列:一共可存放5个元素,每个元素 20 最大字节 */
    pxQueue = xQueueCreate(5, 20);

    if (pxQueue == NULL) {
        printf1("queue create failed! \n");

        while (1) {}
    }

    /* 2) 创建任务:发送 */
    xTaskCreate(TaskSend,
                "TaskSend",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 3) 创建任务:接收 */
    xTaskCreate(TaskRecv,
                "TaskRecv",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    printf1("tasks created. \n");

    vTaskStartScheduler();

    while (1) {}
}

你可以运行这段代码观察现象:

  1. 初始时 CheckValue2 = 0xBBBBBBBB
  2. 一旦发生接收拷贝,CheckValue2 很大概率会被改写

这就说明:队列元素大小与实际接收缓冲区大小不匹配,会导致越界写入。

也就能理解:

为什么 FreeRTOS 会在某些结构体首尾设计边界校验成员,用于快速发现"内存被非法覆盖"的问题。

消息队列的使用举例(任务与中断通信)

现在我们来完成这样的一道编程题目,实现以下功能。

题目描述如下:

在基于SPL库 + FreeRTOS的开发环境下实现一个简单的串口命令解析程序。

PC 通过串口工具向STM32发送字符串命令:

  1. 当收到字符串 "OK" 时,点亮 LED,并通过串口回复PC端 Light On
  2. 当收到字符串 "ERROR" 时,熄灭 LED,并通过串口回复PC端 Light Off

要求如下:

  1. 串口采用 USART1 中断方式,进行接收数据。
  2. 用于接收指令字符串的 Buffer 数组必须始终保持为合法的 C 字符串(以 '\0' 结尾)
  3. 为防止接收数据过长导致数组越界,需要实现 Buffer缓冲区溢出保护
  4. 若接收到 非法指令(如 ABC ,系统应能够自动清空 Buffer 并恢复正常工作,避免后续命令无法识别。
  5. 成功解析命令后,应 清空 Buffer 并重置索引变量,以便接收下一条命令。
  6. 建议对\r\n这两个Windows环境下的换行符,直接过滤掉,不处理。

求解这道题目,建议采用下面的思路:

  1. 在USART1中断中处理数据接收
  2. 创建一个FreeRTOS任务,在任务中处理接收数据后的业务逻辑

很不使用消息队列的解决方式然,中断和任务之间要共享同一个存储字符串指令的字符数组,于是就涉及到任务间通信。

在本章节的开头,我们已经提到过:如果不使用消息队列,那么中断和任务之间的"任务间通信",就只能靠共享同一块内存Buffer来完成。

说白了就是:

  1. ISR(中断)负责把字符一个一个塞进 Buffer
  2. Task(任务)负责在合适的时机读取 Buffer,并解析命令

直接给出参考实现代码:

LED.h头文件:

c 复制代码
#ifndef __LED_H__
#define __LED_H__

#include "stm32f10x.h"

void LED_Init(void);
void LED_On(void);
void LED_Off(void);

#endif

LED.c头文件:

c 复制代码
#include "LED.h"

void LED_Init(void) {
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);

    GPIO_InitTypeDef initType;
    initType.GPIO_Pin = GPIO_Pin_13;
    initType.GPIO_Mode = GPIO_Mode_Out_PP;
    initType.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOC, &initType);

    // LED默认熄灭
    LED_Off();
}

// PC13 低电平点亮
void LED_On(void) {
    GPIO_ResetBits(GPIOC, GPIO_Pin_13);
}

void LED_Off(void) {
    GPIO_SetBits(GPIOC, GPIO_Pin_13);
}

USART.h头文件:

c 复制代码
#ifndef __DEBUG_USART1_H__
#define __DEBUG_USART1_H__

#include "stm32f10x.h"
#include <stdio.h>  
#include <stdarg.h>
#include <string.h>

#define BUFFER_SIZE 10

// 全局变量仅声明,共享main.c中的全局变量
extern char Buffer[BUFFER_SIZE];
extern uint8_t BufferIndex;

/**
 * @brief  初始化串口 USART1
 */
void USART1_Init(void);

/**
 * @brief  调试打印函数(串口版 printf)
 */
void printf1(const char *format, ...);

#endif

USART.c源文件:

c 复制代码
#include "USART1.h"

/* ================= 内部函数(不对外暴露) ================= */

/**
 * @brief  USART1 发送单字节(阻塞式)
 * @note   仅供本文件内部使用
 */
static void USART1_SendByte(uint8_t Byte) {
    /* 等待发送数据寄存器为空 */
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);

    USART_SendData(USART1, Byte);
}

/**
 * @brief  初始化 USART1 串口
 */
void USART1_Init(void) {
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitTypeDef GPIO_InitStructure;

    /* PA9 -> USART1_TX:复用推挽输出 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* PA10 -> USART1_RX:上拉输入 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* USART 参数配置:115200, 8N1 */
    USART_InitTypeDef USART_InitStructure;
    USART_InitStructure.USART_BaudRate = 115200;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_InitStructure.USART_Parity = USART_Parity_No;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_Init(USART1, &USART_InitStructure);
    USART_Cmd(USART1, ENABLE);

    // 以下为USART1全局中断配置
    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

    NVIC_InitTypeDef NVIC_InitStructure;
    NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
    // 使用FreeRTOS时
    // 中断优先级一律建议采用分组4,即4位优先级寄存器全部配置给抢占优先级
    // 抢占优先级设置为12,不建议高于11(可以设置为11)
    // 原因请看文档或者询问老师
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 12;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    USART_Cmd(USART1, ENABLE);
}

/**
 * @brief  调试打印函数(串口版 printf)
 * @param  format: 格式化字符串,和printf函数的第一个参数完全一致
 * @note
 *         1. 用法与 printf 类似
 *         2. 实际输出通过 USART1 完成
 *         3. 用于 FreeRTOS 任务调试与运行观测
 */
void printf1(const char *format, ...) {
    char buffer[100];
    va_list list;
    va_start(list, format);
    vsprintf(buffer, format, list);
    va_end(list);

    for (uint16_t i = 0; buffer[i] != '\0'; i++) {
        USART1_SendByte((uint8_t)buffer[i]);
    }
}

/**
  * @brief USART1全局中断处理函数,用于接收PC端数据
  */
void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {
        char ReceiveChar = (char)USART_ReceiveData(USART1);

        if (ReceiveChar == '\r' || ReceiveChar == '\n') {
            return; // 不接收处理换行符
        }

        if (BufferIndex < BUFFER_SIZE - 1) {
            Buffer[BufferIndex++] = ReceiveChar;
            Buffer[BufferIndex] = '\0'; // 永远保证当前字符的下一个位置是空字符
        } else {
            // 如果接收过长的数据导致Buffer溢出
            // 那么多余数据直接用空字符拼在Buffer末尾
            Buffer[BUFFER_SIZE - 1] = 0;
        }
    }
}

main.c源代码文件

c 复制代码
#include "stm32f10x.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "LED.h"
#include "USART1.h"

char Buffer[BUFFER_SIZE];
uint8_t BufferIndex;

/* -------------------- 任务1: 判断串口接收数据并处理-------------------- */
void TaskRecv(void *Argument) {
    while (1) {
        if (strcmp(Buffer, "OK") == 0) {
            printf1("Light On \n");
            LED_On();
            // 清零Buffer并归零Idx
            memset(Buffer, 0, sizeof(Buffer));
            BufferIndex = 0;
        } else if (strcmp(Buffer, "ERROR") == 0) {
            printf1("Light Off \n");
            LED_Off();
            // 清零Buffer并归零Idx
            memset(Buffer, 0, sizeof(Buffer));
            BufferIndex = 0;
        }

        // 输入非法指令,就清零Buffer
        if (BufferIndex > 0 && Buffer[0] != 'O' && Buffer[0] != 'E') {
            memset(Buffer, 0, sizeof(Buffer));
            BufferIndex = 0;
        }

        // 长度超过最大命令长度,就清零Buffer
        if (BufferIndex > 5) {
            memset(Buffer, 0, sizeof(Buffer));
            BufferIndex = 0;
        }

        // 避免死循环空转,导致空闲任务无法上CPU
        vTaskDelay(10);
    }
}

int main(void) {
    // 使用优先级分组4
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
    LED_Init();
    USART1_Init();

    xTaskCreate(TaskRecv,
                "TaskRecv",
                configMINIMAL_STACK_SIZE + 128, // 适当增加一些任务栈空间
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    vTaskStartScheduler();

    while (1) {}
}

使用全局变量来实现,优点是比较简单,缺点是功能需求越多时,需要用的全局变量就会越多,代码就会变得越发难以维护。

下面我们来看一下,使用消息队列的方式来实现这个功能。

当然,在此之前,我们还需要学习一个专门用于,在中断中向消息队列发送数据的函数------xQueueSendFromISR

xQueueSendFromISR:在中断中向消息队列发送数据

函数原型如下:

复制代码
BaseType_t xQueueSendFromISR(
    QueueHandle_t xQueue,                     // 表示要操作的队列句柄
    const void * pvItemToQueue,               // 表示要发送数据的地址
    BaseType_t * pxHigherPriorityTaskWoken    // 是否需要在ISR结束后,触发高优先级任务切换
);

xQueueSendFromISR,在主要作用上其实和xQueueSend函数并无不同:

都是向消息队列 发送一个元素,也就是将用户提供的数据 拷贝到消息队列的内部存储空间。

但仍然主要有两点不同:

  1. 该函数专门用于中断服务函数(ISR)中调用,xQueueSend则必须在任务中使用。
  2. 该函数既然用于中断中,那就不存在所谓阻塞,在调用时:
    1. 如果队列仍然有空位,则发送成功,返回pdPASS
    2. 如果队列已满,则会直接发送失败,退出函数,返回errQUEUE_FULL

前面两个参数和xQueueSend函数一致,这里不再赘述。

这里讲一下第三个参数:pxHigherPriorityTaskWoken。

这个参数通常,只有两种传参的方式,两种不同的调用方式。

第一种,可以直接传参NULL,表示无事发生,什么都不会做,相当于函数没有这个参数。

第二种,调用和传参的方式,如下:

c 复制代码
BaseType_t Flag = pdFALSE;
xQueueSendFromISR(xx, &xx, &Flag);
portYIELD_FROM_ISR(Flag);

解释一下这种调用方式。

简单来说,这三行代码实际上完成了 三件事情

  1. 向消息队列发送数据(xQueueSendFromISR函数的基本功能)
  2. 判断是否唤醒了更高优先级任务(xQueueSendFromISR函数内部实现此功能)
  3. 若唤醒了,则在ISR结束后,立即进行一次任务调度,让更高优先级的任务上CPU(宏函数 portYIELD_FROM_ISR 的作用)

下面用流程的形式来描述一下这三条代码:

任务A正在运行

​ ↓

USART1中断发生,串口接收数据 ↓ 进入 USART1 ISR ↓ xQueueSendFromISR() ↓ 向消息队列发数据 ↓ 如果系统中有任务B,阻塞等待接收队列数据 ↓ 任务B从 阻塞态 → 就绪态 ↓ 如果任务B优先级高于当前运行任务A ↓ Flag = pdTRUE ↓ portYIELD_FROM_ISR(Flag) ↓ 触发 PendSV(只有Flag设置为pdTRUE,才会触发PendSV) ↓ USART1 ISR 退出 ↓ PendSV 中断执行 ↓ FreeRTOS 调度器运行 ↓ 切换到更高优先级任务B执行

所以一旦采用了这种调用方式,可以达成这样的效果:

在 USART1 的 ISR 中向队列发送数据时:

如果系统中存在比当前任务,更高优先级任务正在等待该队列数据,那么在 ISR 结束后,系统会立刻切换到该高优先级任务执行。

接下来可以思考一个问题:

既然任务B优先级更高,那么即使不在 ISR 结束后立即触发任务调度

等到下一个 Tick 到来时,系统依然会触发调度,并最终切换到任务B。

既然如此,又为什么那么"着急"、在ISR结束后就手动触发任务切换呢?

原因很简单:

为了尽可能降低任务响应延迟。

如果不调用 portYIELD_FROM_ISR,系统会按照正常的调度节奏运行:

USART1 ISR结束 ↓ 继续执行任务A ↓ 等待下一次 SysTick ↓ FreeRTOS 调度器运行 ↓ 切换到更高优先级任务B执行

在这种情况下,任务B 最多可能要等待一个 Tick 才能运行。

也就是说,任务B 最坏情况下可能会被延迟约 1ms 才开始执行。

在很多普通应用中,这个延迟可能并不明显。

但在一些 实时性要求较高的系统 中,1ms 的延迟可能已经是比较大的时间开销。

因此,通过这种方式可以让系统在 ISR 结束后立刻进行任务调度,从而让高优先级任务更快地处理数据。


当然,如果系统对这点延迟并不敏感,也可以采用更简单的写法:

c 复制代码
xQueueSendFromISR(xx, &xx, NULL);

这种写法表示:

不关心是否唤醒了更高优先级任务,也不主动请求立即调度。

系统会在下一次正常调度时机(例如下一次 Tick 中断)再进行任务切换。

为什么不学习任务向中断发送数据的函数?

在 FreeRTOS 中,我们更多关注的是"中断 → 任务"的通信,而基本不会讨论"任务 → 中断"。

其根本原因在于:中断不具备等待能力。

任务可以通过消息队列,或其他任务间通信机制进入阻塞态,从而等待数据,因此需要中断或其他任务去"唤醒它"。

而中断必须快速执行并退出,既不能阻塞,也不会长期处于等待状态,因此不存在"等待任务发送数据"的需求。

因此,从设计上来说:

通信机制的核心,是让"能等待的一方去等待"。

而中断无法等待,只能作为"事件的产生者"或"数据的发送者",这就是我们主要学习"中断 → 任务"通信的原因。

使用消息队列的参考实现代码

讲完了上面的函数,那么使用消息队列来实现上述功能,就很简单了。

主要做两点核心变化:

  1. USART1_IRQHandler():收到 1 字节 → xQueueSendFromISR()向消息队列中发送这个字节数据
  2. TaskRecv()任务入口函数:无限期阻塞 xQueueReceive() 接收队列数据 → 拼接到局部变量 Buffer[] → 判断 "OK"/"ERROR"

参考代码如下:

USART.h头文件:

c 复制代码
#ifndef __USART1_H__
#define __USART1_H__

#include "stm32f10x.h"
#include <stdio.h>  
#include <stdarg.h>
#include <string.h>
#include "FreeRTOS.h"
#include "queue.h"

/* main.c 里创建的队列句柄 */
extern QueueHandle_t UartRxQueue;

/**
 * @brief  初始化串口 USART1
 */
void USART1_Init(void);

/**
 * @brief  调试打印函数(串口版 printf)
 */
void printf1(const char *format, ...);

#endif

USART.c源文件:

c 复制代码
#include "USART1.h"

/* ================= 内部函数(不对外暴露) ================= */

/**
 * @brief  USART1 发送单字节(阻塞式)
 * @note   仅供本文件内部使用
 */
static void USART1_SendByte(uint8_t Byte) {
    /* 等待发送数据寄存器为空 */
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);

    USART_SendData(USART1, Byte);
}

/**
 * @brief  初始化 USART1 串口
 */
void USART1_Init(void) {
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitTypeDef GPIO_InitStructure;

    /* PA9 -> USART1_TX:复用推挽输出 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* PA10 -> USART1_RX:上拉输入 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* USART 参数配置:115200, 8N1 */
    USART_InitTypeDef USART_InitStructure;
    USART_InitStructure.USART_BaudRate = 115200;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_InitStructure.USART_Parity = USART_Parity_No;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_Init(USART1, &USART_InitStructure);
    USART_Cmd(USART1, ENABLE);

    // 以下为USART1全局中断配置
    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

    NVIC_InitTypeDef NVIC_InitStructure;
    NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
    // 使用FreeRTOS时
    // 中断优先级一律建议采用分组4,即4位优先级寄存器全部配置给抢占优先级
    // 抢占优先级设置为12,不建议高于11(可以设置为11)
    // 原因请看文档或者询问老师
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 12;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    USART_Cmd(USART1, ENABLE);
}

/**
 * @brief  调试打印函数(串口版 printf)
 * @param  format: 格式化字符串,和printf函数的第一个参数完全一致
 * @note
 *         1. 用法与 printf 类似
 *         2. 实际输出通过 USART1 完成
 *         3. 用于 FreeRTOS 任务调试与运行观测
 */
void printf1(const char *format, ...) {
    char buffer[100];
    va_list list;
    va_start(list, format);
    vsprintf(buffer, format, list);
    va_end(list);

    for (uint16_t i = 0; buffer[i] != '\0'; i++) {
        USART1_SendByte((uint8_t)buffer[i]);
    }
}

/**
  * @brief USART1中断:收到 1 字节就塞进 FreeRTOS 队列
  */
void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {
        char ch = (char)USART_ReceiveData(USART1);

        if (UartRxQueue != NULL) {
            BaseType_t xFlag = pdFALSE;
            xQueueSendFromISR(UartRxQueue, &ch, &xFlag);
            // 如果xFlag在上述函数中被设置为pdTRUE
            // 则下面的宏函数会触发PendSV,使得ISR结束后触发任务切换
            portYIELD_FROM_ISR(xFlag);
        }
    }
}

main.c源文件:

c 复制代码
#include "stm32f10x.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "LED.h"
#include "USART1.h"

#define BUFFER_SIZE  20

QueueHandle_t UartRxQueue;

/* -------------------- 任务:接收并解析串口命令 -------------------- */
void TaskRecv(void *Argument) {

    char Buffer[BUFFER_SIZE] = { 0 };   // 局部变量必须手动初始化0值
    uint8_t BufferIndex = 0;

    while (1) {
        char ch;

        /* 无限阻塞等待接收队列数据,也就是等待串口发数据 */
        if (xQueueReceive(UartRxQueue, &ch, portMAX_DELAY) == pdPASS) {
            /* 如果接收到的是换行符相关的,直接丢弃 */
            if (ch == '\r' || ch == '\n') {
                continue;
            }

            /* 拼接字符串,永远保证 '\0' 结尾 */
            if (BufferIndex < BUFFER_SIZE - 1) {
                Buffer[BufferIndex++] = ch;
                Buffer[BufferIndex] = '\0';
            } else {
                // 如果接收过长的数据导致Buffer溢出
                // 那么多余数据直接用空字符拼在Buffer末尾
                Buffer[BUFFER_SIZE - 1] = 0;
            }

            if (strcmp(Buffer, "OK") == 0) {
                printf1("Light On \r\n");
                LED_On();
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            } else if (strcmp(Buffer, "ERROR") == 0) {
                printf1("Light Off \r\n");
                LED_Off();
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            }

            /* 非法指令输入,清空Buffer */
            if (BufferIndex > 0 && Buffer[0] != 'O' && Buffer[0] != 'E') {
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            }

            /* 长度超过 ERROR(5) 就清空Buffer */
            if (BufferIndex > 5) {
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            }
        }
    }
}

int main(void) {
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);

    LED_Init();

    /* 创建消息队列:长度 20,每个元素 1 字节 */
    UartRxQueue = xQueueCreate(20, sizeof(char));

    if (UartRxQueue == NULL) {
        printf1("Error: UartRxQueue create failed. \n");

        while (1) {}
    }


    USART1_Init();

    xTaskCreate(TaskRecv,
                "TaskRecv",
                configMINIMAL_STACK_SIZE + 128,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    vTaskStartScheduler();

    while (1) {}
}

以上。

使用消息队列的好处

相比较于使用全局变量实现任务间通信,使用 FreeRTOS 消息队列(Queue) 具有更好的系统结构和更高的可靠性。

下面从几个角度简单说明两者的区别。

首先,从 数据传递方式 来看。

使用全局变量时,本质上是:

  1. 中断函数 直接修改共享变量
  2. 任务 周期性读取共享变量

这种方式依赖任务不断轮询数据是否发生变化。

而使用消息队列时:

  1. 中断函数 将数据发送到队列
  2. 任务 从队列中接收数据

队列在内核中负责缓存数据并协调任务与中断之间的通信。

因此,消息队列本质上是一种内核提供的安全通信机制。

其次,从 CPU利用率 来看。

使用全局变量方案时,任务通常需要写成:

c 复制代码
// 伪代码
while(1){
    判断全局变量状态
    vTaskDelay(...)
}

这种方式实际上属于 轮询机制

如果不加入 vTaskDelay(),任务会一直空转,占用大量 CPU 时间。

而加入延时,任务在延时期间会进入阻塞态,在此期间即使数据已经到达,任务也无法立刻处理。

从而 引入额外的响应延迟,降低系统的实时响应能力。

而使用消息队列时,接收数据的任务可以这样写:

c 复制代码
xQueueReceive(queue, &data, portMAX_DELAY);

当队列中没有数据时:

  1. 任务会 自动进入阻塞态,不占用 CPU
  2. 有数据到来时,会被自动唤醒

因此 消息队列属于事件驱动机制,而不是轮询机制,CPU利用率更高。

因此 消息队列属于事件驱动机制,而不是轮询机制,CPU利用率更高。

第三,从 系统可靠性 来看。

使用全局变量时,需要程序员自己处理很多细节,例如:

  1. Buffer越界问题
  2. 字符串结束符问题
  3. 非法数据清理
  4. 中断与任务访问冲突

如果处理不当,很容易导致程序逻辑错误。

而消息队列由 FreeRTOS 内核管理缓冲区,具有以下特点:

  1. 自动维护队列长度
  2. 自动保证数据顺序(FIFO)
  3. 不会发生数组越界
  4. 中断与任务访问是安全的

因此,使用FreeRTOS后,系统稳定性会更高。

第四,从 程序结构 来看。

全局变量方案中:

  1. 中断函数和任务 强耦合
  2. 两者依赖同一个变量

这种设计在系统复杂后容易变得混乱。

而使用消息队列时:

  1. 中断只负责 发送数据
  2. 任务只负责 处理数据

两者通过队列解耦。

这种设计更加符合现代软件工程的设计理念,即**"高内聚低耦合"**。

最后,从 系统扩展性 来看。

如果系统中只有一个任务处理串口数据,两种方法差别不大。

但如果系统变复杂,例如:

  1. 多个任务需要接收串口数据
  2. 串口数据需要缓存多条消息
  3. 系统中有多个中断源发送数据

此时全局变量方案就会变得非常难维护。

而消息队列天然支持:

  1. 数据缓存
  2. 任务阻塞唤醒
  3. 多任务通信

因此在实际工程中,FreeRTOS 更推荐使用消息队列或其他RTOS通信机制,而不是直接使用全局变量。


总结来说:

使用全局变量实现任务间通信的优点是 实现简单、代码少,简单实验

而使用消息队列则具有以下优势:

  1. 任务可阻塞等待数据,避免CPU空转
  2. 由内核负责管理缓冲区,可靠性更高
  3. 中断与任务之间解耦,程序结构更清晰
  4. 更适合复杂系统和工程开发

因此,在实际 FreeRTOS 项目开发中,推荐优先使用 消息队列 来实现任务间通信。

FreeRTOS消息队列特点总结

经过上面文档的阅读与学习,相信大家已经对FreeRTOS的消息队列有了一定的理解。

下面总结一下,FreeRTOS消息队列的核心特点:

  1. 消息队列既可以用于任务之间通信,也可以用于中断与任务之间通信。具体来说:
    1. 任务发 → 任务收(常见)
    2. 中断发 → 任务收(很常见)
    3. 任务发 → 中断收(几乎没有)
  2. 在嵌入式系统中,最常见的一种模式是:外设中断 → 发送消息到队列 → 任务读取消息并处理。
    1. 在这种模式下,中断只负责收集数据,任务负责处理业务逻辑。
    2. 要重点学习这种消息队列的使用模式,这是实际工程中,最常见的消息队列使用方式。
  3. 消息队列的操作是线程安全的,基于临界区的机制,多个任务同时访问同一个队列时,不会产生竞态条件。
  4. 队列在任务与中断之间,同样能够做到线程安全访问。
    1. FreeRTOS提供了专门的 FromISR版本API,用于在中断中操作队列。
    2. 这些函数内部同样会使用临界区机制,从而保证了线程安全。
    3. 但一定要注意:只有优先级小于或等于11的中断(默认配置),才可以安全调用FromISR函数。
  5. 消息队列具有独立的存储空间,可以动态创建,也允许静态创建。
    1. 二者的核心区别在于:消息队列所需内存由谁负责分配。
    2. 通常来说,消息队列使用动态创建即可,由FreeRTOS内核在内核堆上开辟。
  6. 向队列发送数据的本质是数据拷贝,从队列读取数据本质也是内存拷贝。
  7. 消息队列支持任务阻塞机制,发送和接收数据时,任务都可以进入阻塞态等待条件满足后再完成操作。
  8. 中断中的队列操作不能阻塞,数据收发会立刻成功或失败。

因此,在FreeRTOS系统中,消息队列是最常用、也是最基础的任务间通信机制,是我们学习FreeRTOS的重点。

消息队列的常用函数

从实际工程使用的角度来看,FreeRTOS 的消息队列,其实只需要掌握下面 四个核心函数,基本就已经能够满足绝大多数开发需求。

函数名 作用 使用场景 说明
xQueueCreate() 创建一个消息队列 系统初始化阶段 在内核中创建队列对象,并分配存储空间,返回 QueueHandle_t 队列句柄
xQueueSend() 向队列发送数据 任务中发送数据 如果队列满,可以选择立即返回或进入阻塞等待
xQueueReceive() 从队列接收数据 任务中接收数据 如果队列为空,可以选择立即返回或进入阻塞等待
xQueueSendFromISR() 在中断中发送数据 中断 → 任务通信 ISR 专用版本,不能阻塞,可触发高优先级任务立即调度

在实际项目中,绝大多数情况下 只需要使用这几个函数就足够了。

相关推荐
星幻元宇VR2 小时前
消防安全教育体验展厅设备【模拟灭火系统】
科技·学习·安全
RD_daoyi2 小时前
Google SEO第三周:网站站内基础优化——决定排名快慢的核心基建
大数据·人工智能·学习·搜索引擎·百度·googlecloud
MartinYeung52 小时前
[论文学习]大型语言模型的安全性、安全与隐私问题综述:核心挑战、攻击防禦与未来方向分析
人工智能·学习·安全·语言模型
Ricky05532 小时前
基于对比学习的卫星影像目标检测领域适应方法(2024年美国研究)
人工智能·学习·目标检测
梦072 小时前
学习笔记-ClaudeCode快速安装配置上手
笔记·学习
段一凡-华北理工大学2 小时前
工业领域的Hadoop架构学习~系列文章12:Hadoop集群监控与运维
大数据·人工智能·hadoop·学习·架构·高炉炼铁·高炉炼铁智能化
imDwAaY2 小时前
机器学习入门:从感知机到逻辑回归,理解线性分类器与Softmax CS188 Note20 学习笔记
人工智能·笔记·python·学习·机器学习·逻辑回归
萨小耶3 小时前
[Java学习日记11】聊聊深拷贝和浅拷贝
java·开发语言·学习
一只豌豆象3 小时前
第3讲:单端传输线的时域TDR仿真(基于实战的第一次仿真视角切换)
学习·信号完整性·cst·仿真实战