FreeRTOS 通信任务设计(4终)----从字节流到有效帧的完美闭环

🎬 渡水无言个人主页渡水无言

专栏传送门 : 《linux专栏》《嵌入式linux驱动开发》《linux系统移植专栏》

专栏传送门 : 《freertos专栏》 《STM32 HAL库专栏》《linux裸机开发专栏

专栏传送门《产品测评专栏

⭐️流水不争先,争的是滔滔不绝

📚博主简介:第二十届中国研究生电子设计竞赛全国二等奖 |国家奖学金 | 省级三好学生

| 省级优秀毕业生获得者 | csdn新星杯TOP18 | 半导纵横专栏博主 | 211在读研究生

在这里主要分享自己学习的linux嵌入式领域知识;有分享错误或者不足的地方欢迎大佬指导,也欢迎各位大佬互相三连

目录

前言

一、有效帧处理是什么?

二、第一步:再次确认帧的有效性

三、第二步:指令解析与结构化存储

[四、知识点:C 语言结构体的内存对齐](#四、知识点:C 语言结构体的内存对齐)

4.1内存对齐的原因

4.2、struct的内存对齐规则

[五、第三步:通知控制任务 & 线程安全](#五、第三步:通知控制任务 & 线程安全)

总结


前言

在前几期的博客中,我们已经完成了从 "原始字节流" 到 "校验通过的协议帧" 的全链路解析。

从 UART+DMA 接收,到流缓冲区传递,再到状态机的逐字节解析。而这一篇,我们将走完整个接收链路的最后一步------有效帧处理

这一步是连接 "通信层" 与 "业务层" 的桥梁,它不仅要把协议数据转换成控制指令,还要处理数据的线程安全问题,确保最终送达控制任务的指令是完整、一致的。


一、有效帧处理是什么?

有效帧处理,是整个接收链路中最靠近业务层的一步。前面所有的工作 ------DMA 接收、流缓冲区转交、状态机解析、CRC 校验 ------ 本质上都是在回答一个问题:这一帧数据是否可信、是否符合协议规范?

只有当答案是 "是" 的时候,系统才会进入有效帧处理阶段,将通信数据真正转化为 "可以被业务使用的信息"。

二、第一步:再次确认帧的有效性

有效帧处理的触发条件非常严格,除了 CRC 校验通过,我们还需要对协议字段进行语义层面的二次确认,防止异常数据进入业务逻辑。

这部分逻辑,就对应我们代码中的on_valid_frame()函数:

cpp 复制代码
static void on_valid_frame(void)
{
    // 协议版本必须匹配
    if (s_ver != PROTO_VER_EXPECT) return;
    // 只处理我们支持的指令类型
    if (s_msg_id != MSG_CMD_VEL) return;
    // 标志位和序列号校验
    if (s_flags != FLAGS_EXPECT) return;
    if (s_seq != SEQ_EXPECT) return;
    // 载荷长度必须与指令定义严格匹配
    if (s_len != CMD_VEL_LEN) return;

   
}

通过这一系列校验,我们确保了进入后续解析逻辑的数据,不仅格式上是正确的,而且是我们当前固件版本所支持的有效指令。

三、第二步:指令解析与结构化存储

在确认协议字段完全合法后,系统才会真正解析 payload。以我们的CMD_VEL指令为例,payload 中的每一部分都有明确的物理意义:线速度vx、横向速度vy和角速度wz

解析的过程,就是按照协议约定的字节序(小端)数据类型(有符号 16 位整数),将这些原始字节组合成结构化的数据。代码如下:

cpp 复制代码
// 解析 payload: vx, vy, wz 都是int16_t (小端)
CmdVel_t v;
v.vx_mmps  = le16toh(&s_payload[0]); // 解析x轴线速度
v.vy_mmps  = le16toh(&s_payload[2]); // 解析y轴线速度
v.wz_mradps = le16toh(&s_payload[4]); // 解析角速度
v.rx_tick  = xTaskGetTickCount();    // 记录接收时间戳
v.valid    = true;                   // 标记指令有效

解析后的数据,会被存入一个CmdVel_t结构体中,这个结构体就是业务层直接使用的 "控制指令"。

cpp 复制代码
/**
 * @brief 上位机下发的三轴目标速度
 * @note 单位:
 * - vx_mmps:  mm/s
 * - vy_mmps:  mm/s
 * - wz_mradps: mrad/s
 */
typedef struct {
    int16_t vx_mmps;
    int16_t vy_mmps;
    int16_t wz_mradps;

    uint32_t rx_tick; // 收到最新有效帧的时间戳
    bool     valid;   // 是否曾经收到过有效帧
} CmdVel_t;

四、知识点:C 语言结构体的内存对齐

4.1内存对齐的原因

比如下面这个结构体,占用内存是多少?

cpp 复制代码
typedef struct {
    uint32_t rx_tick;
    int16_t  vx_mmps;
    int16_t  vy_mmps;
    int16_t  wz_mradps;
    bool     valid;
} CmdVel_t;

注意:对齐的本质目的,是让 CPU 用最少的硬件动作,最高效地存取数据。

在现代 MCU(如STM32F4,Cortex-M4)中,CPU并不是按字节思考的,而是按字宽和总线宽度访问内存的。对于32位MCU来说,最自然、最高效的访问单位是32位(4字节)。
32位数据希望从4的整数倍地址开始。
16位数据希望从2的整数倍地址开始。
如果数据不对齐,CPU就需要跨两个字边界读取,再拼接数据,这不仅效率低下,还会给硬件设计和功耗带来负担。

4.2、struct的内存对齐规则

在 C 语言中,结构体在内存中的排列顺序,遵循三个基本原则:
字段顺序不变: 成员变量的先后顺序决定了它们在内存中的先后顺序。
按对齐要求插入填充字节(Padding): 为了让每个成员都从 "合适" 的地址开始,编译器会自动插入一些无意义的填充字节。
整体大小按最大对齐单位对齐: 整个结构体的大小,必须是其成员中最大对齐单位的整数倍。

在 ARM Cortex-M 平台上:

int16_t:2 字节对齐。

uint32_t:4 字节对齐。

bool:1 字节对齐(本质是uint8_t)。

举例:

cpp 复制代码
/* 协议帧数据结构(按小端序存储)
 * 地址偏移 | 内容               | 字段类型/说明
 * -------- | ------------------ | ------------
 * +0x00    | vx_mmps 低字节     | int16_t vx_mmps
 * +0x01    | vx_mmps 高字节     |
 * +0x02    | vy_mmps 低字节     | int16_t vy_mmps
 * +0x03    | vy_mmps 高字节     |
 * +0x04    | wz_mradps 低字节   | int16_t wz_mradps
 * +0x05    | wz_mradps 高字节   |
 * +0x06    | padding            | 为了让 rx_tick 4 字节对齐
 * +0x07    | padding            |
 * +0x08    | rx_tick byte0      | uint32_t rx_tick
 * +0x09    | rx_tick byte1      |
 * +0x0A    | rx_tick byte2      |
 * +0x0B    | rx_tick byte3      |
 * +0x0C    | valid (0 或 1)     | bool valid
 * +0x0D    | padding            | 结构体整体对齐到 4 字节
 * +0x0E    | padding            |
 * +0x0F    | padding            |
 */

为什么中间会出现 padding?

wz_mradps 结束后,当前偏移是 0x06

但下一个字段 rx_tickuint32_t,必须从 4 字节对齐的地址开始。

0x06 不是 4 的倍数,于是编译器插入 2 个 padding 字节,把地址补到 0x08

bool valid 只占 1 个字节,但整个结构体的对齐要求是 4 字节(因为里面有 uint32_t)。为了保证 sizeof(CmdVel_t) 是 4 的整数倍,编译器在结尾补 3 个 padding 字节。

最终结构体大小为:

虽然成员变量只占用了4+2+2+2+1=11字节,但加上编译器自动填充的 3 个Padding字节,sizeof(CmdVel_t)最终是16 字节

五、第三步:通知控制任务 & 线程安全

经过之前那一系列操作,就相当于解析好上层发来的指令了,也相当于通信任务接受处理的部分完成了,通信任务并不会直接驱动控制电机,而是通过 FreeRTOS 提供的任务通知机制,告诉控制任务(control_task.c):"有新的控制指令到了!"。

也就是通信任务只负责接受与解析,控制任务只负责执行控制策略。

cpp 复制代码
static void on_valid_frame(void)
{
    // ... 前面的校验和解析逻辑

    // 通知控制任务:有新的cmd指令
    if (g_controlTaskHandle != NULL)
    {
        xTaskNotify(g_controlTaskHandle, NOTIF_NEW_CMD, eSetBits);
    }
}

这里有两个关键设计细节:

1. 指令存储在哪里?

我们的指令数据,是存在一个全局静态变量里的:

cpp 复制代码
static CmdVel_t s_latest_cmd = {0};

它不是分配在栈上的临时变量,而是分配在全局静态存储区(.data.bss段),在程序运行期间一直存在。

从 C 语言作用域来看。这里的 static 是文件级 static,它的含义是:这个符号 (s_latest) 只能在当前 .c 文件内被直接访问,不能被其他 .c 文件通过 extern 引用。

注意:既然别的任务 (比如 ControlTask) 不在这个 .c 文件里,它是怎么访问到 CmdVel_t 的?

答案是: ControlTask 并不是直接访问 s_latest , 而是通过函数接口访问的。

cpp 复制代码
/* ==========================================================
 * 对外接口: 读取最新 CmdVel
 * ----------------------------------------------------------
 * 线程安全: 用临界区保护结构体拷贝 (防止读到一半被写)
 * ========================================================== */
CmdVel_t Comm_GetLatestCmdVel(void)
{
    CmdVel_t out;
    taskENTER_CRITICAL();
    out = s_latest;
    taskEXIT_CRITICAL();
    return out;
}

这个函数本身是 非 static 的对外接口函数,可以在头文件中声明,并被其他 .c 文件调用。ControlTask 调用的是这个函数,而不是直接访问 s_latest 这个变量。

注意:这里我们还使用了临界区保护

因为虽然所有任务看到的是同一块内存,但任务之间是可以被抢占的。如果 CommTask 正在更新 s_latest,而 ControlTask 在此抢占并读取,就可能读到 "更新了一半的数据"。为了解决这个问题,代码在读写 s_latest 时使用了 taskENTER_CRITICAL()taskEXIT_CRITICAL(),保证在拷贝结构体的过程中不会发生任务切换或中断打断,从而确保 ControlTask 读到的数据是完整、一致的。


总结

从串口物理层的字节流,到 DMA 硬件自动搬运,再经空闲中断分割帧、流缓冲区安全传递、状态机逐字节解析,最终来到有效帧处理这最后一站。这里完成了指令合法性校验、小端解析、结构体结构化存储、内存对齐规避、线程安全保护,并最终通知控制任务执行。整套流程实现了通信层与业务层的完美解耦,具备高稳定性、高鲁棒性、高实时性,是 STM32 + FreeRTOS 工程中串口协议通信最标准、最可靠的工业级实现方案。

相关推荐
【ql君】qlexcel2 小时前
可跑在STM32上的EtherCAT主机协议栈
stm32·soem·ethercat·igh·协议栈
m0_502724953 小时前
qt键盘钩子完善
stm32·qt·计算机外设
CinzWS3 小时前
电源管理(上):动态功耗管理与时钟门控——ARMv8的“省电魔法“
嵌入式·芯片验证·原型验证·a53
zzh9204 小时前
20元代做Proteus仿真|51单片机/STM32花样流水灯|心形/圆形/按键切换|从上到下从左到右
stm32·51单片机·proteus
Hello_Embed4 小时前
嵌入式上位机开发入门(二十四):Paho MQTT 嵌入式客户端源码分析
网络·单片机·网络协议·tcp/ip·嵌入式
-Springer-13 小时前
STM32 学习 —— 个人学习笔记11-1(SPI 通信协议及 W25Q64 简介 & 软件 SPI 读写 W25Q64)
笔记·stm32·学习
LN花开富贵13 小时前
【ROS】鱼香ROS2学习笔记一
linux·笔记·python·学习·嵌入式·ros·agv
yrx02030714 小时前
串口空闲中断+DMA接收+环形缓冲区 && 串口DMA发送+环形缓冲区
stm32·单片机
LCG元15 小时前
STM32实战:基于STM32F103的4G模块(EC20)HTTP通信
stm32·嵌入式硬件·http