
我指导两个 AI 做了两套嵌入式 Linux:架构决策实录
我指导两个 AI 做了两套嵌入式 Linux:架构决策实录
一台不能派人到现场的专业音频设备,需要一套不可变、能自愈、跑实时音频、接屏即用的定制 Linux。本文记录我做的每一个架构判断,以及我怎么指导 Claude Code 和 Codex 把它们落地——包括真机两次推翻我的结论,和三次「能跑纯属侥幸」。
本文涉及的产品名、主机名、内网地址、账号口令均已脱敏或替换为代号,系统统称 XOS。文中的架构机制、实测数据、失败场景均为真实记录。
设计的起点:假设没有人能到现场
这台设备装在机柜里,前面板只有一块屏。买它的人不会用 Linux,也不该会用。它一旦升级失败起不来,就是一次上门服务——而”上门”这两个字在设备卖到外地之后,成本会高到让你重新审视整个升级方案。
所以我在动第一行代码之前先定了一条底线:这个系统必须假设没有人能到现场。任何一次升级、任何一次配置变更、任何一次断电,它都得能自己退回到上一个能用的状态。
这条底线后来长成了三条硬约束:
- 不可变——应用二进制烤进只读根文件系统,升级是换掉整个系统分区,而不是原地改文件;
- 能自愈——任何一步失败都要能自己退回去,不能等人来救;
- 实时音频不能被打断——这是台音频处理设备,音频数据面的延迟抖动是硬指标。
做到一半,产品又加了第四条:接上 HDMI 就要能看见界面。这条后来单独长成了一章,因为它要求我把之前刻意裁掉的整个图形栈请回来。
系统之上跑的是一套独立交付的应用层(一个实时音频引擎 + 一个 Web 控制端),本文统称应用层,需要区分时称”引擎”和”Web 端”。
下面讲的是这些判断怎么做出来的,以及我怎么把它们变成两个 AI(Claude Code 和 Codex)能执行的指令。架构是我定的,代码大部分不是我写的。
先给几个数字,好让后面的叙述有个尺度:
| 阶段 | 时间 | 用时 |
|---|---|---|
| x86 线生产化 | 7/02 → 7/31,199 次提交 | 一个月 |
| ARM 线 P0 → P5 | 7/16 出第一个可引导镜像 → 7/20 平台验收冻结契约 | 5 天 |
| ARM 线从起念到 CI 正式发版 | 7/07 问”建仓还是拉分支” → 7/21 全链真机验收 | 两周 |
| 本地显示 | 7/28 当天真机验收通过 | 1 天 + 一周打磨 |
第一个判断:不在通用发行版上打补丁
最省事的做法是拿一台 Ubuntu 装上应用就发货。我否掉了,理由三条:
第一,你改过的 Ubuntu 就不再是 Ubuntu。 你会为了实时性换内核、为了裁体积删包、为了确定性锁版本。做完这些之后,上游的每一次安全更新对你都变成一次赌博——你既不敢跟,也不敢不跟。
第二,现场没人能修,所以系统必须是整块的、可验证的、可整体替换的。 一个”装了很多东西的系统”你没法验证它的状态;一个”由配方生成、内容完全确定”的镜像可以。
第三,通用发行版为”能装任何东西”优化,而我要的恰恰是”只能装这一个东西”。 在线包管理、桌面环境、多用户体系,对这台设备全是纯负债。
所以选了 Yocto。
Yocto 不是一个发行版,是一套”生成发行版的工厂”。你写配方(recipe)描述每个组件怎么编译、装到哪儿,它产出一个只包含你要的东西的镜像。代价是编译一次要几小时,收益是你对镜像里的每一个文件都有解释。
系统定义(DISTRO)大致是这样:
1 | DISTRO_FEATURES:append = " systemd pam usrmerge largefile ipv4 ipv6 rauc" |
去桌面、去 PulseAudio(音频直连 ALSA,中间少一层就少一层抖动来源)、不启用在线包管理、生产构建移除 debug-tweaks。
整个构建的层次是这样的——上游层全部按 commit 锁定、一行不改,所有定制收敛在一个自研层里:
对上游的改动一律通过 .bbappend 叠加,不动上游工作树——这样上游升版时,你的改动是一份可 review 的清单,而不是一堆 merge 冲突。
磁盘布局是这样切的(x86 线):
1 | EFI 1G ESP:GRUB + 模块 + grubenv(注意:不放内核,理由见下一章) |
这里有一条不起眼但很贵的细节,后来成了”关键不变量”清单的第一条:
系统槽必须用 PARTUUID 定位,数据分区可以用文件系统 label。
为什么?升级时 RAUC 会把升级包里的 ext4 镜像整块写进目标槽——这会把文件系统的 label 一起覆盖掉。所以槽如果靠 label 找,升级完就找不到自己了。而 PARTUUID 钉在 GPT 分区表里,升级过程从不碰分区表。数据分区反过来:它永远不被升级写入,label 稳定,用 label 挂载反而更抗磁盘顺序变化。
内核的 root= 参数同理,用固定 PARTUUID,不写 /dev/sda2 这种——SATA、NVMe、eMMC 的设备名完全不同,写死一次就锁死了一种硬件。
这一步我怎么用 AI:让它对照行业实践把候选方案摆开——RAUC / swupdate / Mender,整镜像替换 vs 应用层增量,各自的成熟度、license、和现有技术栈的契合度。这是 AI 最强的一类任务,它能在几分钟内给你一张比你自己查一天更全的对照表。取舍我来做,因为取舍依赖的是”这台设备会活多久、卖到哪里、谁来维护”,这些它不知道。
防砖不是加个 try-catch:三层退路怎么设计
A/B 升级:准备两个一模一样的系统分区,一个正在跑,另一个用来写新版本。写完切过去,出问题切回来。手机 OTA 就是这个套路。
RAUC:管理这套流程的开源框架,升级包(bundle)是一个带签名的整镜像文件。
一次完整的升级长这样,注意它是两步激活的——写入和切槽是分开的两个动作,中间可以停:
拆成两步的价值在于:下载和写入是耗时且可能失败的,但它们不改变系统当前的行为。真正有风险的只有最后那一下切槽。把两者分开,你就能在夜里静默写完、在维护窗口里只做那一下切换。
我的判断是:“能回滚”和”会自己回滚”是两回事。
大多数方案给你的是前者——出事了,你连上去手动切槽。但我的前提是没人能连上去。所以我把失败拆成三种形态,各配一层退路。
第一层:起不来(内核 panic / 早期挂死)
GRUB 的菜单项在加载内核之前先写 <slot>_TRY=1 并保存。如果这一槽没能正常走到健康检查,下次开机 GRUB 读到 _TRY=1,就知道上次那一把没成功,自动换到另一个健康槽。
第二层:起来了,但是哑的
这是最阴的一种:系统进了 multi-user,SSH 能连,systemctl 一片正常,但音频引擎没跑起来——坏的升级包、坏的配置都会这样。第一层对它完全无感。
所以我定了一条规则:只有应用真的健康,这次升级才算成功。
落地方式是把 RAUC 自带的 rauc-mark-good.service 直接 mask 掉,让自研的 health-check 成为唯一的 mark-good 入口。健康就标记成功,不健康就标记失败。
这里有个坑值得展开——光标记失败是不够的。
标记失败只是往 grubenv 里写了个标志,要等下次启动才生效。可这台机器不会有人去重启它,于是它就一直哑着,直到有人打电话来投诉。所以必须闭环成一次主动重启:标记失败成功之后,systemctl --no-block reboot,让 GRUB 在下一轮读到标志、切到健康槽。
但这么做立刻引出新风险:会不会陷入”重启—判坏—重启”的死循环?我给了两条自限:
- 每次只把当前槽标记为不健康。A 坏了切 B,B 也坏了切回 A——最多两个重启周期就收敛,不会无限循环同一个坏槽。
- 只有标记写入成功才重启。grubenv 如果写不进去(磁盘故障),就停在降级态等人工介入——因为这时候连状态都存不下来,重启只会原地回到同一个坏槽,变成真的死循环。
这两条自限不是设计时想到的,是把”如果这里失败会怎样”逐条问过一遍逼出来的。
第三层:两个槽都坏了
一个独立的 RECOVERY 分区,里面是精简的救援系统。它的配方直接 require 主镜像配方生成,所以和生产系统逐字节同源(同一套 RAUC 配置、同一份签名根证书、同一套网络和 SSH 配置),只是 :remove 掉整个音频栈。
GRUB 里有独立入口,内核参数带 rauc.external——让 RAUC 把两个槽都视为非活动,于是两个槽都能被重装。
这里我做了一个明确的定位决策,并且写进了文档:
Recovery 是”最小不可变救援台”,不是 root shell,不是厂商后门。
它抗的是分区损坏、坏升级包、坏内核;它不抗物理攻击。厂商要做深度维护,走”签名的调试镜像经 RAUC 正常下发”这条路——复用现有的签名信任链,装回生产包就回滚,零新增设备侧攻击面。而不是在最兜底的那个分区里焊死一个静态的 root 账号。
这个决策在当时是有争议的:留个后门显然更方便。但”最兜底的那一层”恰恰是最不能有后门的一层——它是唯一一个在系统全坏时还能启动的东西,一旦它有后门,前面所有的安全设计都白做了。
三层放在一起是这样的,每一层对应一种它上一层看不见的失败:
三层退路共用一个真相:grubenv
三层全部依赖 ESP 上那个几 KB 的 grubenv 文件:GRUB 启动时读它选槽,RAUC 用 grub-editenv 写它。
这两边必须指向同一个文件。 曾经指错过——RAUC 写的是一个路径,GRUB 读的是另一个,于是回滚静默失效:标记写得好好的,重启之后毫无反应。这是这个项目里的一个 P0,也是”关键不变量”清单的第二条。
内核和内核参数必须跟着系统槽走
一开始内核放在 ESP 上,因为传统做法就是这样。但 ESP 不在 RAUC 的槽里,这意味着——内核根本没法 OTA。你升级了根文件系统,内核还是老的,/lib/modules 里的模块版本和运行的内核对不上。
改法是:GRUB 不从共享的 ESP 加载内核,而是从选中槽的根文件系统里加载 /boot/bzImage。内核参数同理,放进各槽的 /boot/cmdline.cfg,菜单项设好 root 之后 source 它,覆盖内联的兜底值。
于是:内核能 OTA、坏内核能回滚、改启动参数不用碰 ESP、不会出现内核和模块版本错位。ESP 最后收敛到只剩 GRUB 本体 + 模块 + grubenv,日常升级永远不需要动它。
这里还有一个被真机教出来的细节:GRUB 里绝对不能硬编码磁盘号。设备可能插着刷机 U 盘,盘号会变。正确做法是从 UEFI 传下来的 Loaded Image 信息里快照出本次启动的 ESP,只派生一次磁盘号,后面全用它。同时要关掉 Poky 内置 GRUB 配置里”按同名文件全盘搜索 grub.cfg”的行为——否则 GRUB 可能从 U 盘上加载配置,启到别的系统去。
把这一章的东西串起来,从上电到应用跑起来的完整链路是这样:
中间那条 解析失败 → 不定义任何启动项 的分支值得单说:如果 grubenv 读不出来,正确的行为不是”随便挑一个槽启动”,而是什么都不启动,停在诊断界面。因为这时候你已经不知道哪个槽是健康的了,猜错的代价是把一台本可救的机器推进一个坏槽的重启循环。状态未知时,停下来比往前走安全——这条在整个防砖设计里反复出现。
一个更完整的方案,被我否了
中间有一轮,AI 给了一个方案:把 ESP 也做成 A/B,用 RAUC 的 boot-gpt-switch 原子切换,再单独拆一个分区专门放 grubenv。这样连 GRUB 配置本身都能 OTA。
方案本身没问题,逻辑自洽,论证扎实。
我否了。 理由是:改分区几何是这个项目里最贵的一种变更——投产之后无法轻易改,只能整盘重刷。为了”grub.cfg 也能 OTA”这个低频需求(它一年也改不了两次),把分区表复杂度提升一档、ESP 从一个变两个、再多一个状态分区,不值。
最后走的是分阶段迁移方案,把”grub.cfg 走 OTA”挂账,先把内核和内核参数移出 ESP——这就解决了 90% 的实际需求。
这类判断 AI 给不了你。 它能把技术可行性论证得非常扎实,但”这个复杂度值不值得”是产品决策,取决于你对这台设备生命周期、变更频率、返修成本的判断。这些它不知道,你也很难在提示词里说清楚。
真机推翻我的第一个常识:isolcpus 反而更慢
实时音频的常识是:把音频线程绑到专用核,然后用 isolcpus + nohz_full + rcu_nocbs 把这几个核从调度器手里彻底拿走,不让任何东西打扰它。教科书这么写,AI 这么建议,我也这么想。
于是做了:内核参数加隔离、内核配置开 CONFIG_NO_HZ_FULL 和 RCU_NOCB。
然后上真机测:
| 配置 | 饱和峰 CPU | 高负载 biquad |
|---|---|---|
| 仅 CPU 亲和 | 94.9% | 基线 |
| 亲和 + isolcpus 隔离 | 95.6% | 反而更慢 |
峰值差异落在噪声范围内,而高负载下的滤波器处理反而退化了。
根因不难理解,但很反直觉:isolcpus 会关掉这些核之间的负载均衡。而音频引擎是多线程并行处理的——负载均衡一关,工作线程全聚堆在第一个核上,并行度反而降了。
我为了”不被打扰”,把”能并行”给关掉了。
于是撤掉隔离参数,只保留 CPU 亲和(把线程绑到大核)。
但我在这里做了一个当时看起来多余、后来证明很值的决定:撤结论,留机制。
那套”内核参数跟着根文件系统走 OTA”的机制,是为了上隔离才做的,隔离撤了之后它就没有直接用户了。我还是把它完整留下了。理由是——今天实测隔离没用,是在这颗 CPU、这个负载模型下没用。换一批硬件、换一个负载,结论完全可能翻过来。到那天我需要的是”改一行内核参数然后 OTA 下发”,而不是”把所有已发货的设备召回重刷”。
这条后来成了我的一条通用原则:实测结论会过期,承载结论的机制不会。 做取舍的时候尽量把两者分开——撤结论,留机制。
把图形栈请回一个刻意没有桌面的系统
前面为了裁剪,我把 x11、Wayland、整个桌面全砍了。后来产品要求:接上 HDMI 就要能看见 Web 端界面,现场调试不用再拎一台笔记本过去。
我给这件事定了四条约束:
- 不引入 X11 / XWayland——好不容易砍干净的,不能因为一个显示需求整个请回来;
- 不能影响常规构建——浏览器是几小时级、几十 GB 的编译;
- 没接屏的机器行为必须完全不变——不能因为多了个显示功能就多出报错;
- 显示器是热插拔的——平时不接,需要时接上就得亮。
我逼 AI 做外部选型
这一步我给的指令很明确:
不能读现有的文档,需要搜索分析要实现这个功能的推荐的最佳实践方案是什么?以及主流方案是哪些,需要对比。
这句话是有针对性的。AI 的默认行为是”先读你的仓库”——读完之后它基于你已有的东西给方案,看起来非常贴合,实际上把选型空间限死在了你已经知道的范围里。仓库只能回答”我们有没有”,回答不了”世界上有没有”。
对比出来是这么几档:
- Cage:极简 kiosk compositor,一个应用全屏,代码量很小。但能力也就到这儿了,配置面几乎为零。
- Weston + kiosk-shell:Wayland 的参考实现,kiosk-shell 正是为”一个应用占满一屏”设计的,DRM 后端成熟,配置能力足够。
- WebKitGTK vs Chromium:中途我回头质疑过一次选型——WebKitGTK 明显更轻。但 Web 端用 Three.js 做 3D 视图,WebGL2 的支持度和跨版本一致性上 Chromium 是更稳的那个。这是被应用倒逼的选择,不是偏好。
最终定:Weston kiosk-shell + Chromium(--ozone-platform=wayland)+ systemd 三层看门狗。
系统定义里加回 Wayland 是这么写的:
1 | DISTRO_FEATURES:append = " ... wayland" # kms/egl/kiosk-shell 与 mesa 的 wayland 平台都由它门控 |
一行 machine override 就把两条产品线的图形栈分开了——这是 Yocto 相对”改一个通用发行版”最值的地方之一。同一套配方,两条线出两种形态,不需要维护两份。
浏览器不能进主构建
Chromium 编一次几小时、几十 GB,还要额外拉三个 layer(clang20 工具链、rust mixin、meta-browser)。如果并进基线构建配置,每次常规镜像构建都要背上这些 layer 的解析开销,而且 Chromium 版本一动,整盘编译缓存失效。
所以拆成独立 overlay + 两段式构建:先单独编浏览器产出安装包喂进 sstate 缓存,再编整镜像时秒取。
那镜像里没装浏览器的时候呢?
1 | ConditionPathExists=/usr/bin/chromium |
Condition* 不满足时 systemd 是受控跳过,不是 failed。这和”缺组件就假报错”是完全不同的运维体验——现场跑 systemctl --failed 应该是干净的一片,而不是让运维去分辨”这三个红的是正常的,那两个红的才是真出事了”。
桌面上白送的东西,这里一个都没有
真正花时间的不是架构,是下面这一串。它们的共同点是:全部静默降级,没有一条会报错。
中文全是方框。 整机只有 Liberation 字体,纯拉丁字符集。
鼠标形状不对。 整机一个光标主题都没装。补的时候又撞上:xcursor-themes 在纯 Wayland 的系统上根本构建不了,得换成 adwaita-icon-theme-cursors。
光标配了还是不生效。 这个最有意思。Weston 配置里那份光标设置只管 compositor 自己画的指针;Chromium 是 Wayland 客户端,自己提交光标缓冲。但它跑在 --ui-toolkit=gtk 下时,主题名是向 GTK 要的(GtkSettings 的 gtk-cursor-theme-name),根本不读 XCURSOR_THEME 环境变量。
怎么确认的?把主题里五个手型别名全换成 I 型光标,重启后形状纹丝不动——证明它压根没查这个主题。真正的开关在 /etc/xdg/gtk-3.0/settings.ini。三处配置必须同名同尺寸,否则鼠标移进移出页面的时候会跳变。
导出配置时,另存为的文件名变成了 download。 systemd 默认给服务传 LANG=C,而 C 的字符集是 ASCII。Chromium 清洗”建议文件名”时按系统字符集转换,非 ASCII 一个都表示不了,整个名字被清空,于是回落到它的内置默认名。真机取证:
1 | LANG=C "默认配置.cfg" → "download" "test.txt" → "test.txt" |
最阴的地方在于:机器码、日志、诊断包这三个导出口,因为文件名恰好是纯 ASCII,看起来一切正常。只在中文名上翻车的问题,极容易被判成”偶发”然后放过。
修法是 LANG=C.UTF-8——这里只需要”字符集是 UTF-8”,不需要任何区域数据,界面语言由应用自己的 i18n 决定。C.UTF-8 由 glibc 内置,零额外体积。
两个”你以为写了就生效”
StartLimitIntervalSec 写在 [Service] 段会被静默丢弃。 systemd 只在 journal 里留一行 Unknown key 'StartLimitIntervalSec' in section [Service], ignoring,然后按系统默认的 5 次/10 秒执行。它必须写在 [Unit] 段。这个是靠翻 journal 才发现的——配置文件看起来完全正常。
--kiosk 一个快捷键都不禁。 这个我一开始不信,都叫 kiosk 模式了。实测 Ctrl+N / Ctrl+T / Ctrl+W 全部可用,能从”单屏应用”里开出新窗口、跑到别的网站去。查下来也没有任何企业策略能禁这些键——上游有个 IsCommandAllowedInAppMode() 白名单,是硬编码的。
我连问了三轮”到底怎么才能禁掉”,最后走到:既然 Chromium 是我们自己编的,直接改源码。 加一个 --kiosk-lock 开关收窄那个白名单,把 Ctrl+N/T/W/Shift+T/Tab/Alt+F4 全封掉。
一个产品级 kiosk 和”全屏打开一个网页”的差别,基本就在这类地方。
三层看门狗,最容易漏的是第三层
- 进程级——systemd 的
Restart=:进程死了拉起来; - 硬件级——
RuntimeWatchdogSec=:PID 1 不喂狗就硬复位; - 内容级——屏幕上是白屏 / “Aw Snap” / 停在错误页。
前两层对第三种故障完全无感:浏览器进程活得好好的,PID 1 也在喂狗,两层都认为一切正常,而现场看到的是一块废屏。这一层是业界反复强调、也最常被漏掉的一层。
第三层的实现有三个细节值得说:
- 只比对 origin(
scheme://host:port),不比路径。 Web 端是单页应用,站内路由跳转是正常行为,比路径必然误杀。真正要抓的是”scheme 变了(TLS 开关切换后没跟上)”和”跑到别的主机去了”。 - 期望地址每轮重新解析,绝不写死。 这顺带实现了”TLS 开关一变,kiosk 自动跟随”——切换后解析出的地址和当前页面不符,连败到阈值就重启 kiosk,包装脚本重新解析后落到新地址。
- 解析器探不通不算判坏。 那可能只是后端在重启,不能把后端的一次抖动当成页面有病。
整个显示栈和三层看门狗的关系:
前两层看的是”进程还在不在”,第三层看的是”屏幕上是不是还有该有的东西”。这两个问题的答案经常不一致,而现场用户只关心后一个。
第一版实现犯了个更严重的错误
“热插拔”那条约束比想象中难。Weston 13.0.1 的 DRM 后端在零连接输出时直接 exit 1——枚举完发现没有已连接的显示器就安静退出,日志里连 fatal 都没有,--help 里也没有任何”无输出也继续”的选项。所以 compositor 只能在”已经有屏”之后才启动。
第一版把这个等待写成了 weston 服务的阻塞式 ExecStartPre。
这是个更严重的错误。 该 unit 带隐式 Before=multi-user.target,于是不接屏开机时:
1 | weston.service running(卡在等屏) |
为了让接屏的机器能显示,把不接屏的机器搞得起不来 multi-user 了。 一个新增的、可选的、锦上添花的功能,把主链路堵死了。
正确做法是拆出独立的 watcher:监听 DRM connector 的状态,检测到有输出变成 connected 再去拉 compositor,全程不阻塞任何 target。
我 review 出的一处疏漏
代码注释里写着「这不是可选优化:Chromium 冷启动会把一次开屏变成一次 xrun」——说的是 kiosk 必须被 CPU 亲和策略约束住,不能让浏览器启动去抢音频线程的核。
但 unit 的排序根本没兑现这句话。真机时序:
1 | udev-settle active @ 3.611s |
284 毫秒。这不是保证,这是侥幸——它取决于 weston 的 EGL/DRM 初始化耗时,换个显卡、换个分辨率就可能翻过来。修它只要加两行 After=,把”侥幸”变成”保证”。
注释写对了、代码没写对,是 AI 生成的代码里一类很典型的问题:它知道正确的道理,也把道理清清楚楚写进了注释,但没落实到实际约束上。 这类问题静态看代码很难发现——注释和代码放在一起,你会下意识认为它们是一致的。
换 SoC:一套层打两个平台,5 天走完
x86 线跑通之后,产品线要加一块国产 ARM 板(RK3576)。
第一个问题我是原样问出来的:“应该重新创建仓库,还是在此基础上单独拉去分支即可?”
三个选项,代价完全不同:
- 新建仓库:干净。但那套系统集成(所有 systemd 服务、运维脚本、用户、sudoers 白名单、健康检查)会立刻分叉成两份,从此每一个安全修复都要改两遍——而且必然会漏一遍。
- 拉长期分支:更糟。长期分支等于延迟的分叉,而且冲突会攒到某一天集中爆发。
- 同仓 + machine 差量:共用一套自研层,只加一个平台差量层放 SoC 相关的东西。
我选了第三个。判断依据不是”哪个更优雅”,而是:这两条线真正共享的是安全模型和运维模型,那才是最贵、最不能分叉的部分。 而 SoC 差异(bootloader、内核树、分区布局)反而是有限的、可枚举的。
事实证明这个判断值 5 天:7 月 16 日出第一个可引导镜像,7 月 20 日平台层验收、冻结契约。
启动链怎么做到语义同构
x86 用 GRUB,ARM 用 U-Boot,完全是两个东西。但 RAUC 的命令行是 bootloader 无关的,只要两边的语义对齐,上层就能共用一套逻辑:
| 概念 | x86(grubenv) | ARM(u-boot env) |
|---|---|---|
| 启动顺序 | ORDER="A B" |
BOOT_ORDER="A B" |
| 槽健康 | A_OK / B_OK |
BOOT_A_LEFT / BOOT_B_LEFT(剩余重试次数) |
| 本轮在试 | A_TRY / B_TRY,加载内核前置位 |
每次进槽前自减 LEFT |
| 谁来写 | RAUC grub backend | RAUC uboot backend(fw_setenv) |
| 双坏兜底 | GRUB 菜单进 Recovery 分区 | u-boot 顺序落空后 fall-through 到 recovery 分区 |
语义对齐之后,上层的升级逻辑就能收成一份:ARM 的升级启动器最后是 x86 那个类的一个薄子类,两边复用同一套安装 / 两步激活 / 回滚 / 标记逻辑,唯一的差异是校验摘要里的架构标签。
这不是省代码,是省认知。两条线的升级链路如果各写各的,任何一次升级流程的改动你都要在脑子里跑两遍。
两条线的共用与差量边界画出来是这样——共用的是最贵的部分,分叉的是可枚举的部分:
Phase P / Phase A:先做平台,再注应用
我把移植切成两段:
- Phase P:只做 OS 平台层,不含任何应用。目标是”一个空的、但完整的产品 OS”——A/B 能切、OTA 能装、健康检查能判、三个用户齐、安全门全过。
- Phase A:把应用二进制注入进去。
切开的价值在于,Phase P 的验收标准可以做到完全客观,六道门:
1 | ① u-boot A/B 选槽启到 multi-user,两槽任一可启 |
第 ⑤ 条是我特意加的:没有应用的时候,平台服务不许假报错。 这逼出了一堆条件化——首启脚本和健康检查里所有依赖应用配置的分支都得能优雅跳过,而不是找不到文件就 fail。
这个要求当时看起来有点较真,后来直接付了利息:“镜像里没装 Chromium 时 kiosk 受控跳过”这个能力,用的就是同一套条件化模式。
冻结契约
Phase P 收官时,我要求把 OS 和应用之间的接口冻结成一份文档,8 类:挂载点与分区标识、应用安装路径、systemd 单元、用户/权限/sudoers、RAUC 槽与签名根、配置模板、首启目录骨架、升级链路。
冻结的意思是:Phase A 只往冻结的路径里填载荷,永不新增或改写任何 OS 侧接口。
这份文档最大的价值不在于记录,在于它变成了给 AI 的边界。后面每一次让 AI 改 ARM 侧的东西,提示词里都带上”先读契约文档,不要改契约”。没有这个约束,AI 会非常乐意帮你”顺手把这个路径结构优化一下”——而那个路径正好是应用层写死的。
三个必守护栏
这三条是踩出来的,写在了 README 最显眼的位置:
- 必须钉死真 provider。 板厂 SDK 的出厂配置里用 dummy 顶掉了内核和 bootloader 的 provider。这个配置一旦泄漏进构建,wks 修复流程会静默删掉分区定义行——wic 不报错,正常出镜像,烧进去黑屏。所以 machine 配置里硬钉
PREFERRED_PROVIDER_virtual/{kernel,bootloader},并且成像后必须跑产物门,校验分区编号、PARTUUID 和选槽脚本三方一致。 - 内核基线必须指板厂的 RT 树,不能回落到社区 layer 的默认快照(那里既没有这块板的支持,也没有实时补丁)。
- 板厂 layer 的血统必须锁死。 同名的 mainline 仓库里根本没有 RK3576,混用直接白干半天。
第 1 条是这三条里最贵的,因为它是静默失败:构建绿、镜像出、烧进去黑屏,而且你在构建日志里找不到任何线索。这类失败只能靠产物门去抓——门必须检查产物,不能只检查源码。
给 AI 的 GOAL 提示词
ARM 移植这一轮,我是用一份结构化的目标提示词开的新会话。关键的不是任务描述,是这三句:
- 起手先读,不要重新调研(方案已多轮定稿 + 外部验证)
- 架构已评审拍板,不要重新选型
- 分两阶段:Phase P(OS 平台先做完,不含应用)→ Phase A(应用解耦注入)
这三句都是在约束 AI 的自由度。
AI 有个很强的倾向:接到任务先重新调研一遍、重新给一版方案。在探索期这是优点;在执行期这是灾难——你会发现它把上周已经拍板的事情又推翻了一遍,而且推翻得很有道理,于是你要么被说服(然后返工),要么花半小时把上周的理由重讲一遍。
我的做法是把”已决策”和”待决策”明确分开写进提示词:已决策的给结论 + 文档路径 + 明确禁止重新讨论;待决策的才让它发散。
六个只有真板子才教得会的坑
ARM 侧的救援方案,我的要求是:两个槽都坏了,机器要能自己修好自己,不需要人到现场。
x86 侧的救援是一个独立的 Recovery 根文件系统分区。ARM 侧一开始放的是个占位的 FIT 镜像(内核 + 设备树打包在一起的格式),只能启起来,不能真救。
升级成”自含 initramfs 的 RAM 救援”——把一个完整的最小根文件系统打进 FIT,整个从内存里跑,不依赖磁盘上任何一个根文件系统。流程是:u-boot 发现两个槽都坏 → fall-through 到 recovery 分区 → 起 initramfs → 找升级包 → 验签 → 装回主槽 → 复位启动顺序 → 自动重启。
写完这个流程只花了一会儿。让它在真板子上跑通,花了六个坑。
① FIT 里 ramdisk 的加载地址硬编码 → data abort。
写死一个物理地址,结果和别的东西撞了,内核直接 data abort 挂掉。改成占位地址交给 u-boot 的内存管理自己分配。
② /init 跑在分区枚举之前 → 按 PARTUUID 找分区全空。
initramfs 的 /init 就是 PID 1,启动极早。它一上来就想按 PARTUUID 定位分区,可 eMMC 的分区还没枚举完,/dev/disk/by-partuuid/ 是个空目录。得先显式等分区出现。
③ 救援模式的槽标识没有匹配的槽 → RAUC 服务直接崩。
recovery 的内核参数里带 rauc.slot=R 标识自己在救援模式,但 RAUC 配置里根本没有名叫 R 的槽,服务一启动就崩。解法是启动时 --override-boot-slot=B 骗它一下。
④ busybox 的 mount 不支持 -L。
救援环境里是 busybox。mount -L DATA 这种写法在完整版 util-linux 上没问题,busybox 版本不支持这个参数。改用 blkid -L 先解析出设备节点再挂。
⑤ PID 1 的 PATH 太干净 → resize2fs: not found。
PID 1 继承的环境变量几乎是空的,PATH 里不含 /usr/sbin。装完系统要 resize 根分区,直接找不到命令。/init 开头 export 完整 PATH。
⑥ 板子没接 RTC → 证书 not-yet-valid。
这块测试板没接 RTC 电池,冷启动后系统时间回到纪元原点。RAUC 按安装时刻的墙钟校验证书有效期,于是”证书还没生效”,验签失败,救不了。
这六个坑有一个共同点,我觉得值得单独说:没有一个是”代码逻辑写错了”。
逻辑全对。全是”运行环境和你以为的不一样”——PID 1 的环境不是你 shell 里的环境,busybox 不是 coreutils,initramfs 阶段的设备状态不是系统跑起来之后的设备状态。
这恰好是 AI 最容易出错的地方。 它写出来的代码在”一个正常的 Linux 用户态”里全对,而救援环境根本不是一个正常的 Linux 用户态。它没法凭空知道你的 busybox 是怎么编的、你的 eMMC 枚举要多久。
第 ⑥ 个坑的后续,引出了这个项目里我印象最深的一次分歧——在下下节。
真机推翻我的第二个判断:「ARM 中断少」
x86 侧做了一轮 USB 声卡的中断优化——把 xhci 中断线程的优先级提上去、绑到合适的核。做完之后自然要看 ARM 侧要不要同样处理。
当时的结论是:ARM 不用做,因为 ARM 的中断量远小于 x86。 数据支撑是 /proc/interrupts 里 xhci 的累计中断数:ARM 那台 223 次,x86 那台 1.9 亿次。差了六个数量级,看起来毫无悬念。
这个结论是错的——那块 ARM 板当时根本没接 USB 声卡。 223 次是一块没在跑音频的板子上的累计值,再除以 2.9 天的 uptime,才得出”中断很少”。
你做的事很简单:
我已经把真实的 USB 声卡接入 arm 的板子,你再重新测试验证。
接上真声卡(32 通道、48kHz、period 256、双向 RUNNING)重测 60 秒增量:
| 平台 | xhci 中断 | 中断线程 CPU | 每次中断成本 |
|---|---|---|---|
| ARM(接真声卡) | 3313/s | 4.8% | 14.5 µs |
| x86(未优化) | 2965/s | 15.7% | 53.2 µs |
| x86(已优化) | 2982/s | 7.9% | 26.4 µs |
ARM 的中断量比 x86 还多。 但成本只有 x86 未优化时的 1/3.7。
“ARM 不用做这个优化”这个结论仍然成立,但理由全错了。真实原因是这颗 SoC 不暴露 cpuidle,CPU 从来不进深睡——所以没有”从 C10 唤醒要 230 微秒 + 频率从低位往上爬”这笔账。x86 那笔昂贵的中断成本,大头是唤醒开销,不是中断处理本身。
这反过来成了对 x86 侧归因的一次独立交叉验证:同型号声卡、同一量级的中断,在一个不进深睡的平台上,每次成本只有 1/3.7。这比原来那套单平台的分析可信得多。
结论对、理由错,是最危险的一种状态。 因为结论不会立刻出事,而错误的理由会在下一次决策时把你带沟里——比如换一块会进深睡的 ARM SoC,”ARM 中断少所以不用优化”这条经验会直接失效,而你根本不知道它为什么失效。
同一轮里揪出的一个”侥幸”
x86 的调优脚本里有一段”把音频中断钉到大核”,这段逻辑被照搬到了 ARM。
但 ARM 线的内核参数是 isolcpus=nohz,domain,4-7 nohz_full=4-7 irqaffinity=0-3——大核 4-7 完全隔离、所有中断限定在小核 0-3、大核纯跑音频引擎。在这种平台上”钉大核”会把中断拉回被隔离的核,破坏隔离。 而且破坏极其隐蔽:音频照样出声,只是抖动变差,零报错。
它为什么一直没爆?因为 ARM 上这个 IRQ 的名字是 xhci-hcd:usb3(连字符),而照搬过来的匹配模式写的是 xhci_hcd(下划线),凑巧不匹配。
我在 commit message 里的原话是:「改动前没出事纯属侥幸」。
更糟的是,同一个设备在另一处(中断线程那边)的模式又能匹配上——同一个设备一处匹配一处不匹配,这本身就是 bug,只是它的表现形式恰好是”没生效”而不是”出错”。
修法两条:匹配模式统一成 xhci[-_]hcd 覆盖两种命名;以及显式判断 /sys/devices/system/cpu/isolated,内核已经做了隔离就别去动它。第二条才是根治——不是修一个字符,是承认”内核的隔离策略优先于我的调优脚本”。
让 AI 审自己写的代码:10 维度 + 对抗性核实
两条线都跑通之后要做投产判断。这时候的处境是:代码大部分不是我写的,但责任是我的。
常规做法是让 AI “review 一下这个仓库”。基本没用——你会拿到一份四平八稳、每个方面都提一点的报告,读完不会比读之前更有信心。
我用的是这么一套:
第一步:拆维度,并行独立审
10 个维度:启动链与选槽、RAUC/OTA/Recovery、安全面、systemd 集成、身份与凭据、时间子系统、网络、实时音频、CI 门禁与发版、两线一致性。每个维度一个独立的 reviewer,各自打开真实文件读,互相不知道对方在看什么。
拆维度的价值是强制覆盖。一个 agent 审全仓,注意力会自然漂向它觉得有意思的地方(通常是安全和架构),冷门角落——比如 CI 的 rules 表达式、时间子系统——根本不会有人看。十个 agent 各守一块,冷门角落才有人守。
第二步:每条 finding 独立对抗性核实
这是关键的一步。 第一轮出来的 finding 里,有相当一部分是”看起来像问题”——AI 读到一段代码,脑补出一个失败场景,写得头头是道、证据链完整,但那个场景在这个系统里根本不可达。
所以每条 finding 都单独派一个 agent 去证伪,任务写得很直白:去证明这条是假的。证不倒的才留下,被判 REFUTED 的直接剔除。
这一步和”让 AI 再检查一遍”完全不同。你要求它站在对立面,它的默认倾向就从”补全和附和”变成了”挑毛病”,而后者恰好是 review 需要的。
第三步:分级 + 人工亲验头号问题
存活 29 条:P0 零条、P1 两条、P2 七条、P3 十九条。
头号的 P1 我自己 grep + git log 亲验了一遍。AI 的对抗性核实能去伪,但最贵的那条判断我要自己看过——不是不信任,是这条判断如果错了,代价由我承担。
结论是”有条件投产”:防砖主链健全,残余风险集中在三类——安全决策的文档与实现不和解、两条线的 CI 门禁漂移、可观测性缺口。
在这个过程里,我实际做的是三件事
一、推翻
头号 P1 的由来,正是第 6 章那个”无 RTC 板证书 not-yet-valid”的后续。
AI 给出的方案是:打开 RAUC 的 use-bundle-signing-time——让验签用签名包里自带的签名时间,而不是设备的墙钟。这样时钟不准也能装。
方案能跑,问题当场解决。我看到之后是这么回的:
还是正常关闭这个,需要校验。不要因为这个测试板子而修改正确策略。当前只是这个板子没有接入 rtc 的电池。所以修复,统一这个问题。按照正常逻辑走。不用为了兼容没有 rtc 的这个而关闭校验。
理由展开是这样的:
signingTime 是签名者自己声明的,可以倒填。一旦打开这个开关,签名私钥万一泄漏,攻击者可以把时间倒填到证书有效期内,签出一个永久可安装的恶意包——连”证书过期”这道自动止血都废掉了。而配套宣称的补偿手段(CRL 证书吊销)在配置里根本没有实现,离线机群也没有分发通道。
也就是说:为了迁就一块没插 RTC 电池的测试板,把整条产品线的一道安全阀关掉了,换来的补偿是空的。
这不是技术判断,是取舍判断。 无 RTC 是硬件配置问题,量产板必接 RTC——不该让软件为硬件的临时缺陷牺牲正确策略。这类”用一个 flag 绕过障碍”的方案,AI 提得非常自然,因为从”解决当前阻塞”的角度看它确实最优。
二、砍复杂度
方向定了之后,AI 给的时间方案又长出了好几层:自定义 systemd 启动时钟下限、时钟持久化保存与恢复、定时快照……每一层都有合理的理由。
我的回复是:
整个方案不需要这么复杂的实现,只需要优先 ntp 时间(网络国内网都能访问的),在没有设置内网 ntp 服务器的情况下,然后依靠 rtc。不再做多余的操作。
最后收敛成标准的四级:内网 NTP → 公共 NTP → 板载 RTC → 手动设置,多出来的定制全删了。
顺带还消掉了一个 bug——那个”时钟只前进”的持久化机制会把一次合法的时间回拨给顶回去(这是另一条 P2 的成因):你手动把时间调回正确值,重启后它又被”恢复”到那个错误的未来时间。
多余的机制不是中性的,它自己会长出 bug。 AI 在”补全方案”这件事上非常勤奋——每一个边缘场景它都想兜住,你不砍它就会一直加,而每加一层都是新的状态和新的失败模式。
三、裁决”不修”
这一条我觉得最被低估。
P2-1:ARM 的 u-boot 环境变量只有单份,掉电损坏后会把已判坏的槽复活。
AI 分析得很扎实:唯一的真解是冗余双份环境变量区,但这个能力被板厂 u-boot 树的编译配置门控着,要啃 vendor 树 + 重新做全套真机验证。
我的判断是直接接受,不修——不是推迟,是不做。理由:
- 现有自愈链已经兜住了:复活的坏槽会经过重试自减 → 健康检查再判坏 → 自动轮到健康槽,两个周期内收敛;再往下还有 recovery 和
panic=10。 - 触发窗口极窄:需要”每次启动那几毫秒的环境变量写入期间恰好掉电”,并且恰好某个槽处于判坏状态。
原话是:「不是首批接受,是这个直接接受,后续也不用。因为当前已经能做到自愈了。」
P2-2:ARM 上的音频引擎继承了 x86 的提权面。
确实是无谓的攻击面放大——那组特权是给一个 x86 独有的可选渲染组件用的,ARM 上根本没有那个组件。
但那个组件后续要上 ARM。现在收紧、将来再放开,是来回折腾两次而且中间还要改一次契约文档。正确做法是把这组特权的授予耦合到”给 ARM 启用那个组件”这次改动里,届时两条线统一同一套特权模型。所以:接受现状,挂条件复评。
“不修”必须写下理由和触发条件,否则它就变成了债。 这两条我都要求写进报告:为什么接受、什么条件下必须重新评估。半年后接手的人看到的应该是一个”经过判断的决定”,而不是一个”没人管的 TODO”。
一个我没料到的发现:门禁不对等
第二条 P1 不是代码问题,是门的覆盖问题。
sudoers 白名单、首启脚本这些文件是两条线共享的单一副本,但校验它们的那套安全断言(别名精确比对、禁止裸命令通配、visudo 语法检查、CRLF 检查)只挂在 x86 的 CI 分支上跑。ARM 单线发版的时候,这些门一次都不会执行。
失败场景非常具体:某次改动把一条 sudoers 规则写成了裸命令形式(在 sudoers 语义里,这等于”任意参数都能提权”),然后走 ARM 线发版——x86 的校验脚本在这条流水线上压根不运行,ARM 的两道门都不查这个。一份提权被弱化的配置就随镜像出厂了,全程无人拦截。
修法是把机型无关的安全断言抽成一个独立脚本,两条线的门都调用它,单一真相、两线对等。
这类问题的共性,和第 4 章那个 284 毫秒、上一节那个下划线是同一类:当前是正确的,但正确纯属运气。
三次”侥幸”,和我固化下来的规则
回头看,这个项目里让我印象最深的不是任何一个技术方案,是”侥幸”这个词出现了三次:
- kiosk 的启动只比 CPU 亲和策略下发晚了 284 毫秒——不是排序保证的,是 weston 初始化耗时凑巧;
- 一段会破坏 CPU 隔离的逻辑,因为 IRQ 名里是连字符而匹配模式写的下划线,凑巧没匹配上,所以一直没出事;
- 共享的安全文件只有一条产品线上有校验门,另一条线全程无门——至今没出事,纯属还没人写错。
三次的形态完全不同,本质是同一个:系统能跑,不等于设计对了。
而这恰好是 AI 辅助开发最大的风险面。AI 交付的东西通常能跑——它见过海量正确代码,生成的东西在 happy path 上很少出问题。所以如果你的验证方式是”跑一下看看”,那你几乎必然会通过,也几乎必然把这类侥幸一起收下,然后在半年后的某个现场把它们一次性还掉。
这段时间里我固化下来的五条规则,都是围绕这一点:
一、真机门
每一个”完成”必须对应一次真机验收,而且验收标准要写成客观的、可勾选的项。ARM 平台层那六道门就是这么来的。
「逻辑上应该没问题」不算完成。这篇文章里两次结论被推翻——isolcpus 和中断数据——都是真机把纸面推翻的,而且两次都是”分析过程完全正确,只是前提和现实不一样”。
二、对抗性核实
不要让 AI 复述自己的结论,要让另一个 agent 去证伪它。任务直接写成”去证明这条是假的”,证不倒才留下。
这一条能剔掉相当比例的”看起来像问题”,也能剔掉一部分”看起来像正确”。成本是多一轮调用,收益是你手上留下的每一条都经过了对抗。
三、把已决策的事钉死
提示词里明确区分”已决策”和”待决策”:已决策的给结论 + 文档路径 + 明确禁止重新讨论;待决策的才让它发散。
否则你会不断收到对上周结论的重新推翻,而且每次都很有道理。那份冻结的契约文档,最大的价值就是变成了 AI 的边界——它不再”顺手优化”一个应用层写死的路径。
四、选型优先
这条是从这个项目里长出来的一条全局规则,现在写在我的全局配置里。
AI 有个很强的默认倾向:接到”实现某个功能”的任务,直接开始写。写出来的东西通常能跑,但你会在两个月后发现自己在维护一个半成品的日期库、虚拟滚动、或者重试退避。
所以我现在的硬规则是:任何”实现一个功能/组件”的任务,在写实现代码之前必须先产出选型结论——现有依赖能不能覆盖、有哪两三个成熟库、各自的代价、最后决定用哪个。决定自研必须写明理由是”无合适库”或”依赖不可接受”,而不是”这个简单”。
本地显示那次选型,我给的指令是「不能读现有的文档,需要搜索分析主流方案是哪些,需要对比」——就是这条规则的现场版。仓库只能回答”我们有没有”,回答不了”世界上有没有”。
五、撤结论,留机制
isolcpus 那次教的:实测结论会过期,承载结论的机制不会。
所以撤掉隔离参数的同时,把”内核参数跟着根文件系统走 OTA”的机制完整留下了。今天用不上,但换一批硬件之后,我需要的是改一行然后 OTA,而不是召回重刷。
最后说说分工
这两套系统,代码绝大部分不是我写的。
但架构决策——不在通用发行版上打补丁、防砖要三层且能自愈、Recovery 定位成救援台而不是后门、否掉 ESP 双分区那个更完整的方案、同仓 machine 差量而不是新建仓、为了一块没插电池的测试板不能关掉验签、那两条已知问题直接接受不修——每一条都是我做的,而且每一条 AI 都给过一个技术上更”完整”的替代方案。
它给的方案通常不是错的。boot-gpt-switch 那套 ESP 双分区方案是对的,签名时间脱钟能解决当时的阻塞,多层时钟兜底每一层都有合理场景。它们只是不值得——而”值不值得”依赖的是这台设备会活多久、卖到哪里、返修一次多贵、半年后谁来接手。这些信息不在代码里。
AI 把”能不能做出来”这件事的成本压得非常低,低到 5 天可以换一套 SoC,一天可以出一个本地显示栈的真机验收版本。但也正因为如此,“要不要做、做到哪一格停手、什么情况下接受不修”这类判断的权重被放大了——以前这些判断被实现成本天然过滤掉一大半(想做也做不完),现在没有那个过滤器了,什么方案都能落地,于是全靠你自己判断。
架构能力从来没这么值钱过。



