仓库: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 跳新镜像”
的意图仍满足(跳的是已拷贝到主区的新镜像),备用区是暂存
区而非直接启动区。
排除过的备选:
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 批次),本批不擅自扩缓冲。
启动信息扇区(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(复位时重算比对)。
verify_package
全流水线(解包密钥、解密包体、流式验签),版本须与
命令一致、固件非空。任一步不过零写盘。0xFF 到 32 字节编程行。扇区擦除约 1 秒级阻塞,发生在
OTA 任务内(与 Modbus/主循环不同任务;MQTT 保活 60 秒
不受影响;心跳 10 秒至多延迟 1 秒),文档中明说可接受。FWVR、格式 2、型号
stm32h743vi、固件名 gateway、版本等于待切版本)rollback unavailable 日志);前者向量表已坏,尽力
跳等于跳进 HardFault,故停机。应用在 MQTT 上线状态发布成功后(status 话题、QoS 1
retained、socket.write_all 成功之后),调存储的
confirm_boot():仅当启动信息待确认 = 1 时才重写启动信息
(待确认 0、重试 0),否则无操作(每次重连都调一次也不
磨损扇区)。写失败则告警(镜像仍待确认,下次上线状态时
再试;重试用尽仍会回滚,安全)。
旧 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 次用尽回滚 | 重试机制 |
| 回滚拷贝中 | 主区半写,但回滚区完好;下次复位仍可重做回滚拷贝(记录仍说待确认/重试用尽) | 回滚区是拷贝源,不动 |
补充:暂存区在安装失败后不清(只清待切标志);下次复位 重算哈希不符即清待切,哈希相符(安装已完成、只是切换 写失败)则照常拷贝——已验镜像的拷贝是正确行为,不是故障。
任务书字面“最后验一次包头/签名”在本设计下做不到,也 没必要,原因:
复位验的是:版本块(结构 + 身份 + 版本)+ SHA-256 重算
(完整性)。签名只在安装时验一次(verify_package),
这是唯一需要密钥的环节。
ota.rs 的状态机(只换存储实现,不改流程)。boards/(引脚与网络不动)、h743-hello(只作参考)。bootloader/src/ota_data/,绝不用于量产)。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)
或整片擦除重烧启动程序 + 应用。
ok:false + 原因(验签失败类),当前版本不变。ok:true、message: installed、
data.version = 新版本、data.active_version = 旧版本
(注意:安装成功时运行版本仍是旧版本,见上“回执语义变化”)。text
boot: copying primary to rollback area
boot: copying staging <新版本> to primary
boot: new primary <新版本> awaiting confirmation
boot: jumping to primary
随后测试固件版本横幅(证明切换)。boot: primary <新版本> (unconfirmed, attempt 1/2/3),
第 4 次复位有 boot: rolled back to <旧版本>。unconfirmed、无 rolled back)。| 场景 | 日志 |
|---|---|
| 正常启动 | 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),随后停机 |
boot_test 的空闲区写回读:擦除范围 [0x08100000,
0x08100000 + 补齐后明文长度) 的末端不一定扇区对齐,
而 embassy 驱动要求擦除起止都扇区对齐(ensure_sector_aligned),
故该步在真板上会报未对齐失败。本批未动 boot_test
(超出范围),板上跑 boot_test 前须先把擦除范围扩到
整扇区。