前言:在 FreeRTOS 中,仅仅让任务“并发运行”还远远不够,更重要的是让它们能够有序协作。为了解决这些问题,FreeRTOS 提供了三类非常核心的机制:同步、互斥和队列。其中,同步与互斥主要解决“谁先做、谁能访问”的问题,而队列则重点解决“任务之间如何传递数据”的问题。

1. 同步与互斥

        在多任务系统中,多个任务并发运行只是第一步,更重要的是让这些任务能够按照正确顺序协作,并安全访问共享资源。这就引出了两个核心概念:同步与互斥。它们看起来很像,但解决的问题并不相同。

1.1. 同步与互斥的概念

1.1.1. 什么是同步

        同步解决的是:任务之间的执行顺序问题。也就是说,一个任务往往不能“想什么时候跑就什么时候跑”,而是需要等待另一个任务先完成某个动作。例如:

  • 任务A采集传感器数据
  • 任务B负责显示结果

那么任务B就必须等待任务A先把数据准备好,否则就会读到错误数据。这个“等待某件事发生”的过程,本质上就是同步。

        可以理解为:同步 = 谁先做,谁后做。

1.1.2. 什么是互斥

        互斥解决的是:共享资源冲突问题。当多个任务都可能访问同一个资源(例如串口、LCD、I2C总线)时,如果同时访问,就可能发生数据混乱。因此必须保证同一时刻只有一个任务能访问这个资源。例如两个任务同时打印串口:

  • Task A 打印 "Hello"
  • Task B 打印 "World"

如果没有互斥,最终输出可能变成:

HalloWorld

这就是典型的资源竞争问题。可以理解为:互斥 = 谁先用,别人先别碰。

1.1.3. 同步与互斥的联系与区别

        同步和互斥在 FreeRTOS 中经常放在一起讲,是因为它们都属于任务协作机制,本质上都是在解决多任务并发运行时的“冲突问题”。但二者关注的核心并不相同:同步强调任务之间的先后执行关系,而互斥强调共享资源的独占访问关系

        先看同步。同步的核心思想是“一个任务必须等待另一个任务完成某件事后才能继续执行”。例如任务 A 先采集数据,任务 B 必须等 A 完成后才能读取结果,这里 B 的“等待”就是同步。它解决的是 谁先做、谁后做 的问题。

        再看互斥。互斥关注的是多个任务访问同一个资源时的安全性。例如任务 A 和任务 B 都要访问串口或 LCD,如果同时访问就会导致数据错乱,因此必须保证同一时刻只有一个任务可以使用资源。它解决的是 谁先用、其他任务先别碰 的问题。

        互斥往往就是通过同步机制实现的。例如任务 A 正在使用串口时,任务 B 会被阻塞等待;等 A 使用完释放资源后,再唤醒 B 继续执行。这个“阻塞-唤醒”的过程本质上就是同步机制,而最终达到的效果却是资源互斥访问。可以总结为两句话:

同步解决顺序问题,互斥解决资源问题;互斥通常借助同步机制实现。

同步是“等顺序”,互斥是“抢资源”;互斥是目的,同步常常是手段。

1.2. 实现同步与互斥的各类方法

        FreeRTOS 中能实现同步和互斥的内核对象并不只有一种,常见的主要有以下五类:

  • 任务通知(Task Notification)
  • 队列(Queue)
  • 事件组(Event Group)
  • 信号量(Semaphore)
  • 互斥量(Mutex)

它们看起来很像,很多都包含“获取 / 释放 / 阻塞 / 超时”这类操作,但底层语义完全不同。最好的区分方式,就是从“它到底传什么”来理解。这里先给出韦东山老师的一个总结表,然后再细分叙述。

1.2.1.队列

  • 里面可以放任意数据,可以放多个数据
    队列本质上是一个 FIFO 缓冲区,内部可以存放任意类型的数据,例如整型、结构体甚至自定义数据包,并且支持多个元素连续存储,因此非常适合任务间连续数据传输。
  • 任务、ISR 都可以放入数据;任务、ISR 都可以从中读出数据
    队列既支持任务上下文使用,也支持中断服务函数(ISR)中使用对应的 FromISR 接口,因此它是 FreeRTOS 中最通用的数据通信方式之一。
  • 图例:

同步 + 数据传输一起完成

1.2.2. 事件组

  • 一个事件用一个 bit 表示,1 表示事件发生了,0 表示事件没发生
    事件组本质上是一组 bit 位,每一位对应一个事件状态,因此它更适合表达“多个条件是否满足”。
  • 可以用来表示事件、事件的组合发生了,不能传递数据
    它只能表达状态,不负责传输真实数据。例如 bit0 表示按键按下,bit1 表示通信完成。
  • 有广播效果:事件或事件组合发生了,等待它的多个任务都会被唤醒
    这是事件组最大的特点之一,多个任务可以同时等待同一组事件,因此很适合“一次事件通知多个任务”。
  • 图例:

事件组更像“状态广播器”

1.2.3. 信号量(Semaphore)

  • 核心是“计数值”
    信号量的本质是一个计数器,可以表示资源数量,也可以表示事件发生次数。
  • 任务、ISR 释放信号量时计数值加 1
    每次 Give,相当于“资源增加一个”或“事件发生一次”。
  • 任务、ISR 获取信号量时计数值减 1
    每次 Take,相当于“消耗一个资源”或“处理一次事件”。
  • 图例:

信号量 = 带数量概念的同步机制

1.2.4. 任务通知(Task Notification)

  • 核心是任务的 TCB 里的数值
    每个任务的 TCB 中都自带一个通知值,因此任务通知不需要额外创建内核对象,效率非常高。
  • 会被覆盖
    如果连续通知同一个任务,旧值可能被新值覆盖,因此使用时要特别注意模式选择。
  • 发通知给谁?必须指定接收任务
    它是典型的一对一通信,必须明确目标任务句柄。
  • 只能由接收任务本身获取该通知
    不像队列谁都能读,任务通知的接收方是固定的。
  • 图例:

任务通知 = 最轻量级的一对一同步方式

1.2.5. 互斥量(Mutex)

  • 数值只有 0 或 1
    本质就是“锁”的状态:0 表示已锁定,1 表示可用。
  • 谁获得互斥量,就必须由谁释放同一个互斥量
    这是它和普通信号量最大的区别,强调“谁上锁谁解锁”。
  • 示例:

核心目标不是同步,而是防止资源竞争

总结:队列传数据,事件组传状态,信号量传数量,任务通知传单任务消息,互斥量负责上锁。

2. 队列

        队列(Queue)是 FreeRTOS 中最常用的任务间通信方式之一,它既可以完成任务同步,又可以完成数据传输其本质是内核维护的一块 FIFO 缓冲区,适用于生产者-消费者模型,也是后续信号量和消息机制理解的重要基础。

2.1. 队列的特性

2.1.1.队列的常规操作

        队列最核心的特点可以概括为:固定大小、先进先出、可多任务共享

        首先,队列中可以包含多个数据项,这个数量在创建队列时就已经确定,通常称为队列长度(length)。同时,每一个数据项的大小也是固定的,也必须在创建时指定。例如创建一个长度为 5、每项为 int 的队列,本质上就是在内核里开辟了一块能存放 5 个整数的 FIFO 缓冲区。

        其次,队列默认采用 FIFO(First In First Out) 的方式工作:

  • 写数据时从尾部进入
  • 读数据时从头部取出

也就是说,谁先写入,谁就先被读取。例如:

  • 任务 A 先写入 10
  • 任务 B 再写入 20

那么读取顺序一定是:10 → 20。这就是最典型的生产者-消费者流程。

另外,FreeRTOS 也支持强制向队列头部写入数据,这种方式常用于“高优先级消息插队”,例如报警信息优先处理。

2.1.2. 队列数据传输的两种方法

        从数据传输角度看,队列有两种常见思路:拷贝 和 引用

  • 拷贝:把数据、把变量的值复制进队列里
  • 引用:把数据、把变量的地址复制进队列里

(1)拷贝

        这是 FreeRTOS 最常用、也是默认推荐的方式。发送时,系统会把变量当前的值复制到队列内部缓冲区中,因此即使原变量后续被修改或销毁,也不会影响队列中的数据。例如:

int value = 100;
xQueueSend(xQueue, &value, portMAX_DELAY);

这里发送的并不是变量本身,而是:100 这个值复制进队列

这种方式的优点非常明显:

  • 局部变量可以安全发送
  • 函数退出后数据依然有效
  • 发送端与接收端彻底解耦
  • 队列空间由内核统一管理,数据生命周期由内核负责

(2)引用

        如果数据本身很大,例如:图像帧、大结构体、DMA Buffer。那么每次完整拷贝成本就会很高,此时更适合发送数据地址。例如:

uint8_t *pFrame;
xQueueSend(xQueue, &pFrame, portMAX_DELAY);

此时队列里保存的是:指针值(地址)

这样做优点是效率高,但缺点也很明显:必须保证发送端和接收端都能访问这块内存,而且生命周期要足够长。

总结:小数据传值,大数据传地址

2.1.3. 队列的阻塞访问

        队列支持阻塞式访问。所谓阻塞,本质上就是:当本次读写操作无法立即完成时,任务可以选择进入等待状态,而不是立刻返回失败。        

        对于读队列来说,如果当前队列为空,任务就可以阻塞等待数据到来;对于写队列来说,如果当前队列已满,任务同样可以阻塞等待空位释放。也就是说,无论是“没数据可读”还是“没空间可写”,都可以通过设置等待时间,让任务先进入 Blocked 状态,等条件满足后再继续运行。

1. 读队列阻塞

xQueueReceive(xQueue, &rxData, pdMS_TO_TICKS(1000));

这里表示:

  • 最多等待 1000ms
  • 数据来了 → 立即唤醒
  • 超时还没来 → 返回失败

如果多个任务都在等同一个队列数据,那么:

  • 优先级最高的任务优先唤醒
  • 若优先级相同 → 等待时间最长的先唤醒

2.  写队列阻塞

同理,如果队列已满,任务也可以阻塞等待空位。例如:

xQueueSend(xQueue, &txData, pdMS_TO_TICKS(1000));

表示:

  • 队列满了先等
  • 有空位就立即写入
  • 超时则失败返回

多个任务等待同一个空位时,同样遵循:

  • 高优先级优先
  • 同优先级按等待时间排序

阻塞访问本质上是:任务主动让出 CPU,等待队列条件满足后再恢复

2.2. 队列函数

        使用队列的流程:创建队列、写队列、读队列、删除队列。

2.2.1.创建

        队列的使用第一步是创建。根据内存来源不同,FreeRTOS 提供了两种创建方式:

  • 动态创建:队列空间由内核自动申请
  • 静态创建:用户提前准备好内存空间

二者本质区别就在于:队列控制块和存储区由谁来管理

1. 动态创建:xQueueCreate

        动态创建是最常见的方式,适合入门和大多数普通任务通信场景。调用该函数后,FreeRTOS 会自动从堆区申请队列控制块和数据存储空间,因此使用起来最简单。

QueueHandle_t xQueueCreate(
    UBaseType_t uxQueueLength,
    UBaseType_t uxItemSize
);

示例:创建一个:长度为 5。每个元素为 int的FIFO 队列

QueueHandle_t xQueue;

xQueue = xQueueCreate(5, sizeof(int));

2. 静态创建:xQueueCreateStatic

        如果项目对内存可控性要求更高,不允许动态内存分配,那么更推荐使用静态创建。

这种方式要求开发者提前准备:

  • 队列存储 buffer
  • 队列控制块

FreeRTOS 只负责初始化,不再额外申请堆内存。

QueueHandle_t xQueueCreateStatic(
    UBaseType_t uxQueueLength,
    UBaseType_t uxItemSize,
    uint8_t *pucQueueStorageBuffer,
    StaticQueue_t *pxQueueBuffer
);

使用示例:

StaticQueue_t xStaticQueue;
uint8_t ucQueueStorage[5 * sizeof(int)];

QueueHandle_t xQueue;

xQueue = xQueueCreateStatic(
    5,
    sizeof(int),
    ucQueueStorage,
    &xStaticQueue
);

静态方式创建一个长度为 5 的整型队列。其中 StaticQueue_t xStaticQueue 用来保存队列的控制信息,可以理解为队列的“管理结构”;ucQueueStorage[5 * sizeof(int)] 则是真正用于存放数据的缓冲区,大小正好可以容纳 5 个 int 元素。调用 xQueueCreateStatic() 时,我们把队列长度、单个元素大小、数据存储区以及控制块一起传入,FreeRTOS 只负责完成初始化,而不会再额外申请堆内存。最终返回的 xQueue 就是后续发送和接收数据时使用的队列句柄。

2.2.2. 队列复位:xQueueReset

        队列刚创建完成时默认是空的,但在实际运行过程中,队列里可能已经积累了旧数据。如果希望让它重新回到“刚创建时的空队列状态”,就可以调用 xQueueReset()

BaseType_t xQueueReset(QueueHandle_t pxQueue);
  • pxQueue:需要复位的队列句柄
  • 返回值:通常返回 pdPASS
  • 使用示例:
xQueueReset(xQueue);

清空队列中的所有数据,并把读写索引恢复到初始状态。

2.2.3. 队列删除:vQueueDelete

        当队列不再需要使用时,可以调用删除函数释放资源。

void vQueueDelete(QueueHandle_t xQueue);
  • xQueue:需要删除的队列句柄
  • 使用示例
vQueueDelete(xQueue);

释放动态创建队列时由 FreeRTOS 内核申请的内存资源

2.2.4. 写队列(发送数据)

        写队列是队列通信中最核心的操作之一,本质上就是把数据发送到 FIFO 缓冲区中。根据写入位置不同,可以写到:

  • 队列尾部(正常 FIFO)
  • 队列头部(高优先级插队)

1. 写入队列尾部

BaseType_t xQueueSend(
    QueueHandle_t xQueue,
    const void *pvItemToQueue,
    TickType_t xTicksToWait
);

2. 写入队列头部

BaseType_t xQueueSendToFront(
    QueueHandle_t xQueue,
    const void *pvItemToQueue,
    TickType_t xTicksToWait
);
  • xQueue:目标队列句柄
  • pvItemToQueue:要发送的数据地址
  • xTicksToWait:队列满时等待时间
  • 示例代码:
int txData = 100;

xQueueSend(
    xQueue,
    &txData,
    pdMS_TO_TICKS(100)
);

        写队列的本质就是:把指定数据复制到队列内部 buffer 中。默认情况下使用 xQueueSend() 时,数据会进入队列尾部,符合 FIFO 规则;如果使用 xQueueSendToFront(),则会直接插入头部,优先被读取。当队列已满时,可以通过 xTicksToWait 设置阻塞等待时间:如果有空位就立即写入,否则等待超时后返回失败。这种机制让写队列不仅能完成数据传输,也能天然支持任务同步。

2.2.5. 读队列:xQueueReceive

        读队列的核心作用是:从队列头部取出一个数据项,并将其从队列中移除。这意味着它严格遵循 FIFO 规则,谁先进入队列,谁就先被读取。

BaseType_t xQueueReceive(
    QueueHandle_t xQueue,
    void *pvBuffer,
    TickType_t xTicksToWait
);

示例代码:

int rxData;

xQueueReceive(
    xQueue,
    &rxData,
    pdMS_TO_TICKS(1000)
);

把队列头部的数据复制到用户 buffer 中,然后将该数据项从队列中删除

2.2.6. 查询队列状态

UBaseType_t uxQueueMessagesWaiting(
    const QueueHandle_t xQueue
);
UBaseType_t uxQueueSpacesAvailable(
    const QueueHandle_t xQueue
);

这两个函数分别用于查询:当前队列中已有多少个数据项,以及队列还剩多少可用空间。它们不会影响队列本身的数据内容,非常适合用于调试、状态监控以及防止消息积压。例如在串口日志任务中,可以通过查询剩余空间来判断消息发送是否过快。

2.2.7. 覆盖:xQueueOverwrite

        它的特点是:无论队列当前是否已有旧数据,都会直接用新数据覆盖原内容,因此不会发生阻塞。这非常适合长度为 1 的状态队列,例如温度值、ADC 最新采样值、系统状态标志等场景,本质上是“永远只保留最新消息”。

BaseType_t xQueueOverwrite(
    QueueHandle_t xQueue,
    const void *pvItemToQueue
);

2.2.8. 偷看:xQueuePeek

BaseType_t xQueuePeek(
    QueueHandle_t xQueue,
    void *pvBuffer,
    TickType_t xTicksToWait
);

xQueuePeek()xQueueReceive() 最大的区别在于:它只复制数据,不删除数据。因此同一条消息可以被多个任务反复读取,特别适合“状态查询”或“多个消费者读取同一状态值”的场景。例如多个任务都需要查看当前系统模式,就可以使用 Peek 而不是 Receive。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐