Skip to content

LV016-选槽与回滚机制 ​

一、 启动参数区设计 ​

1. 为什么需要参数区 ​

Bootloader 上电后面临的第一个问题是"A、B 两个槽,我该启动谁"。镜像本身回答不了这个问题——新固件写进 B 槽不代表可以信任它,昨天写进去的也可能是下载了一半的垃圾。因此需要一块独立于镜像的启动参数区(boot control),由 App 和 Bootloader 共同维护,作为选槽的唯一权威依据。

参数区设计的两条原则:

  • 镜像不可信,参数区才可信:Bootloader 只依据参数区做决策,再对镜像做独立校验
  • 任命权在 App,执行权在 Bootloader:Bootloader 从不主动改换正式槽,只负责"试用"和"回退"

2. 字段设计 ​

一个可量产的参数区结构(STM32 示例,共 32 字节):

c
// 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 单调递增:

text
时刻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. 参数区读写的完整实现 ​

c
// 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 万多次,早已超寿命。

追加式的做法是把整页划分为多条定长记录,只追加不擦除,写满再整页翻转:

c
// 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 的分区表):

text
0x0807C000  槽位状态副本 1     双副本,每次升级各擦一次
0x0807C800  槽位状态副本 2     双副本,每次升级各擦一次
0x0807D000  下载断点记录页     追加式,写满 64 条才擦一次
0x0807E000  预留               未来扩展或日志

断点记录的字段定义与它在下载流程中的位置见 LV007-升级包格式与传输机制 第三节;本节只提供它所需的存储层。

三、 Bootloader 选槽决策 ​

1. 决策流程 ​

Bootloader 上电选槽决策流程

流程的三个要点:

(1)先信参数区,再独立验镜像:参数区说启谁,还要对该槽镜像做 CRC / 验签复查,两层都过才跳转

(2)试用期计数先行:boot_count++ 在跳转之前写回参数区,否则新固件一上电就死的话,计数器永远停在 0,回滚机制失效

(3)逐级退路:目标槽坏 → 试另一槽 → 都坏 → 留在 Bootloader 恢复模式(UART / USB 等待上位机重刷),任何情况下 Bootloader 自己不跳飞

2. 选槽与跳转的完整实现 ​

c
// 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 下载校验完成后"申请试用":

c
// 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 自检通过后"确认转正":

c
// 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. 看门狗与参数区的协同 ​

看门狗复位本身不改变参数区状态——它只负责"让设备重启"。真正的状态推进仍由参数区完成:

c
// 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 技能辅助生成