LV007-升级包格式与传输机制
一、 升级包格式设计
1. 为什么不能直接发裸 bin
裸固件 bin 只是一段字节流,设备收到后无法回答几个关键问题:这是给谁升的?什么版本?多长?写到哪里?数据有没有损坏?所以 OTA 升级包必须自带元数据,即"包头 + 负载"的结构。
2. 通用包结构
一个兼顾 MCU 与 Linux 的升级包结构如下:
偏移 字段 说明
────────────────────────────────────────────────
0x0000 magic 魔数,识别包类型(如 0x4F544121 "OTA!")
0x0004 header_version 包头结构版本,便于将来扩展
0x0008 package_type 整包 / 差分包 / Bootloader 包
0x000C target_product 目标产品型号(防刷错设备)
0x0010 src_version 源版本(差分包专用,整包填 0xFFFFFFFF)
0x0014 dst_version 目标版本
0x0018 payload_offset 负载在包内的偏移
0x001C payload_size 负载字节数
0x0020 payload_crc32 负载 CRC32(快速校验)
0x0024 payload_sha256 负载 SHA-256(32 字节,安全校验)
0x0044 signature 数字签名(如 ECDSA P-256,64 字节)
0x0084 保留 / 扩展字段
────────────────────────────────────────────────
header_size 负载(固件镜像或差分补丁,可压缩)【设计要点】
- 型号与版本字段是"防呆"底线:防止把 A 产品固件刷进 B 产品、防止差分包打在错误的源版本上
- CRC32 与 SHA-256 并存:CRC32 给资源紧张的 MCU 快速校验用,SHA-256 作为验签前的安全摘要
- 签名单独放元数据区,验签通过前负载一个字节都不写 Flash
3. 包头解析与校验的完整实现
// ota_package.h —— 升级包格式定义与解析
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#define OTA_MAGIC 0x4F544121u /* "OTA!" */
#define OTA_HEADER_VERSION 1u
#define OTA_PRODUCT_ID 0x0001u /* 本产品的型号编码 */
typedef enum {
OTA_PKG_FULL = 1, /* 整包 */
OTA_PKG_DELTA = 2, /* 差分包 */
} ota_pkg_type_t;
typedef struct {
uint32_t magic;
uint32_t header_version;
uint32_t package_type;
uint32_t target_product;
uint32_t src_version; /* 差分包的源版本,整包为 0xFFFFFFFF */
uint32_t dst_version;
uint32_t payload_offset;
uint32_t payload_size;
uint32_t payload_crc32;
uint8_t payload_sha256[32];
uint8_t signature[64]; /* ECDSA P-256: r(32) || s(32) */
uint8_t reserved[40];
} ota_header_t;
/* 解析并做基础合法性检查(不含验签,验签见 LV019) */
int ota_header_parse(const uint8_t *buf, uint32_t len, ota_header_t *hdr)
{
if (len < sizeof(ota_header_t)) {
return -1;
}
memcpy(hdr, buf, sizeof(ota_header_t));
if (hdr->magic != OTA_MAGIC) return -2; /* 不是 OTA 包 */
if (hdr->header_version != OTA_HEADER_VERSION) return -3;
if (hdr->target_product != OTA_PRODUCT_ID) return -4; /* 不是给本机的 */
if (hdr->package_type != OTA_PKG_FULL &&
hdr->package_type != OTA_PKG_DELTA) return -5;
if (hdr->payload_offset < sizeof(ota_header_t)) return -6;
if (hdr->payload_size == 0) return -7;
if (hdr->payload_offset + hdr->payload_size > len) return -8;
return 0;
}二、 差分升级原理
1. bsdiff 的核心思想
bsdiff 观察到一个事实:新旧两版固件之间,大部分字节是相同但位置错开的(插入一个函数,后面所有内容整体后移)。它的做法是:
(1)对旧文件建后缀数组(suffix array),作为快速查找索引
(2)扫描新文件,尽量在旧文件中找到近似匹配的长段
(3)输出三类数据:ctrl 控制块("从旧文件哪里拷多少字节、跳过多少")、diff 差异块(近似匹配段的逐字节差值)、extra 全新内容块
(4)整体用 bzip2 压缩
因为二进制差异以"位置调整 + 少量字节差"为主,diff 块里大量是 0,压缩率非常高——这就是差分包能做到整包 5%~30% 的原因。
2. 设备端的重建过程(bspatch)
设备端执行的是 bsdiff 的逆过程 bspatch:以本地旧镜像为底,按 ctrl 块指示把"旧数据 + diff"逐段拼出新镜像。
设备端落地差分需要三个条件:
- 旧镜像可读且与补丁源版本逐字节一致(XIP 场景直接读运行区即可)
- 重建输出有地方放(非活动槽或外部 Flash)
- 额外的 RAM / 临时空间开销(bspatch 需要读旧 + 写新,补丁本身解压也要缓冲)
MCU 上有针对小资源优化的差分算法变种(如 Jdiff、HDiffPatch 的嵌入式端口),RAM 占用可压到几 KB。算法对比、嵌入式移植要点与差分/A/B 组合的完整决策流程见 LV010-差分算法对比与嵌入式移植。
3. 差分与整包的关系
差分是优化项,整包是保底线,两者共用同一套包头格式与校验流程——差分包只是 package_type 取值不同、负载换成补丁,本节的解析代码与第三节的传输机制对它同样适用。
差分链路上任何一环不满足(源版本不符、旧镜像被改、重建哈希不对)都必须能退回整包。这套降级决策的完整流程与参考实现见 LV010-差分算法对比与嵌入式移植。
三、 传输机制
1. 分块下载与断点续传
升级包不是一次性灌进设备的,而是按固定块(MCU 常用 1~4 KB,Linux 常用 1 MB)逐块拉取:
- HTTP 场景天然支持
Range头请求任意区间,服务器侧零定制 - 私有协议场景在协议里带"偏移 + 长度"字段,等价实现
断点续传的关键是下载进度要持久化到 Flash(而不是只存在 RAM),否则断电后进度丢失只能重来:
// ota_download.h —— 下载状态记录(存 Flash 参数区)
typedef struct {
uint32_t magic;
uint32_t total_size; /* 升级包总大小 */
uint32_t downloaded; /* 已下载字节数 */
uint32_t dst_version; /* 正在下载的目标版本 */
uint32_t record_crc; /* 本记录自身 CRC */
} ota_dl_record_t;每次成功写入一块后更新 downloaded 并重算 CRC。重启后读到有效记录即续传,读不到(CRC 错)就从零开始——逻辑简单且掉电安全。为避免同一 Flash 页频繁擦写,记录可与启动参数区共用磨损均衡策略(见 LV016-选槽与回滚机制)。
2. 分块校验
分块 CRC 表随版本信息一起下发(或放在包头扩展区),每收到一块立即校验:
- 校验失败:只重传这一块,而不是整个包
- 全部块到位后再做整包 SHA-256 + 验签的终验
"块级 CRC 尽早排错 + 整包哈希兜底 + 签名防伪"是三层递进的校验体系,前两层管"传输错没错",第三层管"包是不是真的"。
本节讲的是与链路无关的传输机制:无论走 HTTP、UART 还是 BLE,分块、断点、块级校验的逻辑都一样。各条具体链路的帧格式与协议实现见 LV013-升级通道协议。
四、 写入 Flash 的实现要点
1. 擦写约束
无论内部 Flash 还是 SPI NOR,都遵守三条铁律:
(1)先擦后写:存储单元只能从 1 写 0,改写前必须整页/扇区擦除回全 1
(2)对齐写入:按芯片最小编程单元(STM32F103 半字 2 字节、NOR Flash 页 256 字节)对齐,缓冲不足补 0xFF
(3)XIP 冲突:代码正在某 Bank 上执行时不能擦写该 Bank(会总线冲突或取指异常)。单 Bank 芯片写 Flash 的例程必须拷贝到 RAM 执行,STM32 HAL 的 HAL_FLASH_Program 在写内部 Flash 期间会关中断并等待,工程上通常把整个下载写盘任务安排在 RAM 运行或确保相关代码在允许范围内
2. 写缓冲管理
下载块大小(4 KB)通常与 Flash 擦除单位(STM32F103 页 2 KB、NOR 扇区 4 KB)不一致,正确的写入流程是:
(1)接收数据先入 RAM 环形缓冲
(2)缓冲攒够一个擦除单位 → 擦除 → 按编程单元连续写入 → 读回校验
(3)写完后更新断点记录
读回校验不可省:Flash 写入受电压、温度影响可能出错,"写完立即读回比对"是早期发现写故障的唯一手段。
3. 掉电时的半成品处理
下载中途掉电,暂存区里是不完整的新固件——这没关系,因为:
- 暂存区 / 非活动槽的内容本来就"不可信",Bootloader 只会依据参数区的标志启动已确认的镜像
- 断点记录里有
downloaded,续传时该偏移之后的内容一律视为垃圾重新下载 - 真正需要原子性保证的只有参数区那几个字节,这是下一篇的主题
本文档由 markdowncli 技能辅助生成