前言

前一天我完成了环形缓冲区,解决的是“UART 接收来的字节先放在哪里”的问题。今天继续往后走,开始处理“这些字节怎样组成一条有效消息”。

这次练习文件是 03_uart_frame_parser.c,目标是从一段二进制字节流中找到第一条合法 UART 数据帧。它不是字符串处理,而是协议解析:要识别帧头、读取长度、提取命令和 payload,还要用 checksum 判断这条帧是否可靠。

今天的完成情况

本次完整运行 Day5 相关练习,关键输出如下:

03_uart_frame_parser passed
Day5 UART 帧解析练习通过

这说明今天的 UART 帧解析练习已经通过当前测试。

一、这次协议长什么样

本练习约定的帧格式是:

0xAA 0x55 LEN CMD PAYLOAD... CHECKSUM

每个字段的含义如下:

  • 0xAA 0x55:帧头,用来从连续字节流中定位一条消息的开始。
  • LEN:表示 CMD + PAYLOAD 的总长度。
  • CMD:命令字,表示这条消息要做什么。
  • PAYLOAD:命令携带的数据,长度为 LEN - 1
  • CHECKSUM:从 LEN 到 payload 结束的低 8 位累加和。

例如测试中的合法帧:

uint8_t valid[] = {
    0xAA, 0x55, 0x03, 0x10, 0x01, 0x02, 0x16
};

这里 LEN = 0x03,表示后面有 3 个参与业务的数据字节:CMD=0x10,payload 为 0x01 0x02。checksum 的计算是:

0x03 + 0x10 + 0x01 + 0x02 = 0x16

所以最后一个字节 0x16 是正确校验和。

二、解析函数的核心流程

今天的核心函数是:

static int ParseFirstFrame(const uint8_t *stream,
                           uint16_t stream_len,
                           UartFrame *frame,
                           uint16_t *used_len)

它的返回值设计比较清晰:

  • 1:找到一条合法帧。
  • 0:当前没有合法帧,或者数据还不完整。
  • -1:参数无效。

解析过程可以拆成 6 步:

  1. 检查 streamframeused_len 是否为空。
  2. 从字节流中查找帧头 0xAA 0x55
  3. 找到帧头后读取 LEN
  4. 判断长度是否合法,不能小于 1,也不能超过最大 payload 限制。
  5. 判断当前字节流里是否已经包含完整帧。
  6. 计算 checksum,校验通过后填充 UartFrame

其中最容易写错的是下标关系。以帧头位置 i 为起点:

i + 0: 0xAA
i + 1: 0x55
i + 2: LEN
i + 3: CMD
i + 4: PAYLOAD 起始位置
i + 3 + LEN: CHECKSUM

完整帧长度是:

2 字节帧头 + 1 字节 LEN + LEN 字节内容 + 1 字节 CHECKSUM
= LEN + 4

因此代码里判断完整帧时使用:

if (i + len + 4u > stream_len) {
    break;
}

三、今天踩到的错误

一开始测试失败在第一条合法帧:

FAIL ...03_uart_frame_parser.c:130 expression:
ParseFirstFrame(valid, sizeof(valid), &frame, &used_len) == 1

根因是 checksum 的位置算错了。原先把校验和位置理解成:

stream[i + 2 + len]

但这个位置实际上还在 CMD + PAYLOAD 区间内。正确的 checksum 位置应该跳过帧头、LENLEN 个内容字节:

stream[i + 3 + len]

同时,used_len 也应该返回从字节流开头到这条完整帧结束的总消耗长度:

*used_len = i + len + 4u;

这个错误很典型:协议解析里差一个字节,下游看到的就不是“差一点”,而是整条帧都解析失败。

四、测试覆盖了哪些场景

当前测试不是只测了一条正常数据,而是覆盖了几个重要边界:

  1. 正常合法帧可以解析出 cmd 和 payload。
  2. 前面存在噪声字节时,解析器可以跳过噪声找到后面的合法帧。
  3. 半包数据不会被误认为合法帧。
  4. checksum 错误的数据会被拒绝。
  5. 空指针参数会返回 -1

其中 noise_then_valid 这个用例很贴近真实串口场景。UART 是连续字节流,接收缓冲区里不一定刚好从帧头开始,可能前面残留了无效字节。解析器不能假设第一个字节就是 0xAA,必须有同步能力。

五、和嵌入式开发的关系

在 STM32 项目中,UART 接收通常会和中断、DMA、环形缓冲区或者 FreeRTOS 队列结合使用。接收层负责把字节安全地收下来,协议层负责判断这些字节能不能组成一条有效消息。

一个更合理的分层可以是:

UART ISR / DMA
    -> 环形缓冲区或队列
    -> 协议解析器
    -> 命令处理逻辑

这样做的好处是 ISR 不需要执行复杂业务逻辑。查找帧头、计算 checksum、复制 payload 这些操作都可以放在主循环或任务中完成,避免中断执行时间过长。

这也让我更清楚地理解了昨天和今天的衔接:

  • 环形缓冲区解决“字节流暂存”。
  • 帧解析器解决“从字节流中识别消息”。
  • 后续命令处理解决“消息对应什么业务动作”。

六、今天必须能口述的问题

这部分是我今天真正需要能说出来的内容。代码通过测试只是第一步,如果不能解释下面几个问题,说明对协议解析还没有真正消化。

1. 一帧通常由哪些字段组成?

一帧通常由帧头、长度、命令字、数据区和校验字段组成。帧头负责同步位置,长度字段说明后面有多少有效内容,命令字说明消息类型,payload 保存业务数据,checksum 或 CRC 用来判断这一帧在传输或接收过程中有没有明显错误。

用今天的协议表示就是:

0xAA 0x55 LEN CMD PAYLOAD... CHECKSUM

真实项目里的字段可能更多,比如设备地址、序号、版本号、CRC16 等,但核心思想一样:先解决“从哪里开始”,再解决“有多长”,最后解决“是否可靠”。

2. LEN 表示什么,为什么容易算错?

今天这个练习里,LEN 表示 CMD + PAYLOAD 的长度,不包含帧头本身,也不包含 LEN 字段自己,更不包含最后的 CHECKSUM

也就是说:

payload_len = LEN - 1
full_frame_len = LEN + 4

它容易算错,是因为不同协议对 LEN 的定义不一样。有的协议让 LEN 只表示 payload 长度,有的表示从命令字到校验前的长度,有的甚至包含校验字段。如果不先把定义写清楚,后面的 checksum 位置、payload 长度和完整帧长度都会跟着错。

今天的 bug 本质上就是下标边界没有按这个定义统一起来。

3. 半包为什么不能直接当错包丢弃?

半包不是错误数据,而是“数据还没收完”。UART 是连续字节流,程序某一次读取缓冲区时,可能只拿到了帧头和一部分内容,剩下的字节还在路上。

例如:

AA 55 03 10

这只能说明当前还缺 payload 和 checksum,不能说明这条帧是坏的。如果直接丢弃,后续真正到达的字节就失去了前面的上下文,原本完整的一帧会被人为拆坏。

所以半包应该返回“需要更多数据”或“当前没有完整帧”,并把已有数据保留在缓冲区中,等待下一次接收后继续解析。

4. checksum 错误后如何继续寻找下一帧?

checksum 错误说明当前这个候选帧不可信,但不能简单清空整个缓冲区。正确做法是从当前帧头的下一个字节继续扫描,重新寻找新的 0xAA 0x55

原因是坏帧后面可能紧跟着一条好帧,如果一次性把后面的数据全部丢掉,就会误删有效数据。

实际解析策略可以概括为:

找到帧头 -> 长度足够 -> checksum 错
继续从当前帧头后一位扫描,而不是清空全部数据

这也是协议解析器“重新同步”的能力。串口通信不保证每次读取都刚好对齐到一帧的开头,所以解析器必须能从错误中恢复。

5. 噪声字节为什么必须跳过?

噪声字节是帧头之前的无效数据,可能来自上电瞬间、电气干扰、上一次残留数据,或者程序开始读取时刚好切在一帧中间。

如果解析器要求第一个字节必须是 0xAA,那只要缓冲区前面多一个无效字节,后面的合法帧也会解析失败。今天的 noise_then_valid 测试就是这个场景:

00 33 AA 55 02 20 7E A0

前面的 00 33 不是有效帧的一部分,但后面的 AA 55 ... 是合法帧。解析器要跳过噪声,并在成功解析后让 used_len 包含这些被跳过的字节,这样调用者才能从缓冲区里移除已经处理过的部分。

6. UART 粘包与 TCP 粘包有什么共同点?

共同点是:它们都提供连续字节流,而不是天然的一条条业务消息。

发送端可能分多次发送一帧,接收端一次只读到半帧;发送端也可能连续发送多帧,接收端一次读到多帧拼在一起。这就是“半包”和“粘包”问题。

解决思路也相同:应用层协议必须自己定义消息边界。常见做法包括:

  • 固定长度。
  • 帧头 + 长度。
  • 分隔符。
  • 长度 + 校验。

今天的 UART 练习使用的是“帧头 + 长度 + checksum”。TCP 场景里也经常用类似结构,只是底层传输介质从串口换成了网络连接。

7. 为什么二进制协议不能使用 strlen 判断长度?

strlen 只能用于以 \0 结尾的 C 字符串,它遇到字节 0x00 就会认为字符串结束。但二进制协议里的 payload 可以包含任意字节,包括 0x00

例如 payload 是:

01 00 02

这在二进制协议里是 3 个有效字节,但如果按字符串处理,strlen 会在 00 处提前停止,得到错误长度。

所以二进制协议必须使用显式长度字段,比如 stream_lenLENpayload_len,不能依赖字符串结束符。

七、今天的收获

今天主要掌握了 UART 帧解析中的几个关键点:

  1. 二进制协议不能按字符串思路处理,必须严格按字节和长度字段解析。
  2. LEN 的定义要先讲清楚,否则 checksum、payload 和完整帧长度都会算错。
  3. 帧头可以帮助解析器从噪声中重新同步。
  4. 半包和错包不能强行解析,要返回“当前没有合法帧”。
  5. 协议解析里最容易出错的是下标和边界,测试必须覆盖这些场景。

总结

Day5 完成了 UART 二进制帧解析练习,实现了从字节流中查找帧头、校验长度、提取命令和 payload、验证 checksum 的基本流程。今天的 UART 帧解析练习已经通过验证。

下一步可以继续围绕 UART 做更贴近真实工程的训练,例如把环形缓冲区和帧解析器串起来,模拟连续接收多帧数据,或者增加更多异常场景,比如 payload 中包含帧头、checksum 溢出、坏帧后紧跟好帧等。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐