LV028-Linux平台OTA方案
一、 Linux 平台与 MCU 的本质差异
1. 多层启动栈
MCU 的 OTA 对象只有一个 App 镜像;Linux 设备上,"系统"是由一串逐级加载的部分拼成的:
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 内部 Flash | 8 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 切 VTOR | altbootcmd 脚本切槽 |
| 确认转正 | App 写参数区 | 用户空间 fw_setenv |
| 双副本防掉电 | 参数区双页副本 | U-Boot redundant environment |
可以看到 LV016-选槽与回滚机制 里手写的每个字段,在 Linux 上都有现成对应物,只是换了个名字。
二、 启动链与槽位机制
1. 从 BootROM 到 rootfs
链路的关键点在 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 脚本:
# 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 服务实现最稳妥:
# /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#!/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 为例)
┌────────────┬──────────────┬──────────────┬───────────┬───────────┬───────────┐
│ 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 位数),配错会导致数据随机出错
# 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; fi3. 升级包与数据迁移
一个重要但常被忽略的问题:新版本需要的配置项、数据库结构、文件路径变了怎么办?
推荐做法:
(1)应用启动时读取"已迁移版本号"(存在 userdata)
(2)若低于当前固件要求的版本,执行迁移脚本(幂等、可重入)
(3)迁移成功后写入新的迁移版本号
(4)回滚时:若新固件已迁移数据而随后回滚,旧固件可能读不懂新格式——因此迁移脚本必须向前兼容(新格式仍能被旧版本解析),或者在回滚路径上也提供反向迁移
四、 开源框架对比
自研一套完整的 Linux OTA 工作量很大,成熟框架能覆盖 90% 的通用需求。
1. SWUpdate
- 定位:嵌入式 Linux 的通用更新代理,支持单拷贝(Recovery)与双拷贝(A/B)两种模式
- 描述文件
sw-description定义镜像、目标设备、校验方式 - 服务端对接 hawkBit,可做设备分组与灰度
- 适合:需要与服务端配合、升级对象较多的项目
/* 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 场景、希望方案轻量可控的项目
# /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=B3. Mender
- 定位:端到端方案(客户端 + 服务端),自带设备管理后台
- 内置 A/B 回滚、增量更新、设备分组与灰度
- 适合:不想自建服务端、需要可视化运维界面的项目
4. OSTree / libostree
- 定位:文件系统级版本管理,类似 Git 管理 rootfs
- 天然支持多版本共存、增量下载、原子切换与回滚
- 常与
rpm-ostree、Flatpak 生态一起使用 - 适合:追求"操作系统像 Git 一样管理"的架构,学习成本较高
5. 选型对照
| 框架 | 模式 | 服务端 | 签名 | 学习成本 | 适用 |
|---|---|---|---|---|---|
| SWUpdate | 单/双拷贝 | 需 hawkBit(可选) | 支持 | 中 | 通用嵌入式 |
| RAUC | A/B | 无(自带状态查看) | 支持 | 低 | 轻量 A/B |
| Mender | A/B | 自带 | 支持 | 低 | 需要运维后台 |
| OSTree | 文件系统级 | 需自建 | 支持 | 高 | 大规模版本管理 |
6. Yocto 集成
用 Yocto 构建量产镜像时,上述框架都有现成的 layer,不建议自己从头集成:
| 框架 | Yocto Layer | 说明 |
|---|---|---|
| SWUpdate | meta-swupdate | 官方 layer,含 sw-description 打包与 hawkBit 客户端 |
| RAUC | meta-rauc | 官方 layer,自动生成 bundle、配置槽位 |
| Mender | meta-mender | 官方 layer,提供参考板级支持 |
| U-Boot A/B | meta-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_controlHAL 负责切换槽位- 用
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 错误:
# 生成 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 与签名节点打包在一起:
# 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";
};
};
};
};构建与使用:
# 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 技能辅助生成