5、OTA升级包制作(下):payload.bin结构解析、payload生成器源码分析、自定义OTA包签名、多分区打包策略

上一讲我们把OTA升级包的骨架搭好了,今天来啃最硬的骨头——payload.bin。说实话,我第一次看到这个二进制文件时也是一头雾水。但搞懂了它的结构,你就能掌控整个升级流程。

5.1 payload.bin 结构深度解析

payload.bin 不是随便拼出来的二进制流。它有一套严谨的协议格式。我习惯把它分成三个层次来看:

  • 元信息层:文件头、manifest 描述
  • 操作层:一系列 install 操作序列
  • 数据层:实际的差分或全量数据块

来看它的核心结构:

// 简化后的 payload 结构
PayloadManifest {
  repeated PartitionUpdate partitions  // 分区列表
  optional int64 block_size = 1        // 块大小,通常 4K
  optional int64 minor_version = 2     // 版本兼容标记
}

PartitionUpdate {
  string partition_name                // 分区名,如 system、vendor
  optional string hash_func            // 哈希算法,如 sha256
  repeated InstallOperation operations // 操作序列
  optional uint64 new_partition_size   // 新分区大小
  optional bytes old_partition_info    // 旧分区校验信息
}

InstallOperation {
  enum Type {
    REPLACE = 0      // 全量替换
    REPLACE_BZ = 1   // bzip2 压缩替换
    MOVE = 2         // 块移动
    SOURCE_COPY = 4  // 源拷贝
    ZERO = 5         // 填充零
    DISCARD = 6      // 丢弃块
    BROTLI_BSDIFF = 7 // brotli 差分
  }
  Type type
  repeated Extent src_extents   // 源数据范围
  repeated Extent dst_extents   // 目标数据范围
  bytes data_offset             // 数据在 payload 中的偏移
  bytes data_length             // 数据长度
}

这里有个关键点:操作类型决定了升级策略。全量升级用 REPLACE,差分升级用 BROTLI_BSDIFF。我在项目中遇到过一个问题——差分升级时如果源分区被修改过(比如 root 后改了 system),BROTLI_BSDIFF 会失败,因为源数据对不上。这时候就得回退到全量升级。

核心要点:payload.bin 的 manifest 是 protobuf 序列化的,你可以用 protoc 反序列化出来看。我经常用这个命令来调试:

# 从 payload 中提取 manifest 并解析
python3 -c "
import sys
sys.path.insert(0, 'path/to/update_engine/scripts')
import payload_info
payload_info.print_payload_info('payload.bin')
"

5.2 payload 生成器源码分析

生成 payload 的核心工具是 delta_generator。它的源码在 AOSP 的 system/update_engine/ 下。我建议你重点看这几个文件:

文件 作用 关键函数
payload_generator.cc 主入口,协调整个生成流程 GeneratePayload()
delta_diff_utils.cc 差分算法实现 DiffPartitions()
payload_signer.cc 签名逻辑 SignPayload()
extent_utils.cc 块范围管理 ExtendExtents()

生成流程大致是这样的:

  1. 读取新旧镜像文件
  2. 按分区逐个对比,生成 diff 操作
  3. 将操作序列写入 protobuf manifest
  4. 把数据块追加到 payload 尾部
  5. 计算整体哈希,写入签名

我举个例子,假设你要生成 system 分区的差分升级包:

# 命令行调用
delta_generator \
  --new_image system_new.img \
  --old_image system_old.img \
  --new_partition_name system \
  --old_partition_name system \
  --operations_file ops.bin \
  --out_file payload.bin

# 源码中核心逻辑
bool GeneratePayload(const PayloadGenerationConfig& config) {
  // 1. 加载新旧镜像
  ImageReader old_image(config.old_image_path);
  ImageReader new_image(config.new_image_path);
  
  // 2. 逐块对比
  for (size_t block = 0; block < new_image.block_count(); ++block) {
    if (old_image.ReadBlock(block) == new_image.ReadBlock(block)) {
      // 相同块,跳过
      continue;
    }
    // 不同块,生成差分操作
    InstallOperation op;
    if (TryBrotliDiff(old_image, new_image, block, &op)) {
      // 成功生成差分
    } else {
      // 差分失败,回退到全量
      GenerateReplaceOp(new_image, block, &op);
    }
  }
  
  // 3. 序列化并写入
  return WritePayload(manifest, data_blobs, output);
}

个人经验:生成 payload 时,--chunk_size 参数很关键。默认是 4K,但如果你分区很大(比如 4GB 的 system),建议调大到 64K 或 128K。这样能减少操作数量,加快生成速度。不过代价是差分精度会降低,升级包会大一点。需要权衡。

5.3 自定义 OTA 包签名

OTA 包的签名不是随便签的。它有一套完整的信任链。我见过不少团队在这上面栽跟头——签名对了但升级失败,因为公钥没烧进 bootloader。

标准签名流程是这样的:

# 1. 生成密钥对(如果还没有)
openssl genpkey -algorithm RSA -out ota_private.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -pubout -in ota_private.pem -out ota_public.pem

# 2. 对 payload 签名
delta_generator \
  --in_file payload.bin \
  --signature_size 256 \
  --private_key ota_private.pem \
  --out_file payload_signed.bin

# 3. 验证签名
delta_generator \
  --in_file payload_signed.bin \
  --public_key ota_public.pem \
  --verify

但如果你要自定义签名方案,比如用 ECDSA 或者 SM2,就得改源码了。核心修改点在这里:

// payload_signer.cc 中的 SignPayload 函数
bool SignPayload(const std::string& payload_file,
                 const std::string& private_key_file,
                 const std::string& output_file) {
  // 默认使用 RSA-PSS 签名
  // 如果你想用 ECDSA,需要替换这里的签名算法
  std::string signature;
  if (!SignWithRsaPss(payload_data, private_key, &signature)) {
    return false;
  }
  
  // 签名写入 payload 尾部
  return WriteSignature(output, signature);
}

// 自定义签名示例:使用 ECDSA
bool SignWithEcdsa(const std::string& data,
                   const std::string& private_key,
                   std::string* signature) {
  // 这里调用 OpenSSL 的 ECDSA_sign 接口
  // 注意:需要同时修改验证端的签名算法
  // 否则升级会失败
}

避坑指南:我曾经帮一个客户排查 OTA 失败问题,折腾了两天。最后发现是签名算法改了,但 recovery 里的公钥没更新。记住:签名和验签必须配对。你改了签名算法,recovery 镜像、bootloader 里的公钥都得同步更新。

5.4 多分区打包策略

现代车载系统不止 system 和 vendor 两个分区。我见过最多的有 12 个分区需要同时升级。多分区打包不是简单地把它们拼在一起,要考虑依赖关系和升级顺序。

先看一个典型的多分区 manifest:

partitions: {
  partition_name: "boot"
  operations: { type: REPLACE ... }
}
partitions: {
  partition_name: "dtbo"
  operations: { type: REPLACE ... }
}
partitions: {
  partition_name: "system"
  operations: { type: BROTLI_BSDIFF ... }
}
partitions: {
  partition_name: "vendor"
  operations: { type: BROTLI_BSDIFF ... }
}
partitions: {
  partition_name: "vbmeta"
  operations: { type: REPLACE ... }
}

打包策略我总结为三条原则:

  • 依赖优先:boot 依赖 dtbo,所以 dtbo 要先升级
  • 大小排序:小分区(如 vbmeta)放前面,大分区(如 system)放后面
  • 类型分组:全量操作放一起,差分操作放一起

生成多分区 payload 的命令示例:

delta_generator \
  --new_image system_new.img --old_image system_old.img \
  --new_partition_name system \
  --new_image vendor_new.img --old_image vendor_old.img \
  --new_partition_name vendor \
  --new_image boot_new.img --old_image boot_old.img \
  --new_partition_name boot \
  --new_image dtbo_new.img --old_image dtbo_old.img \
  --new_partition_name dtbo \
  --new_image vbmeta_new.img --old_image vbmeta_old.img \
  --new_partition_name vbmeta \
  --out_file payload.bin

实战建议:多分区打包时,我习惯先做一个依赖分析图。比如 boot 分区升级后,kernel 模块可能变了,vendor 里的驱动得匹配。所以 vendor 必须在 boot 之后升级。这个顺序错了,系统可能起不来。

最后,给你看一张我画的 payload 结构图,把今天讲的内容串起来:

payload.bin 结构全景图 文件头 (Magic + Version + Manifest Size) Payload Manifest (Protobuf) 分区列表 操作序列 校验信息 操作数据块 (InstallOperation 序列) REPLACE BROTLI_BSDIFF MOVE ZERO 签名信息 (RSA-PSS / ECDSA / 自定义) 元信息层 操作层 数据层

嗯,payload.bin 的结构其实不复杂。你只要记住:头 + manifest + 数据 + 签名,四层结构。每一层都有固定的格式和校验。搞懂了这些,你就能自己写工具来生成、解析、甚至修改 payload 了。

我在实际项目中,经常用 Python 写脚本直接操作 payload.bin。比如批量替换分区镜像、修改签名算法、调整分区顺序。这些骚操作的前提,就是今天讲的这些底层知识。


公众号:蓝海资料掘金营,微信deep3321