Qualcomm Linux · OTA
有一件事曾让我心里发怵:向放在某个货架上的设备推送软件更新——没有键盘、没有显示器,一旦出问题也没人能在旁边插上 USB 线。一次糟糕的更新就能让设备变砖。再乘以整个设备群的规模,你就明白为什么有人会为此失眠。 好消息是,Qualcomm Linux 2.0 已经内置了一个非常可靠的解决方案,它基于 OSTree 构建。当我真正坐下来在 IQ-8275 EVK 上实际使用它时,最让我惊讶的是我自己需要构建的部分竟然那么少。大部分困难的工作都已经完成了。 这是一篇实操教程。命令就在正文里,你可以从头读到尾,也可以在真实硬件上跟着操作,最终得到一台能够通过网络自我更新的设备。文中展示的所有内容都实际运行过,凡是引用的数字都来自真实开发板。 先说清一件事,免得有人不小心把它交付给客户:这是一个实验室概念验证(POC)。它使用明文 HTTP 并关闭了签名校验,好让你能看清整个机制的运转。这对学习来说很棒,但对生产环境来说很糟糕。我会在文末谈谈”成熟版”该怎么做。
我们究竟要做什么
计划很小,但回报很有趣。我们构建同一镜像的两个版本,把第一个刷写到开发板上,然后通过空中下载把运行中的设备迁移到第二个版本,无需二次刷写。
然后我们:
- 用 v1 刷写设备一次,因为在第一个 SOTA 镜像之前是没有 OTA 可用的。
- 搭建一个极其简单的更新服务器。
- 构建 v2,并通过设备自身的小网页(就像真实用户那样)把它推送到运行中的设备上,无需重新刷写。
- 全程观察旧版本作为回滚保留在设备上。
- 测量实际变更究竟有多小。
Qualcomm 已经完成了重活的部分
先坦白,毕竟我在这里工作:这一切之所以简单,是因为工作早已内置在各层(layer)中。发行版由meta-qcom-distro 提供(meta-qcom 本身只是 BSP),它有两种口味。默认的 qcom-distro 是普通的包管理根文件系统。有意思的是 qcom-distro-sota。SOTA 意为软件空中下载(software over the air),选择这个发行版就会为你打开整套更新栈。
“整套更新栈”是什么意思?用 qcom-distro-sota 构建时,你会得到内置 OSTree 的镜像、一个懂得如何引导 OSTree 部署的 initramfs,并且构建会在你的常规可刷写镜像旁生成一个 ostree_repo。设备就是从这个仓库拉取更新的。这些你一行都没写,只是选了一个不同的发行版。从这里开始,我会把构建结果称为 SOTA 镜像:一个内置了 OTA 栈的普通可刷写镜像。
构建命令说实话几乎平淡无奇,而对构建命令来说这是我能给出的最高赞誉:
qcom-distro-sota.yml。换成 qcom-distro.yml 就得到一个没有 OTA 的普通镜像。同一台机器、同样的配方,只差一个片段。我真心喜欢 OTA 能力只是一个构建时开关,而不是一个需要额外拼装的庞大独立项目。(稍后构建 v1 和 v2 时,你会在这一行上看到多出一个片段 ota-demo.yml。那不是 Qualcomm 官方片段,而是我们自己添加的小演示层,马上就会创建它。)
有一点值得提前知晓:OTA 只有在设备上已经存在 OSTree 栈时才能工作,而普通的 qcom-distro 镜像没有。所以你需要通过 USB 刷写一次 SOTA 镜像,从那以后就都通过空中下载来更新了。
两分钟看懂 OSTree
人们称 OSTree 为”根文件系统的 git”,说实话,这个心智模型作为入门足够好了。它把文件系统的完整快照作为提交(commit)存储在仓库中。每个提交按内容寻址,因此相同的文件只存一份并被共享。设备拉取新提交,从中构建一个新部署(deployment),并在下次重启时引导进入该部署。旧的那个原封不动地留在原地。 最后那句话就是全部的安全保障。你正在运行的系统永远不会被就地修改。新版本完全在一旁组装完成,切换只在一次干净的重启时发生。如果新部署始终没有出现,你仍然运行在旧版本上,毫发无损。 以下是更新落地后设备上的样子。星号是你当前引导的版本,第二行是你的安全网:/usr——真正的操作系统内容——是以只读方式挂载的,因此没有任何东西能悄悄篡改它:
/etc、/var 和 /home;这些运行时区域的设计目的就是跨更新保留,而不是被清除。这是一条设计规则,而不是对你在构建时随手丢进去的任何内容都生效的魔法,我们会在文末用一个小文件回到这个区别上。
OSTree 还是 A/B,以及我为什么选择了 OSTree
这是所有人都会问的问题,所以让我认真回答一下。 A/B 更新,也叫双分区(dual bank),是把存储划分为系统的两份完整副本——A 槽和 B 槽。你从 A 运行,把整个新镜像写入 B,翻转一个标志位,下次就引导 B。它的逻辑简单明了、坚如磐石,这也是手机一直采用它的原因。 但 A/B 有两个容易被忽略的代价。第一是空间。你要永久性地让出足够容纳两份完整操作系统的闪存,且始终如此,尽管你同一时刻只运行其中一份。在成本敏感的边缘设备上,第二个槽位就是实打实的物料成本。第二是带宽。在最简单的 A/B 方案中,一次更新意味着传输一个全新的完整系统镜像,因为你要写满整个槽位。 OSTree 从另一个方向切入。它没有两个固定槽位,而是有一个存储部署的仓库,各部署共享所有相同的文件。更新时,设备只拉取真正发生变化的对象。有趣的部分就在这里,稍后我会给你一个至今仍觉得有点离谱的真实数字。 所以权衡大致如此:A/B 给你极其简单的心智模型和一个有保证的已知良好槽位,代价是双倍闪存和笨重的更新包。OSTree 给你微小、带宽高效的更新和带回滚保障的原子化部署,无需预留第二个完整槽位。它要求你付出两样东西:一个稍微不那么直观的心智模型,以及只读操作系统的纪律——这意味着你必须预先决定可写数据放在哪里(/etc、/var、/home),而不是散落在一个随处可改的文件系统上。对于一台需要定期发布功能的边缘 Linux 设备来说,OSTree 这一侧的权衡确实非常有吸引力,而且再次强调,Qualcomm 已经把它接好了。
环境准备:三台机器
你需要三个角色。可以是三台机器,或者说实话,一块开发板加一台身兼两职的笔记本电脑。~/yocto-build,最重要的目录是构建输出所在的位置:
meta-qcom 各层和 kas 片段均已就位。它不涉及 repo 初始化或首次冷克隆所有层,因此不是一份从零开始的 SDK 搭建指南。如果你是第一次搭建构建树,请先按照常规的 Qualcomm Linux 入门流程完成那部分——参见使用 Yocto 构建 Qualcomm Linux——然后再回到这里。
测试环境为 Qualcomm Linux 2.0,
meta-qcom / meta-qcom-distro 位于 qli-2.0 发布标签,硬件为 IQ-8275 EVK。本教程对层版本较为敏感——如果新版本中路径或软件包名称发生了变动,请固定到该标签以便完全按文操作。sstate-cache 和 downloads。不要清除它们。有了热缓存,这里每次重新构建只需大约八分钟,而不是数小时的冷构建。这就是快速迭代与去喝咖啡等待之间的差别。
把一切串起来的小 Yocto 层
我把所有自定义内容都放进一个小层meta-ota-demo。它有三项任务:安装更新 agent、向 /etc/ota-demo-version 写入一个可见的版本标记,以及覆盖桌面壁纸使更新在屏幕上一目了然。这里没有任何魔法,只有几个小文件。先在构建主机上创建层的目录结构:
layer.conf 开始,保存到 meta-ota-demo/conf/layer.conf。它把 meta-ota-demo 注册到构建中。
接着是版本标记配方 ota-demo-version.bb,保存到 meta-ota-demo/recipes-ota/ota-demo-version/ota-demo-version.bb。提升 OTA_DEMO_VERSION 就是后面的”旋钮 1”;它只是把该值写入 /etc/ota-demo-version,并作为 conffile 打包,因此能在更新后保留。
然后是壁纸覆盖 weston-init.bbappend,保存到 meta-ota-demo/recipes-graphics/wayland/weston-init.bbappend。它前置我们的文件路径,让我们的 qcom-background.png 覆盖原始版本(“旋钮 2”)。
agent 配方 ota-agent_1.0.bb,保存到 meta-ota-demo/recipes-ota/ota-agent/ota-agent_1.0.bb。唯一不那么直观的部分是 RDEPENDS:agent 只用纯 Python 标准库,但在最小化的 Yocto 镜像上标准库被切分成多个独立软件包,所以必须精确列出 agent 导入的那些包(稍后详述)。
它的 systemd 单元 ota-agent.service,保存到 meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.service。
agent 的配置文件 ota-agent.conf,保存到 meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.conf。
agent 程序本身 ota-agent.py,保存到 meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.py——它是唯一长到值得单独一节讨论的文件,稍后我们会回到它。最后是 kas 片段 ota-demo.yml,保存到 meta-qcom/ci/ota-demo.yml。这个片段把该层引入构建并向镜像添加软件包;注意它与其他 Qualcomm CI 片段放在一起,而不是在层内部。
我们会在每次构建时编辑该片段的 IMAGE_INSTALL:append 行来添加或移除 Vim。这就是我们三个”旋钮”之一。
以下是所有文件的位置。八个文件放在新的 meta-ota-demo 层中;第九个 ota-demo.yml 则位于 meta-qcom 中的 Qualcomm 官方 CI 片段旁边:
weston-init/qcom-background.png 这个文件不需要你手动创建——每次构建会把 wallpapers-v1.png 或 wallpapers-v2.png 复制到该路径上,bbappend 中的 FILESEXTRAPATHS:prepend 正是这样找到它的。这个复制操作会在下面各版本的构建步骤中完成。
趁现在,把 agent 指向你的服务器。agent 在设备上读取 /etc/ota-agent.conf,它来自层中的这个文件:
简单聊聊 agent,因为它几乎没什么东西
OSTree 给了你引擎,但没给你按钮。成熟的生产方案是 aktualizr-lite 对接真正的后端(例如 SOTA 栈的 TUF 路径),而且该客户端已经包含在发行版中。但我想看清齿轮如何转动,所以写了最小可行的东西:一个微型 Python 服务,无框架、无需 pip install,在 8088 端口提供一个网页。页面显示当前运行的版本,向服务器查询可用版本,如果有新版本就显示一个按钮。按钮背后运行的正是这几行:ota-agent.py——从那里复制而不要手打约 320 行代码,并保存到配方期望的位置 meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.py。
如果你想不离开本页就大致浏览一下,它的核心很小:一个 do_update() 函数注册 remote、运行 ostree pull 再 ostree admin deploy、然后重启,外面包了一个单页 HTTP 处理器来渲染状态卡片和按钮。其余部分都是在解析 ostree admin status 和排版页面。
那个配方里藏着一个值得指出的 Yocto 坑,因为它花了我一个小时。在最小化镜像上,Python 标准库被切分成一堆独立软件包。所以尽管 agent 只导入”内置”模块,镜像里实际上并没有它们,服务在首次启动时崩溃循环并报 ModuleNotFoundError: No module named 'html'——当你确信自己没导入任何花哨的东西时,读到这个错误会非常困惑。解决方法是在配方中精确声明你需要哪些软件包:
两张壁纸
我们希望变化清晰可见,所以每个版本使用不同的壁纸。两张 PNG 如下所示——你也可以用自己的任意两张图片。
wallpapers-v1.png

wallpapers-v2.png
把两张 PNG 放进去:
构建 v1:标记 1、壁纸 A、无 Vim
调好三个旋钮,然后构建:搭建更新服务器
这部分简单得几乎让人不好意思。服务器就是一个指向某个 Web 根目录的静态 HTTP 服务器,该目录包含两样东西:构建产物ostree_repo 的副本(发布 v2 时会同步过来),以及一个说明”这是最新版本及其提交”的小 version.json。创建 Web 根目录并开始服务:
version.json 会在发布 v2 时写入。
以后想让更新来自互联网而不是你的笔记本?把 agent 指向一个公开 URL,或把仓库放到 CDN 后面即可。设备端不需要任何改动,因为对 OSTree 来说 remote 只是一个 URL。
把 v1 刷写到设备上
这是我们唯一一次通过 USB 刷写。在第一个 SOTA 镜像之前没有 OTA 可用,所以 v1 必须按常规方式刷写。构建在ostree_repo 旁边生成了一个可刷写镜像:
qdl / 你平时用于这块板子的 flat-build 刷写工具)刷写该镜像。我不打算在这里重新记录 EDL 刷写流程,开发板的入门材料已有说明,而且无论镜像是否为 SOTA,刷写过程完全相同。
启动后,确认 v1 已上线且 agent 已自行启动:
构建 v2,用简单的方式更新
同样三个旋钮,不同的值:标记 2、壁纸 B,这次装入 Vim。ostree_repo 复制到服务器的 Web 根目录,然后写入供设备端 agent 读取的 version.json。如果构建主机和更新服务器是同一台机器,复制就是本地 rsync;如果分属两台机器,脚本会通过 SSH 拉取(脚本里有为此准备的注释行)。这是个小脚本——完整源码见配套文件页面的 publish.sh。
把它放到更新服务器上,赋予可执行权限,并浏览一下脚本开头,确认 DEPLOY_DIR 与构建输出目录一致:
如果更新服务器和构建主机是两台不同的机器,请取消
publish.sh 顶部附近 SSH rsync 行的注释(就在本地那行的上方),并将 DEPLOY_DIR 设置为构建主机上的路径。
vim 出现了,版本文件读作 2。视觉差异一目了然:

Version: 字段,对每次构建它都是发行版版本 2.0,而不是我的演示标记。它仍然是通过实际的提交哈希正确比较的,所以”已是最新”的判断没有问题,但如果我要将其产品化,我会从签名的元数据中显示真实的产品发布版本,而不是依赖那个字段。
你刚刚通过网络把一台运行中的设备迁移到了新构建的操作系统版本,而旧版本就在那里作为回滚保留着。没有插线。放在一年前,光是这一点就足以让我惊叹了。
我答应给你的那个数字
重头戏来了。添加整个文本编辑器、更换壁纸、修改版本文件,实际变更究竟有多大? 首先看在朴素的完整镜像推送中我们需要替换的操作系统内容有多大:static-delta generate 测量的是变更压缩后有多小——约 7.8 MB。本文这个极简 POC 流程并没有把该增量发布到服务器上,所以设备的 ostree pull 实际是以松散对象方式拉取变更的(即上面的 loose=40542896,约 40 MB),而不是一个整洁的 7.8 MB 文件。这相对 2.1 GB 依然微不足道,但要让设备在网络上真正拉取那 7.8 MB 的增量,你需要在设备更新之前将静态增量生成到被服务的仓库中——生产环境正是这么做的。
现在想象一下这一切发生在使用计费蜂窝网络或不稳定农村链路的设备群上。每台设备拉取个位数兆字节还是几个吉字节,就是”周二随手推一次更新”与”像军事行动一样排期更新”之间的差别。生产环境中你会在服务器上预生成这些静态增量,让每台设备得到一个整洁的文件。思路相同,线上传输更精简。
验证持久化的承诺
我说过/etc 能在更新后保留。让我实际演示而不是空口断言。在 v1 到 v2 更新之前,往 /etc 里放一个文件:
/usr 中的操作系统被整体替换,但 /etc 里你的东西一路跟随。有一件事构建过程会警告你,你也应该听:构建时放进 /var/lib 或 /home 之类位置的数据,并不会以你想象的方式被保留。在 OSTree 系统中,初始镜像内容和持久化运行时状态是两回事,决定什么放在哪里是你产品的一项真正的设计工作,而非事后补救。
下一步往哪走
我构建的东西是有意为之的概念验证。它使用明文 HTTP,跳过签名以保持机制的可见性,agent 以 root 运行——因为部署和重启需要该权限。这些都不是你该交付的东西。生产环境中你应该对提交签名、通过 HTTPS 提供服务、给设备真实的身份,并依靠 SOTA 发行版已经打包的 aktualizr-lite 加 TUF 栈,这样设备能精确验证自己安装的内容,你也能获得包含发布策略和更新报告的真正设备群管理能力。你还应为已知的版本路径预生成静态增量。 但支撑这一切的基础与我们刚才使用的完全相同,而且是 Qualcomm Linux 2.0 开箱自带的。原子化部署、保留的回滚、微小的增量、只读操作系统与持久数据之间清晰的界线。那些困难、吓人、会把设备变砖的部分都已被处理好。剩下的是真正有趣的部分——决定你的设备应该如何、何时更新,而这部分你可以完全按产品需求来设计。 对嵌入式客户来说,这里有意思的从来不是那个小 Python 网页。那个页面是用完即弃的。有意思的是:选择qcom-distro-sota 就能把镜像变成由 OSTree 管理的系统,一台真实设备可以在真实的 Yocto 构建的操作系统提交之间通过网络迁移而无需重新刷写,旧版本作为安全网保留,只为变更的部分付出代价。这才是你可以在其上构建产品的基石。
如果你想亲自运行,较长文件的完整源码都在配套文件页面——ota-agent.py Web agent 和 publish.sh 服务器脚本,均带复制按钮——而简短的 meta-ota-demo 配方已在上文以可复制粘贴的 heredoc 形式内联给出。按图创建它们,把三个 IP 地址指向你自己的设备,然后去给某台设备推一次更新吧。
