4、全量升级方案:制作全量升级包、升级包签名与校验、升级流程设计

全量升级,说白了就是把整个 rootfs 完整地换一遍。你想想看,不管设备当前跑的是哪个版本,升级包一刷,整个文件系统全部覆盖。这种方式最直接,也最可靠。我在项目中遇到过好几次,客户要求做差分升级来省流量,结果因为文件系统碎片太多,差分包打出来比全量包还大——最后还是老老实实切回全量方案。

所以别觉得全量升级“笨”,它反而是嵌入式设备里最稳的兜底方案。今天我们就把它拆开来看:包怎么做,怎么签名防篡改,流程怎么设计才不出事。

4.1 制作全量升级包

全量升级包的核心,就是把目标 rootfs 打包成一个可部署的镜像。我习惯用 tar 或 squashfs,具体选哪个看你的文件系统类型。

4.1.1 打包方式对比

方式 压缩率 挂载支持 适用场景
tar.gz 中等 需解压到分区 可读写文件系统(ext4/ubifs)
squashfs 高 可直接 loop 挂载 只读 rootfs,节省空间
cpio.gz 低 initramfs 专用 早期引导阶段

我个人更推荐 squashfs。为什么?因为它打包后可以直接挂载,升级时先挂载新镜像,再通过 switch_root 切过去,省掉解压这一步,速度能快不少。

4.1.2 制作步骤

假设你的目标 rootfs 目录在 /build/rootfs_v2,我们把它打成 squashfs 镜像:

# 安装工具
sudo apt install squashfs-tools

# 制作 squashfs 镜像
mksquashfs /build/rootfs_v2 rootfs_v2.squashfs \
  -comp xz -b 131072 -noappend

# 查看镜像信息
unsquashfs -s rootfs_v2.squashfs

这里我用了 xz 压缩,块大小设成 128KB。嗯,这里要注意:块大小越大压缩率越高,但解压时占的内存也越多。如果你的设备只有 64MB RAM,建议把块大小降到 64KB 甚至 32KB。

小技巧: 打包前先清理掉日志、缓存、临时文件。我曾经见过一个升级包,里面塞了 200MB 的 journal 日志,客户下载时直接崩溃。加一条 rm -rf /build/rootfs_v2/var/log/* 就能避免。

4.2 升级包签名与校验

包做好了,但你不能直接丢给设备去刷。万一中间被人篡改了怎么办?我在一个 IoT 项目里就遇到过,有人伪造了升级包,往设备里植入了挖矿程序。从那以后,签名校验成了我方案里的必选项。

4.2.1 签名流程

我们采用“先哈希,后签名”的方式。用 RSA-2048 或 ECDSA,我个人倾向 ECDSA,密钥更短,嵌入式设备验签更快。

# 生成密钥对(服务端)
openssl ecparam -genkey -name prime256v1 -out private.pem
openssl ec -in private.pem -pubout -out public.pem

# 计算升级包哈希并签名
sha256sum rootfs_v2.squashfs > rootfs_v2.sha256
openssl dgst -sha256 -sign private.pem \
  -out rootfs_v2.sig rootfs_v2.squashfs

签名后,升级包由三部分组成:镜像文件、哈希文件、签名文件。设备端拿到后,先验签,再比对哈希,最后才刷写。

4.2.2 设备端校验

// 伪代码:设备端验签流程
int verify_update_package(const char *image_path,
                          const char *sig_path) {
    // 1. 读取公钥(烧录在设备中)
    EVP_PKEY *pubkey = load_public_key("/etc/update_pub.pem");

    // 2. 计算镜像哈希
    unsigned char hash[32];
    compute_sha256(image_path, hash);

    // 3. 验签
    EVP_MD_CTX *ctx = EVP_MD_CTX_new();
    EVP_DigestVerifyInit(ctx, NULL, EVP_sha256(),
                         NULL, pubkey);
    EVP_DigestVerifyUpdate(ctx, hash, 32);

    FILE *sig_file = fopen(sig_path, "rb");
    // 读取签名数据...
    int ret = EVP_DigestVerifyFinal(ctx, sig_buf, sig_len);

    return (ret == 1) ? SUCCESS : FAIL;
}
警告: 公钥一定要烧死在设备的 OTP 或安全存储区,不能放在可读写的文件系统里。否则攻击者替换公钥,你的签名校验就形同虚设。

4.3 升级流程设计

流程设计是整章的核心。一个好的升级流程,要保证“刷到一半断电也不变砖”。我见过太多设备因为升级流程没考虑异常恢复,最后只能返厂。

4.3.1 双分区方案(A/B 系统)

最稳妥的做法,就是双分区。系统有两个 rootfs 分区,一个 active,一个 standby。升级时刷 standby,刷完重启切过去。如果新系统起不来,自动回滚到 active。

全量升级双分区流程 分区 A(active)v1.0 分区 B(standby)v1.0 下载升级包 升级包 ① 刷写 standby 分区 分区 B(standby)v2.0 ② 校验签名 + 哈希 校验通过 ③ 切换 active 标记 分区 A(standby)v1.0 分区 B(active)v2.0 ④ 重启 启动成功 ⑤ 启动失败 → 回滚到分区 A

4.3.2 升级步骤详解

  1. 下载与校验:设备从服务器拉取升级包,先验签,再比对哈希。不通过就直接丢弃,不写 flash。
  2. 刷写 standby 分区:把解压后的 rootfs 写入非活跃分区。这里我建议用 dd 或 ubiupdatevol,逐块写入并校验。
  3. 标记切换:写入完成后,更新 bootloader 的启动标记。比如在 uboot 的 env 里把 boot_part 改成 B。
  4. 重启与回滚:设备重启,从新分区启动。如果内核 panic 或 init 进程起不来,看门狗复位后 bootloader 检测到启动失败,自动切回旧分区。
关键点: 回滚机制不是靠软件检测,而是靠 bootloader 的“启动计数”。每次启动时计数加 1,如果用户空间确认系统正常,就清零计数。计数超过阈值(比如 3 次),bootloader 就认为新系统有问题,自动回滚。

4.3.3 单分区升级(兜底方案)

有些低端设备 flash 太小,做不了双分区。这时候只能用单分区升级,但风险更高。我的做法是:

  • 先把新 rootfs 写入临时区域(比如剩余的空闲块)。
  • 写入完成后,用 memcmp 逐块比对,确保数据完整。
  • 比对通过后,再覆盖旧 rootfs。
  • 如果覆盖过程中断电,设备只能进 recovery 模式,从网络或 SD 卡重新刷写。
注意: 单分区升级没有原子性。如果你必须用单分区,一定要在 bootloader 里保留一个 mini recovery 系统,哪怕只有 2MB,也能救命。

4.4 避坑指南

最后,分享几个我踩过的坑:

  • 签名算法别用 MD5:我曾经在一个项目里看到有人用 MD5 签名,理由是“设备性能太差”。结果呢?被爆出签名碰撞,全线设备需要召回。用 SHA256 或更高,性能不够就硬件加速。
  • 升级包版本号要严格递增:设备端要检查新版本号必须大于当前版本。否则运维误操作,把旧包推上去,系统就“降级”了,可能引发兼容性问题。
  • 升级过程中保留日志:把升级的每一步都写到 /var/log/update.log。如果升级失败,这些日志是排查问题的唯一线索。我见过太多设备变砖后,连日志都没有,只能盲猜。