边缘模型 OTA:更新模型前,先准备好回滚
边缘模型 OTA更新模型前先准备好回滚一、深度引言OTA 不是下载文件是给现场设备换大脑边缘设备一旦部署到用户手中模型更新就是绕不开的命题。新场景需要新模型、新数据驱动优化、安全漏洞修复、甚至监管合规要求都可能触发一次 OTA。但模型 OTA 和普通应用更新有本质区别模型是设备的大脑更新模型等于改变设备的判断能力。搞砸了不是闪退是持续做出错误决策。传统固件 OTA 已经有一整套成熟的工程实践A/B 分区、签名校验、回滚机制、断电保护。模型 OTA 的场景复杂度更高——模型体积可能几十 MB、和推理 runtime 有版本依赖、对前处理参数和后处理逻辑高度耦合。只把 model.tflite 下载下来覆盖旧文件几乎等于在没做任何准备的情况下做开颅手术。一条合格的模型 OTA 链路至少要具备以下能力版本兼容性校验runtime 版本、算子支持、输入 shape、完整性校验SHA256、签名验证防篡改、A/B 槽位切换不停机更新、自检机制小流量跑几帧验证、自动回滚自检失败切回旧版本、断电恢复任何时刻断电都能回到可工作状态。少一项都是在用底线换方便。二、原理剖析A/B 槽位状态机与模型签名验证2.1 A/B 槽位切换状态机A/B 槽位思想来自 Android 的无缝更新机制在边缘设备上同样适用。核心思路永远保留一个确定可用的模型版本active新版本先安装到另一个槽位standby验证通过后才执行切换。状态机定义如下状态 IDLE当前运行槽位 A槽位 B 空闲。状态 DOWNLOADING正在下载新模型包到槽位 B。状态 VERIFYING下载完成校验 SHA256 和签名。状态 TESTING校验通过新模型加载后在小流量环境下自检。状态 ACTIVE_B自检通过切换主槽位为 B。此时旧模型在槽位 A 作为回滚 target。状态 ROLLBACK自检失败或运行时异常切回旧槽位。stateDiagram-v2 [*] -- IDLE: 系统启动 IDLE -- DOWNLOADING: 收到 OTA 指令 DOWNLOADING -- DOWNLOADING: 断点续传 DOWNLOADING -- VERIFYING: 下载完成 VERIFYING -- DOWNLOADING: 校验失败重试 VERIFYING -- TESTING: 校验通过 TESTING -- ACTIVE: 自检通过 TESTING -- ROLLBACK: 自检失败 ACTIVE -- ROLLBACK: 运行时异常 ROLLBACK -- IDLE: 回滚完成 ACTIVE -- [*]: 正常运行2.2 HMAC-SHA256 签名验证模型文件在传输和存储过程中可能被篡改。即使使用 HTTPS也需要端到端的签名验证。推荐使用 HMAC-SHA256密钥在设备出厂时烧录到 eFuse 或安全元件中。验证流程服务器端用密钥对模型文件计算 HMAC-SHA256将签名附在 OTA 包中。设备端收到后用本地密钥重新计算 HMAC 并比对。任何一位不匹配模型包即被拒绝。2.3 断电恢复策略边缘设备没有 UPS。OTA 过程中的任何时刻都可能断电。恢复策略的核心是先写标记后写数据下载阶段断电pending_slot_version不存在或未写入完成标记 → 重新下载。校验阶段断电已完成下载但未写verified标记 → 重新校验。切换阶段断电verified存在但active_slot未切换 → 继续切换流程。最坏情况Flash 写坏两个槽位都不可用 → 进入 Recovery 模式通过 USB/SD 卡恢复。每个阶段由非易失性存储中的一个状态字节记录。启动时先读这个字节决定恢复到哪个阶段。2.4 存储空间规划小容量 Flash如 16MB NOR Flash上做 A/B 分区需要精密的空间计算。假设模型包 4MB、OTA 下载临时空间 4MB、回滚保留 4MB总计需要 12MB。如果 Flash 总量 16MB剩下 4MB 给固件和文件系统非常紧张。建议模型包做差分更新delta OTA只传输变化部分。回滚槽位使用只读压缩格式如 lz4降低占用。如果 Flash 确实不够至少保留一个最小可用模型在固定地址作为最后的 fallback。三、代码实现完整 OTA 更新引擎/** * 边缘模型 OTA 更新引擎 * * 特性A/B 槽位、HMAC-SHA256 签名、断点续传、断电恢复、自检回滚 * 存储布局Flash 分区: * - 0x000000: OTA 状态字4B * - 0x001000: Slot A 模型max 4MB * - 0x401000: Slot B 模型max 4MB * - 0x801000: Download buffermax 4MB */ #include stdint.h #include stdbool.h #include string.h #include stdio.h /* 常量定义 */ #define SLOT_SIZE (4 * 1024 * 1024) /* 4MB */ #define SLOT_A_ADDR 0x001000 #define SLOT_B_ADDR 0x401000 #define DOWNLOAD_ADDR 0x801000 #define OTA_STATE_ADDR 0x000000 #define MODEL_MANIFEST_SIZE 512 #define SIGNATURE_SIZE 32 /* HMAC-SHA256 */ /* OTA 状态值写入 Flash 的状态字 */ typedef enum { OTA_STATE_IDLE 0x00, /* 正常运行 */ OTA_STATE_DOWNLOADING 0x01, /* 下载中 */ OTA_STATE_DOWNLOADED 0x02, /* 下载完成待校验 */ OTA_STATE_VERIFIED 0x03, /* 校验通过待切换 */ OTA_STATE_TESTING 0x04, /* 切换后自检中 */ OTA_STATE_ROLLBACK 0x05, /* 回滚中 */ } ota_state_t; /* 模型包 manifest */ typedef struct __attribute__((packed)) { char model_name[64]; char version[32]; uint32_t model_size; /* 模型文件大小 */ uint32_t total_size; /* 整个包的大小含签名 */ uint8_t sha256[32]; /* 模型文件 SHA256 */ uint8_t signature[SIGNATURE_SIZE]; /* HMAC-SHA256 签名 */ uint32_t min_runtime_version; uint32_t crc32; /* manifest 自身 CRC */ } model_manifest_t; /* OTA 引擎上下文 */ typedef struct { ota_state_t state; int active_slot; /* 0A, 1B */ uint32_t download_offset; /* 断点续传偏移 */ uint32_t download_total; /* 预期总大小 */ model_manifest_t manifest; /* 回调函数由平台层实现 */ int (*flash_read)(uint32_t addr, uint8_t *buf, uint32_t len); int (*flash_write)(uint32_t addr, const uint8_t *buf, uint32_t len); int (*flash_erase)(uint32_t addr, uint32_t len); int (*hmac_sha256)(const uint8_t *key, int key_len, const uint8_t *data, uint32_t len, uint8_t *out); int (*model_infer_test)(int slot); /* 自检跑 N 帧测试 */ void (*reboot)(void); } ota_engine_t; /* 工具函数 */ static uint32_t crc32_ieee(const uint8_t *data, int len) { uint32_t crc 0xFFFFFFFF; for (int i 0; i len; i) { crc ^ data[i]; for (int j 0; j 8; j) crc (crc 1) ^ (0xEDB88320 -(crc 1)); } return ~crc; } static uint32_t get_slot_addr(int slot) { return (slot 0) ? SLOT_A_ADDR : SLOT_B_ADDR; } static void set_state(ota_engine_t *e, ota_state_t state) { e-state state; uint8_t s (uint8_t)state; e-flash_write(OTA_STATE_ADDR, s, 1); } /* 核心 OTA 流程 */ /** * 步骤 1校验 manifest 完整性和兼容性 * return 0成功, -1manifest CRC 错, -2版本不兼容, -3模型大小超限 */ static int verify_manifest(ota_engine_t *e, const model_manifest_t *m) { if (!e || !m) return -1; /* CRC 校验 manifest 自身 */ uint32_t calc_crc crc32_ieee((const uint8_t *)m, sizeof(*m) - sizeof(m-crc32)); if (calc_crc ! m-crc32) { printf([OTA] manifest CRC 校验失败: calc0x%08X expect0x%08X\n, calc_crc, m-crc32); return -1; } /* 模型大小不能超过槽位 */ if (m-model_size SLOT_SIZE) { printf([OTA] 模型大小 %u 超过槽位容量 %u\n, m-model_size, SLOT_SIZE); return -3; } /* Runtime 版本兼容性 */ uint32_t current_runtime 0x020E00; /* 示例v2.14.0 */ if (m-min_runtime_version current_runtime) { printf([OTA] Runtime 版本不兼容: 需要 %u, 当前 %u\n, m-min_runtime_version, current_runtime); return -2; } return 0; } /** * 步骤 2HMAC-SHA256 签名验证 * return 0通过, -1签名不匹配 */ static int verify_signature(ota_engine_t *e, const model_manifest_t *m, const uint8_t *model_data) { if (!e || !m || !model_data) return -1; uint8_t computed[SIGNATURE_SIZE]; /* 对 model_data不含签名和 manifest做 HMAC */ int ret e-hmac_sha256(NULL, 0, model_data, m-model_size, computed); if (ret ! 0) { printf([OTA] HMAC 计算失败\n); return -1; } if (memcmp(computed, m-signature, SIGNATURE_SIZE) ! 0) { printf([OTA] 签名验证失败模型可能被篡改\n); return -1; } return 0; } /** * 主入口执行完整 OTA 流程 * param data_chunk 本次收到的数据块NULL 表示仅做状态恢复 * param chunk_len 数据块长度 * param is_last 是否最后一个块 * return 0成功, 负值失败原因 */ int ota_process_chunk(ota_engine_t *e, const uint8_t *data_chunk, uint32_t chunk_len, bool is_last) { if (!e) return -1; switch (e-state) { case OTA_STATE_IDLE: case OTA_STATE_DOWNLOADING: /* ---- 下载阶段 ---- */ if (e-state OTA_STATE_IDLE) { set_state(e, OTA_STATE_DOWNLOADING); e-download_offset 0; /* 擦除下载缓冲区 */ e-flash_erase(DOWNLOAD_ADDR, SLOT_SIZE); } if (data_chunk chunk_len 0) { int ret e-flash_write(DOWNLOAD_ADDR e-download_offset, data_chunk, chunk_len); if (ret ! 0) { printf([OTA] Flash 写入失败 offset%u\n, e-download_offset); return -10; } e-download_offset chunk_len; } if (is_last) { set_state(e, OTA_STATE_DOWNLOADED); /* 解析末尾的 manifest位于包的最后 MODEL_MANIFEST_SIZE 字节 */ uint8_t manifest_buf[MODEL_MANIFEST_SIZE]; if (e-download_offset sizeof(model_manifest_t)) { printf([OTA] 下载数据过小\n); set_state(e, OTA_STATE_ROLLBACK); return -11; } e-flash_read(DOWNLOAD_ADDR e-download_offset - sizeof(model_manifest_t), manifest_buf, sizeof(model_manifest_t)); memcpy(e-manifest, manifest_buf, sizeof(model_manifest_t)); } /* fall through: 如果是最后一个块继续校验 */ if (!is_last) break; /* 否则等待更多数据 */ return 0; case OTA_STATE_DOWNLOADED: { /* ---- 校验阶段 ---- */ int ret verify_manifest(e, e-manifest); if (ret ! 0) return ret; /* 读取下载的模型数据做签名校验 */ uint8_t *model_buf (uint8_t *)malloc(e-manifest.model_size); if (!model_buf) return -12; e-flash_read(DOWNLOAD_ADDR, model_buf, e-manifest.model_size); ret verify_signature(e, e-manifest, model_buf); free(model_buf); if (ret ! 0) { set_state(e, OTA_STATE_ROLLBACK); return -13; } printf([OTA] 校验通过: model%s version%s\n, e-manifest.model_name, e-manifest.version); /* 将新模型写入 standby 槽位 */ int standby 1 - e-active_slot; uint32_t standby_addr get_slot_addr(standby); e-flash_erase(standby_addr, SLOT_SIZE); /* 分块拷贝避免一次性 malloc 太大 */ uint8_t copy_buf[4096]; for (uint32_t off 0; off e-manifest.model_size; off sizeof(copy_buf)) { uint32_t chunk (off sizeof(copy_buf) e-manifest.model_size) ? (e-manifest.model_size - off) : (uint32_t)sizeof(copy_buf); e-flash_read(DOWNLOAD_ADDR off, copy_buf, chunk); e-flash_write(standby_addr off, copy_buf, chunk); } set_state(e, OTA_STATE_VERIFIED); /* fall through */ } case OTA_STATE_VERIFIED: { /* ---- 自检阶段 ---- */ int standby 1 - e-active_slot; set_state(e, OTA_STATE_TESTING); int test_ret e-model_infer_test(standby); if (test_ret ! 0) { printf([OTA] 自检失败: code%d, 开始回滚\n, test_ret); set_state(e, OTA_STATE_ROLLBACK); return -20; } /* 自检通过切换主槽位 */ e-active_slot standby; /* 擦除旧 download buffer */ e-flash_erase(DOWNLOAD_ADDR, SLOT_SIZE); set_state(e, OTA_STATE_IDLE); printf([OTA] 更新成功新槽位%s\n, standby ? B : A); return 0; } case OTA_STATE_ROLLBACK: { /* ---- 回滚 ---- */ printf([OTA] 回滚到槽位 %s\n, e-active_slot ? B : A); e-flash_erase(DOWNLOAD_ADDR, SLOT_SIZE); set_state(e, OTA_STATE_IDLE); return -100; /* 返回负值告知上层回滚完成 */ } default: return -99; } return 0; } /** * 启动时调用从 OTA 状态字恢复 */ int ota_recover(ota_engine_t *e) { uint8_t state_byte; e-flash_read(OTA_STATE_ADDR, state_byte, 1); printf([OTA] 恢复状态: 0x%02X\n, state_byte); switch (state_byte) { case OTA_STATE_IDLE: e-state OTA_STATE_IDLE; return 0; case OTA_STATE_DOWNLOADING: case OTA_STATE_DOWNLOADED: /* 下载未完成或未校验 → 丢弃重新下载 */ printf([OTA] 上次 OTA 未完成清理并回到 IDLE\n); e-flash_erase(DOWNLOAD_ADDR, SLOT_SIZE); set_state(e, OTA_STATE_IDLE); return 0; case OTA_STATE_VERIFIED: /* 已校验但未切换 → 继续自检 */ e-state OTA_STATE_VERIFIED; return ota_process_chunk(e, NULL, 0, true); case OTA_STATE_TESTING: /* 自检未完成 → 认为失败回滚 */ set_state(e, OTA_STATE_ROLLBACK); return ota_process_chunk(e, NULL, 0, true); case OTA_STATE_ROLLBACK: /* 继续回滚 */ e-state OTA_STATE_ROLLBACK; return ota_process_chunk(e, NULL, 0, true); default: printf([OTA] 未知状态: 0x%02X, 强制 IDLE\n, state_byte); set_state(e, OTA_STATE_IDLE); return 0; } }四、边界分析OTA 最容易忽略的七种风险风险一Flash 磨损不均。A/B 槽位每次 OTA 都在同一个地址擦写如果频繁更新如灰度测试期每天一次某些 NOR Flash 在 10 万次擦除后会出现 bit 错误。对策记录每槽位擦写次数超过阈值后触发磨损均衡或告警。风险二差分更新中的基准版本错。差分 OTA 依赖设备端当前的模型版本与服务器期望的基准版本完全一致。如果设备曾跳过某个版本、或部分更新后回滚版本链断裂差分 patch 无法应用。对策每次 OTA 包同时携带该版本的完整包下载地址作为 fallback。风险三前处理参数和新模型不匹配。模型更新到 v3输入归一化从mean[0.485,0.456,0.406]改成mean[0.5,0.5,0.5]但前处理代码没有随模型一起更新。模型跑出的结果完全错误自检却可能通过因为自检测试集可能是用新参数准备的。对策模型包 manifest 中声明前处理版本号设备端启动时校验对齐。风险四多个模型的原子性更新。一个产品可能有检测模型 分类模型 后处理模型三者有依赖关系。如果只更新了检测模型而分类模型未更新整体行为异常。对策多模型绑定为一个原子更新单元全部下载并校验通过后一并切换。风险五回滚后的数据积累。回滚到旧模型后旧模型的推理结果可能发现新模型已经处理过的场景中出现误判。如果回滚期间累积了大量数据设备再次升级到新模型时需要处理版本跨越。对策回滚时记录已回滚标记和时间戳下次 OTA 时携带上下文信息。风险六设备时钟不准导致的证书过期。部分签名方案使用 X.509 证书链验证依赖准确的系统时间。断电后 RTC 复位到 1970 年签名验证会失败。对策使用 HMAC 替代证书链或 OTA 前先通过 NTP 同步时间。风险七OTA 流量放大效应。10 万台设备同时 OTA单台 4MB 就是 400GB 流量。如果没有灰度分批和 CDN服务器和带宽成本会超出预期。对策在云端做分批推送每批间隔 5-10 分钟并支持 P2P 分发设备间共享。五、总结边缘模型 OTA 要按固件升级的严肃程度来设计。核心原则更新前先准备好回滚。A/B 槽位让设备在任何时刻都有一个可工作的模型HMAC-SHA256 保证模型未被篡改断电恢复状态机保证任何中断都能回到已知状态。从工程交付角度看OTA 不是锦上添花的功能而是边缘 AI 产品必须具备的生命线。模型总会迭代场景总会变化bug 总会出现——不能 OTA 的设备本质上是一个部署即废弃的系统。能安全回滚才敢放心升级。