断电、重启、断点续跑:嵌入式多步流程的检查点恢复设计

声明: loxseq 是一个开源项目,作者为 Vanderhell。本文是阅读该项目源码和文档后整理的学习笔记,用于理解嵌入式掉电恢复状态机的工程实现方式。本文作者不是该项目的开发者,未参与该项目的任何代码贡献。 文中所有工程细节均来自对开源代码的分析,不代表本文作者的设计决策。

项目仓库:github.com/Vanderhell/loxseq(断电恢复状态机的 C 语言参考实现)

概述

嵌入式设备经常跑多步流程:灌装、加热、排空,或者充电、标定。任何一步执行中掉电、看门狗复位或 OTA 重启,设备就忘了自己干到哪一步。重做的代价有三种形态:重复执行会破坏(投料、加药、校准),会危险(加热中重启再加热可能过温),会浪费(小时级流程从头再来)。

解决办法是一套设计思想:把进度以检查点形式持久化,重启后读回检查点,按每步预设的恢复策略决定续跑、重跑、放弃还是问人。检查点不只是一个步骤号:写入可能只完成一半(撕裂写),固件升级后步骤表会变,旧记录可能比新的更新,这些都要靠校验、版本、指纹等附加信息兜住。本文以开源项目 loxseq 作为这套思想的参考实现展开,重点在思想本身。这个库的适用面很窄,后面会说明。

断电恢复流程

恢复策略按步骤声明

"断电后怎么办"没有统一答案,同一流程里每步都不一样。把策略声明在步骤表里,执行交给状态机:

恢复策略 重启后行为 典型场景
LOXSEQ_RESUME_AT_STEP 从断点继续本步 断电不影响步骤前提
LOXSEQ_RESUME_FROM_START 回到本步,重头执行 需要重新归零或复位
LOXSEQ_NEVER_RESUME 放弃流程,进入安全初始化 步骤状态不可信
LOXSEQ_OPERATOR_DECIDES 挂起,等待操作员裁决 需要人工判断

举例:灌装可以续(液位到设定值即可),加热可以续(温度到设定值即可),加药绝不恢复(药不能加两次),排空要问人(罐里有什么只有操作员知道)。策略按步声明,机器只执行裁决,人工闸门留在该留的地方。

最小集成示例

以 loxseq 为例看这套思想怎么落地:三步接入。

步骤表

每步声明标签、动作、超时和恢复策略。动作返回 LOXSEQ_STEP_RUNNING 表示进行中,返回 LOXSEQ_STEP_DONE 表示完成。

enum { S_FILL, S_HEAT, S_DRAIN, STEP_COUNT };

static loxseq_step_status_t step_fill(loxseq_t *s, uint32_t now_ms, void *u) {
    (void)u;
    if (loxseq_step_age_ms(s, now_ms) >= 2000) return LOXSEQ_STEP_DONE;
    return LOXSEQ_STEP_RUNNING;
}
/* step_heat、step_drain 结构相同,此处省略 */

static const loxseq_step_def_t steps[STEP_COUNT] = {
    [S_FILL]  = { .tag = "fill",  .action = step_fill,
                  .timeout_ms = 30000,
                  .resume_policy = LOXSEQ_RESUME_AT_STEP },
    [S_HEAT]  = { .tag = "heat",  .action = step_heat,
                  .timeout_ms = 30000,
                  .resume_policy = LOXSEQ_RESUME_AT_STEP },
    [S_DRAIN] = { .tag = "drain", .action = step_drain,
                  .timeout_ms = 30000,
                  .resume_policy = LOXSEQ_NEVER_RESUME },
};

存储钩子

实现写、读、擦三个回调。后端持久化序列化后的 32 字节检查点,不保存 C 结构体的内存映像。

static const loxseq_storage_t storage = {
    .write_checkpoint = my_flash_write,  /* 持久化 32 字节检查点 */
    .read_checkpoint  = my_flash_read,
    .erase_checkpoint = my_flash_erase,  /* 擦除后表现为冷启动 */
};

开机流程

开机先 loxseq_recover() 得出裁决,再按裁决启动;主循环周期性调用 loxseq_tick() 驱动步骤执行。

static loxseq_err_t my_safe_init(loxseq_t *s, uint32_t t, void *u) {
    (void)s; (void)t; (void)u; return LOXSEQ_OK; /* 安全初始化,示例为空实现 */
}
static const loxseq_safe_init_t safe_init_cfg = { .run = my_safe_init, .user = NULL };

loxseq_t seq;
loxseq_init(&seq, steps, STEP_COUNT, &storage, &safe_init_cfg);

loxseq_recovery_verdict_t v = loxseq_recover(&seq, LOXSEQ_REBOOT_NORMAL);
switch (v) {
    case LOXSEQ_RECOVERY_COLD_START: loxseq_start_fresh(&seq, now_ms);     break;
    case LOXSEQ_RECOVERY_RESUME:     loxseq_start_resume(&seq, now_ms);    break;
    case LOXSEQ_RECOVERY_RESTART:    loxseq_start_restart(&seq, now_ms);   break;
    case LOXSEQ_RECOVERY_SAFE_INIT:  loxseq_start_safe_init(&seq, now_ms); break;
    case LOXSEQ_RECOVERY_OPERATOR:   loxseq_start_operator_wait(&seq);     break;
}

while (1) {
    loxseq_tick(&seq, now_ms());   /* 每步的动作在这里被调用 */
}

运行中分支

默认流程单向推进。需要按业务条件跳转时,动作里先调 loxseq_set_next_step() 设目标,再返回 LOXSEQ_STEP_BRANCH。以质检步骤为例:合格跳去打包,不合格先跳去重工。跳转目标不限相邻;返回 BRANCH 却没设目标会让序列器进入 FAILED。

完整可编译示例(含打印步骤顺序的 main):下载分支示例

场景约束

这套设计有三个明确的禁区,loxseq 的文档也写明了:

  1. 涉及生命安全的场景。loxseq 自己声明不提供安全等级:无 SIL 认证、无 IEC 61511、无 ISO 26262。检查点只记录软件知道的进度,不记录液位、温度、位置这些物理事实;断电期间物理过程还在演化,恢复时世界已经变了。安全场景的恢复必须止步于安全状态并等人工确认,自动续跑是错误设计。
  2. 重大财产损失场景。恢复策略不可证明安全时,机器替人做决定就是风险。
  3. 生产现场的主控。工厂工艺层的主控是 PLC/DCS 加安全 PLC,联锁、报警、配方都在那一层,断电恢复非常保守。检查点恢复适合的是设备内部的序列(点胶机归零分装、电池充电阶段、打印机断电续打),位置在 PLC 之下,两者叠加使用,互不替代。

另外要说明的是:这个库的适用面很窄,没有公开的采用记录,项目作者也举不出确切的用户。把它当作高质量的参考实现来阅读更合适,价值在思想,不在这份代码。

需要检查点恢复的四个特征

用不用这套设计,判断标准是"重做代价",与断电概率无关。四个特征命中其一就值得考虑:

  1. 流程里有一次性或不可逆步骤,重复执行会破坏物料或设备。
  2. 单步分钟到小时级,重跑代价大。
  3. 断电后"知道刚才在干什么"本身就是安全条件(加热中、均衡中)。
  4. 重启源多样:看门狗、OTA、电池耗尽、用户关机。

四个特征全不命中,重启后从头跑一遍没损失,就不需要这套设计。大多数项目属于这一类,自己写几十行状态机比引入依赖更划算。

即便不采用检查点恢复,这套思想里的几个设计点值得带走:恢复策略按步声明、终结写墓碑防止重做、步骤表指纹防固件升级误恢复、数据可疑时保守降级、时钟驱动状态机可测可移植。