
🎬 渡水无言 :个人主页渡水无言
❄专栏传送门 : 《linux专栏》《嵌入式linux驱动开发》《linux系统移植专栏》
❄专栏传送门 : 《freertos专栏》 《STM32 HAL库专栏》《linux裸机开发专栏》
❄专栏传送门 :《产品测评专栏》
⭐️流水不争先,争的是滔滔不绝
📚博主简介:第二十届中国研究生电子设计竞赛全国二等奖 |国家奖学金 | 省级三好学生
| 省级优秀毕业生获得者 | csdn新星杯TOP18 | 半导纵横专栏博主 | 211在读研究生
在这里主要分享自己学习的linux嵌入式领域知识;有分享错误或者不足的地方欢迎大佬指导,也欢迎各位大佬互相三连
目录
[四、知识点:C 语言结构体的内存对齐](#四、知识点:C 语言结构体的内存对齐)
[五、第三步:通知控制任务 & 线程安全](#五、第三步:通知控制任务 & 线程安全)
前言
在前几期的博客中,我们已经完成了从 "原始字节流" 到 "校验通过的协议帧" 的全链路解析。
从 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_tick 是 uint32_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 工程中串口协议通信最标准、最可靠的工业级实现方案。