LV030-网络升级帧协议与三条通道
前置阅读:LV020-网络通信实验 用 TCP 通道、LV025-基于TFTP的网络升级实验 用 TFTP 通道,两条通道都"发完即止"。本篇把两条通道下面共同的那一层——固件帧协议与收帧状态机——单独讲透。
一、 为什么三条通道要共用一套帧格式
1. 三条通道分别是什么
板子上的固件可以经三条完全不同的链路进来:
| 通道 | 载体 | 触发方式 | 代码位置 |
|---|---|---|---|
| 串口 | USART1(CH340 转 USB) | 上位机脚本直接发字节 | usart1 驱动 + iap.c |
| TCP | 以太网 + lwIP | tools/send_iap_tcp.py 连接 8888 端口 | port/iap_tcp.c |
| TFTP | 以太网 + UDP | 串口输入 update 命令后从服务器拉取 | Drivers/BSP/Ota/ota_tftp.c |
2. 共用帧格式的两个好处
(1)板侧只有一份校验与写 Flash 的逻辑。三条通道的字节最终都交给同一个状态机 iap.c,所以 CRC32、向量表、长度上限、写 Flash 与回读比对这些规则不会出现"串口一套、网络一套"的分歧。
(2)同一份固件产物可以走任意通道。tools/pack_iap.py 打出来的 app.img 既能喂串口脚本、也能喂 TCP 脚本、还能直接扔进 TFTP 服务器根目录,不需要为通道再打一次包。
这也是为什么 LV025 里能说"帧校验与写 Flash 全部复用 iap.c,本模块一行不重复实现"。
二、 IAPBIN01 帧格式
1. 头部 16 字节
帧格式定义在 Drivers/BSP/Iap/iap.h 的文件头注释里,与上位机 tools/pack_iap.py 必须严格一致,任一侧改动都要同步另一侧:
+---------------- 头部 16 字节 ----------------+
| 偏移 0 8B 魔数 "IAPBIN01" |
| 偏移 8 4B 载荷长度 N (小端 uint32) |
| 偏移 12 4B 载荷 CRC32 (小端 uint32) |
+---------------- 载荷 N 字节 ------------------+
| 偏移 16 N 原始 bin 字节 |
+---------------- 尾部 8 字节 ------------------+
| 偏移 16+N 8B 魔数 "IAPEOF01" |
+-----------------------------------------------+2. 载荷与尾部魔数
| 字段 | 长度 | 说明 |
|---|---|---|
| 魔数 | 8 字节 | IAPBIN01,逐字节扫描匹配 |
| 载荷长度 N | 4 字节 | 小端,上限 IAP_PAYLOAD_MAX = 128 KB |
| 载荷 CRC32 | 4 字节 | 小端,覆盖载荷 N 字节 |
| 载荷 | N 字节 | 原始 bin,来自 flash-app 工程的编译产物 |
| 尾部魔数 | 8 字节 | IAPEOF01,用来确认"这一帧真的收完了" |
128 KB 这个上限不是随便定的:它与上位机 pack_iap.py 的 APP_MAX_SIZE 对齐,等于 APP 区(0x08020000 ~ 0x08040000)能容纳的最大 bin。它是溢出防线的硬上限——头部声明的长度一旦超过它就直接报错,绝不进入载荷接收阶段,也就不会出现"按一个伪造的长度往 Flash 里猛写"的情况。
3. 版本号的设计
魔数的末两位是帧格式版本:
// Drivers/BSP/Iap/iap.h
#define IAP_MAGIC_PREFIX "IAPBIN" /* 前 6 字节 */
#define IAP_MAGIC_VERSION "01" /* 帧格式版本, 2 字节 */这样设计的好处是:前缀匹配而版本不符时会单独报"版本错误",而不是被当成"没对齐"从头继续扫描。升级协议时能一眼看出是版本问题,而不是在"找不到帧头"上白费功夫。
三、 收帧状态机与错误码
1. 四个阶段与四个事件
状态机由 iap_rx_byte() 逐字节推进,由 iap_rx_poll() 在主循环里推进超时与校验:
| 阶段 | 做什么 | 产出事件 |
|---|---|---|
| 扫描 | 逐字节比对魔数前缀与版本 | 匹配到帧头 → 进入收头部 |
| 收头部 | 读出长度与 CRC32,校验长度上限 | IAP_EVT_HEADER |
| 收载荷 | 逐字节写入载荷缓冲并累计长度 | — |
| 收尾部 | 比对 IAPEOF01 | 收齐 → IAP_EVT_FRAME_OK |
typedef enum
{
IAP_EVT_NONE = 0, /* 无新进展 */
IAP_EVT_HEADER, /* 头部解析完成且长度合法, 载荷开始接收 */
IAP_EVT_FRAME_OK, /* 整帧收齐, CRC32 校验通过 */
IAP_EVT_FRAME_ERR /* 收帧失败, 原因见 iap_rx_last_error() */
} iap_evt_t;用"事件"而不是"回调"的写法有个实际好处:业务层可以在主循环里一次性处理结果,不必担心收帧过程与业务逻辑重入。
2. 六种失败原因
typedef enum
{
IAP_ERR_NONE = 0, /* 尚未失败过 */
IAP_ERR_VERSION, /* 魔数前缀匹配但帧格式版本不符 */
IAP_ERR_TOO_LARGE, /* 头部声明的长度非法或超过 IAP_PAYLOAD_MAX */
IAP_ERR_TRAILER, /* 尾部魔数不匹配 */
IAP_ERR_CRC, /* CRC32 校验失败 */
IAP_ERR_TIMEOUT /* 收帧超时 */
} iap_err_t;对着下表就能判断"是哪一侧的问题",这是这套错误码最大的价值:
| 错误 | 大概率原因 |
|---|---|
IAP_ERR_VERSION | 上位机与新板子的帧版本不一致(升级时只更新了一侧) |
IAP_ERR_TOO_LARGE | 传的不是 APP 固件(比如误传了整片 Flash 的 bin) |
IAP_ERR_TRAILER | 传输中途被打断,字节数对不上 |
IAP_ERR_CRC | 链路有误码,或 app.img 与板侧期望的内容不一致 |
IAP_ERR_TIMEOUT | PC 侧卡住(防火墙、杀软、脚本被挂起) |
3. 超时重对齐
收帧中途允许的最大字节间隔是 IAP_RX_TIMEOUT_MS = 3000(3 秒),超过就判定超时并重新对齐。
这一步的必要性在于"主机中途取消"这个场景:如果 PC 侧传了一半就关掉连接,板子按字节数算还差一半,会永远停在半帧状态,后续任何一次正确的升级请求都被当成"半帧的后续字节"处理。超时重对齐让状态机能自己爬出这个坑。
四、 载荷缓冲与 CRC32 的时机
1. 128 KB 载荷放在哪
这是一个必须先解决的硬约束:STM32F103ZET6 只有 64 KB 内部 SRAM,而载荷上限是 128 KB。
所以载荷缓冲放在外部 SRAM(IS62WV51216,挂在 FSMC 的 NE3 上)。这也带来两个连带约束:
(1)访问外部 SRAM 与访问 DM9000 共用同一条 FSMC 总线,所以"写索引 + 读数据"这类操作要防并发,理由见 LV001-以太网与DM9000硬件接口 第五节。
(2)MX_FSMC_Init() 必须先于任何网络与 IAP 操作执行,否则访问外部 SRAM 直接进 HardFault。
2. CRC 为什么放在主循环算
iap_rx_byte() 运行在串口接收中断上下文,它每字节只做两件事:一次 O(1) 的状态迁移、一次缓冲写入。不算 CRC、不打印。
原因是算力账:128 KB 载荷用逐位实现的 CRC32 要跑一百多毫秒,放在中断里意味着这段时间内的所有后续字节都会被丢掉。
所以 CRC32 被挪到 iap_rx_poll()(主循环,建议 1 ms 一次)。这也是 iap.h 注释里那句"必须在主循环里周期调用"的由来,与 LV010-lwIP协议栈移植 里"回调里只做 O(1) 搬运"是同一条原则。
五、 三条通道的接入方式
1. sink 回调的统一套路
三条通道的差别只在"字节从哪来",接入方式完全一致——都实现成一个 sink 回调:
| 通道 | 字节怎么到 iap_rx_byte() | 期间串口怎么办 |
|---|---|---|
| 串口 | usart1_set_rx_sink(iap_rx_byte),中断里直接喂 | 命令识别挂起 |
| TCP | iap_tcp_recv() 遍历 pbuf 链,逐字节喂 | 建连时 usart1_set_rx_sink(0) 摘掉 |
| TFTP | UDP 收到块后经 sink 回调喂 | 传输期间摘掉串口消费器 |
还有一处防呆设计:匹配到帧头的那一刻,状态机会调用 usart1_cmd_set_enabled(0) 挂起命令识别,回到扫描态时恢复。 因为二进制载荷里恰好凑出 go、reboot 这种命令字样的概率虽然低,但没必要赌——一旦真发生,就会在收帧途中触发一次芯片复位。
2. 通道互斥的三个检查点
| 场景 | 检查点 | 行为 |
|---|---|---|
| 已有 TCP 会话,又来一个连接 | iap_tcp.c accept 回调 | tcp_abort 新连接 |
| TFTP 正在传输,来了 TCP 客户端 | 同上 | tcp_abort,避免两路字节混进同一状态机 |
TCP 会话中,输入 update | ota_update.c 的 cmd_update() | 打印拒绝原因并返回 |
原则是在入口处拒绝,而不是在状态机里纠错:与其让两路字节混进来、收下半帧垃圾才报错,不如让第二次请求立刻失败。
六、 上位机打包与向量表校验
1. pack_iap.py 的两道校验
上位机侧的发包与打包都在 tools/ 下:
| 脚本 | 作用 |
|---|---|
pack_iap.py | 把裸 bin 打成 IAPBIN01 帧(算 CRC32、加头尾魔数),可加 -f 做填充 |
send_iap_tcp.py | 走 TCP 通道发帧,给裸 bin 时会自动调用打包逻辑 |
pack_iap.py 在打包前会先校验向量表:
(1)栈顶指针(MSP)必须落在内部 SRAM 范围。
(2)复位向量必须落在 APP 区且最低位为 1(置位表示 Thumb 状态)。
这两条在 PC 侧拦掉了大部分错误产物——比如把 Bootloader 自己的 bin 当成 APP 传、或者忘了改 APP 的链接地址。在 PC 侧花几十微秒能发现的问题,等板子写完 Flash 再报错就要多花几十秒,这是很划算的一步。
2. 与板侧校验的配合
板侧在收完帧之后还有三道校验,两侧一起构成完整的防线:
| 位置 | 校验内容 |
|---|---|
| PC 侧打包时 | 魔数版本、载荷长度上限(APP_MAX_SIZE)、向量表两条 |
| 板侧收帧时 | 头部长度是否超 IAP_PAYLOAD_MAX、尾部魔数、载荷 CRC32 |
| 板侧写 Flash 后 | 写完后回读比对(对应 FLASH OK 回执) |
三道校验分别拦"打错包""传错行""写错位"三类问题,缺一不可。
七、 小结与参考资料
1. 本篇小结
- 串口、TCP、TFTP 三条通道共用一套帧格式与同一个状态机,好处是校验规则统一、固件产物可复用。
- 帧结构是"16 字节头(8B 魔数 + 4B 长度 + 4B CRC32)+ N 字节载荷 + 8B 尾部魔数",长度上限 128 KB,与 APP 区大小对齐。
- 魔数末两位是版本号,版本不符会单独报
IAP_ERR_VERSION,而不是被当成"没对齐"。 - 状态机逐字节推进、以事件形式上报;六种错误码可以直接定位问题出在哪一侧。
- 收帧中途超过 3 秒(
IAP_RX_TIMEOUT_MS)就超时重对齐,避免永远停在半帧状态。 - 128 KB 载荷放外部 SRAM;CRC32 放在主循环算,绝不能放在中断里。
- 三条通道统一用 sink 回调接入,入口处互相拒绝;匹配到帧头时还要挂起命令识别。
- 上位机的向量表校验与板侧的长度/魔数/CRC 校验、写后回读,共同构成三道防线。
2. 参考资料
- 帧格式与状态机:
iap.h、iap.c - 业务层与命令:
iap_service.c - 通道实现:
iap_tcp.c、ota_tftp.c - 上位机:
tools/pack_iap.py、tools/send_iap_tcp.py
本文档由 markdowncli 技能辅助生成