backgroundbackground
让 Opus 5.5 把 SteamOS 塞进一台旧手机:一次 AI 主导的硬件移植实录

让 Opus 5.5 把 SteamOS 塞进一台旧手机:一次 AI 主导的硬件移植实录

SteamOS / Claude Code / Opus 5.5 / 红米 K40 / POCO F3 / 骁龙 870 / FEX / Proton

技术

2026-11-01 04:00

Valve 给 Steam Frame 发了一份 ARM64 的 SteamOS。我手上正好有一台闲着的红米 K40 —— 骁龙 870、Adreno 650、8 GB 内存,2021 年的旗舰,今天拿来刷短视频都嫌浪费。

一个念头就这么冒出来:能不能让它开机直接进 Steam 的 Game Mode,像一台掌机那样玩独立游戏?

这活儿要是自己干,要读高通的设备树、前向移植面板和触摸驱动、调音频、对付 bootloader,我大概会在第二个周末放弃。所以这次换了个玩法:把方向盘交给 Opus 5.5,我只负责划红线、按按键、当它的眼睛和鼻子。

结果是这样的:

红米 K40 上运行的 Steam Game Mode

这是从手机屏上抓下来的画面。两侧各 240 px 的黑边是因为 Steam 的 UI 是 16:9,而这块屏是 20:9。

先给结论

设备红米 K40 / POCO F3(alioth),骁龙 870 sm8250,Adreno 650
系统Valve Steam Frame 的 ARM 版 SteamOS + 主线 Linux 7.2.8
能做什么开机进 Game Mode,纯触摸登录 Steam,经 FEX + Proton 跑 Windows x86 游戏
主线工期2026-10-22 立项 → 10-24 阶段 2 验收,12 项里 11 项通过
产出11 个内核补丁、16 个断言脚本、一份 2700 行的结论文档
bootloader一个字节都没动过
代价一个听筒被烧出了糊味(后面细说)

如果只记住一句话:

AI 做硬件移植,难的从来不是写代码,而是「症状和原因离得极远」。模型会非常自信地从症状直接推到原因 —— 真正让这件事做成的,是把规则写成断言、把观测通道做扎实、把不可逆的那一步留给人。

项目在 ShouChenICU/SteamOSforAlioth,下面讲过程。

阅读地图
章节重点
一、这件事为什么现在才做得成Frame 镜像 + 上游手机模板,研究变适配
二、开工前,先给 AI 划红线三个永远不许写的分区,以及「ERASE」为什么只能人来敲
三、第一次真机:它推翻了自己前一天的结论fastboot boot 的 OKAY、翻车的「零风险探针」
四、一块黑屏,四个独立缺陷修好任意三个,屏幕还是黑的
五、触摸:bug 藏在参考实现里4 字节的缓冲区发 10 字节
六、事故:听筒被烧出了糊味+18.5 dB,而所有故障位都是绿的
七、能玩了,然后调帧率绑大核是负优化
八、和 Opus 一起干活的几条心得我真正做了什么

一、这件事为什么现在才做得成

先纠正一个说法:「移植 SteamOS 到 ARM」本身是错的。 SteamOS 3 是 x86_64 的 Arch 衍生版,公开渠道没有 ARM64 镜像。能用的,是 Valve 为 Steam Frame(一台 VR 头显)发的官方 ARM 镜像 —— 里面有 FEX(x86 → ARM 的翻译层)、有 ARM64 版 Proton、有 Mesa Turnip。

所以准确的说法是:把 Frame 的系统拿过来,底下换成能驱动这台手机的内核。

立项那天,Opus 做的第一件有价值的事不是写代码,而是找到了 hashtagbasit/SteamOS-ARM-Port。这个项目已经把 Frame 镜像搬到了红魔 6(骁龙 888)上,而它的约束和 K40 几乎一一对应:

  • 手机,无 SD 卡
  • 原厂解锁的 bootloader,不能换 ABL
  • A/B 双槽
  • 没有内置手柄
  • 固件不可再分发

这一个发现,把整个项目从「研究」降成了「适配」。

剩下的拼图:内核基线用 ROCKNIX 的 sm8250 内核,设备树、面板和触摸驱动从 postmarketOS 的 pmaports 前向移植(6.19 → 7.2.8,跨三个大版本)。

还有一条项目终止条件在第一天就被钉死:FEX 只能跑在 4K 页 的内核上。如果这台机器只能用 16K/64K 页,后面全部白干。Opus 没有等 40 GB 的构建跑完再去 grep .config,而是直接读 ROCKNIX 稀疏克隆里的基线配置 —— 几秒钟就确认了。

二、开工前,先给 AI 划红线

让 AI 碰一台手机的分区表,第一件事不是告诉它「做什么」,而是告诉它「绝对不能做什么」。我给的红线就三条:

分区为什么不能碰
abl_a / abl_bbootloader。写坏了只能走高通 EDL 9008,要签名的 firehose 程序,基本等于砖
superAndroid 的系统分区,8.5 GiB。留着,将来还能刷回 Android
vbmeta根本不需要。手机在 orange 状态下照样启动

userdata(Android 的 /data,106 GiB)在阶段 2 才放开授权,SteamOS 的 rootfs 就装在那里。

关键在于:红线不能只写在 prompt 里。 对话会被压缩,上下文会丢,下一个接手的 agent 根本没看过这段对话。所以每一条都被写成了脚本里的断言:

# 刷机脚本在断言不过时拒绝执行
for t in scripts/assert-alioth-*; do "$t" || echo "FAILED: $t"; done

其中 assert-alioth-boot-image.sh 会扫描刷机脚本全文,只要出现 abl 这个字符串就失败,注释也算。整个仓库里,那两个分区的名字一次都没出现在会执行的代码里。

另外两条机制是我特别要求的:

  1. --test 模式运行时读 fastboot getvar current-slot,目标槽等于当前活动槽就拒绝执行,永远不写正在用的那个系统。
  2. --install 是唯一会写 userdata 的路径,执行前必须在交互终端里手动输入 ERASE。

第二条值得多说一句。AGENTS.md 里专门写了这么一句给后来的 agent:

Do not type ERASE on the user's behalf. The prompt requires an interactive terminal specifically so that a script — or an agent — cannot satisfy it.

不可逆的那一步,交给能为后果负责的那个人。这不是不信任模型,是分工。

三、第一次真机:它推翻了自己前一天的结论

计划写得再漂亮,第一次插上真机就会被打脸。这次打脸了三次,最精彩的是前两次。

fastboot boot 的 OKAY 什么也不保证

为了「零写入试一下」,第一次用的是 fastboot boot,主机输出漂亮得很:

Sending 'boot.img' (14440 KB)   OKAY [  0.348s]
Booting                         OKAY [  0.774s]
Finished. Total time: 1.128s

屏幕一片黑。Opus 去翻了主机的 dmesg:从进 fastboot 开始,USB 一次 disconnect 都没有。

跳进内核意味着 bootloader 放弃 USB 控制器,主机必然看到断开。没有 disconnect,就是根本没跳。 fastboot 协议里 boot 的 OKAY 是在跳转之前发的 —— 它从来不是「启动成功」的证据。

「零风险探针」执行了

为了证实这一点,Opus 设计了一个探针:把 boot 镜像的 ANDROID! 魔数打坏,「ABL 解析头部时就会拒绝,不可能跳进去执行」。

它漏算了一层:主机上的 fastboot 程序。看到没有魔数的文件,它不报错,而是当成裸内核、用默认参数自己套了一个合法的 boot 头。ABL 收到一个结构合法、内核是垃圾的镜像,跳了进去,崩了,手机开始循环重启。

教训不是「别做探针」,而是探针的风险要算到工具链的每一层。一个声称「不可能执行」的测试,执行了。

自动回退存在 —— 和它前一天写的正相反

强制断电进 fastboot 读变量,current-slot 从 b 变成了 a,slot-unbootable:b:yes。期间没人执行过 --set-active —— 是 bootloader 自己回退的。

而就在前一天,Opus 在独立审查里论证过「这台机器没有自动回退」,证据是 UART 日志里的一行 retry count NOT decremented,外加一份 getvar 抓取 —— 后来发现那份抓取来自 pmaports 仓库里另一台机器。

它在 journal 里是这么记的:

用一行日志的否定式去证明一个机制不存在,是拿不完全的证据下完全的结论。

我很喜欢这种处理方式:不删掉错的结论,而是把三版结论和各自的依据都留下来,并且把这次教训写成一条新断言 —— 既不许把「等自动回退」当退路,也不许宣称「自动回退不存在」。

四、一块黑屏,四个独立缺陷

内核起来、触摸能用之后,进入阶段 2:装上 Valve 的 rootfs,让 Game Mode 跑起来。

然后是一块很长时间的黑屏。

黑屏的四层缺陷

这四个缺陷任何一个单独都足以黑屏,修完其中三个,屏幕依然是黑的。 你没法通过「修了一个,看看有没有变好」来确认方向对不对 —— 每一步的反馈都是同一块黑屏。

其中第二个是整个项目里我觉得最阴的:

a650_zap.mbn(GPU 的 zap shader)在 linux-firmware 里有同名文件,连字节数都一模一样(13964 字节),只差签名。小米的 TrustZone 只认自己签过的那份。

看文件名对、看大小对、看在不在也对,唯一的区别是屏幕一直黑着。最后的方案是从这台手机自己的 modem_a 和 vendor_a 分区里把签名版抽出来 —— 这也是为什么这个项目没有预编译镜像:小米签的东西,不能再分发。

第四个则是很典型的「配置写了,但没人读」:drop-in 改了 gamescope 的启动超时,但已经在跑的 systemd user manager 根本没 reload。daemon-reload 一下,gamescope 活过 91 秒、8 个 steamwebhelper 起来,Steam 界面出现了。

五、触摸:bug 藏在参考实现里

触摸 IC 是敦泰 FT8756。驱动报错:

Chip ID mismatch: expected 0x5652, got 0xefef

Opus 先做的不是猜,而是一条条砍掉「我们改坏了」的可能:

怀疑怎么排除的
DTS 写错和 pmaports 原版、厂商 LineageOS 的节点逐字对过
前向移植改坏了驱动和原版逐行 diff,635 行里唯一差异是自己加的 of_match_table
上电时序竞态空闲 403 秒后手工 rebind,读到完全相同的值
总线/协议故障状态字节和 CRC 都对得上

四项排除之后,范围被压到了「参考实现本身」。然后它找到了这个:

u8 *txBuf = devm_kzalloc(dev, 4, GFP_KERNEL);   /* 4 字节 */
...
dp    = txlen + 3;            /* 7 */
txlen = dp + val_size;        /* 8 */
if (txBuf[1] & DATA_CRC_EN)
        txlen += 2;           /* 10 */

4 字节的缓冲区,SPI 要发 10 字节,后 6 字节是相邻的堆内存。

这个 bug 能一直活着,是因为它不会让任何一层报错:前 4 个命令字节是对的,芯片正常应答,而状态字节和 CRC 是芯片对自己发出的数据算的。整条校验链全部通过,驱动行为却取决于 slab 分配器里剩下什么 —— 6.15 上碰巧读对,7.2.8 上读到 0xef。

转折点是那次手工 rebind:十几秒就把「时序」整类假设砍掉了,因为手机一直连着 USB 串口 shell。把观测通道做扎实,比任何单个假设都值钱。

六、事故:听筒被烧出了糊味

这一节我想写得诚实一点,因为它是这次协作里最贵的一课。

扬声器通路修通之后,Opus 做了一次听音确认:

speaker-test -D hw:0,0 -c 2 -t sine -f 440 -l 0

放了几分钟,我闻到了糊味,位置在机身上方的听筒附近。

事后复盘,操作上错了三处:

  1. -D hw:0,0 绕过了 PipeWire,走的是功放满刻度
  2. 用的是正弦波,同样峰值下送进音圈的平均功率比粉噪高得多
  3. -l 0 是无限循环

但更值得记下来的,是两个会复现的配置缺陷:

  • 两颗功放不该同增益。 参考配置两颗都是 +18.5 dB,照搬了。可 K40 的上方声道由听筒兼任,振膜和音圈比底部主扬声器小一个量级。
  • −12 dB 的数字衰减从来没生效过。 它写在 UCM 的 BootSequence 里,而这台机器上没有任何东西会执行 BootSequence。所以事故时的真实增益是 +18.5 dB,比 Opus 以为的高了 12 dB。

最让人后背发凉的是事后读两颗 CS35L41 的中断状态寄存器:六个故障位一个都没亮。 功放的过温保护测的是芯片结温,音圈根本没有传感器。

芯片视角一切正常,而听筒已经在烧。

这次之后,规则被一层层钉死:

  • 听筒增益必须低于主扬声器,assert-alioth-overlay.py 同时断言顺序和绝对上限
  • 听音测试只能用 scripts/alioth-speaker-test.sh:只走 PipeWire、只用粉噪、时长硬上限 5 秒、放前放后读故障寄存器
  • AGENTS.md 里写着:用户说「声音很大」是警告,不是成功

后来还有一段插曲:之后几轮「所有应用都没声音」,Opus 两次往「硬件已经烧坏」的方向推,最后查出来是 WirePlumber 的权限管理器 —— Frame 为了强制走 VR 空间音频,把一个裸 sink 对应用藏了起来,而那个 sink 的节点名恰好和这台手机的一字不差。和硬件毫无关系。

七、能玩了,然后调帧率

到这里,阶段 2 的 12 项验收过了 11 项(剩下的「待机一夜掉电」需要一整夜)。蓝牙手柄经 InputPlumber 被识别成 Steam Deck 手柄,Windows 版的 Ori and the Blind Forest 经 FEX + Proton 跑在 Adreno 650 上:

Ori 带性能浮层

浮层里那个 GPU 占用率也是一个小坑:MangoHud 读的 debugfs 文件主线内核里根本没有(于是有了补丁 0028),而且它把路径写死成 dri/0,开机早期偏偏被 simpledrm 抢走了 minor 0。

自己手上的 Windows 老游戏也能塞进来,这是 2003 年的《东方妖妖梦》,32 位 x86 + DirectX 8:

东方妖妖梦作为非 Steam 游戏运行

帧率调优里有个反直觉的结论,值得单独拎出来:

配置妖妖梦 fps
出厂默认(schedutil)48.40
CPU + GPU 切 performance60.00
schedutil + 线程绑到大核 cpu4-7更快 36.79

瓶颈不是算力,是调速器爬坡。 而「把游戏绑到大核」在两种调速器下都是负优化:schedutil 按核心容量归一化算利用率,同样的负载放到大核上算出来更低,反而不愿意升频;performance 下则是游戏和 gamescope、Steam 客户端挤在同一组核心里互相抢。

落地方案是一个只在 Steam 游戏运行时切 performance 的小守护进程,并且有一条断言专门禁止把绑核加回来。

八、和 Opus 一起干活的几条心得

回头看,我在这个项目里真正做的事情其实很少:解锁 bootloader、按音量下 + 电源、在终端里敲 ERASE、告诉它「屏幕右边点不到」「闻到糊味了」「先不要做任何操作」。剩下的 —— 读代码、写补丁、构建、刷机、排查、写文档 —— 全是它做的。

但这件事能做成,靠的不是模型「聪明」,而是下面几条:

1. 红线写成断言,不写在 prompt 里。 16 个断言脚本,每一个都对应一次真实犯过的错。AGENTS.md 里有一句:删掉一个失败的断言让流程变绿,是这个仓库里唯一被明确禁止的事。

2. 改之前先在真机上验证前提。 触摸那一轮,有四条看起来很对的结论,是回原厂 Android 上实测之后才被推翻的。

3. 两次猜错,就别猜第三次,把数据打出来。 ADSP 对着两个合理的理论两次返回 EBADPARAM,第三轮加了一段配置转储,答案当场可见:24 位采样塞进了 16 位的槽。

4. 让它记 journal,尤其是错的那些。docs/alioth-journal.md 里大半是走错的路。有用的信息,大半就在那里。

5. 喊停的时候,它真的会停。 有一次它在后台装 Firefox,我说了句「先不要做任何操作」,它立刻杀掉任务,然后主动列出了自己已经改动过的两处设备状态。这种「先披露、再等指示」的习惯,比任何一次成功的排查都让我放心。

当然它也不是不会犯错。就在上周,给 Chromium 配窗口时它按 2400 宽给了参数,而 Steam 会悄悄把 XWayland 的屏幕改成 1920 —— 右边 20% 既画不出来也点不到。它自己造了一个触摸死区,又自己查了出来。


最后说说充电。把充电电流从 2 A 提到 3 A 之前,我只跟 Opus 说了一句:

「操作的时候要注意,不能再像喇叭那样出问题了,电池更危险。」

它的回应是先查驱动,发现主线的 PM8150B 充电驱动根本没实现 JEITA(按电池温度自动降流的那套保护);于是 3 A 和一个电池温度看门狗必须一起上线,测量时电池超过 42 °C 立即中止。看门狗在真机上自己触发了一次:电池到 40 °C,输入上限从 3.3 A 压到 1.5 A,峰值 42 °C,降到 38 °C 以下再自动放回去。

原厂的 33 W 快充要走 BQ25970 电荷泵,主线没有驱动。那条路,还没开工。