真实 Flash 存储与启动切换

仓库:https://github.com/ffffxxxjjj/gateway

板上“安装成功”从此代表真事:新镜像写进备用 Flash、 验签与版本比对都过、启动信息已写;重启后启动程序把新 镜像拷到主区再跳过去;新镜像起不来,自动回滚旧镜像。 不再是 RAM 测试存储改个版本标志。

实现在 bootloader/src/flash_layout.rs(布局与启动信息 编解码,启动程序与应用共用同一份,不许两处各写一套)、 flash_io.rs(闪存访问抽象)、flash_store.rs(真实 Flash 版 ImageStore,不改 ota.rs 的状态机,只换存储 实现)、boot.rs(启动决策与拷贝引擎);启动程序 bin 在 bootloader/src/bin/bootloader.rs;应用侧接线在 app/src/main.rs(EmbassyFlashIo + FlashStore + 上线状态成功后写健康确认)。

为什么是“拷贝到主区”,不是“跳到备用区”

应用是为 0x08100000 链接的定址镜像:向量表复位地址、 数据段加载地址、字面量地址全按 0x08100000 编排。若启动 程序直接跳到备用区 0x08180000 的同一镜像,读到的向量表 复位地址仍指向主区(会跑旧镜像),数据段会从主区错误拷贝, 字面量地址全部错位,必然跑偏或崩溃。

软件-only(不用选项字节 bank 互换位,见下“绝不碰”)唯一 正确做法:启动程序在复位时把已验备用镜像拷贝到固定主区 0x08100000,再跳主区。任务书“重启后 bootloader 跳新镜像” 的意图仍满足(跳的是已拷贝到主区的新镜像),备用区是暂存 区而非直接启动区。

排除过的备选:

Flash 布局

STM32H743VI:Flash 2MB,双 bank,每 bank 8 扇区 × 128KB。 扇区 128KB、编程粒度 32 字节,取自本芯片的 embassy-stm32 0.6 生成代码(erase_size = 131072、write_size = 32, bank1 0x08000000、bank2 0x08100000)。下表每个边界 都是扇区边界,扇区擦除永不碰邻区。

区域 地址 大小 扇区 说明
启动程序 0x08000000–0x08040000 256KB bank1 0–1 启动程序 bin(量产)与 boot_test 共用链接脚本,LENGTH 限死 256K,超长即链接报错,绝不悄悄长进回滚区
回滚区 0x08040000–0x080C0000 512KB bank1 2–5 上一版主区镜像的整区快照(512KB 全拷,不是按镜像长度截断),供回滚拷贝源
保留区 0x080C0000–0x080E0000 128KB bank1 6 故意留空(以后配配置区、信任区等),本批无任何写操作
启动信息 0x080E0000–0x08100000 128KB bank1 7 启动信息扇区(双份 128 字节记录,见下)
主区 0x08100000–0x08180000 512KB bank2 0–3 运行应用,链接脚本 LENGTH 限死 512K,超长即链接报错,绝不悄悄长进暂存区;版本块在主区 + 0x1000 处
暂存区 0x08180000–0x08200000 512KB bank2 4–7 OTA 写盘区(新镜像先写这里,复位时拷到主区);永不直接启动

尺寸依据(实测,沙盒构建):

镜像 debug release 上限 余量(release)
启动程序 bin(bootloader,含拷贝引擎 + 启动决策 + Flash 驱动 + 日志,不含验签流水线) 40832 字节 25340 字节 256KB 约 231KB
boot_test bin(验签流水线 + Flash 回读测试) — 90204 字节 256KB 约 166KB
应用(gateway-app,MQTT + OTA + 真实存储) 258780 字节 188516 字节 512KB 约 324KB

注:应用 release 镜像约 184KB,仍在 512KB 主区内,但已超 128 KiB 的 OTA 缓冲上限(包与明文缓冲各 128 KiB,RAM 仅余 约 30–50KB,放不下更大的缓冲)。整应用 OTA 在本批后仍会 被拒(package too large),板上测试必须用 ≤128 KiB 的测试 固件;暂存区 512KB 已向前兼容整应用尺寸,放宽上限需另做 RAM 重算(后续 RAM/TLS 批次),本批不擅自扩缓冲。

启动信息记录(128 字节,双份)

启动信息扇区(0x080E0000,128KB)只存两份同一记录: 偏移 0 处一份,偏移 0x1000 处一份。记录 128 字节 = 4 个 32 字节编程行,字段全小端:

[0..4]    魔数 "BTIF"
[4..8]    格式 (= 1)
[8..12]   序号(单调递增;读时取校验通过且序号最大的一份)
[12..16]  待切有效 (0/1:暂存镜像等复位拷贝)
[16..22]  待切版本 (3 x u16)
[22..24]  保留 (0)
[24..28]  待切长度(暂存镜像字节数)
[28..60]  待切 SHA-256(暂存镜像的哈希)
[60..64]  待确认 (0/1:主区是未确认的新镜像)
[64..68]  重试计数(未确认镜像已启动次数)
[68..72]  回滚有效 (0/1:回滚区有有效镜像)
[72..76]  回滚长度(恒 512KB:回滚区是整区快照)
[76..108] 回滚 SHA-256(回滚区整区的哈希)
[108..112] 拷贝阶段 (0 无、1 正拷往回滚区、2 正拷往主区)
[112..124] 保留 (0)
[124..128] 校验(SHA-256(前 124 字节)的前 4 字节)

读规则:两份都校验(魔数、格式、字段范围、保留字节、 校验和)才认;取校验通过且序号最大的一份;两份皆坏则 默认“无待切、无待确认、无回滚”,直接跳主区(安全默认: 旧镜像照常跑)。

写规则:序号取当前最大有效序号 + 1;整扇区擦除后先写 A 份、读回校验通过再写 B 份(B 写失败仍有 A 份兜底)。 A 份本身写失败则无有效记录(下次复位走默认,主区旧镜像 照常跑;各调用点的后果见“断电安全”)。

字段含义(“当前槽”映射):拷贝模型下启动地址恒为主区, 没有“当前槽”这个可变字段;任务书的“当前槽”映射为 “当前运行镜像版本(读主区版本块)+ 固定主区地址”, “待切版本”即暂存镜像版本,“已验标志”即待切有效 + 待切 SHA-256(复位时重算比对)。

安装 + 启动切换 + 回滚流程(按步骤)

安装(应用内,OTA 任务)

  1. 命令层先验:密钥包 hex/长度、版本串、排序(相等跳过、 更旧拒绝)、密钥注入与否、包地址(仅 http + IP)。 任一不过直接回执,不进 worker。
  2. Worker 下载(1024 字节块轮询)→ 攒够 312 字节包头立刻 解析(型号/固件名/超限早拒)→ 整包到齐跑 verify_package 全流水线(解包密钥、解密包体、流式验签),版本须与 命令一致、固件非空。任一步不过零写盘。
  3. 明文分 1024 字节块写暂存区:按扇区懒擦(只擦本次写盘 实际触及的扇区;≤128 KiB 镜像只擦 1 个扇区),尾块补 0xFF 到 32 字节编程行。扇区擦除约 1 秒级阻塞,发生在 OTA 任务内(与 Modbus/主循环不同任务;MQTT 保活 60 秒 不受影响;心跳 10 秒至多延迟 1 秒),文档中明说可接受。
  4. 写完读回暂存区算 SHA-256,与明文哈希比对;不一致则 清待切(半成品永不启动),回执失败。
  5. 比对通过才“切换”:读暂存镜像版本块得版本、算暂存镜像 SHA-256、写启动信息(待切有效、待切版本/长度/哈希、 待确认 0、重试 0、回滚无效、拷贝阶段 0)。这一步才叫 “安装”。主区未动,运行版本未变。

启动切换(启动程序,复位时)

  1. 读启动信息。若无待切、无待确认,直接跳主区(正常启动, 先过向量表 sanity 检查,见下)。
  2. 若主区是待确认镜像:
  3. 向量表 sanity 不过:走回滚(回滚区有效且哈希比对通过 → 把回滚区整区拷回主区、写 settled 记录(待确认 0、 重试 0、待切 0),跳主区旧镜像;回滚区无效 → 停机, 绝不跳坏向量,见“回滚的边界”)。
  4. 重试已用尽(3 次仍未确认)且向量表正常:走回滚(同上); 回滚区无效 → 尽力跳主区(向量表正常,跳转安全;新镜像 也许只是暂时连不上云,见“回滚的边界”)。
  5. 否则重试计数 + 1(写启动信息),跳主区新镜像(再给 一次确认机会)。
  6. 若有待切镜像:
  7. 先验暂存镜像:版本块解析(魔数 FWVR、格式 2、型号 stm32h743vi、固件名 gateway、版本等于待切版本)
  8. 阶段 1(除非记录已是阶段 2):写阶段 1 记录(回滚无效, 防断电后误用半成品回滚)→ 把主区整区(512KB)拷到 回滚区(整区擦除 + 按块拷 + 重读哈希比对)→ 写记录 (回滚有效、回滚长度 512KB、回滚哈希)。
  9. 阶段 2:写阶段 2 记录 → 把暂存镜像拷到主区(只擦镜像 跨的扇区 + 按块拷 + 重读哈希比对等于待切哈希)→ 写 最终记录(待切清零、待确认 1、重试 0、拷贝阶段 0、 回滚保留)。
  10. 跳主区新镜像(先过向量表 sanity 检查;新镜像向量表 不过则立即回滚,不给重试机会——签过名但无法启动的 镜像没必要浪费三次启动)。
  11. 跳转前向量表 sanity 检查:主栈指针 8 字节对齐、非零、 在 RAM 范围(低于外设空间、不在 Flash 内)、复位向量 Thumb 位为 1 且指向主区内。任一不过不跳转(见回滚/ 停机规则)。

回滚的边界(诚实说明)

健康确认(应用内,不另起通道)

应用在 MQTT 上线状态发布成功后(status 话题、QoS 1 retained、socket.write_all 成功之后),调存储的 confirm_boot():仅当启动信息待确认 = 1 时才重写启动信息 (待确认 0、重试 0),否则无操作(每次重连都调一次也不 磨损扇区)。写失败则告警(镜像仍待确认,下次上线状态时 再试;重试用尽仍会回滚,安全)。

回执语义变化(相对旧 RAM 测试存储,必须同步改云端理解)

旧 RAM 存储:切换即翻版本标志,回执两版本均为新版本。 新真实存储:安装成功 = 已暂存 + 待复位拷贝,回执为:

{"cmd_id": "…", "ok": true, "message": "installed",
 "data": {"version": "<新版本,已暂存>", "active_version": "<旧版本,仍在运行>"}}

直至复位拷贝 + 健康确认后,状态上报的版本才变新版本。 云端不得把“已安装”误读为“已在运行新版本”。 docs/device-ota.md 的回执映射表已同步改成此语义。

断电安全(逐点)

断电点 下次复位行为 依据
下载/验证中(未写盘或写盘未完成) 主区旧镜像照常跑;暂存区半成品被下一轮安装从偏移 0 重写(扇区重擦) 切换未发生;写盘从 0 起
安装写盘中(暂存区半写) 主区旧镜像照常跑;待切未写,半成品永不启动 待切记录只在读回比对通过后才写
切换写启动信息中(记录撕裂) 双份序号机制:撕裂的新份校验失败,取旧份(无待切),主区旧镜像照常跑;或两份皆坏走默认,同样跑旧镜像 双份 + 校验和 + 序号
拷贝阶段 1 中(回滚区半写) 记录阶段 1、回滚无效;主区完好,重做阶段 1(整区重拷) 阶段 1 不碰主区
拷贝阶段 2 中(主区半写) 记录阶段 2、回滚有效;跳过阶段 1(不拿半写主区覆盖好回滚),重做阶段 2(暂存完好) 阶段字段 + 回滚区已验
拷贝完成、最终记录写失败 主区是已验新镜像,走默认记录照常跑(无回滚跟踪,见上“回滚的边界”) 镜像已验完整
待确认镜像运行中(未确认) 重试计数 + 1,继续跑新镜像;3 次用尽回滚 重试机制
回滚拷贝中 主区半写,但回滚区完好;下次复位仍可重做回滚拷贝(记录仍说待确认/重试用尽) 回滚区是拷贝源,不动

补充:暂存区在安装失败后不清(只清待切标志);下次复位 重算哈希不符即清待切,哈希相符(安装已完成、只是切换 写失败)则照常拷贝——已验镜像的拷贝是正确行为,不是故障。

复位时为什么不重验 Ed25519 签名(设计取舍,明说)

任务书字面“最后验一次包头/签名”在本设计下做不到,也 没必要,原因:

  1. 安装只存明文,不存包体密文与包头:启动程序没有密文 可解密,也没有包头可验(包头只在安装时 worker 手里)。
  2. 设备私钥在一次性可编程存储区,本批不读(第 3 件), 即使有密文也解不开。
  3. 不需要:安装时已验签(签名 → 明文真实),安装读回哈希 证明暂存 = 已验明文,复位重算哈希证明暂存未变。三段 链条合起来与“复位重验签”等价,且不需要密钥与密文。

复位验的是:版本块(结构 + 身份 + 版本)+ SHA-256 重算 (完整性)。签名只在安装时验一次(verify_package), 这是唯一需要密钥的环节。

绝不碰(本批边界)

主机测试

cd bootloader
cargo test

78 项全过:原 44 项(验证 12 + provisioning 13 + OTA 19) + 新增 34 项——布局 10 项(无交叠且占满 2MB、边界扇区对齐、 启动信息编解码往返 + 篡改检出 + 擦除扇区判无效 + 字段范围 校验 + 状态 helper + 版本块解析好/坏 + 擦除窗口扇区取整 + 地址 helper)、假闪存 2 项(擦除 + AND 语义、对齐/越界拒绝)、 存储 10 项(写/校验/切换往返、懒擦只碰已写扇区、大镜像逐扇 区擦一次、写失败分类、版本块无效拒切换、空存储拒切换、 作废与复位清待切、确认仅在待确认时写盘、单份损坏取另一份、 主区版本块读)、启动 12 项(正常启动跳主区、待切拷贝后跳 主区且回滚区是旧镜像、确认重试 3 次后回滚、暂存哈希不符拒 绝且跑旧镜像、主区拷贝中断后从阶段 2 恢复且不重拷回滚、 回滚拷贝中断后重做阶段 1、主区向量坏恢复回滚、无回滚时主区 向量坏停机、待确认镜像向量坏且有回滚则恢复、待确认镜像向量 坏且无回滚则停机(绝不尽力跳坏向量)、向量 sanity 表、双份 启动信息皆坏跑主区)。

应用 lib 主机测试(42 项)回归全过:

cd app
cargo test --lib --no-default-features --target x86_64-unknown-linux-gnu

芯片构建(debug/release)零警告:启动程序 bin、boot_test bin、板级库、应用(见持续集成)。

板上测试步骤(用户审过启动程序改动后跑)

前置:测试 MQTT 账号与口令(EMQX 现无任何 MQTT 用户, 设备账号登记环节未排期,须先有账号);包下载地址须明文 http + 局域网 IP(无 DNS/TLS);测试固件 ≤128 KiB(整应用 约 184KB 超上限,OTA 会拒)。

恢复路径(任何一步卡住):擦启动信息扇区(0x080E0000) 或整片擦除重烧启动程序 + 应用。

(a) 错签名包:零写盘、不切换

  1. MQTT 应用(主区,旧版本)运行时推错签名包。
  2. 预期回执:ok:false + 原因(验签失败类),当前版本不变。
  3. 预期串口:OTA 失败日志,无 Flash 写盘(暂存区未动)。
  4. 复位后:主区旧应用照常运行,版本不变。

(b) 好包下载中途断电:旧镜像仍能起

  1. 推好包(≤128 KiB 测试固件,新版本),下载中途断电。
  2. 复位后:主区旧应用仍运行(切换未发生),版本不变。
  3. 重推好包:可成功(半写暂存区从偏移 0 重写、扇区重擦)。

(c) 好包安装 + 复位:跑新版本(核心)

  1. 推好包(≤128 KiB 测试固件,新版本)。
  2. 预期回执:ok:true、message: installed、 data.version = 新版本、data.active_version = 旧版本 (注意:安装成功时运行版本仍是旧版本,见上“回执语义变化”)。
  3. 复位。预期启动程序串口日志(按顺序): text boot: copying primary to rollback area boot: copying staging <新版本> to primary boot: new primary <新版本> awaiting confirmation boot: jumping to primary 随后测试固件版本横幅(证明切换)。
  4. 状态上报版本变新版本(健康确认后)。

(d) 无确认测试固件复位 3 次:回滚旧应用

  1. 用无健康确认逻辑的测试固件做 (c)(它不会发上线状态, 故永不确认)。
  2. 再复位 3 次。预期启动程序串口日志:第 1–3 次复位各有 boot: primary <新版本> (unconfirmed, attempt 1/2/3), 第 4 次复位有 boot: rolled back to <旧版本>。
  3. 复位后:主区旧应用运行,版本回旧版本。

(e) 可选:带确认逻辑的测试应用:保持新版本

  1. 用带健康确认逻辑(发上线状态)的小测试应用做 (c)。
  2. 复位后:保持新版本不回滚(确认已永久化),启动日志为 正常启动(无 unconfirmed、无 rolled back)。

预期日志总表(启动程序,defmt RTT)

场景 日志
正常启动 boot: primary <版本> (normal) → boot: jumping to primary
待确认镜像再启动 boot: primary <版本> (unconfirmed, attempt N) → boot: jumping to primary
回滚 boot: rolled back to <旧版本> → boot: jumping to primary
回滚区无效(且向量表正常) boot: rollback unavailable, booting primary anyway
暂存镜像被拒 boot: staged image rejected (<原因>), booting old primary
拷贝失败 boot: copy failed (<原因>)(后跟回滚或停机日志)
主区向量坏且无回滚 boot: primary corrupt, no valid rollback; halting (re-flash required),随后停机

附:已知未修(本批外,报给主代理定)