恢复机制设计原则:原子性、幂等性、可重入性、状态持久化

做固件升级最怕什么?我最怕写到一半断电了。不是怕设备变砖,而是怕它变砖之后你还不知道它怎么变砖的。早期我接手过一个项目,OTA 升级成功率只有 87%,剩下的 13% 全是升级中断后恢复失败。查了三个月,最后发现是恢复机制的设计原则没想清楚。

这一章,我们就来聊聊这四个原则。说白了,它们是中断恢复的「四根柱子」。少一根,你的升级方案就站不稳。

原子性:要么全做,要么全不做

原子性这个概念,搞数据库的人很熟。但在嵌入式里,它意味着一个升级操作不能被拆成一半。你想想看,如果写 Flash 写到第 3 个扇区时断电了,第 1、2 扇区已经写了新固件,第 4、5 扇区还是旧固件——这设备还能跑吗?大概率不能。

我个人的习惯是,把升级过程拆成若干个「原子操作单元」。每个单元要么完整执行,要么完全不执行。怎么实现?靠一个「提交点」机制。

原子操作的核心思路:

  • 每个原子单元开始前,记录当前状态到持久化存储
  • 执行过程中如果中断,恢复时回滚到上一个提交点
  • 只有确认写入完成后,才更新提交点

举个例子,我做过一个双分区升级方案。升级时先把新固件写入备份分区,全部写完后才切换启动标志。这就是一个典型的原子操作——切换标志是唯一的提交点。

// 原子提交示例:写入备份分区后,再切换启动标志
bool upgrade_atomic_write(const uint8_t *data, uint32_t len) {
    // 步骤1:写入备份分区
    if (!flash_write(BACKUP_PARTITION, data, len)) {
        return false;  // 写入失败,不提交
    }
    
    // 步骤2:验证写入数据
    if (!flash_verify(BACKUP_PARTITION, data, len)) {
        return false;  // 校验失败,不提交
    }
    
    // 步骤3:原子提交——切换启动标志
    // 这里用双字写入,防止单次写入被中断
    uint64_t commit_flag = 0xA5A5A5A5A5A5A5A5;
    flash_write_64bit(BOOT_FLAG_ADDR, &commit_flag);
    
    return true;
}

我的经验:原子性最容易出问题的地方是「你以为它是原子的,其实不是」。比如写一个 32 位标志位,如果 MCU 是 16 位总线,一次写入会被拆成两次操作。中间断电,标志位就变成了一个随机值。我建议所有提交点都用 64 位写入,或者用双字校验。

幂等性:重复执行也不怕

幂等性这个术语听着挺唬人,其实意思很简单:同一个操作执行一次和执行多次,结果是一样的。

为什么升级恢复需要幂等性?因为中断恢复时,你没法确定「上次到底执行到哪一步了」。可能写完了,也可能没写完。如果你恢复时重复执行某个步骤,会不会把已经写好的数据又覆盖一遍?会不会把标志位写反?

我在项目中遇到过最典型的幂等性问题,是擦除 Flash 扇区。擦除操作本身是幂等的——擦一次和擦十次,结果都是空扇区。但问题出在擦除后的写入。如果恢复时没判断扇区是否已擦除,直接再擦一遍,然后重新写入——那之前写好的数据就没了。

避坑指南:我曾经在一个 NB-IoT 模块的升级方案里,把「设置升级标志」和「擦除扇区」放在同一个原子单元里。结果恢复时,每次重启都重新擦除扇区,导致永远写不完新固件。后来改成先检查扇区状态,已擦除就跳过,才解决问题。

实现幂等性的常用方法:

  • 状态检查前置:执行操作前,先检查当前状态是否已经是目标状态
  • 操作可重复:设计操作本身时,确保重复执行不会产生副作用
  • 使用版本号:每次升级带一个版本号,恢复时判断版本是否已更新
// 幂等写入示例:先检查,再写入
bool idempotent_write_sector(uint32_t sector, const uint8_t *data) {
    // 先检查扇区是否已经包含目标数据
    if (flash_compare(sector, data, SECTOR_SIZE) == 0) {
        return true;  // 数据已存在,直接返回成功
    }
    
    // 只有数据不一致时才擦除并写入
    flash_erase(sector);
    flash_write(sector, data, SECTOR_SIZE);
    
    // 写入后再次验证
    return (flash_compare(sector, data, SECTOR_SIZE) == 0);
}

可重入性:中断嵌套别崩溃

可重入性,说白了就是你的恢复函数能不能被中断打断,然后又被重新调用。这在裸机系统里不太常见,但在 RTOS 或者带中断嵌套的场景下,这是个硬伤。

我记得有一次调试一个 BLE 设备的升级问题。升级过程中,蓝牙中断来了,中断服务程序里又调用了升级状态查询函数。结果那个函数里有个全局变量没保护,直接导致状态机跑飞了。

可重入设计的关键点:

设计要点 说明 常见错误
避免全局变量 使用局部变量或参数传递状态 用全局 flag 控制流程
保护共享资源 临界区用关中断或互斥锁保护 忘记关中断就操作 Flash
函数可重入 不依赖静态变量保存中间状态 用 static 变量做计数器
状态机独立 每个调用实例有自己的状态副本 所有实例共享一个状态机

我的建议:写恢复函数时,默认假设它会被中断,然后又被重新调用。如果你发现某个函数里用了全局变量或者 static 变量,先问自己一句:「如果这个函数执行到一半被中断,中断里又调了它一次,会不会出事?」会出事,就改。

状态持久化:断电不能丢进度

状态持久化,是这四个原则里最容易被低估的。很多人觉得「我在 RAM 里记个状态不就行了?」——断电之后 RAM 里的东西全没了,你拿什么恢复?

状态持久化,就是把升级进度、当前步骤、校验信息这些关键数据,写到掉电不丢失的存储里。一般是 Flash 或者 EEPROM。

我个人习惯用「状态槽」的方式。在 Flash 里划出一块区域,专门存升级状态。每次状态变化时,写入一个新的状态槽,而不是覆盖旧的。这样即使写入过程中断电,至少还有一个完整的状态槽可以读取。

// 状态槽写入示例:写新槽,不覆盖旧槽
#define STATUS_SLOT_COUNT  4
#define STATUS_SLOT_SIZE   64

typedef struct {
    uint32_t magic;       // 魔数,用于校验完整性
    uint32_t version;     // 升级版本号
    uint32_t step;        // 当前执行步骤
    uint32_t checksum;    // 状态数据校验和
    uint8_t  reserved[48];
} upgrade_status_t;

bool write_status_slot(upgrade_status_t *status) {
    // 找到下一个空闲槽
    for (int i = 0; i < STATUS_SLOT_COUNT; i++) {
        uint32_t addr = STATUS_SLOT_BASE + i * STATUS_SLOT_SIZE;
        upgrade_status_t old;
        flash_read(addr, &old, sizeof(old));
        
        if (old.magic != STATUS_MAGIC) {
            // 空闲槽,写入新状态
            status->magic = STATUS_MAGIC;
            status->checksum = calculate_checksum(status);
            flash_write(addr, status, sizeof(upgrade_status_t));
            return true;
        }
    }
    
    // 所有槽都满了,循环覆盖最旧的
    // 这里省略了查找最旧槽的逻辑
    return false;
}

注意:Flash 写入是有寿命的。如果每次状态变化都写 Flash,频繁升级时可能把状态区写坏。我建议状态变化超过一定阈值(比如 100ms)才写一次,或者只在关键步骤(如擦除完成、写入完成、校验完成)时写。

四个原则的关系

这四个原则不是孤立的。它们互相配合,才能构建一个可靠的恢复机制。我画了一张图,帮你理清它们的关系:

恢复机制设计原则关系图 可靠恢复机制 原子性 幂等性 可重入性 状态持久化 四个原则缺一不可 原子性保证操作完整,幂等性容忍重复执行 可重入性应对中断嵌套,状态持久化保存进度

从这张图你能看出来,四个原则共同支撑起「可靠恢复」这个目标。少了任何一个,恢复机制都会出现漏洞。

实战中的权衡

理论说完了,聊聊实际落地时怎么取舍。说实话,不是所有场景都需要把这四个原则做到极致。你得根据项目特点来权衡。

  • 资源受限的 MCU(比如 2KB RAM、32KB Flash):优先保证原子性和状态持久化。幂等性和可重入性可以适当简化,比如用关中断代替完整的可重入设计。
  • 高可靠性场景(比如医疗设备、汽车电子):四个原则一个都不能少。而且每个原则都要做冗余设计,比如状态持久化用双备份。
  • 消费类产品(比如智能家居、可穿戴):原子性和幂等性必须做,可重入性看 RTOS 使用情况,状态持久化可以用简化版。

我的经验:别一开始就想把四个原则都做到完美。先搭一个最小可行版本,保证原子性和状态持久化。然后通过测试,看看中断恢复时会不会出问题。出问题了,再补幂等性和可重入性。这样迭代,比一开始就追求完美要高效得多。