4、队列(Queue):队列的工作原理、队列创建、队列发送与接收、队列阻塞机制
4.1 队列到底是个什么东西?
队列,说白了就是任务与任务之间、或者中断与任务之间传递数据的「邮局」。你想想看,在裸机编程里,我们经常用全局变量来传数据。但到了 FreeRTOS 这种多任务环境里,全局变量就成了一个定时炸弹——谁都能改,改的时候还可能被打断。
我刚开始用 FreeRTOS 做项目时,就吃过这个亏。一个传感器任务采集数据,放到全局变量里,显示任务去读。结果显示任务读到一半,传感器任务又更新了数据,显示出来的数值就变成了「四不像」。后来改用队列,这个问题就再也没出现过。
队列的本质,是一个先进先出(FIFO)的数据缓冲区。它把数据从一个地方「搬」到另一个地方,而且保证搬的过程是安全的。每个队列都有一个长度,也就是能存放多少个数据项。每个数据项的大小,在创建队列时就定死了。
核心要点:队列是 FreeRTOS 中任务间通信的基石。它解决了共享资源访问的互斥问题,也解决了数据生产者和消费者之间的速度匹配问题。
4.2 队列的工作原理
队列内部维护了一个环形缓冲区。为什么用环形?说白了就是为了高效利用内存。数据从队尾写入,从队头读出。当读指针或写指针跑到缓冲区末尾时,会自动折回到开头。
每个队列还维护着三个关键列表:
- 等待发送列表:那些想往队列里写数据,但队列已经满了的任务,会被挂到这个列表上
- 等待接收列表:那些想从队列里读数据,但队列为空的任务,会被挂到这个列表上
- 锁定计数:用于中断服务程序中操作队列时的特殊处理
嗯,这里要注意。队列的读写操作,本质上是一个内存拷贝的过程。你把数据「复制」进队列,接收方再把数据「复制」出来。所以如果你的数据项很大(比如一个 1KB 的结构体),每次拷贝的开销就很大。我个人习惯是,对于大数据,只传递指针,而不是传递数据本身。
4.3 队列的创建
创建队列有两种方式:动态分配和静态分配。我一般用动态分配,省心。
// 动态创建队列
QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength, UBaseType_t uxItemSize );
// 参数说明:
// uxQueueLength:队列能容纳的数据项个数
// uxItemSize:每个数据项的大小(字节)
// 返回值:
// 成功:队列句柄
// 失败:NULL
举个例子,我要创建一个能存放 10 个 int 类型数据的队列:
QueueHandle_t xMyQueue;
xMyQueue = xQueueCreate( 10, sizeof(int) );
if( xMyQueue == NULL )
{
// 创建失败,内存不够了
// 我遇到过这种情况,通常是堆空间设置得太小
}
我的经验:创建队列时,FreeRTOS 会从堆中分配内存。如果你发现 xQueueCreate 老是返回 NULL,先检查一下 FreeRTOSConfig.h 中的 configTOTAL_HEAP_SIZE 是不是设得太小了。我曾经在一个项目中把堆设成 4KB,结果创建了三个队列就爆了。
4.4 队列的发送与接收
发送和接收是队列的核心操作。FreeRTOS 提供了多种变体,但最常用的就这几个:
| 函数 | 用途 | 能否在中断中使用 |
|---|---|---|
| xQueueSend() | 向队尾发送数据 | 否 |
| xQueueSendToFront() | 向队首发送数据(插队) | 否 |
| xQueueSendToBack() | 向队尾发送数据 | 否 |
| xQueueReceive() | 从队头接收数据 | 否 |
| xQueueSendFromISR() | 中断中发送数据 | 是 |
| xQueueReceiveFromISR() | 中断中接收数据 | 是 |
发送数据的标准写法:
int data_to_send = 42;
BaseType_t xResult;
xResult = xQueueSend( xMyQueue, &data_to_send, portMAX_DELAY );
if( xResult != pdPASS )
{
// 发送失败,队列满了且超时
}
接收数据的标准写法:
int received_data;
BaseType_t xResult;
xResult = xQueueReceive( xMyQueue, &received_data, portMAX_DELAY );
if( xResult == pdPASS )
{
// 成功收到数据,received_data 就是我们要的值
}
注意:发送和接收时,传递的是数据的地址,而不是数据本身。队列内部会把这个地址指向的数据拷贝到自己的缓冲区里。所以发送后,原来的变量可以放心地修改或释放。
4.5 队列的阻塞机制
这才是队列最精彩的部分。为什么说 FreeRTOS 的队列好用?因为它自带阻塞机制。
你想想看,当一个任务试图从一个空队列里读数据时,会发生什么?在裸机编程里,你只能轮询,白白浪费 CPU。但在 FreeRTOS 里,你可以让这个任务「睡一会儿」,等队列里有数据了再叫醒它。
阻塞机制的核心就是那个超时时间参数:
- portMAX_DELAY:一直等,等到天荒地老,直到有数据为止
- 0:不等待,队列空就立即返回
- 某个 tick 值:最多等这么长时间,超时了就返回
具体流程是这样的:
- 任务 A 调用 xQueueReceive(),但队列是空的
- FreeRTOS 把任务 A 从「就绪态」移到「阻塞态」,同时把它挂到队列的等待接收列表上
- 任务 A 不再占用 CPU,调度器去运行其他任务
- 某个时刻,任务 B 调用 xQueueSend() 往队列里放了一个数据
- FreeRTOS 发现等待接收列表上有任务,就把任务 A 从阻塞态移回就绪态
- 任务 A 拿到数据,继续执行
发送时的阻塞也是类似的。如果队列满了,发送任务可以选择等待,直到队列里有空位。
避坑指南:我曾经在中断服务函数里用了 xQueueSend(),结果系统直接死机。后来才想起来,中断里必须用带 FromISR 后缀的函数。而且中断里的发送函数,超时时间必须设为 0,因为中断里不能阻塞。
4.6 实际项目中的队列使用模式
我常用的一个模式是「生产者-消费者」模型。一个任务负责采集数据,通过队列发给另一个任务去处理。这样采集任务和处理任务可以独立运行,互不干扰。
// 生产者任务
void vSensorTask( void *pvParameters )
{
int sensor_value;
while(1)
{
sensor_value = read_sensor();
xQueueSend( xDataQueue, &sensor_value, portMAX_DELAY );
vTaskDelay( pdMS_TO_TICKS(100) ); // 100ms 采集一次
}
}
// 消费者任务
void vProcessTask( void *pvParameters )
{
int value;
while(1)
{
if( xQueueReceive( xDataQueue, &value, pdMS_TO_TICKS(500) ) == pdPASS )
{
process_data( value );
}
else
{
// 500ms 没收到数据,可能是传感器挂了
handle_error();
}
}
}
这个模式的好处很明显:采集任务只管采集,处理任务只管处理。如果处理任务偶尔卡住了,队列还能起到缓冲作用,数据不会丢失。
我的建议:队列的长度要根据实际情况来定。如果生产者比消费者快很多,队列就要设大一点,否则生产者会频繁阻塞。反过来,如果消费者比生产者快,队列设小一点就够了,省内存。
好了,队列的基本内容就这些。说白了,它就是 FreeRTOS 里最常用的任务间通信手段。掌握了队列,你就掌握了多任务编程的「交通规则」。下一章我们会讲信号量,其实信号量的底层实现就是队列——一个长度为 1 的队列。你想想看,是不是很有意思?