Skip to content

LV028-Linux平台OTA方案 ​

一、 Linux 平台与 MCU 的本质差异 ​

1. 多层启动栈 ​

MCU 的 OTA 对象只有一个 App 镜像;Linux 设备上,"系统"是由一串逐级加载的部分拼成的:

text
BootROM(芯片固化)→ SPL → U-Boot → Kernel + DTB → rootfs → 应用程序

每一层都可以独立升级,但风险差异极大:

层升级频率风险建议
BootROM不可升级—掩膜固化,只能靠 SoC 自身机制补救
SPL / U-Boot极低(几乎不升)最高,失败即变砖只在严重缺陷时升级,必须有双份 Bootloader 备份
Kernel + DTB中中与 rootfs 一起走 A/B
rootfs高低OTA 的主力对象
应用程序 / 容器最高最低可用容器或包管理单独更新

工程上常见的做法是:把 kernel 与 rootfs 打包成一个"槽镜像"整体升级,Bootloader 永不参与 OTA。

2. 大容量块设备 ​

维度MCU(F103)Linux(RK3568 / i.MX6ULL)
存储512 KB 内部 Flash8 GB eMMC / 256 MB~1 GB NAND
A/B 空间捉襟见肘,需要精算直接翻倍分区即可
文件系统无ext4 / UBIFS / squashfs
镜像与地址与链接地址绑定与物理分区无关,一份镜像放哪个槽都能跑
差分升级需自研或用嵌入式差分库bsdiff / casync / OSTree 成熟

最本质的差异是第二行与第四行:Linux 上 A/B 是"便宜"的,不需要为空间或地址绑定做任何妥协,因此真 A/B 是默认选择。

3. 与 MCU 方案的对照 ​

机制MCU 方案Linux 方案
选槽依据自研参数区(boot_ctrl_t)U-Boot 环境变量
试用计数boot_count 字段bootcount / bootlimit
回滚动作Bootloader 切 VTORaltbootcmd 脚本切槽
确认转正App 写参数区用户空间 fw_setenv
双副本防掉电参数区双页副本U-Boot redundant environment

可以看到 LV016-选槽与回滚机制 里手写的每个字段,在 Linux 上都有现成对应物,只是换了个名字。

二、 启动链与槽位机制 ​

1. 从 BootROM 到 rootfs ​

Linux 启动链与 A/B 回滚联动

链路的关键点在 U-Boot:它是唯一同时了解"槽位状态"与"启动动作"的角色,因此选槽逻辑全部落在这里。rootfs 启动后,用户空间再通过 fw_setenv 反向更新状态,形成闭环。

2. bootcount / bootlimit 机制 ​

U-Boot 环境变量中与 OTA 相关的几个:

变量含义
boot_slot当前应引导的槽(a / b)
upgrade_available是否有待确认的新固件(1 表示试用中)
bootcount已尝试启动次数,U-Boot 每次启动自增
bootlimit允许的最大尝试次数(通常 3)
altbootcmd超过 bootlimit 时执行的备用命令

典型的 U-Boot 脚本:

text
# U-Boot 环境变量片段
bootlimit=3
boot_slot=a
upgrade_available=0

# 正常引导:先判断是否需要走备用命令
bootcmd=if test ${upgrade_available} = 1; then run boot_ab; else run boot_default; fi

boot_default=run set_bootargs; load ${devtype} ${devnum}:${bootpart_a} ${kernel_addr} Image; booti ${kernel_addr} - ${fdt_addr}

boot_ab=if test ${boot_slot} = a; then setenv bootpart ${bootpart_a}; else setenv bootpart ${bootpart_b}; fi; run boot_default

# 试用超限时执行:切回另一个槽,并清空试用标记
altbootcmd=if test ${boot_slot} = a; then setenv boot_slot b; else setenv boot_slot a; fi; setenv upgrade_available 0; setenv bootcount 0; saveenv; run bootcmd

配合 U-Boot 的 CONFIG_BOOTCOUNT_LIMIT 配置,bootcount 会在每次启动时自动加一,达到 bootlimit 后 U-Boot 不再执行 bootcmd,转而执行 altbootcmd——这就是 Linux 上"试用超限自动回滚"的全部实现。

3. 用户空间的确认(mark good) ​

新系统启动成功后,用户空间需要清掉升级标志。用 systemd 服务实现最稳妥:

ini
# /etc/systemd/system/ota-confirm.service
[Unit]
Description=Confirm OTA upgrade after self check
After=network-online.target myapp.service
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
# 关键:等待业务自检通过再确认,避免"带病转正"
ExecStart=/usr/bin/ota-selfcheck.sh
ExecStartPost=/usr/bin/fw_setenv upgrade_available 0
ExecStartPost=/usr/bin/fw_setenv bootcount 0

[Install]
WantedBy=multi-user.target
bash
#!/bin/sh
# /usr/bin/ota-selfcheck.sh —— 业务自检:全部通过才返回 0
set -e

# 1. 网络可用
ping -c 3 -W 2 8.8.8.8 > /dev/null 2>&1

# 2. 关键外设存在
[ -c /dev/ttyS3 ] || exit 1

# 3. 业务进程健康
systemctl is-active --quiet myapp.service

# 4. 启动耗时未超限(防止"能起但极慢"的隐性故障)
uptime_sec=$(cut -d. -f1 /proc/uptime)
[ "$uptime_sec" -lt 120 ] || exit 1

exit 0

【注意】

自检不能只判"进程起来了",而要覆盖业务真正的可用性。若自检脚本本身有 Bug 导致永远失败,设备会反复回滚——上线前必须验证"正常固件一定能通过自检"。

三、 分区设计 ​

1. eMMC 平台(以 RK3568 为例) ​

text
┌────────────┬──────────────┬──────────────┬───────────┬───────────┬───────────┐
│ bootloader │   boot_a     │   boot_b     │ rootfs_a  │ rootfs_b  │ userdata  │
│ SPL+U-Boot │ kernel+dtb_a │ kernel+dtb_b │  2 GB     │  2 GB     │  剩余空间  │
│   16 MB    │   64 MB      │   64 MB      │ (只读)     │ (只读)     │ (可写)     │
└────────────┴──────────────┴──────────────┴───────────┴───────────┴───────────┘
       ↑ 双份备份(eMMC boot 分区 A/B)              ↑ 由 U-Boot 按 boot_slot 选择

要点:

(1)bootloader 双份:eMMC 自带两个 boot 分区,把 U-Boot 分别写入,配合 SoC 的启动失败自动切换,避免刷坏 Bootloader 变砖

(2)rootfs 只读:A/B 两个 rootfs 分区挂载为只读,配合 dm-verity 校验完整性;所有可变数据放 userdata

(3)userdata 不参与 A/B:它是设备数据,升级时不能动。新版本若需要数据格式变更,必须在应用程序里做数据迁移(migration),并在自检中验证迁移结果

2. NAND 平台(以 i.MX6ULL 为例) ​

NAND 与 eMMC 的差异带来额外的工程约束:

  • 坏块:NAND 有出厂坏块与使用中新增坏块,必须用 UBI/UBIFS 等带坏块管理的层,不能用裸分区 + ext4
  • A/B 形态:体现为两组 UBI 卷(rootfs_a / rootfs_b),U-Boot 通过 ubi volume 挂载指定卷
  • 擦写寿命:需要磨损均衡,UBIFS 已内置;频繁写日志的话要评估寿命
  • ECC:SoC 的 NAND 控制器负责 ECC,需与 NAND 型号匹配(BCH 位数),配错会导致数据随机出错
text
# i.MX6ULL NAND 上的 U-Boot 引导片段(按卷引导)
ubi part rootfs
if test ${boot_slot} = a; then ubi read ${loadaddr} rootfs_a; else ubi read ${loadaddr} rootfs_b; fi

3. 升级包与数据迁移 ​

一个重要但常被忽略的问题:新版本需要的配置项、数据库结构、文件路径变了怎么办?

推荐做法:

(1)应用启动时读取"已迁移版本号"(存在 userdata)

(2)若低于当前固件要求的版本,执行迁移脚本(幂等、可重入)

(3)迁移成功后写入新的迁移版本号

(4)回滚时:若新固件已迁移数据而随后回滚,旧固件可能读不懂新格式——因此迁移脚本必须向前兼容(新格式仍能被旧版本解析),或者在回滚路径上也提供反向迁移

四、 开源框架对比 ​

自研一套完整的 Linux OTA 工作量很大,成熟框架能覆盖 90% 的通用需求。

1. SWUpdate ​

  • 定位:嵌入式 Linux 的通用更新代理,支持单拷贝(Recovery)与双拷贝(A/B)两种模式
  • 描述文件 sw-description 定义镜像、目标设备、校验方式
  • 服务端对接 hawkBit,可做设备分组与灰度
  • 适合:需要与服务端配合、升级对象较多的项目
text
/* sw-description 片段 */
software =
{
    version = "2.1.0";
    my-board = {
        hardware-compatibility = ["1.0", "1.1"];
        rootfs_a = {
            images: ({
                filename = "rootfs.ext4.gz";
                device   = "/dev/mmcblk0p4";
                sha256   = "e3b0c442...";
                compressed = "zlib";
            });
        };
    };
}

2. RAUC ​

  • 定位:轻量的 A/B 更新框架,与 U-Boot / barebox / GRUB 集成良好
  • 通过 system.conf 声明槽位与 Bootloader 后端,内置 rauc status 查看槽位状态
  • 支持 bundle 签名(X.509),与 systemd 服务集成简洁
  • 适合:纯 A/B 场景、希望方案轻量可控的项目
ini
# /etc/rauc/system.conf 片段
[system]
compatible=my-board-1.0
bootloader=uboot

[keyring]
path=/etc/rauc/ca.cert.pem

[slot.rootfs.0]
device=/dev/mmcblk0p4
type=ext4
bootname=A

[slot.rootfs.1]
device=/dev/mmcblk0p5
type=ext4
bootname=B

3. Mender ​

  • 定位:端到端方案(客户端 + 服务端),自带设备管理后台
  • 内置 A/B 回滚、增量更新、设备分组与灰度
  • 适合:不想自建服务端、需要可视化运维界面的项目

4. OSTree / libostree ​

  • 定位:文件系统级版本管理,类似 Git 管理 rootfs
  • 天然支持多版本共存、增量下载、原子切换与回滚
  • 常与 rpm-ostree、Flatpak 生态一起使用
  • 适合:追求"操作系统像 Git 一样管理"的架构,学习成本较高

5. 选型对照 ​

框架模式服务端签名学习成本适用
SWUpdate单/双拷贝需 hawkBit(可选)支持中通用嵌入式
RAUCA/B无(自带状态查看)支持低轻量 A/B
MenderA/B自带支持低需要运维后台
OSTree文件系统级需自建支持高大规模版本管理

6. Yocto 集成 ​

用 Yocto 构建量产镜像时,上述框架都有现成的 layer,不建议自己从头集成:

框架Yocto Layer说明
SWUpdatemeta-swupdate官方 layer,含 sw-description 打包与 hawkBit 客户端
RAUCmeta-rauc官方 layer,自动生成 bundle、配置槽位
Mendermeta-mender官方 layer,提供参考板级支持
U-Boot A/Bmeta-rauc / meta-swupdate 附带提供 bootcount 脚本模板

集成的关键步骤:

(1)在 local.conf 中加入 layer 并声明机型(MACHINE)支持的升级框架

(2)配置分区布局(WKS 文件)预留 A/B 槽位与 userdata

(3)让构建产物直接输出 .swu / .raucb 升级包,避免手工打包出错

(4)用 meta-secure-core 等 layer 统一处理签名与密钥

Yocto 路线的价值在于把"升级包"作为构建的标准产物,从源头上避免"手工拼包"带来的版本错乱。

五、 Android 与容器化方案 ​

1. Android 的 A/B seamless update ​

Android 7.0 起引入 A/B 无缝更新(Seamless Updates):

  • 系统在后台把更新写入非活动槽,用户继续使用设备
  • 重启时直接从新槽启动,只需一次常规开机时间
  • update_engine 负责差分下载与应用;boot_control HAL 负责切换槽位
  • 用 bootloader 的 slot_suffix 与 markBootSuccessful 完成"试用—确认"流程

这套机制与本专题描述的 A/B 完全同构,可以视为工业级参考实现。

2. 容器与应用级更新 ​

如果只是应用程序迭代,未必要动系统:

  • 容器化(Docker / containerd):新版本镜像推送后重启容器即可,回滚只需切回旧 tag
  • 应用级差分包:只更新应用目录,配合启动脚本做版本软切换(current 软链接指向 v1.2.3)
  • 包管理(opkg / apt):仅建议开发阶段使用。它不具备原子性,一次升级中途掉电可能留下半更新的依赖树,量产设备不推荐

选择原则:系统级变更走 A/B,业务级变更走容器或应用目录,两者分层避免互相拖累。

六、 安全与完整性 ​

1. 安全启动 ​

信任链的原理与 MCU 上的折中做法见 LV019-升级安全机制 第三节,本节只讲 Linux 侧的具体落地。第一级由 SoC 厂商的硬件机制负责:

  • i.MX6ULL:HAB(High Assurance Boot),BootROM 用 OTP 中的 SRK 哈希验证 U-Boot 签名
  • RK3568:Secure Boot,烧写 eFuse 根密钥后 BootROM 逐级验签 SPL/U-Boot

开启安全启动后,Bootloader 与内核的签名校验由硬件完成,OTA 框架只需负责 rootfs 与应用的签名。

2. dm-verity 与只读根文件系统 ​

dm-verity 为只读分区建立哈希树,运行时按块校验,任何篡改都会在读取时触发 I/O 错误:

text
# 生成 verity 元数据(构建阶段)
veritysetup format rootfs.img rootfs.verity --hash=sha256

# 挂载(U-Boot 传参或 fstab 配置)
veritysetup open /dev/mmcblk0p4 rootfs /dev/mmcblk0p6 rootfs.verity \
    --hash=sha256 --root-hash=<root_hash>
mount -o ro /dev/mapper/rootfs /

【注意】

dm-verity 与差分升级天然冲突:镜像做了 verity 后,差分包必须在重建完整镜像后重新计算哈希树,因此差分包里通常要一并携带新的 verity 元数据。SWUpdate 与 Mender 都处理了这个细节。

3. 升级事务性 ​

Linux 上的事务性依赖两件事:

(1)写入非活动槽 + 原子切换标志(与 MCU 相同的思路)

(2)同步落盘:写入完成后执行 sync 并确认块设备 flush,再置标志。文件系统缓存未落盘就重启,是新固件"校验失败"的常见原因

4. fitImage 与内核签名 ​

本章第 1 小节讲的是 SoC 级安全启动(BootROM 验签 Bootloader),内核层的完整性还要单独做:U-Boot 在加载内核时,也要验证内核镜像确实来自厂商。

FIT(Flattened Image Tree)是 U-Boot 的标准镜像容器,可以把 kernel、dtb、ramdisk 与签名节点打包在一起:

text
# kernel.its —— FIT 镜像描述文件(含签名节点)
/dts-v1/;

/ {
    description = "kernel image for my-board";
    #address-cells = <1>;

    images {
        kernel-1 {
            description = "Linux kernel";
            data = /incbin/("arch/arm64/boot/Image");
            type = "kernel";
            arch = "arm64";
            os = "linux";
            compression = "none";
            load = <0x00200000>;
            entry = <0x00200000>;
            hash-1 { algo = "sha256"; };
        };
        fdt-1 {
            description = "Device tree";
            data = /incbin/("arch/arm64/boot/dts/my-board.dtb");
            type = "flat_dt";
            arch = "arm64";
            compression = "none";
            hash-1 { algo = "sha256"; };
        };
    };

    configurations {
        default = "conf-1";
        conf-1 {
            description = "my-board";
            kernel = "kernel-1";
            fdt = "fdt-1";
            signature-1 {
                algo = "sha256,rsa2048";
                key-name-hint = "dev-key";
                sign-images = "kernel", "fdt";
            };
        };
    };
};

构建与使用:

bash
# 1. 生成密钥对(私钥离线保存)
openssl genrsa -out dev.key 2048
openssl rsa -in dev.key -pubout -out dev.crt

# 2. 打包并签名
mkimage -f kernel.its -k keys/ kernel.itb

# 3. U-Boot 侧:公钥编译进 U-Boot 的 control DTB
#    CONFIG_OF_CONTROL + 将 dev.crt 放入 u-boot.dtb 的 /signature/key-dev 节点
bootm ${kernel_addr}   # 签名不符时 U-Boot 直接拒绝启动

配合 U-Boot 的 CONFIG_FIT_SIGNATURE,内核验签失败时 bootm 会拒绝引导,从而把信任链从 Bootloader 延伸到内核与设备树。

【注意】

FIT 签名要解决"公钥怎么进 U-Boot"的问题:u-boot.dtb 在设备上通常是可写的,因此公钥必须随 U-Boot 一起被安全启动验证,否则攻击者换掉公钥即可绕过内核验签。

七、 落地检查清单 ​

在量产前逐项确认:

(1)分区表是否留足 A/B 空间?userdata 是否独立且不参与升级?

(2)Bootloader 是否双份备份?刷坏时能否由 SoC 自动切换?

(3)bootcount / bootlimit / altbootcmd 是否配置并实测?

(4)自检脚本是否覆盖业务可用性?正常固件是否 100% 通过?

(5)数据迁移是否幂等、可回滚、向前兼容?

(6)rootfs 是否只读 + dm-verity?差分包是否携带新的 verity 元数据?

(7)内核是否启用 FIT 签名?公钥是否随 U-Boot 一起被安全启动验签?

(8)升级包是否由构建系统自动产出(Yocto layer),而非手工打包?

(9)升级后是否 sync 再置标志?

(10)是否在断电、断网、坏包三种异常下做过实测?


本文档由 markdowncli 技能辅助生成