嵌入式硬件笔记(3):FreeRTOS的同步、互斥、队列
前言:在 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。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)