Skip to content

LV050-app在线升级原理与流程设计 ​

前置阅读:LV045-WiFi-TFTP文件下载 解决了"怎么把 app.img 从局域网拉进外部 SRAM",本篇接着讲"拉进来之后怎么安全地装进内部 Flash"。链路上两道校验各自成篇:CRC32 的原理见 LV055-CRC32校验原理,镜像签名与验签见 LV060-Ed25519签名校验原理——本篇只讲它们在流程里的位置和分工。

在线升级最容易被写成一个"能跑就行"的流程:下载、擦、写、跳转,四步走完,测试时一切正常。但只要把这四步放到真实环境里,问题立刻出现——正在执行的那份代码,正好就是准备被擦掉的那份。擦到一半掉电,设备就既跑不了新程序,也回不到旧程序。

所以这一整套设计其实在回答一个问题:如何让"替换自己"这件事在任何时刻被打断,都还留有一条完整的退路。

答案被拆成了三方:app 只负责把新镜像放进交接区,bootloader 只负责把交接区的东西装进内部 Flash,中间用一份掉电不丢的记录串起来。本篇就按这个顺序,把每一处设计的理由讲清楚。

一、 升级要跨两个执行环境 ​

1. 谁在什么位置运行 ​

板子上有两段互不重叠的代码,分别由两次独立编译产出:

程序链接地址职责谁更新它
bootloader0x08000000上电第一段、收固件、跳转、装 A/B 槽J-Link(唯一允许的通道)
flash app0x08020000业务功能、WiFi 下载、写槽、确认只能靠 bootloader 搬运

关键点在于第二行的"只能靠 bootloader 搬运"。app 自己不能覆盖 0x08020000。

2. 为什么 app 不能自己擦内部 Flash ​

这不是保守,是硬性限制:

  1. 自擦除会取指失败。app 的代码就跑在 0x08020000 起始的区域里。擦除操作以 1KB/2KB 页为单位,一旦擦到当前 PC 所在的页,下一条指令就再也取不到了——不是报错,是直接 HardFault 或跑飞。
  2. 擦写期间没有可用中断。内部 Flash 编程时总线会阻塞,任何跑到 Flash 里取指的中断服务程序都会卡住。业务代码里定时器、串口、WiFi 全在跑,关不完。
  3. 没有事务性。就算把关键代码搬到 RAM 里执行、成功擦完全部页,编程阶段掉电仍然会留下一个"半新半旧"的 app 区。这个半成品既跑不起来,也没有任何机制知道它坏了——直到复位后跳转时才 HardFault。

第 3 点是最致命的。它意味着安装动作必须由一个不打算被覆盖的程序来执行,也就是 bootloader。

3. 三个角色与两条分工线 ​

text
app(下载方)  ──写──▶  外部 SPI Flash(交接区)  ──读──▶  bootloader(安装方)  ──写──▶  内部 Flash

两条不能越过的分工线:

  • app 永远不写内部 Flash,只写外部 Flash 的镜像槽与记录;
  • bootloader 永远不下载,只读外部 Flash、校验、写内部 Flash。

中间之所以需要一个"交接区",是因为 app 与 bootloader 在时间上不共存:app 下载完就复位了,bootloader 上电时 app 的内存、栈、缓冲区全部蒸发。唯一能跨越复位传递的东西,只有掉电不丢的外部 Flash。

二、 存储布局:两套 Flash 各司其职 ​

两套存储与 A/B 槽位

1. 内部 Flash 三段 ​

区域地址大小说明
bootloader0x08000000128KB上电先跑,永不自我更新
flash app0x08020000128KB唯一运行区,只由 bootloader 改写
SRAM app 暂存0x08040000128KB给"跳到 RAM 里跑的小程序"预留,与在线升级无关

内部 Flash 的 app 区只有 128KB,这决定了不可能在片上同时保存新旧两份镜像——它们总得有一份放到别处。这是引入外部 Flash 的第一个理由。

2. 外部 SPI Flash 分区 ​

板上挂了一颗 W25Q128,16MB,4096 个 4KB 扇区:

扇区地址用途写入者
0 / 10x000000 / 0x001000设备参数,双槽乒乓app / bootloader
2 / 30x002000 / 0x003000升级控制记录,双槽乒乓app 写、bootloader 改写
4 ~ 670x004000镜像槽 A,256KBapp
68 ~ 1310x044000镜像槽 B,256KBapp
132 / 1330x084000 / 0x085000验签公钥记录,双槽乒乓(见 LV060)PC 工具经 spiflash 写入
134 ~ 4094—预留—
40950x3FF000自检区,spiflash test 会擦—

两个槽各 256KB,是 app 区上限(128KB)的两倍,留出了将来镜像变大的余量。

3. 为什么镜像槽一定要两个 ​

单槽就够了——如果升级永远成功。但升级恰恰是最可能失败的操作,所以每写一次新镜像,都要保证旧镜像还在原处、并且还是完整的。

双槽的做法是:永远写"非 active 槽"。

  • 当前内部 Flash 里跑的是 A 槽的拷贝,那么新镜像只写 B 槽;
  • 搬运成功后 A 槽内容原封不动,它就是天然的"上一版备份";
  • 下一次升级写 A 槽,B 槽变成备份。

这样一来,任何一次升级最多牺牲一份槽,另一份始终可用。

这里有个容易忽略的细节:槽里存的是 N + 92 字节的整帧,不是裸 bin。多出来的 92 字节(头 20 + 签名 64 + 尾 8)是 bootloader 独立校验的依据——它不信任记录,只信任自己算出来的结果。

三、 升级的完整时序 ​

升级的三个阶段

1. 阶段一:下载与落盘(app 主导) ​

app 里只有一条命令:

text
upd update                          # 默认 host=192.168.10.103, file=app.img
upd update 192.168.10.103 app.img   # 显式指定

它内部按顺序做六件事,每一步失败都直接退出、不动升级记录:

  1. 判断是否已连上热点。注意这里只判断不重连——模块内部已经保存了 AP 配置,上电会自动连接;主动发 AT+CWJAP 会用默认参数覆盖掉它,还会打断正在进行的自动连接。
  2. 发一次 AT+PING 探 TFTP 主机。链路不通就别开始下载,否则每块数据都要等 3 秒超时重传。
  3. 等通道静默 800ms,再发一次 AT+CIPMUX=1 预热(原因见第七节第 3 小节)。
  4. TFTP 下载整帧到外部 SRAM(0x68000000,1MB)。内部 RAM 只有 64KB,放不下 128KB 镜像。
  5. 整帧校验:魔数、长度自洽、尾标、载荷 CRC32、目标地址、Ed25519 验签——验签失败的镜像不写槽、不置 pending。
  6. 写非 active 槽,成功之后才写记录:pending=1、pending_slot、img_len、img_crc32。

第 6 步的顺序不能反。若先置 pending=1 再写镜像,中途掉电会让 bootloader 去搬一个只写了一半的槽——而它拿到的长度和 CRC 恰好也来自这份记录,于是要么校验不过(浪费一次机会),要么更糟:旧的槽内容碰巧匹配(几乎不可能,但设计上不该依赖"碰巧")。

2. 阶段二:重启与搬运(bootloader 主导) ​

text
reboot          # app 侧执行,回到 bootloader
go flash        # bootloader 侧执行,先安装再跳转

go flash 不再是"直接跳到 0x08020000"。它多了一段前置检查:读记录,pending == 1 才动手,否则维持原行为直接跳。

搬运过程见第五节。成功后的收尾是四个字段一起改:

text
active_slot  = pending_slot     # 生效槽翻到新槽
pending      = 0                # 待安装标志清掉
confirmed    = 0               # 新程序还没人担保
attempts     = 1(换槽时重置)  # 未确认期间搬运次数

3. 阶段三:运行与确认(app 主导) ​

新程序启动时会发现 confirmed == 0,于是打印一行醒目提示:

text
UPD: active slot B @ 0x00044000, running at 0x08020000, NOT confirmed, run 'upd ok' after check

人工确认功能正常后执行 upd ok,把 confirmed 置 1,回滚窗口关闭。

四、 两处关键的数据结构 ​

记录信封与镜像帧

整个流程里只有两种数据结构,但它们的字段语义必须完全对得上——app 写、bootloader 读,任何一处理解偏差都会让校验永远不过。

1. 64B 记录信封 ​

记录和"设备参数"共用同一套信封格式,区别只在魔数:

偏移长度内容说明
04B魔数 UPDT与参数区的 PRAM 区分
41B格式版本当前 1,将来改结构靠它拒绝旧记录
51B保留固定 0xFF
62B载荷长度固定 48
84B写入序号双槽比较"谁更新"
124B载荷 CRC32zlib 参数
1648B载荷见下一张表

为什么记录本身也要双槽:写一个扇区必须先擦后写,而擦除是"整扇区归 0xFF"的原子性较差的操作。如果只有一份记录,擦完还没写回时掉电,记录就彻底没了——设备会以为"从没升级过",把一个正在等待确认的新程序当成出厂状态。

乒乓写的规则很简单:改序号小的那份,序号取另一份 +1。这样任何时刻至少有一份完整记录,读取时取"序号大且校验通过"的那份。

2. 升级载荷 upd_data_t(48B) ​

字段类型谁写语义
active_slotu32bootloader内部 Flash 内容来自哪个槽:0=A,1=B
confirmedu32app正在跑的这一版是否已被人工确认
pendingu32app 置 1、bootloader 清 0是否有待安装镜像
pending_slotu32app待安装的槽号
attemptsu32bootloader未确认期间的搬运次数
img_lenu32app整帧长度(N + 92)
img_crc32u32app整帧 CRC32
reserved[5]5×u32—预留,将来放程序版本号

active_slot 的语义容易被误解:它不是"从哪个槽运行"——CPU 永远只在内部 Flash 上执行。它表达的是"0x08020000 里这份内容对应哪个槽",也就是"如果现在要回滚,该去哪个槽取"。

3. IAPBIN03 帧 ​

字段偏移长度内容
魔数08BIAPBIN03
载荷长度84BN
载荷 CRC32124B只覆盖 N 字节载荷
目标地址164B0x08020000 或 0x20007000
载荷20N裸 bin
Ed25519 签名20+N64B覆盖头部 20B + 载荷 N B
尾标20+N+648BIAPEOF03

与第二版相比只多了 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 的搬运逻辑 ​

go flash 的搬运决策

1. 决策入口 ​

go flash 进来先看 pending。为 0 走原来的路径:校验 0x08020000 的向量表,合法就跳。为 1 才进入安装流程。

这里有个取舍值得说明:安装不自动做,必须由人敲 go flash 触发。自动安装(上电就检查 pending)看起来更方便,但会把"设备无法启动"这个故障和"本来就想重启"混在一起,调试时很难判断到底发生了什么。手动触发让每一步都可观测。

2. 七项校验的顺序不能换 ​

text
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 再记账 ​

搬运成功的收尾顺序是:

text
写 0x08020000(擦页 → 编程 → 回读 CRC32 比对)
    ↓ 成功
写记录(active / pending / confirmed / attempts)

反过来的话,一旦在"记账完成、Flash 未写完"之间掉电,记录会声称 active 已经是新槽,而内部 Flash 里还是旧镜像——下次回滚就会搬错东西。按现在的顺序,最坏情况是"装好了但没记账",此时记录仍是 pending=1,下次 go flash 会重装一遍。安装动作是幂等的,记账不是,所以幂等的那个先做。

4. 失败为什么不会变砖 ​

三条防线叠在一起:

  1. 擦除发生在校验之后。七项校验全过才碰内部 Flash,任何"镜像本身有问题"的情况(含验签失败)都不会破坏现有程序。
  2. 写入前不擦除已确认的槽。搬运只动 0x08020000,A/B 槽保持原样,随时可再装一次。
  3. 失败继续跳旧程序。搬运失败不是"停在 bootloader 等死",而是照旧跳 0x08020000——那里还是完整的旧程序。

真正无法恢复的只有一种情况:写内部 Flash 的过程中掉电。此时 0x08020000 是半成品,但记录里 pending 还在,重新上电后 go flash 会重新搬一遍,把半成品覆盖掉。只要槽里的镜像还在,这个循环总能收敛。

六、 状态机与回滚窗口 ​

升级记录的状态流转

1. 三个状态 ​

记录只有三种稳定形态:

状态pendingconfirmed含义
稳定态01内部 Flash 与 active 槽一致,且已被确认
下载完成1不变非 active 槽有一份新镜像等安装
待确认00新镜像已装上,但还没人担保

状态之间的三条转移,正好对应三个动作:upd update(稳定 → 下载完成)、go flash(下载完成 → 待确认)、upd ok(待确认 → 稳定)。

2. 回滚窗口到底锁住了什么 ​

confirmed = 0 的那段时间,就是回滚窗口。它锁住的不是代码,而是语义:

  • 内部 Flash 里是新程序,active_slot 指向新槽;
  • 另一槽里躺着上一版,内容完整;
  • 记录明确写着"这一版没人确认过"。

如果将来加自动回滚,判据就是它:连续启动失败 / attempts 超阈值 → 把另一槽搬回来。现在的实现只写记录、不做判断,也就是说窗口是"敞开的但没有自动关门的人"。

3. 目前没做的三件事 ​

如实记录边界,避免后来者误以为已经具备:

  1. 没有自动回滚。go rollback 尚未实现,回退只能靠重新下载上一版。
  2. 没有"未确认禁止再升级"。confirmed = 0 时再次 upd update,会直接覆盖非 active 槽——那份备份就丢了。最初的设想是拒绝本次升级以保护备份,实测里为了调试方便先放开了。
  3. 没有程序版本字段。reserved[5] 空着。帧格式已升级到 IAPBIN03(签名版),版本号字段仍留待将来需要显示"当前 v1.2,可回滚到 v1.1"时再启用。

另外还有一处不一致要留意:bootloader 的 update(网口 TFTP)、串口 IAP、TCP IAP 三条直装通道都直接写 0x08020000,不写升级记录。它们装完之后,"内部 Flash 内容 == active 槽内容"这个不变量就暂时失效了。做自动回滚之前,必须先把这几条通道也纳入记账。

七、 实现要点与实测 ​

1. 命令分布 ​

两侧命令不同,别混用:

侧命令作用
appupd update [host] [file]一键下载 + 落槽 + 置 pending
appupd status打印记录、槽地址、运行地址
appupd ok确认当前程序,关闭回滚窗口
bootloaderupd get打印记录
bootloaderupd set key=value ...手工造记录(调试用)

默认主机与文件名集中在 flash-app/Drivers/BSP/Update/upd.h 的 UPD_OTA_HOST_DEFAULT 与 UPD_OTA_FILE_DEFAULT。

2. 一次实测日志 ​

upd update(active 在 A,写 B 槽):

text
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 bootloader

go flash(搬运):

text
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(搬运后):

text
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    = 0x5BBADA10

56KB 镜像(56684 + 92 字节帧)经 ESP8266 的 AT 透传下载约 35 秒(TFTP 512B 一块,111 块),写槽约 2 秒,搬运安装约 8 秒。全程阻塞主循环,属于"接受这个时间"的取舍。

签名校验拒绝的实测同样值得记录——把载荷改一个字节并同步重算 CRC32 后再走一遍 upd update:

text
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 invalid

CRC 是重算过的,所以 CRC 关照常通过;拦下它的是最后一关 Ed25519 验签。此后核对 upd status 与非 active 槽内容,与失败前逐字节一致——验签失败不留下任何副作用。

3. 踩过的坑:ping 之后不能立刻建链 ​

这是本次改动里最隐蔽的一个问题,值得单独记一笔。

给升级流程加上"先 ping 再下载"之后,upd update 每次都回:

text
UPD: wifi up, ping 192.168.10.103 52 ms, fetching app.img ...
UPD: download failed: connect failed

失败发生在建链阶段,耗时很短。奇怪的是用 at 命令逐条手工发同样的指令却全部成功:

text
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 提前发出、判定失败。手工执行时工具调用之间天然隔了几秒,正好避开这个窗口——这也是为什么"手工能通、代码不通"。

最终叠了三层防护:

  1. ping 之后等通道连续 800ms 无帧,再清空接收缓冲;
  2. 建链前单独发一次 AT+CIPMUX=1 把 TCP/IP 事务预热(tftp_get 内部还会再发一次,幂等);
  3. 建链仍失败时,发 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 技能辅助生成