文章目录
- 任务间为什么需要通信?
- 如何实现任务间通信
- FreeRTOS的消息队列
- 消息队列的使用举例(任务与任务通信)
- 消息队列的使用举例(任务与中断通信)
- FreeRTOS消息队列特点总结
- 消息队列的常用函数
任务间为什么需要通信?
在 FreeRTOS 多任务系统中,本质上就是很多个并行干活的工人。
比如:
- A 任务采集传感器数据
- B 任务负责 OLED 刷屏
- C 任务负责串口打印
- D 任务负责报警/蜂鸣器
- ...
但工人多了就一定会遇到一个现实问题:
大多数时候你干你的,我干我的,但总有需要协同工作的时候。
比如:
- A 任务采集到温度数据以后,B 才能刷新 OLED
- A 任务采集到异常值以后,D 才需要鸣叫报警
- C 任务打印数据之前,必须等 A 任务把数据准备好
- ...
它们之间必然要传数据、发通知、同步工作节奏、互相"喊一声"。
在基于 FreeRTOS 实时操作系统的多任务环境中,各任务运行在相互独立的执行上下文中。
为了完成协同工作,例如:
- 任务A采集数据
- 任务B处理数据
- 任务C显示结果
就必须通过某种机制完成:
- 数据传递
- 执行同步
- 资源协调
这些机制统称为:任务间通信(Inter-Task Communication, ITC)
所以,我们可以看到,任务间具有通信的需求,就像"多个工人"完成工作需要沟通协作一样。
除此之外,任务与中断之间也有通信的需求,而且多是中断向中断传输数据、或者发送通知。
虽然标题叫做任务间通信,但任务与中断间的通信,也一并也在这里讲解。
如何实现任务间通信
现在我们已经知道,任务间确实存在通信的需求。
任务之间不仅要各自运行,还必须互相交换数据、协调工作节奏。
那问题来了:
既然任务之间需要通信,那么如何实现任务间的通信呢?
一个最简单,最容易想到的方式是:
使用全局变量。
比如定义一个全局变量:
int16_t Temperature;
A 任务负责采集传感器数据:
Temperature = ReadSensor();
B 任务负责在 OLED 上显示数据:
OLED_ShowNum(Temperature);
乍一看,好像也没什么毛病。
但实际上问题还是挺多的,在多个任务并行的系统中:
- 如果 A 任务数据写到一半,B 任务刚好被调度运行去读,怎么办?
- 读写过程中被抢占怎么办?
- 多个任务同时访问这个变量怎么办?
- B 任务如果想"只在有新数据时再刷新OLED",怎么办?
- ...
你会发现:
事情开始变得复杂了。
为了弥补这些问题,往往就需要:
- 增加各种全局标志位
- 增加状态变量
- 手动加锁
- 手动判断数据是否更新
- ...
慢慢地,你的程序里就会出现一堆xxxFlag:
IsDataReady
DataUpdatedFlag
BusyFlag
UpdateFinishedFlag
你的大量时间,不再用来做功能开发,而是花在维护这些全局变量和状态逻辑上。
这显然是非常不可取的。
因为:
搞的太复杂,既容易出错,又难以维护,还严重影响系统的可扩展性。
那怎么办呢?
FreeRTOS 官方早就替我们思考过这个问题。
在 FreeRTOS 中,已经为我们提供了一套标准、安全、可控的任务间通信机制。
一共有哪些手段实现任务间通信呢?
FreeRTOS 为我们提供了以下常见的任务间通信手段:
- 消息队列(Queue)
- 二值信号量(Binary Semaphore)
- 计数信号量(Counting Semaphore)
- 事件标志组(Event Group)
- 任务通知(Task Notification)
- 互斥信号量(Mutex)
- 队列集(Queue Set)
这些通信机制看起来很多,但并不是每一种都同等重要、同等常用。
从工程实践角度来说,可以抓住三条主线:
- 如果是两个任务/中断之间收发数据,优先考虑使用消息队列。
- 它是最基础、最常用、最核心的任务间通信手段。
- 绝大多数"任务间传数据"的场景,都可以直接使用消息队列去解决。
- 如果是一个任务/中断给另一个任务发"通知"而不是传数据,优先考虑任务通知。
- 它是 FreeRTOS 官方最推荐的,轻量级、高性能的任务间实现通知的通信手段。
- 任务通知实际上类似二值信号量,但它性能更好,某种程度上可以把它视为二值信号量的升级版。
- 如果需要多个任务/中断互斥访问共享资源,实现类似临界区的对共享资源保护,应使用互斥信号量。
- 互斥信号量是基于任务间通信的方式,来实现临界区,本质是一个互斥锁。
- 它带有优先级继承机制,专门用于解决优先级反转问题,是共享资源保护的标准做法。
当然,总的来说:
消息队列是基础,是核心。
不管怎么样,消息队列都是这些手段中的基础。
其余通信手段,其实大部分也都涉及消息队列的使用。
所以接下来,接下来,我们就从最核心的通信手段开始------
看看消息队列是如何实现任务间通信的。
FreeRTOS的消息队列
在前面的课程内容中,我们已经学习过诸如:
- FreeRTOS任务
- FreeRTOS阻塞列表
- FreeRTOS临界区
- ...
等概念。
有了这些概念理论做支撑,我们直接看消息队列的源码,直接搞懂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个字节元素的数组,而且它是一个数组循环队列。
上面描述的内容,可以参考以下源码:


通常这些源码,我们能够得到以下结论:
- FreeRTOS消息队列的控制结构体,和对应的数据区域(数组循环队列区域),是直接一次性分配的连续空间。
- 数组循环队列,消息队列的数据区就紧跟在控制结构体后面。
- 之所以这样设计,是因为都使用连续内存性能更好,且分配和释放都只需要一次,更快捷。
四个操作指针
以下四个成员:
c
int8_t *pcHead;
int8_t *pcTail;
int8_t *pcWriteTo;
int8_t *pcReadFrom;
本质上是:
用来管理一块连续数组内存的"边界指针 + 读写位置指针"。
我们已经知道,消息队列的数据区域本质上是一个数组。
那么这四个指针的作用,就是对这块数组内存区域进行"循环队列式"的管理。
其中:
pcHead指针:指向数组的起始地址,它是整个循环队列的"起点"。
pcTail指针,指向数组末尾的越界地址。
注意:
它并不是指向"最后一个元素",而是指向"最后一个元素的下一个元素"。
也就是说:
pcTail 本质上是循环队列数组的"终止边界标记"。
以上两个指针是固定不动的。
它们只是用来标识这块内存区域的边界,在队列运行过程中不会发生移动。
关于这两点,可以通过以下源码获悉:


在队列进行读写操作时,真正会移动的是下面两个指针:
pcWriteTo 指针:指向"下一次写入元素"的位置。
FreeRTOS 的设计是:
- 写队列操作时,先在 pcWriteTo 指向的位置写一个元素
- 然后再移动pcWriteTo 指针
- 如果该指针移动到pcTail位置,说明越界了,那么pcWriteTo 指针就会回绕到pcHead位置。
- pcWriteTo 指针一开始指向pcHead,表示队列为空,从数组起点开始写入数据。
这就是循环队列的由来。
pcReadFrom 指针:指向"上一次读取元素的位置"。
注意这里非常关键:
它并不是指向"当前可读元素"。
FreeRTOS 的设计是:
- 读队列操作时,先移动 pcReadFrom 到下一个元素
- 如果移动后
pcReadFrom == pcTail,则回绕到pcHead - 如果没到末尾或已经回绕,则从移动后的
pcReadFrom位置读一个元素
把读指针设计为"指向上一次读的位置",可以让读操作也采用"先移动(含回绕处理)→ 再读取"的统一流程,减少边界的特殊判定。
关于这两个读写位置指针,其源码如下图所示:

所以,这四个指针本质就是:
两个边界指针(Head / Tail)
两个移动指针(Write / Read)
它们配合在一起,让一个普通数组具备了:循环队列的能力。
最后还是需要强调一点的就是:
队列满不满,不靠指针判断,而是靠成员uxMessagesWaiting来进行判断。
指针只负责移动,这个计数器用来记录队列状态。
FreeRTOS消息队列的结构示意图
依照上面的内容,我们可以画出以下 FreeRTOS 消息队列的结构示意图:

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

现在向队列 新增一个元素(入队列):
- 首先将新元素 复制到
pcWriteTo指针当前指向的位置。 - 随后将
pcWriteTo移动到下一个元素位置,表示下一次写入的位置。
如下图所示

接下来 从队列读取一个元素(出队列):
- 首先将
pcReadFrom移动到下一个元素位置。 - 如果发现
pcReadFrom == pcTail,说明已经到达数组末尾,此时发生 回绕 ,pcReadFrom指针回到pcHead位置。 - 随后 读取
pcReadFrom指向位置的元素数据,并将该数据返回给调用者。
如下图所示:

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

可以看到:
正是基于两个固定的边界指针(pcHead、pcTail),以及两个可以回绕移动的读写位置指针(pcWriteTo、pcReadFrom)
FreeRTOS 的消息队列最终实现了一个 循环数组队列(Circular Queue) 的数据结构。
阻塞队列的核心(重点)
FreeRTOS的消息队列是一个阻塞队列,那么它为什么可以阻塞呢?
本质原因就是下面两个成员:
c
List_t xTasksWaitingToSend;
List_t xTasksWaitingToReceive;
这个List_t类型,我们并不陌生。
因为我们前面讲过的:就绪列表、阻塞列表等列表,它们底层用的,都是基于List_t类型的一条链表结构。
所以现在你应该意识到一件事:
FreeRTOS的消息队列之所以能"阻塞",是一个阻塞队列
必然不是因为其数组的结构,也跟四个管理指针没有关系
而是队列控制块内部维护了两个"事件等待列表",用于管理因等待队列条件而进入阻塞态的任务。
一旦某个消息队列满了,那么负责往队列发送元素的任务,如果还想继续发送元素,就会:
- 被从就绪列表中移除
- 被插入到
xTasksWaitingToSend列表,也就是进入了"等待发送阻塞列表" - 进入阻塞态
- 主动放弃CPU执行权,让出CPU
那等待什么条件被唤醒呢?
当有其他任务从队列接收数据,从队列中取走一个元素,腾出一个空位之后。
调度器会唤醒此任务,任务从阻塞态回到就绪态,随后根据优先级进行任务调度。
一旦某个消息队列是空的,那么负责从队列接收元素的任务,如果还想继续接收元素,就会:
- 被从就绪列表中移除
- 被插入到
xTasksWaitingToReceive列表,也就是进入了"等待接收阻塞列表" - 进入阻塞态
- 主动放弃CPU执行权,让出CPU
等什么时候被唤醒呢?
当有其他任务往队列发送1个元素,消息队列不再为空之后。
调度器会唤醒此任务,任务从阻塞态回到就绪态,随后根据优先级进行任务调度。
所以,我们可以得出一个非常关键的结论:
FreeRTOS的消息队列是一个阻塞队列
但这个阻塞指的是"执行收发队列元素的任务"被阻塞,而不是队列本身是阻塞式的。
被阻塞的是任务,而不是队列本身
消息队列本身,就只是一个普通的数组循环队列。
消息队列的线程安全(重点)
如果你没深入的看上述文档,但看到这里,你也应该记住:
- FreeRTOS的消息队列,其存储元素的队列部分,底层实现是一个数组循环队列
- FreeRTOS的消息队列,之所以是一个阻塞队列,是因为内核维护了两个等待阻塞列表
到这里,FreeRTOS消息队列的大部分原理,你就搞懂了。
但还有一个非常核心的问题:
两个任务,乃至于多个任务,都需要操作同一个消息队列。
在FreeRTOS的多任务环境下,这很显然属于,访问了共享资源。
这势必产生竞态条件,导致线程安全问题。
那消息队列是如何解决这个问题,保持线程安全,保证多个任务同时访问它,而不会乱呢?
原因也很简单:
FreeRTOS消息队列的读写操作,都是在临界区内完成的,某个任务在执行读写队列操作时,执行的都是一个原子操作。
比如下列源码:

需要注意的是,消息队列使用的临界区是:
c
taskENTER_CRITICAL();
...
taskEXIT_CRITICAL();
这意味着:
- 在任务进行读写操作消息队列时,在临界区内,不会发生任务切换。
- 在默认配置下,此临界区不会被优先级小于等于11的中断打断。优先级高于11的中断(比如10)则可以打断临界区的执行。
- 如果是在自定义中断中调用相关任务间通信API,一定要确保此中断的优先级是11或更低优先级的中断!
- 队列结构体中的关键成员(如
pcWriteTo、pcReadFrom、uxMessagesWaiting)的修改是原子操作,不会被打断。
所以,通过加临界区的方式,使得队列的数据修改阶段,其操作是一个原子操作。
从而保障了消息队列的线程安全。
最后做一个总结:
消息队列的阻塞机制解决了"任务调度问题"
而临界区机制解决了"并发线程安全问题"
两套机制配合,构成了完整的、安全的、消息队列实现。
消息队列核心操作函数
了解了 FreeRTOS 消息队列的核心机制和工作原理之后,下面我们正式进入 消息队列的核心操作函数 部分。
在 FreeRTOS 中,消息队列的核心 API 实际上只有三个,也是我们在实际开发中最常用、最重要的三个函数:
- xQueueCreate函数,创建消息队列
- xQueueSend函数,向消息队列发送数据
- 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, // 数组循环队列中每个元素的大小,以字节为单位
)
两个参数非常关键:
uxQueueLength:队列能存放多少个元素uxItemSize:每个元素的大小(单位:字节)
这两个参数共同决定了消息队列的数据区域------数组循环队列的长度大小,以及可以存储几个元素。
举例:
c
QueueHandle_t Queue = xQueueCreate(5, 4);
这表示:
- 队列最多存 5 个元素
- 每个元素 4 字节
- 底层会分配 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)。
它的取值规则如下:
- 0 表示不等待,立即返回发送的结果(成功或失败)。
- 大于 0 表示最多等待指定 tick 数。
- portMAX_DELAY 表示一直阻塞等待(前提是允许无限期阻塞,FreeRTOS默认允许无限期阻塞)。
关于宏portMAX_DELAY ,这里要多说两句:
第一,从宏定义上来说,它其实就是4字节无符号整型的最大值:

这么看,使用这个宏填在xTicksToWait位置,表示最大阻塞等待大约49天多。
这个时间对于嵌入式系统而言,确实已经非常"大"了。几乎可以说是无限期阻塞等待。
但事实真的是这样吗?
当然不是。
实际上:
在使用宏portMAX_DELAY作为参数,配合默认允许无限期阻塞的宏配置:

熟悉不,这不就是之前学挂起状态的相关宏。
相关的源码如下:

所以内核实际是这么做的:
使用 portMAX_DELAY 且队列操作的条件不满足时,任务实际上被内核移入挂起列表,进入挂起态。
但这个挂起态比较特殊:
- 本质上任务处于"无限期等待"的延时阻塞状态,只不过为了方便,内核将它移入了挂起列表。
- 当事件满足(比如队列腾出空位)时,内核会把任务从挂起列表中移出,重新放回 就绪列表,随后参与调度继续完成发送。
关于函数的返回值也要着重说一下:
- 如果函数返回 pdPASS 表示发送成功。
- 如果函数返回 errQUEUE_FULL 则表示队列已满,无法发送数据到队列。errQUEUE_FULL 等价于 pdFALSE/pdFAIL。
此函数的行为逻辑可以这样描述:
- 如果队列有空位,立刻把数据拷贝到队列,数据发送成功,返回 pdPASS
- 如果队列没有空位,当 xTicksToWait = 0,立即返回 errQUEUE_FULL 表示失败。
- 如果队列没有空位,当 xTicksToWait > 0 时:
- 当前任务进入阻塞态,主动放弃CPU执行权
- 随后任务被延时最多 xTicksToWait 个Tick
- 若在等待时间内队列腾出空间,任务被唤醒回到就绪态,随后获取CPU执行权,完成数据发送。
- 若超时队列仍无空间,返回 errQUEUE_FULL 发送失败。
- 如果队列没有空位,当 xTicksToWait = portMAX_DELAY时:
- 任务进入 无限期等待(实际由内核操作,进入挂起列表)
- 如果队列一直没有空位,那么当前任务就会一直挂起,那么函数就会一直阻塞等待,函数始终不会返回
- 一旦空位出现,内核就会让任务恢复挂起,回到就绪态,随后获取CPU执行权发送数据。
理解了往队列发送数据,那么从队列接收数据也是差不多的,只不过数据方向改变了。
xQueueReceive:从队列接收数据
函数原型如下:
BaseType_t xQueueReceive(
QueueHandle_t xQueue, // 表示要操作的队列句柄。
void * pvBuffer, // 表示用于接收数据的缓冲区地址。
TickType_t xTicksToWait // 消息队列空时,最多愿意等多久(单位:tick)
);
该函数用于,在任务中从消息队列接收数据,也就是从队列头部移除一个元素,并返回元素的取值。
xQueueReceive 的本质是:
从队列中取出一个元素,并拷贝到用户提供的缓冲区,若队列为空,则根据等待时间决定是否阻塞。
**xQueue参数:**表示要操作的队列句柄。
pvBuffer参数:
表示用于接收数据的缓冲区地址。
当接收成功时,队列会将内部存储的一个元素,按 uxItemSize 字节拷贝到 pvBuffer 指向的内存空间。
队列创建时的元素大小、发送时的数据大小,接收时的数据大小应当保持一致。
最简单的设定就是,直接让它们的类型都保持一致。
需要注意的是:
xQueueReceive接收队列一个元素后,该元素会被从队列中移除,也就是所谓的"出队"。
xTicksToWait参数:
使用方式和原理与xQueueSend函数的xTicksToWait参数没有任何区别。
这里不再赘述。
返回值有两种:
- pdPASS 表示接收成功。
- 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) {}
}
程序的行为是:
- 队列容量是 5,且没有接收者(只有生产者,没有消费者)
- 所以前 5 次发送必定成功(返回 pdPASS)
- 第 6 次开始队列满,
xQueueSend(..., 3000)会让任务进入 阻塞等待 - 因为没人接收,等待永远等不到空位。所以 3000 tick 后超时返回 errQUEUE_FULL
- 从第 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);
}
最终程序的行为:
- Send 每 1000ms 发一次,发完立刻进入阻塞状态
- Recv 用
portMAX_DELAY无限期等消息 - Recv 优先级虽然没有更高,但 Send 发完后就会阻塞放弃CPU执行权,所以只要队列有数据,立刻被唤醒,接收打印数据。
扩展了解:利用结构体校验数据边界
在之前学习任务列表时,我们知道列表的基本类型是一个 List_t 类型。如下图所示:

可以看到:这个结构体的首尾会放置 边界校验成员宏,用于检测内存是否存在非法覆盖。
这种机制本质上就是一种 结构体边界保护Guard 保护思想
这是怎么实现的呢?
恰好在学习消息队列时,有同学提出了这样的疑问:
如果我们在使用消息队列时,
队列的元素大小 和 实际收发元素的大小 不一致,会发生什么?
比如:
我们创建消息队列时是这样写的:
pxQueue = xQueueCreate(5, 20);
这意味着:
- 队列中每个元素大小是 20 字节
- 每次发送 / 接收时,FreeRTOS 内部都会执行
memcpy - 并且从指定指针位置开始,固定拷贝 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;
其中:
RecvValue用来"接收"数据(注意:这里故意用错)CheckValue1 / CheckValue2用作边界校验- 如果
CheckValue2的值发生变化,就说明发生了越界覆盖
为什么这个方法有效?
因为结构体成员在内存中是连续排列的。
当我们从 &RecvValue 开始写入 20 字节时:
- 前 4 字节写入
RecvValue - 后续字节会继续写入
CheckValue2所在位置 - 导致
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) {}
}
你可以运行这段代码观察现象:
- 初始时
CheckValue2 = 0xBBBBBBBB - 一旦发生接收拷贝,
CheckValue2很大概率会被改写
这就说明:队列元素大小与实际接收缓冲区大小不匹配,会导致越界写入。
也就能理解:
为什么 FreeRTOS 会在某些结构体首尾设计边界校验成员,用于快速发现"内存被非法覆盖"的问题。
消息队列的使用举例(任务与中断通信)
现在我们来完成这样的一道编程题目,实现以下功能。
题目描述如下:
在基于SPL库 + FreeRTOS的开发环境下实现一个简单的串口命令解析程序。
PC 通过串口工具向STM32发送字符串命令:
- 当收到字符串
"OK"时,点亮 LED,并通过串口回复PC端Light On - 当收到字符串
"ERROR"时,熄灭 LED,并通过串口回复PC端Light Off
要求如下:
- 串口采用 USART1 中断方式,进行接收数据。
- 用于接收指令字符串的
Buffer数组必须始终保持为合法的 C 字符串(以'\0'结尾)。 - 为防止接收数据过长导致数组越界,需要实现 Buffer缓冲区溢出保护。
- 若接收到 非法指令(如
ABC) ,系统应能够自动清空Buffer并恢复正常工作,避免后续命令无法识别。 - 成功解析命令后,应 清空 Buffer 并重置索引变量,以便接收下一条命令。
- 建议对
\r和\n这两个Windows环境下的换行符,直接过滤掉,不处理。
求解这道题目,建议采用下面的思路:
- 在USART1中断中处理数据接收
- 创建一个FreeRTOS任务,在任务中处理接收数据后的业务逻辑
很不使用消息队列的解决方式然,中断和任务之间要共享同一个存储字符串指令的字符数组,于是就涉及到任务间通信。
在本章节的开头,我们已经提到过:如果不使用消息队列,那么中断和任务之间的"任务间通信",就只能靠共享同一块内存Buffer来完成。
说白了就是:
- ISR(中断)负责把字符一个一个塞进 Buffer
- 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函数并无不同:
都是向消息队列 发送一个元素,也就是将用户提供的数据 拷贝到消息队列的内部存储空间。
但仍然主要有两点不同:
- 该函数专门用于中断服务函数(ISR)中调用,xQueueSend则必须在任务中使用。
- 该函数既然用于中断中,那就不存在所谓阻塞,在调用时:
- 如果队列仍然有空位,则发送成功,返回pdPASS
- 如果队列已满,则会直接发送失败,退出函数,返回errQUEUE_FULL
前面两个参数和xQueueSend函数一致,这里不再赘述。
这里讲一下第三个参数:pxHigherPriorityTaskWoken。
这个参数通常,只有两种传参的方式,两种不同的调用方式。
第一种,可以直接传参NULL,表示无事发生,什么都不会做,相当于函数没有这个参数。
第二种,调用和传参的方式,如下:
c
BaseType_t Flag = pdFALSE;
xQueueSendFromISR(xx, &xx, &Flag);
portYIELD_FROM_ISR(Flag);
解释一下这种调用方式。
简单来说,这三行代码实际上完成了 三件事情:
- 向消息队列发送数据(xQueueSendFromISR函数的基本功能)
- 判断是否唤醒了更高优先级任务(xQueueSendFromISR函数内部实现此功能)
- 若唤醒了,则在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 中,我们更多关注的是"中断 → 任务"的通信,而基本不会讨论"任务 → 中断"。
其根本原因在于:中断不具备等待能力。
任务可以通过消息队列,或其他任务间通信机制进入阻塞态,从而等待数据,因此需要中断或其他任务去"唤醒它"。
而中断必须快速执行并退出,既不能阻塞,也不会长期处于等待状态,因此不存在"等待任务发送数据"的需求。
因此,从设计上来说:
通信机制的核心,是让"能等待的一方去等待"。
而中断无法等待,只能作为"事件的产生者"或"数据的发送者",这就是我们主要学习"中断 → 任务"通信的原因。
使用消息队列的参考实现代码
讲完了上面的函数,那么使用消息队列来实现上述功能,就很简单了。
主要做两点核心变化:
USART1_IRQHandler():收到 1 字节 →xQueueSendFromISR()向消息队列中发送这个字节数据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) 具有更好的系统结构和更高的可靠性。
下面从几个角度简单说明两者的区别。
首先,从 数据传递方式 来看。
使用全局变量时,本质上是:
- 中断函数 直接修改共享变量
- 任务 周期性读取共享变量
这种方式依赖任务不断轮询数据是否发生变化。
而使用消息队列时:
- 中断函数 将数据发送到队列
- 任务 从队列中接收数据
队列在内核中负责缓存数据并协调任务与中断之间的通信。
因此,消息队列本质上是一种内核提供的安全通信机制。
其次,从 CPU利用率 来看。
使用全局变量方案时,任务通常需要写成:
c
// 伪代码
while(1){
判断全局变量状态
vTaskDelay(...)
}
这种方式实际上属于 轮询机制。
如果不加入 vTaskDelay(),任务会一直空转,占用大量 CPU 时间。
而加入延时,任务在延时期间会进入阻塞态,在此期间即使数据已经到达,任务也无法立刻处理。
从而 引入额外的响应延迟,降低系统的实时响应能力。
而使用消息队列时,接收数据的任务可以这样写:
c
xQueueReceive(queue, &data, portMAX_DELAY);
当队列中没有数据时:
- 任务会 自动进入阻塞态,不占用 CPU
- 有数据到来时,会被自动唤醒
因此 消息队列属于事件驱动机制,而不是轮询机制,CPU利用率更高。
因此 消息队列属于事件驱动机制,而不是轮询机制,CPU利用率更高。
第三,从 系统可靠性 来看。
使用全局变量时,需要程序员自己处理很多细节,例如:
- Buffer越界问题
- 字符串结束符问题
- 非法数据清理
- 中断与任务访问冲突
如果处理不当,很容易导致程序逻辑错误。
而消息队列由 FreeRTOS 内核管理缓冲区,具有以下特点:
- 自动维护队列长度
- 自动保证数据顺序(FIFO)
- 不会发生数组越界
- 中断与任务访问是安全的
因此,使用FreeRTOS后,系统稳定性会更高。
第四,从 程序结构 来看。
全局变量方案中:
- 中断函数和任务 强耦合
- 两者依赖同一个变量
这种设计在系统复杂后容易变得混乱。
而使用消息队列时:
- 中断只负责 发送数据
- 任务只负责 处理数据
两者通过队列解耦。
这种设计更加符合现代软件工程的设计理念,即**"高内聚低耦合"**。
最后,从 系统扩展性 来看。
如果系统中只有一个任务处理串口数据,两种方法差别不大。
但如果系统变复杂,例如:
- 多个任务需要接收串口数据
- 串口数据需要缓存多条消息
- 系统中有多个中断源发送数据
此时全局变量方案就会变得非常难维护。
而消息队列天然支持:
- 数据缓存
- 任务阻塞唤醒
- 多任务通信
因此在实际工程中,FreeRTOS 更推荐使用消息队列或其他RTOS通信机制,而不是直接使用全局变量。
总结来说:
使用全局变量实现任务间通信的优点是 实现简单、代码少,简单实验。
而使用消息队列则具有以下优势:
- 任务可阻塞等待数据,避免CPU空转
- 由内核负责管理缓冲区,可靠性更高
- 中断与任务之间解耦,程序结构更清晰
- 更适合复杂系统和工程开发
因此,在实际 FreeRTOS 项目开发中,推荐优先使用 消息队列 来实现任务间通信。
FreeRTOS消息队列特点总结
经过上面文档的阅读与学习,相信大家已经对FreeRTOS的消息队列有了一定的理解。
下面总结一下,FreeRTOS消息队列的核心特点:
- 消息队列既可以用于任务之间通信,也可以用于中断与任务之间通信。具体来说:
- 任务发 → 任务收(常见)
- 中断发 → 任务收(很常见)
- 任务发 → 中断收(几乎没有)
- 在嵌入式系统中,最常见的一种模式是:外设中断 → 发送消息到队列 → 任务读取消息并处理。
- 在这种模式下,中断只负责收集数据,任务负责处理业务逻辑。
- 要重点学习这种消息队列的使用模式,这是实际工程中,最常见的消息队列使用方式。
- 消息队列的操作是线程安全的,基于临界区的机制,多个任务同时访问同一个队列时,不会产生竞态条件。
- 队列在任务与中断之间,同样能够做到线程安全访问。
- FreeRTOS提供了专门的 FromISR版本API,用于在中断中操作队列。
- 这些函数内部同样会使用临界区机制,从而保证了线程安全。
- 但一定要注意:只有优先级小于或等于11的中断(默认配置),才可以安全调用FromISR函数。
- 消息队列具有独立的存储空间,可以动态创建,也允许静态创建。
- 二者的核心区别在于:消息队列所需内存由谁负责分配。
- 通常来说,消息队列使用动态创建即可,由FreeRTOS内核在内核堆上开辟。
- 向队列发送数据的本质是数据拷贝,从队列读取数据本质也是内存拷贝。
- 消息队列支持任务阻塞机制,发送和接收数据时,任务都可以进入阻塞态等待条件满足后再完成操作。
- 中断中的队列操作不能阻塞,数据收发会立刻成功或失败。
因此,在FreeRTOS系统中,消息队列是最常用、也是最基础的任务间通信机制,是我们学习FreeRTOS的重点。
消息队列的常用函数
从实际工程使用的角度来看,FreeRTOS 的消息队列,其实只需要掌握下面 四个核心函数,基本就已经能够满足绝大多数开发需求。
| 函数名 | 作用 | 使用场景 | 说明 |
|---|---|---|---|
xQueueCreate() |
创建一个消息队列 | 系统初始化阶段 | 在内核中创建队列对象,并分配存储空间,返回 QueueHandle_t 队列句柄 |
xQueueSend() |
向队列发送数据 | 任务中发送数据 | 如果队列满,可以选择立即返回或进入阻塞等待 |
xQueueReceive() |
从队列接收数据 | 任务中接收数据 | 如果队列为空,可以选择立即返回或进入阻塞等待 |
xQueueSendFromISR() |
在中断中发送数据 | 中断 → 任务通信 | ISR 专用版本,不能阻塞,可触发高优先级任务立即调度 |
在实际项目中,绝大多数情况下 只需要使用这几个函数就足够了。