RTOS:keysking队列实战
前言
写在前面: 最近在跟着 keysking 大佬学习 FreeRTOS,他的教程基于 STM32F103。但我手头只有一块蓝桥杯的 STM32G431RBT6。 本文记录了我将代码移植到 G431 的过程,重点解决了消息队列(Queue)传输结构体指针、串口不定长数据接收以及 CLion 开发环境下的 CMake 配置坑。希望对同样使用 Clion + STM32 的同学有所帮助。
keysking大神对应的视频地址
【【FreeRTOS速通】队列实战:复杂数据、中断发送、多对一通信】www.bilibili.com/video/BV1vo…
核心原理:为什么要用队列?
在裸机开发中,我们习惯用全局变量来传递标志位。但在 RTOS 多任务系统中,全局变量就像一个没有红绿灯的十字路口,容易发生“撞车”(数据竞争)。
消息队列(Queue) 就像是一个传送带:
- 生产者(按键/串口) 把数据打包放上去。
- 消费者(LED任务) 专心从传送带取数据处理。
- 解耦:发指令的人只管发,执行的人只管做,互不等待。
第一步:Cubemax配置
- LED、按键、串口的引脚配置
- 按键需配置为 上拉输入
- 注意 PD2 锁存器引脚,不要遗漏
⚠️ 特别说明: 本实验需要 串口中断接收,因此必须在 NVIC 中开启 USART RX 中断。
关于按键、串口、LED的配置,具体的引脚请大家找到自己的开发板的原理图,然后查看
按键记得设置为上拉输入
上图还有个PD2锁存器忘记标注了,大家不要忘了
因为我们要用到串口接收数据,所以要开启串口的NVIC接收中断
接下来开启FreeRTOS,创建两个任务(Led_Task和Key_Task)以及一个队列(Led_Queue)
要注意任务的优先级:通常情况下,建议给处理数据输入的任务高一些的优先级,而不影响核心功能的显示类任务给低一些的优先级
在 CubeMX 中启用 FreeRTOS,并创建以下对象:
任务(Task)
| 任务名 | 功能 | 优先级 |
|---|---|---|
| Key_Task | 处理按键输入 | 高 |
| Led_Task | 控制 LED | 低 |
🎯 优先级设计原则
- 输入 / 数据采集任务:高优先级
- 显示 / 执行类任务:低优先级
队列(Queue)
Led_Queue- 用于在任务之间传递 复杂数据结构
完成后生成代码,并用 CLion 打开工程。
第二步:打开CLion
选择好Cmake配置文件
目录结构管理
为了保持工程整洁,建议在 Core 目录下新建 APP 文件夹,并细分为 Task(存放任务逻辑)和 Type(存放公共结构体定义)。
再在Task文件夹下新建一个.c文件 KeyTask.c
然后我们再来新建LedTask的源文件
CMakeLists.txt 的坑
痛点:在 CLion 中右键新建 .c 文件,IDE 虽然会自动更新 CMakeLists.txt,但有时位置不对,或者你需要手动整理。
接下来注意一点,在CLion中创建的文件,CLion会自动帮我们写到CmakeLists文件中,因而可以正常通过编译,但是,我们最好将其放到target_sources中
在VSCode等编辑器中新建文件的话,大家记得自己编辑一下CMakeLists文件,添加对应文件到target_sources中,并且重新加载一下Cmake,否则相应的文件不会参与到编译中
然后现在我们来编写按键检测函数,因为我们这次要传递 操作哪个小灯,以及打开还是关闭 两种信息,为了在队列里传递一组而不是一个数据,我们需要定义一个信息结构体
对应结构体数据,一般往往比较大,所以我们往往不直接传递,而是传递结构体的指针
Queue_LED:
- Item Size:
sizeof(Led_message*)—— 注意!这里存的是地址(指针),而不是结构体本身。 - Queue Size: 比如设置为 16。
在Type文件夹下创建LEDType.h文件
LedType.h
//
// Created by WangYuPeng on 2026/1/4.
//
#ifndef P3_QUEUEDEMO_LEDTYPE_H
#define P3_QUEUEDEMO_LEDTYPE_H
typedef enum
{
led_off = 0,
led_on = 1,
}Led_state;
typedef enum
{
led_1 = 0,
led_2 = 1,
led_3 = 2,
}led_now_light;
typedef struct{
Led_state state;
led_now_light led_now;
}Led_message;
#endif //P3_QUEUEDEMO_LEDTYPE_H
为了让其他文件能找到此头文件,我们还需要去修改一下Cmakelists
然后回到KeyTask.c进行包含。
因为因为include文件夹配置只到App层,所以这里我们写#include "Type/LedType.h"
这里直接展示我的KeyTask.c
#include <stdlib.h>
#include "FreeRTOS.h"
#include "cmsis_os2.h"
#include "main.h"
#include "Type/LedType.h"
//
// Created by WangYuPeng on 2026/1/4.
//
#define KEY1 (1 << 0)
#define KEY2 (1 << 1)
#define KEY3 (1 << 2)
#define KEY4 (1 << 3)
void StartKeyTask(void *argument)
{
for (;;)
{
static uint8_t key_down=0,key_old =0,key_up = 0,key_val = 0;
uint8_t temp = 0;
static Led_state state = led_off;
if (HAL_GPIO_ReadPin(GPIOB,GPIO_PIN_0) == GPIO_PIN_RESET)
temp |= KEY1;
if (HAL_GPIO_ReadPin(GPIOB,GPIO_PIN_1) == GPIO_PIN_RESET)
temp |= KEY2;
if (HAL_GPIO_ReadPin(GPIOB,GPIO_PIN_2) == GPIO_PIN_RESET)
temp |= KEY3;
if (HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_RESET)
temp |= KEY4;
key_val = temp;
key_down = key_val & (key_val ^ key_old);
key_up = ~key_val & (key_val ^ key_old);
key_old = key_val;
if (key_down & KEY1) //如果按键1按下
{
HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_10);
GPIOD->BSRR |= 0x01 << 2; // 设置 GPIOD 第2位
GPIOD->BRR |= 0x01 << 2; // 重置 GPIOD 第2位
state = !state;
// Led_message* message = malloc (sizeof(Led_message));
Led_message* message = pvPortMalloc(sizeof(Led_message));
message->state = state;
message->led_now = led_1;
osMessageQueuePut(Led_QueueHandle,(&message),0,osWaitForever);
}
osDelay(10);
}
}
技术难点: 为什么通过指针传递? 如果直接传递结构体,FreeRTOS 需要把整个结构体数据拷贝到队列里(Copy by Value)。当结构体很大时,这非常耗时。 优化方案: 我们只传递结构体的地址(指针) ,数据量恒定为 4 字节,效率极高。但要非常注意内存管理(谁申请,谁释放)。
malloc和pvPortMalloc这两个函数的区别:
我们要将这个头文件换成 #include "FreeRTOS.h"
LedTask:队列接收 + LED 控制
设计亮点
- 事件驱动任务
- 没有数据时任务阻塞
- 不需要 osDelay
LedTask.c(关键点)
osMessageQueueGet(Led_QueueHandle, &message, 0, osWaitForever);
...
vPortFree(message);
⚠️ 重要规则
pvPortMalloc→vPortFreemalloc→free
必须配对使用
然后让我们来到LedTask.c的编写
#include "cmsis_os2.h"
#include "FreeRTOS.h"
#include "main.h"
#include "Type/LedType.h"
//
// Created by WangYuPeng on 2026/1/4.
//
void StartLedTask(void *argument)
{
for (;;)
{
Led_message* message;
osMessageQueueGet(Led_QueueHandle,(&message),0,osWaitForever); //调整好鼠标光标,按下alt + 回车 可以快速补全一些东西缺失的头文件
switch (message->led_now)
{
case led_1:
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_8, message->state? GPIO_PIN_RESET:GPIO_PIN_SET);
GPIOD->BSRR |= 0x01 << 2; // 设置 GPIOD 第2位
GPIOD->BRR |= 0x01 << 2; // 重置 GPIOD 第2位
break;
case led_2:
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_9, message->state? GPIO_PIN_RESET:GPIO_PIN_SET);
GPIOD->BSRR |= 0x01 << 2; // 设置 GPIOD 第2位
GPIOD->BRR |= 0x01 << 2; // 重置 GPIOD 第2位
break;
case led_3:
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_10, message->state? GPIO_PIN_RESET:GPIO_PIN_SET);
GPIOD->BSRR |= 0x01 << 2; // 设置 GPIOD 第2位
GPIOD->BRR |= 0x01 << 2; // 重置 GPIOD 第2位
break;
}
vPortFree(message);
}
}
注意: 这里必须使用 vPortFree 释放内存,做到“借一还一”,否则系统运行一段时间后会因内存耗尽而崩溃。
这里注意释放内存:
malloc vs pvPortMalloc
| 函数 | 适用场景 |
|---|---|
| malloc | 裸机 / 标准 C |
| pvPortMalloc | FreeRTOS 环境(必须) |
因为此任务是由LEDQueue接收到数据这一事件进行驱动的,没有数据时任务根本不会占用cpu运行,因而这个osDelay也没什么必要
然后配置一下下载工程就可以开始上板子看看现象了
--什么?你不会,你说上面的面板配置文件你不知道怎么弄,那还请先看看我之前的一篇博客,讲解了Clion的一些使用时的配置Clion安装以及配置_clion 安装编译器-CSDN博客
第三步:未完待续
一阶段完成,接下来我们进入二阶段,任务如下
为了防止数据错乱,我们设计一个简单的协议帧: 包头(0xAA) + 长度(N) + [数据...] + 校验和
包头 + 长度 + 数据区(2*N) + 校验和
回到cubemax
我们可以在usart.c的最后实现关于串口中断接收的相关逻辑
串口中断回调代码部分
usart.c最底部
/* USER CODE BEGIN 1 */
uint8_t rxData;
void Usart_Start_Receive_Data(void)
{
HAL_UART_Receive_IT(&huart1,&rxData,1);
}
//在中断里,我们只做一件事:把收到的这一个字节扔进队列。千万不要在中断里做复杂的协议解析!
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
osMessageQueuePut(CommandQueueHandle,&rxData,1,0); //这里的第四个参数一定不要写osWaitForever,因为中断我们要求快进快出
HAL_UART_Receive_IT(&huart1,&rxData,1);
}
}
/* USER CODE END 1 */
中断接收原则
- 中断中不可阻塞
osMessageQueuePut的 timeout 必须为 0
osMessageQueuePut(CommandQueueHandle, &rxData, 1, 0);
用到了串口接收记得在开始前指定一下数据放的位置
需要重点注意的是
只能写0,因为中断我们是不能阻塞的,必须尽快处理,尽快结束中断
然后新建CommandTask.c文件
在Cmakelists中修改一下
Commandtask.c
//
// Created by WangYuPeng on 2026/1/4.
//
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include "main.h"
#include "FreeRTOS.h"
#include "usart.h"
#include "Type/LedType.h"
void StartCommandTask(void *argument)
{
uint8_t recv;
uint8_t command[32];
uint8_t commandLength = 0; //命令的长度
uint8_t commandIndex = 0; //解析命令的索引
for (;;)
{
osMessageQueueGet(CommandQueueHandle,&recv,0,osWaitForever);
if (commandIndex == 0)
{
if (recv == 0xAA) //检测包头
{
command[commandIndex++] = recv;
}
}else if (commandIndex == 1)
{
commandLength = recv; // 获取到命令的长度
if (commandLength < 4 || commandLength > sizeof(command)) //说明当前的数据有问题,重置接收索引
{
commandIndex = 0;
commandLength = 0;
memset(command,0,sizeof(command)); //清零数组
continue;
}
command[commandIndex++] = recv; // 存储长度字节
commandIndex = 2; // 更新索引
}else
{
command[commandIndex++] = recv;
if (commandIndex == commandLength) //当接收到所有数据后
{
//开始计算校验和
uint16_t sum = 0;
for (int i = 0; i < commandLength - 1; i++)
{
sum += command[i];
}
uint8_t human_sum = (uint8_t)sum&0xff; //取出手动求和的第八位
if (human_sum == command[commandLength - 1]) //说明计算没有问题,现在我们的command数组已经接收完了这一次的数据
{
/*下面是解析操作*/
for (int i=2; i < commandLength - 2; i+=2)
{
Led_message *message = pvPortMalloc(sizeof(Led_message));
if (message != NULL)
{
message->led_now = command[i];
message->state = command[i + 1];
osMessageQueuePut(Led_QueueHandle, &message, 0, 0);
}
else
{
// 内存分配失败时的处理
printf("Error: Memory allocation failed for Led_message.\n");
}
}
}
// 重置命令接收状态
commandIndex = 0;
commandLength = 0;
memset(command,0,sizeof(command));
}
}
}
}
接下来让我们看看ai对应Commandtask.c代码的点评
gemini
这是一个非常典型的嵌入式串口数据流解析代码。这段代码的设计思想非常成熟,属于在 RTOS 环境下处理不定长串口数据的“教科书式”写法。
我将其结构和设计思想拆解为以下几个层面来讲解:
一、 核心设计思想:有限状态机 (FSM)
这段代码虽然没有使用 explicit 的 switch(state) 语句,但它利用 commandIndex 变量实现了一个隐式的有限状态机。这是处理串口字节流最可靠的方法。
因为串口发来的是一个一个的“字节”,CPU 并不知道哪个字节是开头,哪个是结尾。代码通过判断 commandIndex 的值,将处理流程分成了三个阶段(状态):
-
状态一:寻找包头 (Sync/Header)
- 代码逻辑:
if (commandIndex == 0) - 思想:这是同步阶段。只有收到了特定的
0xAA,才认为数据包开始了。如果收到垃圾数据(比如上电时的乱码),会因为不等于0xAA而被直接丢弃,不会影响后续逻辑。
- 代码逻辑:
-
状态二:获取长度 (Length Extraction)
-
代码逻辑:
else if (commandIndex == 1) -
思想:这是动态内存/缓冲区保护阶段。
-
安全机制:代码在这里做了非常重要的边界检查:
C
if (commandLength < 4 || commandLength > sizeof(command))如果接收到的长度太短(连校验位都不够)或者太长(超过了数组容量),直接复位重来。这防止了数组越界溢出(Buffer Overflow),极大地提高了系统的稳定性。
-
-
状态三:接收载荷 (Payload Collection)
- 代码逻辑:
else { ... } - 思想:这是数据缓存阶段。程序只管把数据存入
command数组,直到接收的数量达到了预期的commandLength。
- 代码逻辑:
二、 数据完整性设计:校验和 (Checksum)
-
代码位置:当
commandIndex == commandLength时。 -
设计思想:串口通信(特别是长距离或强干扰环境)很容易出错。也许你发的是
0x01(开灯),因为干扰变成了0x00(关灯)。 -
实现逻辑:
- 发送方把所有数据加起来,取最后8位算出
Sum。 - 接收方(你的代码)收到数据后,也自己算一遍
human_sum。 - 比对:只有
human_sum == 收到包的最后一位,才说明数据是可信的。否则直接丢弃,宁可不执行,也不能执行错误的指令。
- 发送方把所有数据加起来,取最后8位算出
三、 任务解耦与通信:生产者-消费者模型
这段代码展示了 FreeRTOS 任务间通信的精髓。
-
上游(生产者) :是串口中断(在
usart.c中)。中断只负责把字节塞入CommandQueueHandle,中断里不进行任何解析,保证了中断的快进快出。 -
中游(消费者/解析者) :就是当前的
StartCommandTask。- 它通过
osMessageQueueGet(..., osWaitForever)阻塞等待。 - 设计亮点:
osWaitForever意味着如果没有数据,这个任务会进入“休眠”状态,完全不占用 CPU 资源。只有数据来了,操作系统才会唤醒它。
- 它通过
-
下游(执行者) :
Led_QueueHandle的接收端(即LedTask)。- 解析与执行分离:
CommandTask只负责“翻译”指令,翻译好之后,打包成Led_message结构体,扔给LedTask去执行。 - 这样做的好处是:如果你以后要增加蜂鸣器控制、电机控制,只需要修改解析逻辑,而不需要改动 LED 任务的代码。
- 解析与执行分离:
四、 内存管理技巧
Led_message *message = pvPortMalloc(sizeof(Led_message));
// ... 赋值 ...
osMessageQueuePut(Led_QueueHandle, &message, 0, 0);
-
传递指针而非拷贝结构体:
- 这里申请了一块动态内存,然后把内存的地址(指针) 发送到队列中。
- 优点:不管结构体
Led_message有多大(比如 100 字节),队列只需要传输 4 字节(指针大小),大大减少了数据拷贝的时间。 - 风险点(注意) :必须确保接收端(LedTask)在使用完数据后调用
vPortFree,否则会发生内存泄漏。
五、 代码结构图解
GPT5.2
这段代码非常典型、也非常“工程化” ,本质上它做的是一件事:
把“字节流世界(中断/串口)”转换成“结构化事件(FreeRTOS 消息)”
下面我不会逐行念代码,而是从“结构层次 + 设计思想”的角度,把它拆开来讲,这样你以后看到类似代码,一眼就能看出作者在干什么。
一、这段代码在系统中的「角色定位」
先站在系统架构高度看它:
USART 中断
↓(1 字节)
CommandQueue
↓(字节流)
CommandTask ←←← 你这段代码
↓(结构化消息)
Led_Queue
↓
LedTask
👉 StartCommandTask 的本质职责只有一个:
协议解析任务(Protocol Parser Task)
它不直接控制硬件 它不关心串口中断 它只做三件事:
- 接收「已经脱离中断的字节」
- 按协议拼包 + 校验
- 把“命令”翻译成 系统内部可理解的消息
这是一个非常标准的 RTOS 分层设计
二、整体结构拆分(宏观)
从结构上,这个函数可以分成 5 个逻辑层:
┌────────────────────────────┐
│ 1. 任务入口 + 无限循环 │
├────────────────────────────┤
│ 2. 阻塞式获取 1 字节数据 │
├────────────────────────────┤
│ 3. 协议状态机(3 个状态) │
├────────────────────────────┤
│ 4. 完整帧校验 │
├────────────────────────────┤
│ 5. 命令 → 系统消息映射 │
└────────────────────────────┘
下面我们一层一层讲。
三、第一层:这是一个「事件驱动任务」
for (;;)
{
osMessageQueueGet(CommandQueueHandle, &recv, 0, osWaitForever);
...
}
设计思想
- 没有 osDelay
- 没有主动轮询
- 完全靠事件驱动
这说明作者明确知道:
👉 CommandTask 不是周期任务,而是数据驱动任务
只有当:
- 串口中断收到 1 个字节
- ISR 把字节丢进
CommandQueue
这个任务才会被唤醒。
📌 这是 RTOS 中最省 CPU、最优雅的写法
四、第二层:为什么一次只处理 1 个字节?
uint8_t recv;
你可能会问: “为什么不一次收一帧?”
这是非常重要的设计点
原因只有一句话:
串口是字节流,不是数据帧
- 中断一次 ≠ 一帧完整数据
- 帧可能被拆成 N 次中断
- 甚至可能丢字节、乱序
👉 所以正确做法是:
在任务中,用状态机“拼”出一帧
这也是这段代码的核心思想。
五、第三层:一个“隐式状态机”的设计
你这里没有写 enum STATE, 但状态机是存在的,而且设计得很巧。
状态变量是谁?
uint8_t commandIndex;
它同时扮演了两个角色:
| 含义 | 说明 |
|---|---|
| 当前状态 | 处于协议解析的哪一步 |
| 当前进度 | 已接收多少字节 |
状态 1:等待包头(commandIndex == 0)
if (commandIndex == 0)
{
if (recv == 0xAA)
{
command[commandIndex++] = recv;
}
}
设计思想
- 只认包头
- 所有垃圾数据都会被自动丢弃
- 系统能在“任意时刻”重新同步协议
📌 这叫: 协议自同步能力(Self-Synchronization)
状态 2:读取长度字段(commandIndex == 1)
commandLength = recv;
if (commandLength < 4 || commandLength > sizeof(command))
{
// 重置
}
设计思想
这是一个防御式设计(Defensive Programming) :
- 最小长度 = 包头 + 长度 + 校验
- 最大长度 = 接收缓冲区大小
👉 任何非法长度,立刻丢包
这一步的意义是:
❌ 不让“错误协议”拖垮系统 ❌ 防止数组越界 ❌ 防止恶意数据
状态 3:接收剩余数据(commandIndex >= 2)
command[commandIndex++] = recv;
if (commandIndex == commandLength)
{
// 一帧接收完成
}
设计思想
- 不关心中间字段含义
- 只按“长度”收够字节
- 协议解析与协议完整性解耦
这是成熟协议解析的典型做法。
六、第四层:校验设计思想(Checksum)
uint16_t sum = 0;
for (int i = 0; i < commandLength - 1; i++)
{
sum += command[i];
}
为什么这样设计?
- 简单
- 低成本
- 对 MCU 足够可靠
这不是 CRC,但在:
- 短帧
- 低速串口
- 内部控制系统
场景下非常合理。
👉 重点不是校验算法 👉 而是 “校验失败,整个包直接丢弃”
这保证了:
❌ 错误数据不会进入系统逻辑
七、第五层:协议 → 系统消息(最核心)
for (int i = 2; i < commandLength - 2; i += 2)
{
Led_message *message = pvPortMalloc(sizeof(Led_message));
message->led_now = command[i] - 1;
message->state = command[i + 1];
osMessageQueuePut(Led_QueueHandle, &message, 0, 0);
}
这是整段代码「设计层次最高」的一部分
你在这里做了三件非常工程化的事:
1️⃣ 协议层 ≠ 业务层
command[i] // 协议数据
message->led_now // 系统内部语义
👉 CommandTask 不控制 LED
它只做:
“翻译官”
2️⃣ 一帧命令 → 多个系统事件
i += 2
说明协议支持:
- 一条命令
- 控制多个 LED
而你用的是:
拆分为多个独立系统消息
这让:
- LedTask 完全不关心“是不是串口来的”
- 系统可扩展性极高
3️⃣ 动态内存 + 消息队列 = 解耦
pvPortMalloc
osMessageQueuePut
这意味着:
- CommandTask 不等 LedTask
- LedTask 不知道 CommandTask
- 二者只通过“数据契约”通信
📌 这是 RTOS 解耦设计的教科书写法
八、为什么最后要“彻底清空状态”?
commandIndex = 0;
commandLength = 0;
memset(command, 0, sizeof(command));
设计思想
这一步保证:
- 下一个字节 永远从“等包头”开始
- 上一帧的任何残留都不会污染下一帧
这是一个:
强一致性重置点
————————————————————————————————————————————————————————————————————
callback最终Boss
总结与心得
- 内存管理是核心:在使用 FreeRTOS 队列传递指针时,
pvPortMalloc和vPortFree必须成对出现。相比于直接传递结构体,这种方式极大地减少了 CPU 搬运数据的开销。 - 中断要“短平快” :串口中断只负责“收”,复杂的协议解析(拼包、校验)交给 Task 去做,这就是 RTOS 的优势所在。
- CLion 很好用,但要懂 CMake:如果不熟悉
CMakeLists.txt,添加文件时很容易遇到编译错误。