LV045-IAP串口接收与帧处理
一、 总体设计
1. 三条约束
收帧逻辑同时被三件事约束,最终形态基本是被它们逼出来的:
- 中断里不能干重活:115200 下每 87µs 就可能来一个字节,128KB 载荷的 CRC32 需要一百多毫秒,放中断必然丢字节;
- 内部 RAM 装不下:64KB 内部 SRAM 放不下 128KB 的
flash-app镜像,缓冲只能落外部 SRAM; - 两种镜像共用一套协议:
flash-app与sram-app的帧格式相同,只有头部地址字段不同,落盘目标由地址决定。
2. 五个阶段、两个上下文
状态机只有五个状态,前四个跑在中断里,最后一个跑在主循环:
| 阶段 | 上下文 | 做的事 |
|---|---|---|
IAP_ST_SCAN | 中断 | 8 字节滑窗匹配魔数,前面混进杂字节也能对齐 |
IAP_ST_HEADER | 中断 | 再收 12 字节:长度、CRC32、目标地址 |
IAP_ST_PAYLOAD | 中断 | 逐字节写入外部 SRAM 的载荷缓冲 |
IAP_ST_TRAILER | 中断 | 逐字节比对 IAPEOF02 |
IAP_ST_VERIFY | 主循环 | 算 CRC32、上报事件、由业务层落盘 |
中断与主循环之间没有锁,只有一个"待校验"标志 verify_req:中断把它置 1,主循环看到后执行校验并把状态机推回 SCAN。这样两边永远只碰自己那一半状态。
3. 载荷缓冲为什么钉在外部 SRAM
// atk-f103/Drivers/BSP/Iap/iap.c
static uint8_t s_payload[IAP_PAYLOAD_MAX] __attribute__((at(externalSRAM_BASE)));IAP_PAYLOAD_MAX 是 128KB,内部 SRAM 只有 64KB,只能用 __attribute__((at())) 把数组钉在 FSMC 的 0x68000000。代价是一条硬前提:MX_FSMC_Init() 必须先执行,否则任何一次读写都落在没使能的 FSMC 上,直接 HardFault。Bootloader 的初始化顺序里,FSMC 排在所有 IAP 操作之前,正是因为这个。
二、 帧的类型与"收完"的判定
1. 同一条串口上的两类数据
串口上跑着两种完全不同的东西,它们共用字节流,却用两套语法:
| 数据类型 | 识别方式 | 结束标志 | 处理者 |
|---|---|---|---|
| 文本命令 | 首单词整词匹配命令表,大小写不敏感 | \r\n | usart1_cmd_poll() |
| 二进制镜像帧 | 8 字节魔数 IAPBIN02 | 长度 + 尾部魔数 + CRC32 | iap_rx_poll() |
两者互斥的规则只有一条:匹配到魔数时挂起命令识别(usart1_cmd_set_enabled(0)),帧结束或失败后恢复。这样载荷里即使凑出 go 字样也不会被当成命令。
镜像帧内部还分两种"子类型",但帧里没有类型字段——类型由目标地址隐含:
| 目标加载地址 | 帧的类型 | 落盘方式 |
|---|---|---|
0x08020000 | FLASH APP | 就地编程 |
0x20007000 | SRAM APP | 写暂存区,等 go sram |
| 其它 | 非法 | 直接判 IAP_ERR_ADDR |
这样做的好处是协议最小:上位机不需要额外告诉设备"这是哪种镜像",只要把链接地址填进头部即可;坏处是地址填错时只能靠设备端校验兜住,所以地址判定必须严格——只认两个基址,不做"就近对齐"这种猜测。
2. 四个字段各自管什么
| 字段 | 回答的问题 | 缺了会怎样 |
|---|---|---|
魔数 IAPBIN02 | 这段字节是不是一帧镜像? | 无法从任意字节流里自同步,文本里出现数字就会被误判 |
| 长度 N | 载荷有多长?何时该看尾部? | 只能靠"静默 3 秒"猜结束,慢链路上必然误判 |
| 目标地址 | 装到哪一块?容量上限多少? | 无法区分 Flash 版与 SRAM 版,也不知道该按 128KB 还是 24KB 校验 |
| CRC32 | 内容有没有被改坏? | 链路误码、发错文件都发现不了,坏镜像会直接落盘 |
尾部 IAPEOF02 | 是不是被截断了? | 长度对但少发 8 字节时,会被当成合法帧 |
帧头刻意没有"命令字""序号""时间戳""镜像类型"这些字段:设备端只需要回答"装哪、装多少、装坏没有",其余留给业务语义(比如 go sram 这条文本命令)。版本信息塞在魔数末两位里,既省字段,又让旧固件在比对魔数时自然失败。
3. "收完"是三道证据,不是一个超时
接收结束这件事由三道独立证据共同确认,任何一道缺失都会留下漏洞:
- 长度告诉状态机"何时从载荷切到尾部"——它是唯一的时间点依据;
- 尾部魔数确认"确实收完了、没多也没少"——长度字段自己也可能写错;
- CRC32 确认"内容没被改坏"——这是唯一覆盖载荷全部字节的检查。
帧内超时(3000ms)只做兜底,不参与"成功"的判定:它的职责是把卡在半帧的设备救回扫描态,而不是判断这一帧收没收到。
换个角度理解:如果只靠超时判帧尾,会遇到两个具体问题。一是 115200 下正常字节间隔只有 87µs,但主机偶尔会因为缓冲区调度停顿几十毫秒,静默阈值定小了会误判、定大了会让每次传输都白等;二是 128KB 传完之后还要再等一个阈值才认为结束,白白多耗 3 秒。有了长度字段,收满即切换,超时只用于异常。
这里的"三道"专指判断这一帧收完没有;再加上"目标地址是否合法",就是下一节展开的四道判定——前者决定"能不能用",后者决定"往哪放、放不放得下"。
4. 判定的顺序为什么不能调
四道判定是有依赖关系的,顺序换了要么不安全,要么白花钱:
| 顺序 | 判定 | 位置 | 为什么必须在这 |
|---|---|---|---|
| ① | 长度范围 | 中断(HEADER 解析后) | 决定后续写缓冲的长度,越界会写穿 s_payload |
| ② | 目标地址 | 中断(同上) | 决定落盘目标与容量上限,非法就不该继续收 |
| ③ | 尾部魔数 | 中断(收满 N 字节后) | 先排除截断,避免为不完整的数据白算 CRC |
| ④ | 载荷 CRC32 | 主循环(iap_rx_poll) | 最贵(128KB 约一百多毫秒),只有通过才允许落盘 |
顺序总结成一句话:先用便宜的检查挡住不该发生的写入,再用最贵的检查守住落盘那一步。
三、 中断侧实现
1. SCAN:8 字节滑窗
匹配魔数不能假设"帧从第一个字节开始":串口上可能先收到几行日志回显、TCP 上可能先收到一条文本命令。做法是维护一个 8 字节窗口,先填满,之后每来一个字节整体左移一位,窗口内始终是最近 8 个字节,与 IAPBIN02 逐字节比较。
实现的成本很低:中断里只需要维护 win_len(窗口已填几个字节)与一个 8 字节数组,每来一个字节做一次 memmove 式的左移和一次最多 8 字节的比较,最坏耗时几微秒,完全放得进 86.8 µs 的字节间隔。
这样做的收益是"自同步":主机中途重启、丢掉半帧、发错文件,接收方都能自己重新对齐,不需要额外的"同步字"协议。代价是滑窗恒为 8 字节比较,比"从固定偏移开始比较"多花一点 CPU,但在这个速率下可以忽略。
2. HEADER:收满 12 字节再解析
魔数命中后并不立刻解析,而是继续收满 12 字节(头部长 20 - 魔数 8)再一次性读出三个字段。原因很实际:长度、CRC、地址都是小端多字节,边收边解析容易写出"高字节还没到就用了"的错误,攒齐再解析只多耗 12 字节的时间。
攒齐的 12 字节放在 s_header 里,与命中的 8 字节魔数拼成完整的 20 字节头部备份。它的用途是排查:失败时不必重放数据,直接看这份头部就知道是长度不对、地址不对还是版本不对。
s_header 下标 | 对应头部偏移 | 字段 | 解析方式 |
|---|---|---|---|
| 0 ~ 7 | 0 ~ 7 | 魔数 IAPBIN02 | 命中时已存入 |
| 8 ~ 11 | 8 ~ 11 | 载荷长度 N | 小端拼成 uint32 |
| 12 ~ 15 | 12 ~ 15 | 载荷 CRC32 | 小端拼成 uint32 |
| 16 ~ 19 | 16 ~ 19 | 目标加载地址 | 小端拼成 uint32 |
解析后立刻做两道防线:
- 长度为 0 或超过
IAP_PAYLOAD_MAX→IAP_ERR_TOO_LARGE,不进载荷阶段; - 地址必须是两个容器基址之一、且载荷放得下 → 否则
IAP_ERR_ADDR。
这两道防线在中断里做,成本只是几次比较,但能保证后面的 s_payload[index] 永远不会越界——这是中断里唯一值得做"重"判断的地方。
3. PAYLOAD:直写外部 SRAM
载荷阶段只有一个数组赋值和一个下标自增,收满 len 字节就切到尾部阶段。这里没有任何"边收边校验",因为 CRC 要等最后一段才算得完。
写入期间外设总线(FSMC)一直在被访问,速度远低于 CPU,但因为每字节只写一次、且写入地址连续,实测不构成瓶颈。
4. TRAILER:逐字节比对
尾部 8 字节只做形式校验:逐字节与 IAPEOF02 比较,任一字节不符就 IAP_ERR_TRAILER。它的价值在于区分"长度字段对了但数据被截断"和"整帧完好"——如果只靠长度,发送方少发 8 字节会被当成合法帧。
5. 超时与回退
半帧卡死是现场最常见的情况(主机取消、串口拔线)。判据放在 iap_rx_poll() 里,只对 HEADER/PAYLOAD/TRAILER 三个阶段生效:
// atk-f103/Drivers/BSP/Iap/iap.c
if ((HAL_GetTick() - s_rx.last_tick) > IAP_RX_TIMEOUT_MS)
{
iap_fail(IAP_ERR_TIMEOUT);
return IAP_EVT_FRAME_ERR;
}超时后调 iap_back_to_scan():状态、下标、窗口长度全部归零,并恢复文本命令识别(usart1_cmd_set_enabled(1))。这一步很关键,否则一次失败的传输会让设备再也收不到 go/reboot,只能硬件复位。
四、 主循环侧处理
1. 三种事件
iap_rx_poll() 每 1ms 被调用一次,上报三类事件,业务层按事件做事:
| 事件 | 触发时机 | 业务层动作 |
|---|---|---|
IAP_EVT_HEADER | 头部解析完成、开始收载荷 | 打印"开始接收",可用来估算耗时 |
IAP_EVT_FRAME_OK | CRC32 校验通过 | 读载荷、按目标地址落盘 |
IAP_EVT_FRAME_ERR | 超时、尾部错、CRC 错、地址非法 | 打印具体错误,等待重发 |
事件的语义是"已经发生",不是"请你去做":FRAME_OK 上报时状态机已经回到 SCAN,载荷缓冲的内容保持不变,等业务层取走。
2. CRC32 为什么必须在主循环
128KB 逐位 CRC 在 72MHz 上约需一百多毫秒。如果放在中断里,这段时间的串口字节会全部错过(ORE),传输必然失败。放到主循环后,中断继续服务后续字节——虽然此时已经在等下次传输,但至少不会因为一次校验把整个接收器搞死。
代价是主循环必须够快地响应 verify_req:Bootloader 主循环是 1ms 一拍,iap_service_task() 每拍都会调 iap_rx_poll(),延迟可以忽略。
3. FLASH APP:写完就地可运行
帧头地址是 0x08020000 时,落盘目标就是运行目标:镜像直接写进 Flash 第 2 块,写完不搬动,go flash 只是把控制权交过去。
// atk-f103/Drivers/BSP/Iap/iap_service.c
iap_wr_res_t wr = iap_install_image(base, p, iap_rx_payload_len());
if (wr == IAP_WR_OK)
{
s_last_image_base = base; /* 'go' 不带参数时跳这里 */
printf("IAP: flash image installed at 0x%08X, send 'go flash' to run it\r\n",
(unsigned int)base);
iap_tcp_notify("FLASH OK\r\n"); /* 网络回执 */
}处理要点:
- 擦除按"载荷覆盖到的页数"算,64 页不会全擦;
- 写完之后对 Flash 里的副本算一次 CRC32,与外部 SRAM 里的源数据比对,只信回读结果;
- 成功只打印"已安装",不自动跳转——跳转是
go flash的职责,避免"刚发完就被切走、看不到回执"; - 网络通道另外回执
FLASH OK,上位机据此判断可以发下一条命令。
4. SRAM APP:先落暂存区,等 go sram
帧头地址是 0x20007000 时,落盘目标不是运行目标:镜像写进 Flash 第 3 块暂存区(0x08040000),等 go sram 再搬进内部 SRAM。
// atk-f103/Drivers/BSP/Iap/iap_service.c(同一处分支的另一半)
if (base == IAP_SRAM_APP_BASE)
{
printf("IAP: sram image stored to flash staging 0x%08X (%u bytes), "
"send 'go sram' to load it into SRAM and run\r\n",
(unsigned int)IAP_SRAM_STAGE_BASE, (unsigned int)iap_rx_payload_len());
iap_tcp_notify("SRAM OK\r\n");
}处理要点:
- 容量判据按 SRAM 容器 24KB 算,不是暂存区的 128KB——
iap_target_ok()按容器判,pack_iap.py打包时也按容器拦; - 落盘后
s_last_image_base也更新成0x20007000,此时裸发go会走 SRAM 路径; - 真正运行需要第二次交互(
go sram),中间可以断电重启:暂存区在 Flash 里,重启后一条命令就能装载; - 失败分支打印
sram staging write failed ... stay in bootloader,网络回执SRAM ERR。
5. 两条处理路径的对比
| 对比项 | FLASH APP | SRAM APP |
|---|---|---|
| 帧头地址 | 0x08020000 | 0x20007000 |
| 落盘位置 | 就地 0x08020000 | 暂存区 0x08040000 |
| 容量判据 | 128KB(第 2 块) | 24KB(SRAM 容器) |
| 成功提示 | flash image installed ... 'go flash' | stored to flash staging ... 'go sram' |
| 网络回执 | FLASH OK | SRAM OK |
| 是否自动跳转 | 否 | 否 |
| 让它跑起来 | go flash | go sram(先搬运再跳) |
| 掉电重启后 | 直接 go flash 即可 | 暂存区仍在,go sram 即可 |
| 写在哪个函数里 | iap_install_image() 的直写分支 | 同函数改写到 IAP_SRAM_STAGE_BASE |
两条分支的擦除、编程、回读校验实现完全共用,差别只在"写到哪个地址、提示哪句话"。回读校验这一道是必须的:它验的是介质,不是链路。
五、 常量与错误定位
1. 关键常量
| 常量 | 位置 | 取值 | 说明 |
|---|---|---|---|
IAP_PAYLOAD_MAX | iap.h | 128KB | 单帧载荷上限,也是外部 SRAM 缓冲大小 |
IAP_HEADER_LEN | iap.h | 20 | 头部总长(含 8 字节魔数) |
IAP_TRAILER_LEN | iap.h | 8 | 尾部魔数长度 |
IAP_RX_TIMEOUT_MS | iap.c | 3000 | 帧内字节间超时 |
IAP_MAGIC_LEN | iap.h | 8 | 滑窗长度 |
2. 错误提示与含义
| 提示 | 错误码 | 通常原因 |
|---|---|---|
magic prefix matched but version mismatch | IAP_ERR_VERSION | 固件与上位机帧版本不一致(01 与 02) |
payload length invalid or too large | IAP_ERR_TOO_LARGE | 长度字段为 0 或超过 128KB |
target address invalid or image too large for it | IAP_ERR_ADDR | 地址不是两个容器基址,或载荷超出容器 |
trailer magic mismatch | IAP_ERR_TRAILER | 尾部被截断或多发了字节 |
CRC32 check failed | IAP_ERR_CRC | 链路误码、发的是别的文件、发送工具没按二进制模式发 |
flash/sram staging write failed at 0x... | IAP_WR_* | 擦除或编程失败、回读 CRC 与源数据不一致 |
| 长时间无反应后恢复提示 | IAP_ERR_TIMEOUT | 主机中途取消,超时后自动回到扫描态 |
3. 串口与 TCP 的差异
| 维度 | 串口通道 | TCP 通道 |
|---|---|---|
| 字节入口 | USART1 中断调 iap_rx_byte() | tcp_recv 回调调 iap_rx_byte() |
| 上下文 | 中断 | 主循环(lwip_comm_poll 驱动) |
| 文本命令 | 同一中断并行喂 usart1_cmd_feed() | 同一回调并行喂 usart1_cmd_feed() |
| 半帧超时 | 3 秒 | 3 秒(另外还有 TCP 连接超时兜底) |
| 切走时机 | 不涉及 | accept 时摘掉串口消费者,断开时恢复 |
帧解析代码完全共用,两条通道的差别只在"谁把字节递进来"。
本文档由 markdowncli 技能辅助生成