LV016-选槽与回滚机制
一、 启动参数区设计
1. 为什么需要参数区
Bootloader 上电后面临的第一个问题是"A、B 两个槽,我该启动谁"。镜像本身回答不了这个问题——新固件写进 B 槽不代表可以信任它,昨天写进去的也可能是下载了一半的垃圾。因此需要一块独立于镜像的启动参数区(boot control),由 App 和 Bootloader 共同维护,作为选槽的唯一权威依据。
参数区设计的两条原则:
- 镜像不可信,参数区才可信:Bootloader 只依据参数区做决策,再对镜像做独立校验
- 任命权在 App,执行权在 Bootloader:Bootloader 从不主动改换正式槽,只负责"试用"和"回退"
2. 字段设计
一个可量产的参数区结构(STM32 示例,共 32 字节):
// boot_ctrl.h —— 启动参数区结构定义
#include <stdint.h>
#define BOOT_CTRL_MAGIC 0xB007C721u
#define BOOT_MAX_RETRY 3u /* 新固件试用次数上限 */
typedef enum {
SLOT_A = 0,
SLOT_B = 1,
SLOT_INVALID = 0xFF
} boot_slot_t;
typedef enum {
STATE_CONFIRMED = 0xA5, /* 当前槽已确认,正常启动 */
STATE_PENDING = 0x5A, /* 有新槽待试用确认 */
} boot_state_t;
typedef struct {
uint32_t magic; /* 结构体标识 */
uint8_t active_slot; /* 已确认转正的槽(mark good 才更新) */
uint8_t pending_slot; /* 待试用的槽,无则 SLOT_INVALID */
uint8_t boot_state; /* STATE_CONFIRMED / STATE_PENDING */
uint8_t boot_count; /* 当前试用已启动次数 */
uint32_t fw_version[2]; /* A / B 两槽的固件版本(服务器上报用) */
uint32_t img_crc[2]; /* A / B 两槽镜像 CRC32(冗余备份) */
uint32_t seq; /* 序列号:双副本选新依据,单调递增 */
uint32_t crc32; /* 本结构体(不含本字段)的 CRC32 */
} boot_ctrl_t; /* 共 32 字节 */【字段职责划分】
active_slot:唯一由"新固件自检通过后的 mark good"修改,是全系统最保守的一个字段pending_slot+boot_state:表达"有个新固件想试试"的意图,由旧 App 在重启前写入boot_count:Bootloader 每次试用前加一,超限即放弃新槽——防止损坏的新固件把设备拖进无限重启seq+crc32:服务于双副本机制(见下节)img_crc冗余存放:参数区损坏需要重建时,仍有据可查各槽应然状态
3. 存储位置选择
参数区放哪里,取决于芯片布局:
| 位置 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 内部 Flash 独立页 | 无额外器件 | 占 1~2 页;擦写有寿命 | 大多数 MCU |
| 外挂 SPI Flash | 内部 Flash 全留给固件 | 多一次 SPI 读写 | 本就要外挂暂存 |
| EEPROM / 备份寄存器 + 备份 SRAM | 字节可擦、无页擦除 | 容量小、STM32F1 备份域仅 84 字节 | 字段极精简时 |
以 STM32F103ZET6 为例,用末尾两页(每页 2 KB)分别放槽位状态的两个副本;若还要承载下载断点记录,需再独占一页(完整页分配见本篇第二节第 4 小节)。用整页放 32 字节看似浪费,实则是为了页级擦除双副本——见下节。
二、 双副本与掉电安全
1. 单副本的失效场景
Flash 的更新是"先擦除整页、再写入"的非原子过程。如果参数区只有一份,写更新中途掉电:页已擦、新数据没写完——参数区内容全毁,Bootloader 失去选槽依据,设备变砖。掉电可能发生在任何一次写参数区的时刻,而升级恰恰是高危操作,这个风险不可接受。
2. 双副本 + 序列号方案
经典解法:参数区存两份,各独占一个可独立擦除的页,更新时先写副本 2,成功后再写副本 1,seq 单调递增:
时刻1(稳定) 时刻2(写副本2中) 时刻3(写副本1中) 时刻4(稳定)
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│副本1 seq=7│ │副本1 seq=7│ ←完整 │副本1 损坏 │ │副本1 seq=8│
│副本2 seq=7│ │副本2 损坏 │ 掉电! │副本2 seq=8│ ←完整 │副本2 seq=8│
└──────────┘ └──────────┘ └──────────┘ └──────────┘
↓ 掉电后按 seq 选副本1 ↓ 掉电后按 seq 选副本2任意时刻掉电,至少有一份完整副本存活;读取时选"magic 正确 + CRC 通过 + seq 较大"的那份即可。
3. 参数区读写的完整实现
// boot_ctrl.c —— 启动参数区双副本读写(STM32F103 示例)
#include "boot_ctrl.h"
#include <string.h>
#include <stddef.h>
#include <stdbool.h>
#define CTRL_COPY0_ADDR 0x0807C000u /* 副本 1:倒数第 2 页 */
#define CTRL_COPY1_ADDR 0x0807C800u /* 副本 2:倒数第 1 页 */
#define CTRL_PAGE_SIZE 2048u
/* Flash 擦写与 CRC 的底层实现见 LV022 的 flash_if.c 与 crc32.c */
extern uint32_t crc32_calc(const void *data, uint32_t len);
extern int flash_if_erase_page(uint32_t addr);
extern int flash_if_write(uint32_t addr, const void *data, uint32_t len);
static uint32_t ctrl_crc(const boot_ctrl_t *c)
{
return crc32_calc(c, offsetof(boot_ctrl_t, crc32));
}
static bool ctrl_valid(const boot_ctrl_t *c)
{
return c->magic == BOOT_CTRL_MAGIC && c->crc32 == ctrl_crc(c);
}
/* 读取:从两个副本中选有效且 seq 新者;都无效时填默认值 */
int boot_ctrl_read(boot_ctrl_t *out)
{
const boot_ctrl_t *c0 = (const boot_ctrl_t *)CTRL_COPY0_ADDR;
const boot_ctrl_t *c1 = (const boot_ctrl_t *)CTRL_COPY1_ADDR;
bool v0 = ctrl_valid(c0);
bool v1 = ctrl_valid(c1);
if (v0 && (!v1 || c0->seq >= c1->seq)) {
memcpy(out, c0, sizeof(*out));
return 0;
}
if (v1) {
memcpy(out, c1, sizeof(*out));
return 0;
}
/* 双副本全毁:回落到出厂默认(A 槽、已确认、无待试用) */
memset(out, 0, sizeof(*out));
out->magic = BOOT_CTRL_MAGIC;
out->active_slot = SLOT_A;
out->pending_slot = SLOT_INVALID;
out->boot_state = STATE_CONFIRMED;
return -1;
}
/* 写入:先副本 2 后副本 1,seq 递增;每步写完读回验证 */
int boot_ctrl_write(const boot_ctrl_t *in)
{
boot_ctrl_t c = *in;
boot_ctrl_t cur;
boot_ctrl_read(&cur);
c.seq = cur.seq + 1;
c.crc32 = ctrl_crc(&c);
static const uint32_t addr[2] = { CTRL_COPY1_ADDR, CTRL_COPY0_ADDR };
for (int i = 0; i < 2; i++) { /* 注意顺序:先 COPY1 */
if (flash_if_erase_page(addr[i]) != 0) {
return -1;
}
if (flash_if_write(addr[i], &c, sizeof(c)) != 0) {
return -2;
}
if (memcmp((const void *)addr[i], &c, sizeof(c)) != 0) {
return -3; /* 读回校验失败 */
}
}
return 0;
}【注意】
副本写入顺序必须是"先副本 2 后副本 1"且固定不变,配合"读取选 seq 新者",才能保证任意掉电点都有完整副本。两个副本若放在同一页则毫无意义——擦除会同时毁掉它们。
4. 擦写寿命:双副本与追加式如何分工
双副本解决的是掉电安全,不解决擦写寿命——它每次更新仍然要擦掉两页。这两件事必须分开算,否则会用错方案。
按写入频率,参数区承载的数据分成两类:
| 数据 | 写入频率 | 该用的组织方式 |
|---|---|---|
槽位状态(active_slot / pending_slot / boot_state / boot_count) | 每次升级 4~6 次 | 双副本,各占一整页 |
| 下载断点记录(已下载偏移) | 每写一块更新一次,一次升级可达数百次 | 追加式磨损均衡,独占整页顺序追加 |
槽位状态用双副本就够:STM32F103 内部 Flash 擦写寿命约 1 万次,按一天升级一次算可用 5 年以上。断点记录完全不能这么算——一次 240 KB 的升级按 4 KB 分块就要写 60 次,一天一次升级一年就是 2 万多次,早已超寿命。
追加式的做法是把整页划分为多条定长记录,只追加不擦除,写满再整页翻转:
// wear_level.c —— 追加式磨损均衡:整页只追加不擦,写满再整页翻转
#include <stdint.h>
#include <string.h>
#include <stddef.h>
#define WL_PAGE_ADDR 0x0807D000u /* 断点记录专用页,不得与槽位状态副本同页 */
#define WL_PAGE_SIZE 2048u
#define WL_REC_SIZE 32u
#define WL_REC_MAGIC 0x574C0001u
#define WL_EMPTY 0xFFu
typedef struct {
uint32_t magic;
uint32_t seq; /* 单调递增,最后一条有效记录 seq 最大 */
uint8_t payload[20];
uint32_t crc32;
} wl_record_t; /* 32 字节,一页可容 64 条 */
static uint32_t wl_crc(const wl_record_t *r)
{
return crc32_calc(r, offsetof(wl_record_t, crc32));
}
/* 查找当前有效记录:从页尾向前找第一条 magic 与 CRC 都正确的 */
int wl_read_latest(wl_record_t *out)
{
const wl_record_t *page = (const wl_record_t *)WL_PAGE_ADDR;
for (int i = (WL_PAGE_SIZE / WL_REC_SIZE) - 1; i >= 0; i--) {
if (page[i].magic == WL_REC_MAGIC && page[i].crc32 == wl_crc(&page[i])) {
memcpy(out, &page[i], sizeof(*out));
return 0;
}
}
return -1; /* 页内无有效记录 */
}
/* 追加写入:找到下一个空槽写入;页满则整页擦除后从头写 */
int wl_append(const wl_record_t *rec)
{
wl_record_t cur;
uint32_t next_seq = 1;
if (wl_read_latest(&cur) == 0) {
next_seq = cur.seq + 1;
}
wl_record_t to_write = *rec;
to_write.magic = WL_REC_MAGIC;
to_write.seq = next_seq;
to_write.crc32 = wl_crc(&to_write);
/* 找页内第一个空槽 */
uint32_t slot = 0;
const uint8_t *page = (const uint8_t *)WL_PAGE_ADDR;
for (; slot < WL_PAGE_SIZE; slot += WL_REC_SIZE) {
if (page[slot] == WL_EMPTY) {
break;
}
}
if (slot >= WL_PAGE_SIZE) {
/* 页满:整页擦除,从头写 */
if (flash_if_erase_page(WL_PAGE_ADDR) != 0) return -1;
slot = 0;
}
return flash_if_write(WL_PAGE_ADDR + slot, &to_write, WL_REC_SIZE);
}追加式的收益:一页 64 条记录,相当于把"每页只能写一次"变成"每页可写 64 次",在相同升级频率下寿命放大约 64 倍。代价是写入后要"读页尾找最新",实现复杂度略增。
【注意】
两类数据必须分页隔离。双副本要求"两个副本各占一个可独立擦除的页、每次更新都擦",追加式要求"整页只追加、写满 64 条才擦一次"——两种写入模式放在同一页会互相破坏:追加写会污染副本的有效数据,副本的整页擦除又会清掉全部追加历史。推荐的页分配(承接第一节 STM32F103ZET6 的分区表):
0x0807C000 槽位状态副本 1 双副本,每次升级各擦一次
0x0807C800 槽位状态副本 2 双副本,每次升级各擦一次
0x0807D000 下载断点记录页 追加式,写满 64 条才擦一次
0x0807E000 预留 未来扩展或日志断点记录的字段定义与它在下载流程中的位置见 LV007-升级包格式与传输机制 第三节;本节只提供它所需的存储层。
三、 Bootloader 选槽决策
1. 决策流程
流程的三个要点:
(1)先信参数区,再独立验镜像:参数区说启谁,还要对该槽镜像做 CRC / 验签复查,两层都过才跳转
(2)试用期计数先行:boot_count++ 在跳转之前写回参数区,否则新固件一上电就死的话,计数器永远停在 0,回滚机制失效
(3)逐级退路:目标槽坏 → 试另一槽 → 都坏 → 留在 Bootloader 恢复模式(UART / USB 等待上位机重刷),任何情况下 Bootloader 自己不跳飞
2. 选槽与跳转的完整实现
// bootloader.c —— 选槽决策与跳转(STM32F103 示例)
#include "boot_ctrl.h"
#define SLOT_A_ADDR 0x08004000u
#define SLOT_B_ADDR 0x08040000u
#define SLOT_SIZE (240u * 1024u)
extern uint32_t crc32_calc(const void *data, uint32_t len);
extern int image_signature_verify(uint32_t addr, uint32_t size); /* 见 LV019 */
static uint32_t slot_addr(uint8_t slot)
{
return slot == SLOT_A ? SLOT_A_ADDR : SLOT_B_ADDR;
}
static bool image_valid(uint8_t slot, const boot_ctrl_t *ctrl)
{
uint32_t addr = slot_addr(slot);
if (crc32_calc((const void *)addr, SLOT_SIZE) != ctrl->img_crc[slot]) {
return false;
}
return image_signature_verify(addr, SLOT_SIZE) == 0;
}
static void jump_to_app(uint32_t addr)
{
uint32_t msp = *(volatile uint32_t *)addr;
uint32_t reset = *(volatile uint32_t *)(addr + 4);
__disable_irq();
SCB->VTOR = addr; /* 向量表重映射到目标槽 */
__set_MSP(msp);
((void (*)(void))reset)(); /* 跳转到目标槽复位入口 */
}
static void recovery_mode(void)
{
/* 停留 Bootloader:初始化 UART/USB,等待上位机重新烧录 */
for (;;) {
recovery_task_poll();
}
}
int main(void)
{
system_clock_init();
boot_ctrl_t ctrl;
boot_ctrl_read(&ctrl);
uint8_t target = ctrl.active_slot;
if (ctrl.boot_state == STATE_PENDING && ctrl.pending_slot <= SLOT_B) {
if (ctrl.boot_count < BOOT_MAX_RETRY) {
ctrl.boot_count++; /* 先计数再跳转,防无限重启 */
boot_ctrl_write(&ctrl);
target = ctrl.pending_slot;
} else {
/* 试用超限:回滚——放弃新槽,回到已确认的旧槽 */
ctrl.boot_state = STATE_CONFIRMED;
ctrl.pending_slot = SLOT_INVALID;
ctrl.boot_count = 0;
boot_ctrl_write(&ctrl);
target = ctrl.active_slot;
}
}
if (image_valid(target, &ctrl)) {
jump_to_app(slot_addr(target));
}
/* 首选槽损坏:尝试另一槽 */
if (image_valid(target == SLOT_A ? SLOT_B : SLOT_A, &ctrl)) {
jump_to_app(slot_addr(target == SLOT_A ? SLOT_B : SLOT_A));
}
recovery_mode(); /* 两槽皆坏,进恢复模式 */
}上面的 jump_to_app() 只做了向量表与栈顶的最小处理,实际移植时还要清理 Bootloader 遗留的 SysTick 与外设中断状态,否则 App 起来后会收到来历不明的中断——完整的硬件清理清单见 LV022-MCU无BankSwap落地方案 第三节。
四、 回滚状态机
1. 状态定义与迁移
把固件槽位的生命周期抽象为状态机,所有回滚行为都是状态迁移的自然结果:
【关键不变量】
整个状态机能安全运转,依赖两条必须守住的不变量:
- 试用期旧槽镜像全程不被破坏(A/B 方案天然满足;单区复制方案不满足,这正是它没有回滚能力的根源)
active_slot只由 mark good 修改,Bootloader 在任何分支都不写它
2. App 侧的两个配合动作
旧 App 下载校验完成后"申请试用":
// app_ota.c —— 旧 App:申请试用新固件(重启前调用)
void app_request_upgrade(uint8_t new_slot, uint32_t new_version, uint32_t new_crc)
{
boot_ctrl_t ctrl;
boot_ctrl_read(&ctrl);
ctrl.pending_slot = new_slot;
ctrl.boot_state = STATE_PENDING;
ctrl.boot_count = 0;
ctrl.fw_version[new_slot] = new_version;
ctrl.img_crc[new_slot] = new_crc;
boot_ctrl_write(&ctrl);
NVIC_SystemReset(); /* 重启,交权给 Bootloader */
}新 App 自检通过后"确认转正":
// app_ota.c —— 新 App:自检通过后 mark good
void app_confirm_running(uint8_t self_slot)
{
boot_ctrl_t ctrl;
boot_ctrl_read(&ctrl);
if (ctrl.boot_state == STATE_PENDING && ctrl.pending_slot == self_slot) {
ctrl.active_slot = self_slot; /* 翻牌:唯一的任命点 */
ctrl.pending_slot = SLOT_INVALID;
ctrl.boot_state = STATE_CONFIRMED;
ctrl.boot_count = 0;
boot_ctrl_write(&ctrl);
}
}app_confirm_running() 的调用时机是业务自检全部通过之后——网络注册成功、关键外设正常、主任务跑起来,而不是 main() 一进来就确认。确认得越早,带病转正的风险越大;确认得越晚,误回滚的风险越大,需要根据业务自检耗时权衡。
五、 看门狗与回滚的配合
1. 为什么回滚必须有看门狗
只靠 boot_count 计数有一个盲区:新固件如果能正常启动、但运行中挂死(死循环、中断丢失、任务死锁),系统不会重启,boot_count 永远不会再增加,设备就永远卡在"能启动但已死机"的 PENDING 状态。
独立看门狗(IWDG)正是补这个漏洞的:它一旦使能就只能喂不能停,超时必复位,正好用来检测"启动后是否健康"。
2. 看门狗在试用期的工作方式
(1)App 正常运行:定期喂狗,看门狗不复位
(2)新固件试用启动:Bootloader 跳转前不喂狗、也不提前使能;由新固件自己在初始化早期使能 IWDG 并开始喂狗
(3)新固件挂死:停止喂狗 → IWDG 超时复位 → Bootloader 再次上电,boot_count 已在跳转前递增 → 超过上限后回滚
关键在于"boot_count 在跳转前先递增"(见第三节):新固件一旦死机,看门狗复位会再次进入 Bootloader,此时计数已反映了这次失败尝试。
3. 试用期的喂狗策略
试用期内的喂狗要克制——刚启动就拼命喂狗,等于告诉系统"我很健康",失去了看门狗的检测意义。推荐做法:
- 初始化阶段:完成关键自检前不喂狗(或延长超时窗口)
- 自检通过后:开始周期性喂狗,并把喂狗任务独立出来,避免被业务逻辑阻塞
- mark good 之后:进入正常喂狗节奏
4. 看门狗与参数区的协同
看门狗复位本身不改变参数区状态——它只负责"让设备重启"。真正的状态推进仍由参数区完成:
// main.c(App 启动早期)—— 配合看门狗的自检骨架
void app_early_init(void)
{
iwdg_init(4000); /* 4 秒超时,先留出初始化窗口 */
if (!self_check_peripherals()) { /* 自检不通过:不喂狗,等待看门狗复位 */
while (1) { /* 故意挂住,让 IWDG 超时 */ }
}
iwdg_feed(); /* 自检通过,开始正常喂狗 */
app_confirm_running(self_slot());/* 确认转正,结束试用期 */
}这样设计的完整链路是:看门狗负责发现"死了",参数区负责记录"第几次死",Bootloader 负责"死了几次之后换谁"——三者各司其职,缺一不可。
六、 异常场景穷举分析
用一张表验证机制在各类异常下的行为(A/B 双槽方案):
| 异常时刻 | 设备状态 | 下次上电行为 | 结果 |
|---|---|---|---|
| 下载中途掉电 | 新槽半成品,参数区无变化 | 正常启旧固件,断点续传 | 无感 |
| 置 PENDING 写参数区时掉电 | 双副本至少一份完好 | 按完好副本决策(旧状态) | 无感,重新申请即可 |
| 新固件首启即崩溃 | boot_count 已递增 | 试用重试,超限后回滚 | 自动回旧版 |
| 新固件能跑但功能异常 | 看门狗复位 / 业务重启 | 同上,靠 boot_count 兜底 | 自动回旧版 |
| mark good 写参数区时掉电 | 双副本至少一份完好 | 仍是 PENDING,再试用一次 | 重走确认流程 |
| 升级后两槽镜像均损坏 | 极端情况 | Bootloader 进恢复模式 | 等重刷,不跳飞 |
可以看到,只要"双副本参数区 + 旧槽不动 + 计数先行"三条守住,没有任何单点掉电能砖掉设备——这正是这套机制被称为 OTA 心脏的原因。
【本节与测试清单的分工】
本节是机制正确性的推演:从设计出发论证每个掉电点为什么都有确定行为。它回答"为什么不会砖"。把这些场景变成可执行的注入用例(注入方式、期望结果、判定标准)属于验证工作,清单在 LV031-发布与量产运维 第五节,它回答"怎么测出来"。两张表逐行对应,改动机制时两处都要同步。
本文档由 markdowncli 技能辅助生成