Skip to content

LV007-升级包格式与传输机制 ​

一、 升级包格式设计 ​

1. 为什么不能直接发裸 bin ​

裸固件 bin 只是一段字节流,设备收到后无法回答几个关键问题:这是给谁升的?什么版本?多长?写到哪里?数据有没有损坏?所以 OTA 升级包必须自带元数据,即"包头 + 负载"的结构。

2. 通用包结构 ​

一个兼顾 MCU 与 Linux 的升级包结构如下:

text
偏移        字段                说明
────────────────────────────────────────────────
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. 包头解析与校验的完整实现 ​

c
// 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),否则断电后进度丢失只能重来:

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