Qualcomm Linux · OTA
有一件事曾经让我害怕:向放在某个角落架子上的设备推送软件更新,它没有键盘、没有显示器,如果出了问题也没人能插上 USB 线。一次糟糕的更新,那台设备就成了砖头。乘以整个设备群,您就能明白为什么人们会为此失眠。 好消息是,Qualcomm Linux 2.0 已经内置了一个非常可靠的答案,它构建在 OSTree 之上。当我真正坐下来在 IQ-8275 EVK 上使用它时,最令我惊讶的是我自己需要构建的东西是如此之少。大部分困难的部分已经完成了。 这是一篇动手教程。命令就在正文里,您可以直接通读,也可以在真实硬件上跟着做,最终得到一台能通过网络自我更新的设备。所展示的一切都实际运行过,我引用的数字都来自真实的开发板。 先说清楚一件事,以免有人不小心把它交付给客户:这是一个实验室概念验证。它使用明文 HTTP 并关闭了签名检查,以便您能真正看到齿轮的转动。这对学习来说很棒,对生产来说很糟糕。文末我会谈到成熟的生产版本。
我们实际要做的事情
计划很小,但回报很有趣。我们构建同一镜像的两个版本,把第一个刷到开发板上,然后不进行第二次刷写,通过空中下载把运行中的设备迁移到第二个版本。
然后我们:
- 用 v1 刷写设备一次,因为在第一个 SOTA 镜像之前没有 OTA。
- 搭建一个极其简单的更新服务器。
- 构建 v2,并通过设备自己的小网页(就像真实用户那样)将其推送到运行中的设备上,无需重新刷写。
- 全程观察旧版本作为回滚保留下来。
- 测量实际变更有多小。
Qualcomm 完成了繁重工作的部分
坦白说,因为我在这里工作:这件事之所以容易,是因为工作已经在层里完成了。发行版由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 的普通镜像。同样的机器、同样的 recipe,只差一个片段。我非常喜欢 OTA 是一个构建时开关,而不是一个需要额外附加的巨大独立项目。(稍后构建 v1 和 v2 时,您会在那行命令上看到多一个片段 ota-demo.yml。那不是原厂 Qualcomm 片段,而是我们自己添加的小演示层,一会儿我们就创建它。)
有一件事值得提前了解:OTA 只有在设备上已经有 OSTree 技术栈时才有效,而普通的 qcom-distro 镜像没有。所以您需要通过 USB 刷写一次 SOTA 镜像,从那以后就可以通过空中下载更新了。
两分钟看懂 OSTree
人们称 OSTree 为”根文件系统的 git”,老实说,这个心智模型作为入门足够好了。它将文件系统的完整快照作为提交存储在仓库中。每个提交都是内容寻址的,因此相同的文件只存储一次并被共享。设备拉取一个新提交,从中构建一个新部署,并在下次重启时引导进入该部署。旧的部署保持原样。 最后那句话就是整个安全故事。您正在运行的系统永远不会被就地编辑。新版本完全在一旁组装,切换只在干净的重启时发生。如果新部署没能启动,您仍然停留在旧版本上,完好无损。 以下是更新落地后设备上的样子。星号是您引导的部署,第二行是您的安全网:/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 — 然后再回到这里。
在 IQ-8275 EVK 上使用 Qualcomm Linux 2.0 测试,
meta-qcom / meta-qcom-distro 位于 qli-2.0 发布标签。本教程对层版本很敏感 — 如果路径或软件包名称在较新版本中发生了变动,请固定到此标签以便完全按照本文操作。sstate-cache 和 downloads。不要清除它们。有了热缓存,这里的每次重新构建大约需要八分钟,而不是数小时的冷构建。这就是快速迭代与出门喝咖啡的区别。
把一切串在一起的小 Yocto 层
我把所有自定义内容都放进一个小层meta-ota-demo 中。它有三项工作:安装更新 agent、向 /etc/ota-demo-version 写入可见的版本标记,以及覆盖桌面壁纸,使更新在屏幕上一目了然。其中没有任何魔法,只是几个小文件。先在构建主机上创建层的目录布局:
layer.conf 开始,保存到 meta-ota-demo/conf/layer.conf。它将 meta-ota-demo 注册到构建中。
接下来是版本标记 recipe 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 的 recipe 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 安装任何东西,在 8088 端口提供一个网页。该页面显示正在运行什么,向服务器查询有什么可用,如果有新版本就显示一个按钮。按钮的底层恰好运行这些:ota-agent.py — 从那里复制,而不是重新输入约 320 行,并将其保存到 recipe 期望的位置:meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.py。
如果您想不离开本页快速浏览,它的核心很小:一个 do_update(),它注册远端,运行 ostree pull 然后 ostree admin deploy 并重启,外面包着一个渲染状态卡片和按钮的单页 HTTP 处理程序。其余部分都是解析 ostree admin status 和格式化页面。
那个 recipe 里藏着一个值得指出的 Yocto 坑,因为它花了我一个小时。在最小镜像上,Python 标准库被拆分成一堆单独的软件包。所以即使 agent 只导入”内置”模块,镜像里实际上也没有它们,服务在首次启动时循环崩溃,报 ModuleNotFoundError: No module named 'html'——当您确信自己没有导入任何花哨的东西时,读到这个信息会非常困惑。解决办法是在 recipe 中准确声明需要哪些包:
两张壁纸
我们希望变化是可见的,所以每个版本使用不同的壁纸。两张 PNG 如下所示 — 或者用您自己的任意两张图片。
wallpapers-v1.png

wallpapers-v2.png
把两张 PNG 放进去:
构建 v1:标记 1、壁纸 A、无 Vim
三个旋钮,然后构建:搭建更新服务器
这部分简单得几乎让人不好意思。服务器是一个指向 Web 根目录的静态 HTTP 服务器,该目录包含两样东西:构建产生的ostree_repo 的副本(我们发布 v2 时会同步过来),以及一个小小的 version.json,内容是”这是最新版本及其提交”。创建 Web 根目录并开始提供服务:
version.json。
以后想让更新来自互联网而不是您的笔记本?将 agent 指向一个公网 URL,或把仓库放在 CDN 后面。设备端不需要任何改动,因为对 OSTree 来说远端就是一个 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 的增量,您需要在设备更新之前把静态增量生成到被服务的仓库中 — 这正是生产环境的做法。
现在想象一下,在按流量计费的蜂窝网络或不稳定的乡村链路上跨整个设备群。每台设备拉取个位数兆字节与几 GB 之间的差异,就是”周二随手推送一次更新”与”像军事行动一样安排更新”之间的差异。对于生产环境,您会在服务器上预生成这些静态增量,让每台设备得到一个整洁的文件。同样的思路,在线上更加紧凑。
验证持久性声明
我说过/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 recipe 以可复制粘贴的 heredoc 形式内联在上文中。按所示创建它们,把三个 IP 地址指向您自己的设备,然后去给某个东西推送一次更新吧。
