欢迎光临
我们一直在努力

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

文章目录

  • 任务间为什么需要通信?
  • 如何实现任务间通信
  • FreeRTOS的消息队列
    • 消息队列的机制原理
      • 数组循环队列
      • 四个操作指针
      • FreeRTOS消息队列的结构示意图
      • 阻塞队列的核心(重点)
      • 消息队列的线程安全(重点)
    • 消息队列核心操作函数
      • xQueueCreate:创建消息队列
      • xQueueSend:向队列发送数据
      • xQueueReceive:从队列接收数据
  • 消息队列的使用举例(任务与任务通信)
    • 场景一:一个任务发送不接收
    • 场景二:优先级一致的两个任务通信
    • 扩展了解:利用结构体校验数据边界
  • 消息队列的使用举例(任务与中断通信)
    • xQueueSendFromISR:在中断中向消息队列发送数据
    • 为什么不学习任务向中断发送数据的函数?
    • 使用消息队列的参考实现代码
    • 使用消息队列的好处
  • 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。

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

    消息队列核心数据结构-源码图

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

    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消息队列的基本组成单元,翻译成**“队列元素”**。

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

    数组循环队列

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

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

    一个一个来介绍一下。

    先看第一个:

    volatile UBaseType_t uxMessagesWaiting;

    这个变量表示:

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

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

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

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

    再看第二个成员:

    UBaseType_t uxLength;

    这个成员表示:

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

    强调一点:

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

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

    那么一个元素多大呢?

    这就要看第三个成员:

    UBaseType_t uxItemSize;

    这个成员表示:

    每个元素占多少字节。

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

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

    // 参数1:uxLength, 参数2:uxItemSize
    xQueueCreate( 5, 4 );

    那么:

    uxLength = 5

    uxItemSize = 4

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

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

    创建消息队列源码-图

    创建消息队列源码-图2

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

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

    以下四个成员:

    int8_t *pcHead;
    int8_t *pcTail;
    int8_t *pcWriteTo;
    int8_t *pcReadFrom;

    本质上是:

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

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

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

    其中:

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

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

    注意:

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

    也就是说:

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

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

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

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

    pcHead源码-图

    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消息队列的结构示意-图

    这张图主要展示了:

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

    四个核心指针的指向-图1

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

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

    四个核心指针的指向-图2

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

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

    四个核心指针的指向-图3

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

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

    四个核心指针的指向-图4

    可以看到:

    正是基于两个固定的边界指针(pcHead、pcTail),以及两个可以回绕移动的读写位置指针(pcWriteTo、pcReadFrom)

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

    阻塞队列的核心(重点)

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

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

    List_t xTasksWaitingToSend;
    List_t xTasksWaitingToReceive;

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

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

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

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

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

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

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

  • 被从就绪列表中移除
  • 被插入到 xTasksWaitingToSend 列表,也就是进入了"等待发送阻塞列表"
  • 进入阻塞态
  • 主动放弃CPU执行权,让出CPU
  • 那等待什么条件被唤醒呢?

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

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

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

  • 被从就绪列表中移除
  • 被插入到 xTasksWaitingToReceive 列表,也就是进入了“等待接收阻塞列表”
  • 进入阻塞态
  • 主动放弃CPU执行权,让出CPU
  • 等什么时候被唤醒呢?

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

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

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

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

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

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

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

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

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

  • FreeRTOS的消息队列,其存储元素的队列部分,底层实现是一个数组循环队列
  • FreeRTOS的消息队列,之所以是一个阻塞队列,是因为内核维护了两个等待阻塞列表
  • 到这里,FreeRTOS消息队列的大部分原理,你就搞懂了。

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

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

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

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

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

    原因也很简单:

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

    比如下列源码:

    消息队列的线程安全源码-图

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

    taskENTER_CRITICAL();
    ...
    taskEXIT_CRITICAL();

    这意味着:

  • 在任务进行读写操作消息队列时,在临界区内,不会发生任务切换。
  • 在默认配置下,此临界区不会被优先级小于等于11的中断打断。优先级高于11的中断(比如10)则可以打断临界区的执行。
  • 如果是在自定义中断中调用相关任务间通信API,一定要确保此中断的优先级是11或更低优先级的中断!
  • 队列结构体中的关键成员(如 pcWriteTo、pcReadFrom、uxMessagesWaiting)的修改是原子操作,不会被打断。
  • 所以,通过加临界区的方式,使得队列的数据修改阶段,其操作是一个原子操作。

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

    最后做一个总结:

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

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

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

    消息队列核心操作函数

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

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

  • xQueueCreate函数,创建消息队列
  • xQueueSend函数,向消息队列发送数据
  • xQueueReceive函数,从消息队列接收数据
  • 可以说,只要掌握了这三个函数,消息队列的基本使用就已经完全没有问题了。

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

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

    #define configSUPPORT_DYNAMIC_ALLOCATION 1

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

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

    xQueueCreate:创建消息队列

    其函数原型如下:

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

    两个参数非常关键:

  • uxQueueLength:队列能存放多少个元素
  • uxItemSize:每个元素的大小(单位:字节)
  • 这两个参数共同决定了消息队列的数据区域——数组循环队列的长度大小,以及可以存储几个元素。

    举例:

    QueueHandle_t Queue = xQueueCreate(5, 4);

    这表示:

  • 队列最多存 5 个元素
  • 每个元素 4 字节
  • 底层会分配 5 × 4 = 20 字节的连续内存,也就是一个数组
  • 返回值,得到一个QueueHandle_t类型的返回值。

    QueueHandle_t类型别名-图

    这种情况我们太熟悉的。

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

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

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

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

    xQueueSend:向队列发送数据

    其函数原型如下:

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

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

    xQueue参数:

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

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

    pvItemToQueue参数:

    表示要发送数据的地址。

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

    需要特别强调:

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

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

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

    xQueueSend实现-源码

    例如:

    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字节无符号整型的最大值:

    portMAX_DELAY宏源码-图

    这么看,使用这个宏填在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 消息队列的核心机制以及工作原理。

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

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

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

    示例代码如下:

    #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个数据后,会始终等待队列空位置,会持续阻塞。

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

    示例代码如下:

    #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 类型。如下图所示:

    List_t类型源码-图

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

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

    这是怎么实现的呢?

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

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

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

    比如:

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

    pxQueue = xQueueCreate(5, 20);

    这意味着:

  • 队列中每个元素大小是 20 字节
  • 每次发送 / 接收时,FreeRTOS 内部都会执行 memcpy
  • 并且从指定指针位置开始,固定拷贝 20 个字节
  • 注意这里的关键点队列拷贝是“按创建时的 uxItemSize 固定长度拷贝”它不会管你实际数据有多大

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

    uint32_t Value = 1;
    xQueueSend(pxQueue, &Value, portMAX_DELAY);

    一个 uint32_t 只有 4 字节。

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

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

    也就是说:

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

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

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

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

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

    /* 结构体成员布局是连续的,确保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 在一些数据结构中使用边界校验成员的核心思想。

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

    #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头文件:

    #ifndef __LED_H__
    #define __LED_H__

    #include "stm32f10x.h"

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

    #endif

    LED.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头文件:

    #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源文件:

    #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源代码文件

    #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,表示无事发生,什么都不会做,相当于函数没有这个参数。

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

    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 结束后立刻进行任务调度,从而让高优先级任务更快地处理数据。


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

    xQueueSendFromISR(xx, &xx, NULL);

    这种写法表示:

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

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

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

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

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

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

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

    因此,从设计上来说:

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

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

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

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

    主要做两点核心变化:

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

    USART.h头文件:

    #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源文件:

    #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源文件:

    #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利用率 来看。

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

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

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

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

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

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

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

    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 专用版本,不能阻塞,可触发高优先级任务立即调度

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

    赞(0)
    未经允许不得转载:171主机测评 » FreeRTOS学习(10)——消息队列
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址