LV050-app在线升级原理与流程设计
前置阅读:LV045-WiFi-TFTP文件下载 解决了"怎么把
app.img从局域网拉进外部 SRAM",本篇接着讲"拉进来之后怎么安全地装进内部 Flash"。链路上两道校验各自成篇:CRC32 的原理见 LV055-CRC32校验原理,镜像签名与验签见 LV060-Ed25519签名校验原理——本篇只讲它们在流程里的位置和分工。
在线升级最容易被写成一个"能跑就行"的流程:下载、擦、写、跳转,四步走完,测试时一切正常。但只要把这四步放到真实环境里,问题立刻出现——正在执行的那份代码,正好就是准备被擦掉的那份。擦到一半掉电,设备就既跑不了新程序,也回不到旧程序。
所以这一整套设计其实在回答一个问题:如何让"替换自己"这件事在任何时刻被打断,都还留有一条完整的退路。
答案被拆成了三方:app 只负责把新镜像放进交接区,bootloader 只负责把交接区的东西装进内部 Flash,中间用一份掉电不丢的记录串起来。本篇就按这个顺序,把每一处设计的理由讲清楚。
一、 升级要跨两个执行环境
1. 谁在什么位置运行
板子上有两段互不重叠的代码,分别由两次独立编译产出:
| 程序 | 链接地址 | 职责 | 谁更新它 |
|---|---|---|---|
| bootloader | 0x08000000 | 上电第一段、收固件、跳转、装 A/B 槽 | J-Link(唯一允许的通道) |
| flash app | 0x08020000 | 业务功能、WiFi 下载、写槽、确认 | 只能靠 bootloader 搬运 |
关键点在于第二行的"只能靠 bootloader 搬运"。app 自己不能覆盖 0x08020000。
2. 为什么 app 不能自己擦内部 Flash
这不是保守,是硬性限制:
- 自擦除会取指失败。app 的代码就跑在
0x08020000起始的区域里。擦除操作以 1KB/2KB 页为单位,一旦擦到当前 PC 所在的页,下一条指令就再也取不到了——不是报错,是直接 HardFault 或跑飞。 - 擦写期间没有可用中断。内部 Flash 编程时总线会阻塞,任何跑到 Flash 里取指的中断服务程序都会卡住。业务代码里定时器、串口、WiFi 全在跑,关不完。
- 没有事务性。就算把关键代码搬到 RAM 里执行、成功擦完全部页,编程阶段掉电仍然会留下一个"半新半旧"的 app 区。这个半成品既跑不起来,也没有任何机制知道它坏了——直到复位后跳转时才 HardFault。
第 3 点是最致命的。它意味着安装动作必须由一个不打算被覆盖的程序来执行,也就是 bootloader。
3. 三个角色与两条分工线
app(下载方) ──写──▶ 外部 SPI Flash(交接区) ──读──▶ bootloader(安装方) ──写──▶ 内部 Flash两条不能越过的分工线:
- app 永远不写内部 Flash,只写外部 Flash 的镜像槽与记录;
- bootloader 永远不下载,只读外部 Flash、校验、写内部 Flash。
中间之所以需要一个"交接区",是因为 app 与 bootloader 在时间上不共存:app 下载完就复位了,bootloader 上电时 app 的内存、栈、缓冲区全部蒸发。唯一能跨越复位传递的东西,只有掉电不丢的外部 Flash。
二、 存储布局:两套 Flash 各司其职
1. 内部 Flash 三段
| 区域 | 地址 | 大小 | 说明 |
|---|---|---|---|
| bootloader | 0x08000000 | 128KB | 上电先跑,永不自我更新 |
| flash app | 0x08020000 | 128KB | 唯一运行区,只由 bootloader 改写 |
| SRAM app 暂存 | 0x08040000 | 128KB | 给"跳到 RAM 里跑的小程序"预留,与在线升级无关 |
内部 Flash 的 app 区只有 128KB,这决定了不可能在片上同时保存新旧两份镜像——它们总得有一份放到别处。这是引入外部 Flash 的第一个理由。
2. 外部 SPI Flash 分区
板上挂了一颗 W25Q128,16MB,4096 个 4KB 扇区:
| 扇区 | 地址 | 用途 | 写入者 |
|---|---|---|---|
| 0 / 1 | 0x000000 / 0x001000 | 设备参数,双槽乒乓 | app / bootloader |
| 2 / 3 | 0x002000 / 0x003000 | 升级控制记录,双槽乒乓 | app 写、bootloader 改写 |
| 4 ~ 67 | 0x004000 | 镜像槽 A,256KB | app |
| 68 ~ 131 | 0x044000 | 镜像槽 B,256KB | app |
| 132 / 133 | 0x084000 / 0x085000 | 验签公钥记录,双槽乒乓(见 LV060) | PC 工具经 spiflash 写入 |
| 134 ~ 4094 | — | 预留 | — |
| 4095 | 0x3FF000 | 自检区,spiflash test 会擦 | — |
两个槽各 256KB,是 app 区上限(128KB)的两倍,留出了将来镜像变大的余量。
3. 为什么镜像槽一定要两个
单槽就够了——如果升级永远成功。但升级恰恰是最可能失败的操作,所以每写一次新镜像,都要保证旧镜像还在原处、并且还是完整的。
双槽的做法是:永远写"非 active 槽"。
- 当前内部 Flash 里跑的是 A 槽的拷贝,那么新镜像只写 B 槽;
- 搬运成功后 A 槽内容原封不动,它就是天然的"上一版备份";
- 下一次升级写 A 槽,B 槽变成备份。
这样一来,任何一次升级最多牺牲一份槽,另一份始终可用。
这里有个容易忽略的细节:槽里存的是
N + 92字节的整帧,不是裸 bin。多出来的 92 字节(头 20 + 签名 64 + 尾 8)是 bootloader 独立校验的依据——它不信任记录,只信任自己算出来的结果。
三、 升级的完整时序
1. 阶段一:下载与落盘(app 主导)
app 里只有一条命令:
upd update # 默认 host=192.168.10.103, file=app.img
upd update 192.168.10.103 app.img # 显式指定它内部按顺序做六件事,每一步失败都直接退出、不动升级记录:
- 判断是否已连上热点。注意这里只判断不重连——模块内部已经保存了 AP 配置,上电会自动连接;主动发
AT+CWJAP会用默认参数覆盖掉它,还会打断正在进行的自动连接。 - 发一次
AT+PING探 TFTP 主机。链路不通就别开始下载,否则每块数据都要等 3 秒超时重传。 - 等通道静默 800ms,再发一次
AT+CIPMUX=1预热(原因见第七节第 3 小节)。 - TFTP 下载整帧到外部 SRAM(
0x68000000,1MB)。内部 RAM 只有 64KB,放不下 128KB 镜像。 - 整帧校验:魔数、长度自洽、尾标、载荷 CRC32、目标地址、Ed25519 验签——验签失败的镜像不写槽、不置 pending。
- 写非 active 槽,成功之后才写记录:
pending=1、pending_slot、img_len、img_crc32。
第 6 步的顺序不能反。若先置 pending=1 再写镜像,中途掉电会让 bootloader 去搬一个只写了一半的槽——而它拿到的长度和 CRC 恰好也来自这份记录,于是要么校验不过(浪费一次机会),要么更糟:旧的槽内容碰巧匹配(几乎不可能,但设计上不该依赖"碰巧")。
2. 阶段二:重启与搬运(bootloader 主导)
reboot # app 侧执行,回到 bootloader
go flash # bootloader 侧执行,先安装再跳转go flash 不再是"直接跳到 0x08020000"。它多了一段前置检查:读记录,pending == 1 才动手,否则维持原行为直接跳。
搬运过程见第五节。成功后的收尾是四个字段一起改:
active_slot = pending_slot # 生效槽翻到新槽
pending = 0 # 待安装标志清掉
confirmed = 0 # 新程序还没人担保
attempts = 1(换槽时重置) # 未确认期间搬运次数3. 阶段三:运行与确认(app 主导)
新程序启动时会发现 confirmed == 0,于是打印一行醒目提示:
UPD: active slot B @ 0x00044000, running at 0x08020000, NOT confirmed, run 'upd ok' after check人工确认功能正常后执行 upd ok,把 confirmed 置 1,回滚窗口关闭。
四、 两处关键的数据结构
整个流程里只有两种数据结构,但它们的字段语义必须完全对得上——app 写、bootloader 读,任何一处理解偏差都会让校验永远不过。
1. 64B 记录信封
记录和"设备参数"共用同一套信封格式,区别只在魔数:
| 偏移 | 长度 | 内容 | 说明 |
|---|---|---|---|
| 0 | 4B | 魔数 UPDT | 与参数区的 PRAM 区分 |
| 4 | 1B | 格式版本 | 当前 1,将来改结构靠它拒绝旧记录 |
| 5 | 1B | 保留 | 固定 0xFF |
| 6 | 2B | 载荷长度 | 固定 48 |
| 8 | 4B | 写入序号 | 双槽比较"谁更新" |
| 12 | 4B | 载荷 CRC32 | zlib 参数 |
| 16 | 48B | 载荷 | 见下一张表 |
为什么记录本身也要双槽:写一个扇区必须先擦后写,而擦除是"整扇区归 0xFF"的原子性较差的操作。如果只有一份记录,擦完还没写回时掉电,记录就彻底没了——设备会以为"从没升级过",把一个正在等待确认的新程序当成出厂状态。
乒乓写的规则很简单:改序号小的那份,序号取另一份 +1。这样任何时刻至少有一份完整记录,读取时取"序号大且校验通过"的那份。
2. 升级载荷 upd_data_t(48B)
| 字段 | 类型 | 谁写 | 语义 |
|---|---|---|---|
active_slot | u32 | bootloader | 内部 Flash 内容来自哪个槽:0=A,1=B |
confirmed | u32 | app | 正在跑的这一版是否已被人工确认 |
pending | u32 | app 置 1、bootloader 清 0 | 是否有待安装镜像 |
pending_slot | u32 | app | 待安装的槽号 |
attempts | u32 | bootloader | 未确认期间的搬运次数 |
img_len | u32 | app | 整帧长度(N + 92) |
img_crc32 | u32 | app | 整帧 CRC32 |
reserved[5] | 5×u32 | — | 预留,将来放程序版本号 |
active_slot 的语义容易被误解:它不是"从哪个槽运行"——CPU 永远只在内部 Flash 上执行。它表达的是"0x08020000 里这份内容对应哪个槽",也就是"如果现在要回滚,该去哪个槽取"。
3. IAPBIN03 帧
| 字段 | 偏移 | 长度 | 内容 |
|---|---|---|---|
| 魔数 | 0 | 8B | IAPBIN03 |
| 载荷长度 | 8 | 4B | N |
| 载荷 CRC32 | 12 | 4B | 只覆盖 N 字节载荷 |
| 目标地址 | 16 | 4B | 0x08020000 或 0x20007000 |
| 载荷 | 20 | N | 裸 bin |
| Ed25519 签名 | 20+N | 64B | 覆盖头部 20B + 载荷 N B |
| 尾标 | 20+N+64 | 8B | IAPEOF03 |
与第二版相比只多了 64 字节签名,头部布局一字未动;旧版帧(无签名)一律拒绝,不存在降级开关。签名把头部也一起签了——长度、CRC32、目标地址任何一个被改,验签都会失败,"换信封复用旧签名"的攻击面被堵死。帧格式与验签原理的完整讨论见 LV060-Ed25519签名校验原理。
帧由 tools/pack_iap.py -k keys/iap_priv.pem 生成,打包时必须提供私钥(不存在无签名帧这个产物形态),带向量表检查(栈顶必须在 0x2000xxxx、复位向量必须在 0x0802xxxx),拿错产物(比如调试版链接在 0x08000000)会在打包阶段就被拒。
4. 两层 CRC 与一层签名的分工
链路上有三道校验,各有各的职责,别混:
- 帧内
CRC32(偏移 12):覆盖 N 字节载荷,回答"这份 bin 在传输过程中有没有坏"; - 记录里的
img_crc32:覆盖整帧N + 92(含签名),回答"槽里这一份和当初写进去的是不是同一份"; - Ed25519 验签:覆盖头部 + 载荷,回答"这份 bin 是不是私钥持有者签发的"——CRC 管得住意外、管不住故意,这是签名存在的原因。
前两者在下载时校验一次、搬运前各重新算一次;验签则在下载、落槽、搬运前三处都要过。两个 CRC 的算法参数必须与 bootloader 的 iap_crc32() 完全一致:多项式 0xEDB88320 反射、初值与终值异或 0xFFFFFFFF(即 zlib 实现)。app 侧为了省查表空间是逐位算的,结果相同。CRC 的数学原理见 LV055-CRC32校验原理。
五、 go flash 的搬运逻辑
1. 决策入口
go flash 进来先看 pending。为 0 走原来的路径:校验 0x08020000 的向量表,合法就跳。为 1 才进入安装流程。
这里有个取舍值得说明:安装不自动做,必须由人敲 go flash 触发。自动安装(上电就检查 pending)看起来更方便,但会把"设备无法启动"这个故障和"本来就想重启"混在一起,调试时很难判断到底发生了什么。手动触发让每一步都可观测。
2. 七项校验的顺序不能换
IAPBIN03 魔数 → 载荷长度自洽 → 目标地址 → IAPEOF03 尾标 → 载荷 CRC32 → 整帧 CRC32 → Ed25519 验签顺序是有讲究的:
- 魔数放第一位,能立刻挡掉"空槽"(全
0xFF)和"垃圾数据"; - 长度自洽必须在取签名与尾标之前。签名偏移是
20 + payload_len,如果先按一个未经校验的payload_len去取,很可能直接越界读出 SRAM 之外的地址; - 目标地址放在 CRC 之前,是因为它是"要不要装"的判断依据——SRAM 版镜像(目标
0x20007000)根本不该进 A/B 槽; - 两个 CRC 放倒数第二、三位,计算最贵,前面的廉价判断先把九成错误挡掉;
- 验签殿后,是全部校验里最贵的一步(72MHz 上约 50~60ms),只花在结构完好的帧上。它同时是唯一能挡住"crc 正确但内容被调包"的关口——签名覆盖头部与载荷,前面任何一项被绕过(比如攻击者同步重算了 CRC),到这里仍然现形。
任何一项不过,都打印具体原因并保留记录,然后照旧跳当前镜像。
3. 先写 Flash 再记账
搬运成功的收尾顺序是:
写 0x08020000(擦页 → 编程 → 回读 CRC32 比对)
↓ 成功
写记录(active / pending / confirmed / attempts)反过来的话,一旦在"记账完成、Flash 未写完"之间掉电,记录会声称 active 已经是新槽,而内部 Flash 里还是旧镜像——下次回滚就会搬错东西。按现在的顺序,最坏情况是"装好了但没记账",此时记录仍是 pending=1,下次 go flash 会重装一遍。安装动作是幂等的,记账不是,所以幂等的那个先做。
4. 失败为什么不会变砖
三条防线叠在一起:
- 擦除发生在校验之后。七项校验全过才碰内部 Flash,任何"镜像本身有问题"的情况(含验签失败)都不会破坏现有程序。
- 写入前不擦除已确认的槽。搬运只动
0x08020000,A/B 槽保持原样,随时可再装一次。 - 失败继续跳旧程序。搬运失败不是"停在 bootloader 等死",而是照旧跳
0x08020000——那里还是完整的旧程序。
真正无法恢复的只有一种情况:写内部 Flash 的过程中掉电。此时 0x08020000 是半成品,但记录里 pending 还在,重新上电后 go flash 会重新搬一遍,把半成品覆盖掉。只要槽里的镜像还在,这个循环总能收敛。
六、 状态机与回滚窗口
1. 三个状态
记录只有三种稳定形态:
| 状态 | pending | confirmed | 含义 |
|---|---|---|---|
| 稳定态 | 0 | 1 | 内部 Flash 与 active 槽一致,且已被确认 |
| 下载完成 | 1 | 不变 | 非 active 槽有一份新镜像等安装 |
| 待确认 | 0 | 0 | 新镜像已装上,但还没人担保 |
状态之间的三条转移,正好对应三个动作:upd update(稳定 → 下载完成)、go flash(下载完成 → 待确认)、upd ok(待确认 → 稳定)。
2. 回滚窗口到底锁住了什么
confirmed = 0 的那段时间,就是回滚窗口。它锁住的不是代码,而是语义:
- 内部 Flash 里是新程序,
active_slot指向新槽; - 另一槽里躺着上一版,内容完整;
- 记录明确写着"这一版没人确认过"。
如果将来加自动回滚,判据就是它:连续启动失败 / attempts 超阈值 → 把另一槽搬回来。现在的实现只写记录、不做判断,也就是说窗口是"敞开的但没有自动关门的人"。
3. 目前没做的三件事
如实记录边界,避免后来者误以为已经具备:
- 没有自动回滚。
go rollback尚未实现,回退只能靠重新下载上一版。 - 没有"未确认禁止再升级"。
confirmed = 0时再次upd update,会直接覆盖非 active 槽——那份备份就丢了。最初的设想是拒绝本次升级以保护备份,实测里为了调试方便先放开了。 - 没有程序版本字段。
reserved[5]空着。帧格式已升级到IAPBIN03(签名版),版本号字段仍留待将来需要显示"当前 v1.2,可回滚到 v1.1"时再启用。
另外还有一处不一致要留意:bootloader 的 update(网口 TFTP)、串口 IAP、TCP IAP 三条直装通道都直接写 0x08020000,不写升级记录。它们装完之后,"内部 Flash 内容 == active 槽内容"这个不变量就暂时失效了。做自动回滚之前,必须先把这几条通道也纳入记账。
七、 实现要点与实测
1. 命令分布
两侧命令不同,别混用:
| 侧 | 命令 | 作用 |
|---|---|---|
| app | upd update [host] [file] | 一键下载 + 落槽 + 置 pending |
| app | upd status | 打印记录、槽地址、运行地址 |
| app | upd ok | 确认当前程序,关闭回滚窗口 |
| bootloader | upd get | 打印记录 |
| bootloader | upd set key=value ... | 手工造记录(调试用) |
默认主机与文件名集中在 flash-app/Drivers/BSP/Update/upd.h 的 UPD_OTA_HOST_DEFAULT 与 UPD_OTA_FILE_DEFAULT。
2. 一次实测日志
upd update(active 在 A,写 B 槽):
UPD: wifi up, ping 192.168.10.103 9 ms, fetching app.img ...
[ESP8266] recv 8192 bytes
[ESP8266] recv 16384 bytes
[ESP8266] recv 24576 bytes
[ESP8266] recv 32768 bytes
[ESP8266] recv 40960 bytes
[ESP8266] recv 49152 bytes
UPD: 56776 bytes ok (payload 56684), staging to slot B @ 0x00044000 ...
UPD: slot B ready (56776 bytes, crc32 0x5BBADA10), run 'go flash' in bootloadergo flash(搬运):
UPD: pending slot B @ 0x00044000, 56776 bytes, installing...
IAP: flashing 56684 bytes to 0x08020000 (28 page(s)), please wait...
IAP: flash write OK, readback CRC32 verified
UPD: slot B installed, active slot is B, attempts 1; run 'upd ok' in the app once it works
IAP: jump to APP at 0x08020000 (MSP 0x20003738, reset 0x08020299)upd status(搬运后):
UPD: active_slot = B (1) @ 0x00044000, running at 0x08020000
UPD: confirmed = 0 (NOT confirmed, run 'upd ok' once it works)
UPD: pending = 0 (nothing to install)
UPD: pending_slot = B (1) @ 0x00044000
UPD: attempts = 1
UPD: img_len = 56776
UPD: img_crc32 = 0x5BBADA1056KB 镜像(56684 + 92 字节帧)经 ESP8266 的 AT 透传下载约 35 秒(TFTP 512B 一块,111 块),写槽约 2 秒,搬运安装约 8 秒。全程阻塞主循环,属于"接受这个时间"的取舍。
签名校验拒绝的实测同样值得记录——把载荷改一个字节并同步重算 CRC32 后再走一遍 upd update:
UPD: wifi up, ping 192.168.10.103 174 ms, fetching bad_sig.img ...
[ESP8266] signature invalid (target 0x08020000, 56684 bytes), image rejected
UPD: image verify failed: image signature invalidCRC 是重算过的,所以 CRC 关照常通过;拦下它的是最后一关 Ed25519 验签。此后核对 upd status 与非 active 槽内容,与失败前逐字节一致——验签失败不留下任何副作用。
3. 踩过的坑:ping 之后不能立刻建链
这是本次改动里最隐蔽的一个问题,值得单独记一笔。
给升级流程加上"先 ping 再下载"之后,upd update 每次都回:
UPD: wifi up, ping 192.168.10.103 52 ms, fetching app.img ...
UPD: download failed: connect failed失败发生在建链阶段,耗时很短。奇怪的是用 at 命令逐条手工发同样的指令却全部成功:
at AT+PING="192.168.10.103" → +4 / OK
at AT+CIPMUX=1 → OK
at AT+CIPSTART=0,"UDP",... → 0,CONNECT / OK差别在于时间。atk_mw8266d_ping_host() 是"收到结论就返回"的实现,模块回完 +4 之后还要吐末尾的 OK;代码紧接着就发 AT+CIPMUX=1,模块此时仍在处理上一条事务,新指令被静默丢弃或应答错位,net_wait_for("OK") 匹配到的是迟到的旧 OK,于是 CIPSTART 提前发出、判定失败。手工执行时工具调用之间天然隔了几秒,正好避开这个窗口——这也是为什么"手工能通、代码不通"。
最终叠了三层防护:
- ping 之后等通道连续 800ms 无帧,再清空接收缓冲;
- 建链前单独发一次
AT+CIPMUX=1把 TCP/IP 事务预热(tftp_get内部还会再发一次,幂等); - 建链仍失败时,发
AT+CIPCLOSE=0清掉残留 link,等 500ms 重试一次。
4. 自测清单
按这个顺序走一遍,能覆盖所有关键分支:
- [ ]
upd update后upd status里pending=1,且pending_slot与当前active_slot相反 - [ ]
reboot后upd get仍能读到pending=1(记录掉电不丢) - [ ]
go flash打印 4 行安装日志,跳到新镜像 - [ ] 启动打印
NOT confirmed,upd ok后变confirmed - [ ] 再
reboot+go flash:没有installing日志,直接跳转 - [ ] 再执行一次
upd update:这次应写入另一个槽(乒乓切换) - [ ] 发一条篡改帧(改载荷并重算 CRC):报
signature invalid,槽与记录原样不动
本文档由 markdowncli 技能辅助生成