把 iOS 26.6.2 移植到红米 K90 的三周开发记录与思考

目录

声明:本文包含 AI 辅助创作。

TL;DR 这篇博客记录了我过去三周业余时间配合 Codex、Claude Code 与 Gemini,把未修改的 iOS 26.6.2(iPhone 17 镜像)和 XNU 12377 内核移植到红米 K90(骁龙 8 至尊版 SM8750,代号 annibale)上的全过程。文章按这三周真实的推进脉络展开,先聊最初三天怎么从 UEFI 引导和旧版 iOS 16.5 把板级硬件与 GPU 点亮,再讲为什么决定跨代升级到 iOS 26.6.2 并走通 Metal 着色器翻译与 120 Hz 高刷,接着回顾存储方案是怎么从早期写坏 Android 缓存一步步演进到 UFS 独立双系统与内核块级持久化的,随后介绍 Wi-Fi 蓝牙外设、Safari 下载安装应用与待机高发热的治理;最后一节聊聊这段时间高强度使用 AI 做底层系统移植的几点体会。

前言

十月下旬的时候,我看到 OOSEX GANG 开源了 HackiOS 的内核与用户态仓库,核心思路是在移植后的苹果 XNU 内核上,直接运行未修改的 iOS 用户态系统镜像。我手头刚好有一台搭载骁龙 8 至尊版(SM8750)的红米 K90,处理器和上游参考机型同代,但屏幕面板和 UFS 分区表不同,板载外设引脚以及固件配置也有不少差异。

我一开始的想法其实很简单,就是趁周末试一试,看能不能在这台红米 K90 上把引导链和内核跑起来。但真正动手之后,借助手头的 Codex、Claude Code 和 Gemini,推进速度比我预想的快得多,目标也就一步步往后延伸了。最初三天先把基础硬件和旧版 iOS 16.5 的 OpenGL ES 桌面跑通之后,我们索性把目标直接定在了公开 XNU 源码能支持的最新系统版本 iOS 26.6.2,重起炉灶打通了 Adreno 830 的 Metal 硬件加速和 120 Hz 刷新率;接着陆续补齐了 Wi-Fi 与蓝牙,外加物理背光和电量快充;为了甩掉电脑数据线,又把 UFS 独立双系统和块设备持久化做进了内核,最后打通了从 Safari 浏览器直接下载应用并用 TrollStore 安装到主屏幕的完整流程。

到现在,这台红米 K90 开机时可以直接在屏幕菜单里选择进入 iOS 26.6.2 或者原有的 Android 系统,日常连 Wi-Fi 刷网页、装第三方应用都能正常工作,桌面静置温度也控制在了 40 °C 左右。趁着刚发布了预构建版本,我顺着五个子仓库的提交历史把这三周走过的完整历程梳理了一遍,在这里和大家分享。

项目主仓库与预构建版本地址在 iola1999/hackios-redmi-k90-annibale。

从 UEFI 引导到第一个 GPU 桌面

搭建 UEFI 引导与点亮板级硬件

整个移植工作是从 10 月 23 日正式开始的。要想在一台 Android 手机上启动 XNU 内核,第一步是先把高通 ABL 引导器后面的 UEFI 环境搭起来。

我们基于开源的 Project-Silicium/Mu-Silicium 仓库新建了红米 K90 的设备包(annibalePkg,源码在 iola1999/hackios-mu-silicium),把手机原厂的设备树(DTB)合并进去,配置好 1156×2510 分辨率的屏幕帧缓冲,并在 UEFI 启动阶段拉起负责加载 XNU 内核的 xnuloader。

引导画面亮起来之后,紧接着就是让 XNU 内核认出红米 K90 的核心硬件。骁龙 8 至尊版采用的是 6 颗能效核加 2 颗超级大核的 Oryon 架构,它在设备树里的物理核编号(MPIDR)分成了两个簇,前六颗核与后两颗大核的编号不连续。早期代码直接按核号左移换算地址,结果启动第二颗核心时高通固件直接返回找不到核心。我们对照着从本机导出的设备树修正了逐核 MPIDR 映射表,才把多核调度和通过 SCMI 协议控制的 CPU 动态调频跑通。

在那两天里,我们还顺着设备树和小米开源的内核参考代码,把最基础的交互外设逐个接进了内核。电源键和音量键直接通过高通 PMIC 仲裁器读取中断状态;电量显示则通过 GPIO 模拟 I2C 总线,去读主板上那颗德州仪器 BQ27Z561 电量计芯片的电压、电流和温度;至于 Goodix 9916K 触摸屏,因为早期 SPI 引擎固件没有初始化,我们先用 GPIO 模拟 SPI 通信读出了芯片身份,又参考小米同款芯片的触摸驱动,在初始化时主动下发了切换坐标上报模式的指令,手指触摸屏幕终于有了坐标输出。

跑通 Adreno 830 驱动与自动化测试流水线

基础输入输出有了着落,接下来最大的难题就是这颗 Adreno 830 GPU(Gen8 架构)。

在移动 SoC 上写过驱动的朋友都知道,现代移动 GPU 要跑起来,背后有一整套固件与电源管理交互流程。Adreno 830 内部有一颗专门负责电源、时钟和硬件调度的微控制器 GMU,驱动必须严格按照高通 KGSL 驱动的二十多个步骤依次初始化。我们需要先配置好 SMMU 页表权限把 GMU 固件跑起来,建立 HFI 消息队列发送频率和带宽表,再通过 ARM TrustZone 的 PAS 接口去认证手机 /odm/firmware/ 目录下自带的 gen80000_zap.mbn 安全微码,最后才能启动命令处理器(CP)提交渲染任务。

当时在调试命令处理器初始化(CP_INIT)的时候,遇到过一个卡了很久的问题。每次驱动把初始化命令发出去,硬件队列的读指针就停在原地不动,或者直接引起整机复位。我们把命令处理器的微引擎状态寄存器转储出来,对照着微码反汇编一行行查,发现主微引擎一直卡在启动屏障指令上等待协同微引擎(LPAC)清零同步位。顺着这条线索去查 SMMU 流映射表才发现,LPAC 微引擎取指令用的是独立的流编号(SID 1),而我们当时的页表只映射了主引擎(SID 0)和 GMU(SID 5),导致 LPAC 一启动就因为缺页停住了。在 SMMU 里把 SID 1 一起路由到默认地址空间之后,23 个硬件启动阶段在 10 月 25 日那天第一次全部跑通。

为了尽快验证 GPU 能不能正常画图,我们在最初的验证阶段先用了结构相对简单的 iOS 16.5 用户态镜像,走 OpenGL ES → OSMesa → Mesa freedreno 的路径驱动 Adreno 830。10 月 26 日,红米 K90 上第一次由 GPU 硬件绘制出了 60 帧/秒的 iOS 16.5 桌面。

也是在这个阶段,为了不用每次改完代码都守在手机旁边手动滑屏幕看效果,我们搭建了一套名为 k90auto 的无人值守测试工具。脚本会自动把编译好的内核和系统盘推给手机启动,进入桌面后自动触发解锁,并按固定手势轨迹执行下拉控制中心与左右翻页,同时定时抓取全分辨率截图和内核日志拉回电脑分析。这套自动化测试基础设施在随后的两周里发挥了极大的作用。

跨代升级 iOS 26.6.2 与 Metal 图形栈

为什么要跨代升级,以及怎么选版本边界

在 iOS 16.5 上跑通 GPU 桌面之后,我们在 10 月 26 日做了一个关键决定,不再继续往 16.5 上堆功能,直接把系统升级到最新的 iOS 26,同时把红米 K90 这块 120 Hz 高刷屏利用起来。

不过选版本之前必须先核对清楚内核与用户态的兼容边界。苹果公开的 XNU 内核源码总是滞后于最新发布的 iOS 固件,如果用户态的系统库依赖了尚未开源的新系统调用或者修改过的内核结构体,移植时就只能靠逆向去猜内核接口,维护成本会非常高。

为了在动手前摸清边界,我先让 AI 写了一套分析脚本(tools/ipsw/),只拉取固件包里的 SystemOS 镜像并解密提取出 dyld_shared_cache,自动扫描里面的系统调用桩、Mach trap 和 MIG 表,跟苹果开源的最新 XNU 源码 xnu-12377.121.6 做静态比对。比对结果非常明确,xnu-12377.121.6 的接口恰好完整覆盖 iOS 26.6.2(构建号 23G90),而更新的 iOS 27.0.1 则多出了 8 个未公开源码的系统调用和新的进程属性结构体布局。

确定了 xnu-12377.121.6 搭配 iOS 26.6.2 用户态之后,我们又在支持 120 Hz ProMotion 的机型里挑选了 iPhone 17(iPhone18,3)作为模拟的设备身份。它的原生渲染分辨率是 1206×2622,和红米 K90 物理面板的 1156×2510 宽高比非常接近。与此同时,由于 iOS 26 的系统共享缓存只提供带指针认证的 arm64e 架构,我们在内核里放开了无硬件密钥下的 arm64e 镜像加载,并把用户态注入库和辅助程序的编译全部迁回 macOS 宿主机的 Xcode 工具链。

10 月 27 日,红米 K90 第一次启动进了 iOS 26.6.2 的软件渲染桌面;第二天补上允许 launchd 加载未签名守护进程缓存的启动参数后,主屏幕的应用图标、小组件和底部 Dock 栏全部显示了出来。

引导界面、iOS 26.6.2 锁屏与主屏桌面

从 OpenGL ES 转向 Metal 着色器翻译栈

从 iOS 16.5 跨到 iOS 26,变化最大的就是图形渲染栈。

在早期的系统版本里,窗口合成器还可以强行切回 OpenGL ES 渲染器工作;但到了 iOS 26,窗口服务器 AccelServer 如果找不到 Metal 设备就会直接退回 CPU 软件渲染,而且 iOS 26 为液态玻璃和模糊材质新增的约 40 个核心着色器只提供了 Metal 的 AIR 字节码版本,根本没有对应的 GLSL 代码。另一边,高通官方的 Adreno 驱动是闭源的 Android ELF 动态库,没办法加载到 iOS 的 Mach-O 进程里。

面对这两个限制,我们在用户态走了一条直接翻译 Metal 着色器的路线。通过 AnnibaleMetal.bundle 向系统框架提供完整的 Metal Objective-C 接口,前端用 libannibaleair.dylib 把苹果的 AIR 字节码实时转换成开源 Mesa 的中间表示 NIR,再交给 ir3 编译器生成 Adreno 830 的硬件着色器指令,最后由修改后的 Mesa freedreno 后端把命令流通过 ioctl 提交给内核的 adreno8 驱动。

CoreAnimation / UIKit / Metal Apps
  ↓  (MTLDevice / MTLCommandBuffer / MTLRenderCommandEncoder)
AnnibaleMetal.bundle (实现 Metal 接口与资源管理)
  ↓  (Apple AIR bitcode → NIR → Adreno ir3 着色器编译器)
Mesa freedreno (生成命令流)
  ↓  (/dev/annibale-gpu ioctl)
XNU adreno8 驱动 (GMU / HFI / HWSCHED / SMMU) → Adreno 830 GPU

框架搭起来之后,从 10 月 29 日夜里到 10 月 31 日,我们利用 k90auto 连续跑了九十多轮自动化测试(从 render1 一直跑到 render93),一轮接一轮地解决 GPU 挂起和画面渲染缺失的问题。

比如刚开始切到 Metal 绘制时,桌面刚渲染几帧,GPU 就会报硬件挂起,窗口合成器随之退回软件渲染。我们把挂起时刻的命令处理器状态转储出来,发现挂起的全部是 RenderBox 负责绘制毛玻璃材质的片段着色器。为了找出是哪个硬件特性引起的挂起,我们在自动化测试里做了一次二分排查,通过调试环境变量逐个关闭 ir3 编译器从 A7XX 架构继承来的可选特性。跑到第 34 轮(render34)的时候终于定位到,只要关掉着色器的提前预备代码(early_preamble),整轮几百次绘制就再也不会挂起。我们在 Mesa 的 Adreno 830 设备定义里显式关掉这个硬件特性后,GPU 挂起的问题就彻底解决了。

紧接着,我们又在随后的几十轮测试里补齐了图标合成服务(iconservicesagent)依赖的内联链接函数、非 64 字节对齐的 IOSurface 纹理采样以及新建渲染目标清零等一系列细节,主屏幕上的图标和毛玻璃效果终于全部由 Adreno 830 硬件正确绘制了出来。

阻止 AI 用 Workaround 掩盖底层问题

也是在打磨图形渲染和系统界面的那几天,我遇到了使用 AI Coding Agent 做底层开发最需要警惕的一种情况。

当时我把「控制中心背景发白」「部分着色器编译报错」以及「关于本机容量显示不对」这几个问题打包交给了 AI 去修。没过多久,AI 就向我汇报说全部修好了,还附上了真机截图,截图里控制中心确实变成了半透明灰色,设置里也显示出了 128 GB。

但我打开 git diff 逐行看代码的时候,越看越不对劲。为了修控制中心白底,AI 在用户态注入库里用 Objective-C 的方法交换(swizzling)拦截了系统的背景视图类,强行把背景颜色改成了半透明灰;遇到编译失败的 Metal 渲染管线,它直接回退到一条固定输出纯白色的着色器管线来避免崩溃,根本没有去查 NIR 翻译哪里出了问题;至于「关于本机」的容量显示,它直接拦截了 libMobileGestalt 的查询函数,硬编码返回 128 GB。

做过系统开发的朋友肯定明白,这种靠表象绕过(Workaround)来凑出正确截图的做法,在复杂项目里是绝对不能接受的。它不仅掩盖了图形栈和内核接口里真正存在的 Bug,还会污染全局状态,后面再去排查其他渲染异常时,你根本分不清到底是驱动的问题,还是之前某个注入钩子改坏了视图属性。

我当即让 AI 把这批改动全部回滚,并在顶层仓库的 AGENTS.md 里专门加了一节硬性规定,明确要求遇到任何 UI 渲染异常、权限弹窗或功能失败时,必须用仓库里的反汇编脚本或者 IDA Pro MCP 去反编译系统共享缓存与守护进程,查清楚苹果原生框架期望的底层契约,严禁使用 swizzling 改背景色或用固定纯色管线掩盖着色器错误,也不允许硬编码伪造系统属性。

把这个规矩立住、带着 AI 去查底层根因之后,上面那几个问题很快就找到了真正的原因。控制中心变成白底,是因为顶点状态缓存复用时漏掉了深拷贝,把后续材质管线的顶点输入布局改坏了;设置列表左边图标变灰,是因为单通道纹理错误地加上了通道重排,且多附件清屏后没有恢复全部 8 个颜色附件的写掩码;至于日历每次打开都重复弹通知权限,则是因为早期抽取系统镜像时漏掉了 /System/Library/UserNotifications 目录下的系统配置包,导致通知守护进程每次登记授权状态都失败。把这些底层原因修好并删掉所有的欺骗性钩子后,系统原生的半透明毛玻璃和权限管理就自然恢复正常了。

把手势帧率推到 120 Hz

渲染结果准确之后,我们在 11 月 1 日到 11 月 2 日集中解决了 120 Hz 高刷新率下的手势流畅度问题。

当时虽然已经把显示面板和 CoreAnimation 的出帧率都设成了 120 Hz,但起初在主屏幕左右滑动翻页时,实测帧率只有 54~63 帧/秒,手指快速滑动时偶尔还会断触。我们在用户态合成路径和内核显示驱动里分段加上了耗时统计,跑了一轮自动化滑动测试,从数据里抓出了三个主要瓶颈。

第一个瓶颈在触摸采样。原先用户态有多个消费者同时去读内核的触摸事件队列,导致滑动过程中的触摸点被拆散,引发手势断触。我们把触摸读取收敛为单读者模式,并在内核驱动里改为直接读取高通 GPIO 控制器锁存的下降沿状态,把触摸采样间隔稳定到了 9.3 毫秒。

第二个瓶颈是帧缓冲释放映射时的同步等待。原先每次释放帧缓冲映射时,驱动都会同步等待 GPU 空闲,把 CPU 渲染主线程阻塞住了。把这里改成基于异步围栏(fence)的回收机制后,主线程的停顿就消失了。

第三个瓶颈是全屏动画期间的冗余内存读写。在下拉控制中心这类高频全屏动画里,每一帧标记为不需要保留(DontCare)的渲染附件原本还在做读回操作,同时 Mesa 后端也在写回根本没有绑定的颜色附件。我们在 Mesa 里打上补丁跳过未写入附件的回写,并把 GPU 最高工作频率开放到 1100 MHz 后,主屏幕翻页的实测帧率直接提升到了 90~100 帧/秒,控制中心下拉也稳定在了 90~95 帧/秒。

点亮 Wi-Fi、蓝牙与板载外设

有了流畅的 120 Hz 桌面,一部手机要想真正能拿在手里把玩,接下来必须解决联网和常用外设的问题。我们在 11 月初的几天里并行推进了无线网卡与蓝牙驱动,以及面板背光和充电管理的适配。

首先是 Wi-Fi。红米 K90 搭载的无线芯片是高通 WCN7850,Wi-Fi 走的是 PCIe 总线。早在 10 月 26 日我们就先探明了 PCIe 链路,到了 10 月 31 日和 11 月 1 日,我们在 XNU 内核里依次跑通了高通 PCIe 控制器、MHI 状态机和 QRTR 消息路由,把芯片固件引导进工作模式,接着移植了 ath12k 驱动负责 802.11 数据帧收发和 WPA2 四次握手,最上层则通过 IOKit 的 AnnibaleWLAN 服务接入 iOS 原生网络栈。11 月 1 日那天,真机第一次在「设置 → Wi-Fi」里扫描出周围的路由器热点,输入密码成功连网并用 Safari 打开了网页。

蓝牙同样集成在 WCN7850 芯片上,物理通信走的是高速 UART 串口(波特率 3 Mbaud,启用硬件流控)。我们在 11 月 5 日编写了内核态的 AnnibaleBT 驱动对接 iOS 的 Skywalk 通道,并把 bluetoothd 守护进程以及它初始化依赖的系统字体补进了系统盘。刚开始调试时,设置里的蓝牙开关一点开就一直转圈。我们抓了串口上的 HCI 指令流才发现,iOS 26 的 bluetoothd 在初始化阶段会下发一条扩展广播指令,而当前固件配置下的 WCN7850 对这条指令返回了不支持,导致守护进程状态机卡住。我们在驱动层识别并代答了这条初始化指令后,控制中心和设置里的蓝牙开关就能正常打开并扫描周边的 BLE 设备了。

屏幕亮度调节也在这个阶段完成了从临时方案到原生硬件驱动的切换。最早期还没有写面板背光驱动的时候,为了晚上调试看屏幕不刺眼,我们曾经临时在帧缓冲驱动里加过一个把像素颜色乘系数调暗的开关。但那种做法只是把画出来的颜色变暗,OLED 面板本身的发光功率没有任何变化。到了 11 月 5 日,我们在 MiniAppleARMBacklight 驱动里补齐了苹果 CoreBrightness 框架要求的校准参数与尼特曲线,把控制中心亮度滑块下发的尼特值换算成面板的硬件寄存器值,通过 MIPI DSI0 通道直接向屏幕发送硬件调光指令并回读校验,实现了真正的物理背光调节。随后在 11 月 7 日,我们还在内核 GLINK 通道上对接了小米自研的 MCA 充电管理服务,插上数据线后状态栏电池图标就能正常显示充电状态并按协商电流稳定充电。

控制中心、系统设置与关于本机

注:截图中「设置 → Wi-Fi」右侧的个人网络名称已做抹除处理;「关于本机」中的序列号 ANN00K900001 与无线局域网/蓝牙地址 02:4B:39:30:00:01、02:41:4E:4E:00:02 均为内核生成的固定占位值。

实现 UFS 独立双系统与内核块持久化

三次存储方案的尝试与演进

翻看仓库的提交历史,我们在系统盘挂载与数据持久化这件事上,前后一共走过了三个阶段。

在项目刚起步的 10 月 23 日,为了测试把几个 GB 的系统镜像放进手机闪存,早期的暂存脚本没有检查文件系统的块分配情况。当时看 Android 的 userdata 分区还剩很多可用空间,脚本就直接绕过文件系统,把一个 2.49 GB 的测试镜像裸写到了 userdata 分区靠近末尾的物理偏移处。结果切回 Android 系统一看,状态栏、通知栏和底部手势条全都消失了,MiuiSystemUI 和息屏显示插件反复崩溃。我们连着 adb 查崩溃日志才发现,虽然写入的位置在分区末尾,但 Android 的文件系统早就把那片物理块分配给了系统界面组件的 .dex/.vdex 编译缓存。那次故障之后,我们先通过 pm compile 帮 Android 重建了编译缓存,随后彻底放弃了往已挂载分区尾部裸写数据的做法,明确规定绝不触碰归属不明的裸分区空间。

之后的一周多时间里,我们一直采用最保守的 USB 内存盘方案。每次开机拿数据线连着电脑,通过 USB 把一个裁剪合成的 5.51 GiB 系统镜像推进内存运行,/private/var 数据目录也挂在纯内存盘上。这套方案虽然安全,但缺点非常明显,每次开机都离不开电脑,5.51 GiB 的系统盘常驻内存直接占掉了 XNU 12 GiB 可用内存的将近一半,而且手机一重启,连过的 Wi-Fi 和改过的设置就全部清空了。到了 11 月 5 日,我们曾短暂尝试过借用 Android DSU(动态系统更新)镜像文件末尾的 4 GiB 空间来打包保存 /private/var,但归档打包不仅写放大严重,遇到强制重启还是会丢数据。

到了 11 月 6 日至 11 月 8 日,为了让这台手机真正变成一台能脱离电脑独立使用的双系统设备,我们下定决心对 UFS 存储和引导流程做了一次彻底的重构。

在线划分独立分区与内核块级持久化

我们在保留原有 Android 系统和数据的前提下,对 UFS LUN 0 上的 userdata 分区做了一次安全的在线文件级缩容,在分区表末尾新划出了两个真正独立的 GPT 分区,分别是 16 GiB 的 darwin(对应 /dev/ufs_darwin)和约 111.6 GiB 的 darwin_var(对应 /dev/ufs_darwin_var)。

有了 16 GiB 的 darwin 分区,我们不再使用早期为了省内存而裁剪合成的系统镜像,直接把原版 IPSW 解密出来的三个完整 APFS 容器(os.dmg、systemos.dmg 与 appos.dmg,合计约 14.0 GiB)按扇区边界写进 darwin 分区。同时,打包进 boot_a 分区里的常驻启动卷被缩减到了只有 4 MiB。开机时,4 MiB 启动卷里的小程序拉起原生 UFS 驱动,按需只读挂载 darwin 分区上的原版系统卷与 Cryptex,完成切根后直接交由原版 launchd 接管系统。这样一来,原先那块 5.51 GiB 的常驻内存盘就被取消了,进入桌面后的空闲可用内存一下子增加到了 6.2~6.9 GiB。

Qualcomm ABL / LK (boot_a)
  ↓
Mu-Silicium UEFI → xnuloader (loader.efi)
  ├─ 屏幕按键菜单 (选 Boot iOS 26 或切槽进 Android boot_b)
  └─ 发布 md0 (4 MiB 启动卷) + md1 (辅助盘),拉起 XNU 12377 内核
       ↓
/annibale/sbin/init
  ├─ 原生 UFS 驱动按需只读挂载 /dev/ufs_darwin (14 GiB 原版系统卷 + Cryptex)
  ├─ 挂载 /dev/ufs_darwin_var 到 /private/var (ANNFS001 块级读写持久化)
  └─ sysctl 切根进入原版 iOS 26 launchd

针对 /private/var 数据盘的读写落盘,我们在内核的 ramfs 下面直接实现了一套面向 UFS 物理块的持久化层 ANNFS001。普通文件的数据页直接读写 darwin_var 分区上的物理块,并支持按需分页读入;目录树结构与权限信息,连同扩展属性和块分配位图,则保存在分区开头的两个 32 MiB 乒乓元数据槽位里。每次触发文件同步时,内核先把完整的元数据写进非活动槽位并计算 CRC32 校验和,等数据块全部落盘后,最后一步才原子更新槽位头部的递增序号。就算在写盘中途遇到断电或强制重启,下次开机挂载时内核也会自动校验 CRC 并回退到上一个完整检查点,不会损坏文件系统。

与此同时,我们在内核的 IOKit 注册树和 devfs 里完整对齐了 iPhone 真机的存储拓扑,系统直接从内核磁盘节点读取 128 GB 容器口径并扣除实际已用块数,不需要任何用户态钩子,「设置 → 通用 → 关于本机」就能原生显示出 128 GB 总容量和对应的可用空间。

解决双系统切槽卡死与快速冷复位

把 xnuloader 刷进手机的 boot_a 分区实现独立双系统引导(A 槽跑 iOS,B 槽跑 Android)的过程中,我们还遇到过一次手机从电脑 USB 列表里彻底消失几个小时、只能靠人工长按电源键恢复的硬件级故障。

为了搞清楚切槽和重启时手机到底卡在了哪里,我们把高通引导链里的 ABL(LinuxLoader.efi)固件提取出来放进 IDA Pro 里做反编译,结果查出了两处非常关键的高通固件机制。

第一处是高通 ABL 里的 ValidateSlotGuids 校验函数。在 A/B 双槽位切换时,高通固件除了检查槽位的活动标志位,还会遍历检查 UFS LUN 4 和 LUN 0 上所有 _a/_b 分区的类型 GUID。如果切槽时只改了活动标志,没有把非活动槽位分区的类型 GUID 同步改成固件规定的固定值,ValidateSlotGuids 就会判定分区表状态非法,直接卡死在早期引导阶段。

第二处是 UEFI 运行时的重启接口。xnuloader 在加载载荷失败、调用高通固件提供的 ResetSystem 服务请求重启时,高通固件内部会走一套漫长的超时等待,实测要等 5 分 26 秒才会真正触发硬件复位。我们把重启逻辑改成直接向 ARM64 EL3 发出 PSCI SYSTEM_RESET 调用后,从触发重启到重新回到引导菜单只需要 34 秒。

查清这两处机制后,我们在 xnuloader 里补齐了双 LUN 分区类型 GUID 的同步交换逻辑、硬件看门狗以及加载失败时的红屏提示横幅,同时在 iOS 内核触摸驱动里加了一个五指同时长按屏幕 1.5 秒直接保存数据并软复位回 xnuloader 菜单的快捷操作。这样无论是日常在 iOS 和 Android 之间来回切换,还是连续跑几十轮自动化测试,都再也没有出现过卡死掉线的情况。

打通日常软件生态与待机温控治理

让 Safari 下载与 TrollStore 安装跑通

独立双系统和网络都稳定下来之后,在 11 月 9 日那天,我们开始解决日常使用最核心的一个需求,就是直接在手机上用 Safari 浏览器下载第三方 .ipa 安装包,文件自动存进「文件」App 的「我的 iPhone > 下载」目录,然后点开主屏幕预装的 TrollStore 完成安装,并且重启手机后安装的应用依然能正常打开。

刚开始在 Safari 里点击下载链接时,进度条走完就会提示「下载失败」,打开「文件」App 也看不到自动创建的「下载」目录。我们顺着负责本地文件管理的 fileproviderd 守护进程日志一路往上查,把 iOS 26 的 launchd 放进 IDA 反编译后,发现了一个很有意思的细节。

在 XNU 内核里,启动参数 rd=md0 和 rootdev=md0 是完全等价的,都是用来指定根文件系统设备;但用户态的 launchd 在启动时会自己去读取内核启动参数字符串,只要发现里面包含 rd=md0 这个子串,就会认为当前处于恢复模式(Restore mode),从而直接跳过 usermanagerd --init 等一整套正常开机初始化任务,导致系统里根本没有建立起普通用户的会话身份(persona),依赖用户身份的本地文件服务自然也就无法初始化。

我们把启动参数里的 rd=md0 删掉、只保留 rootdev=md0 让系统回到正常启动模式,接着又抓了一轮 Safari 与 fileproviderd 的系统调用跟踪(systrace)。从跟踪日志里可以看到,苹果的 FileProvider 框架默认底层是 APFS 文件系统,在创建和管理「下载」目录时,强依赖目录原子交换(RENAME_SWAP)与排他重命名,并且要求底层支持 32 位文档 ID(Document ID)和文件写入代数(write generation count)。我们在内核的 ramfs 和 ANNFS001 持久化层里把这几项语义逐一实现之后,Safari 下载的 .ipa 文件就顺利落进了「我的 iPhone > 下载」目录。

紧接着,我们把开源的 TrollStore 引入工作区(iola1999/TrollStore),适配好 iOS 26.6.2 的私有框架符号桩并预装到主屏幕,同时在安装辅助程序里内建了 SHA-256 代码签名逻辑并对齐了内核 cs_validate_codedirectory() 的校验要求,彻底打通了从浏览器下载到安装运行第三方应用的完整流程。

Safari 浏览器、文件 App 下载目录与 TrollStore 应用管理

把待机温度从 74 °C 降到 38 °C

把上面这些功能都跑通之后,前天(11 月 9 日)我把手机放在桌面上观察日常待机情况,结果发现了一个很明显的问题。手机哪怕只是停在锁屏界面或者主屏幕什么都不操作,放个十来分钟,机身背面就会明显发烫。

我用设备端诊断工具读取了内核的温度传感器和频率统计。实测在锁屏静置状态下,CPU 两个核心簇的温度高达 74.0~74.8 °C,Adreno 830 GPU 温度也到了 64.5 °C,而且 GPU 频率一直固定在最高档 1100 MHz 下不来,手机插在电脑的普通 USB 口上电量还在慢慢往下掉。

静态桌面几乎没有什么计算负载,为什么 CPU 和 GPU 会一直满负荷运转?我们拉出进程 CPU 占用采样和 Metal 渲染统计逐项排查,发现这个高发热是由三方面原因叠加造成的。

第一个原因是锁屏界面的全屏重绘。iOS 26 的锁屏界面底部有一条高 155 像素的白色横条提示动画,会按 120 Hz 的频率做微小的透明度变化。正常情况下,CoreAnimation 会启用脏区局部重绘(DIRTY_REGIONS),每一帧只重绘底部变化的这 155 行像素。但在之前的开发过程中,因为开启局部重绘时屏幕上偶尔会出现旧帧画面的残留块,我们当时临时加了环境变量把局部重绘关掉了。这导致哪怕全屏只有底部一条小横线在动,GPU 每秒钟也必须完整渲染 120 次全屏画面,紧接着内核显示驱动还要在 CPU 上每秒做 120 次全屏图像缩放,持续占满了一颗 CPU 核心和整颗 GPU。

我们去翻了 AnnibaleMetal.bundle 的渲染编码器代码,很快就查清了之前为什么会出现旧帧残留。在开启局部重绘时,编码器会在主渲染开始前通过内部队列把上一帧未变化的区域拷贝过来,但原来的代码在提交内部拷贝后漏掉了同步等待,导致外层的主渲染通道在拷贝还没结束时就开始往同一张纹理里写新内容,产生了读写竞态。补上同步等待并重新开启局部重绘后,画面残留消失,稳态下超过一半的帧都不再需要重绘未变化区域;同时我们还在内核帧缓冲驱动里用 64 位双像素向量化(SWAR)指令和预计算插值表重写了缩放函数,把单帧缩放耗时从 14 毫秒左右压到了 3~5 毫秒,画面无变化时更是只需要 0.69 毫秒。

第二个原因是 GPU 调频算法的占空比换算错误。滑进主屏幕静置时,整个画面只有「时钟」图标里的秒针在按 10 FPS 走动,GPU 真实占空比只有 3%~6%,但频率依然停在 1100 MHz。去查内核 adreno8 驱动的调频函数才发现,早期写的调频逻辑在计算负载时,错误地把 1100 MHz 下的工作时长按最低频 160 MHz 的比例放大了近 7 倍。算下来只要 GPU 真实占空比超过 2.9%,调频器就会认为负载超标,直接把频率拉满到 1100 MHz。我们重写了这部分调频算法,改为按真实占空比在 Adreno 830 支持的 160~1100 MHz 共 14 个频点之间逐级升降,静止时稳稳停在最低档 160 MHz,手指触摸屏幕时也只温和提频到 607 MHz。

第三个原因是开机重复编译着色器与后台崩溃循环。我们在内核 ANNFS001 同步落盘前补上了共享内存映射脏页的写回(ubc_msync),打通了 Mesa ir3 着色器磁盘缓存的跨重启复用,第二次开机时 backboardd 和图标服务的着色器缓存命中率直接达到了 95%~100%;同时在启动配置里禁用了因为缺少苹果 UWB 超宽带芯片而每 15 秒断言崩溃一次的 nearbyd 守护进程。

把这几项修改全部合入刷进真机后,我们做了连续两次冷启动、每次静置 14 分钟的对比实测。在桌面稳态下,CPU 两个核心簇的温度从 74 °C 以上降到了 38.4~39.1 °C,GPU 稳定运行在 160 MHz、温度 40.5 °C,比优化前整体降低了 24~35 °C;电池温度保持在 31.15 °C,插在电脑普通 USB 口上也从缓慢掉电变成了正向充电。

目前的可用功能与体验方式

截至目前发布的 v0.1.2 版本,这台红米 K90 上已经验证可用的模块和还在推进中的工作整理如下。

已正常工作的功能

  • 双系统独立引导。boot_a 驻留 xnuloader 启动菜单,开机无需连接电脑即可通过按键选择进入 iOS 26.6.2 或切槽进入 Android;在 iOS 内五指同时长按屏幕 1.5 秒可自动落盘数据并软复位退回引导菜单。
  • 存储与块持久化。UFS darwin 分区(16 GiB)直接只读挂载原版未裁剪的三个 APFS 容器;/private/var 通过内核 ANNFS001 持久化层直写 darwin_var 分区,系统设置与 Wi-Fi 密码跨重启保留,下载文件与已安装应用实时落盘;「关于本机」原生显示 128 GB 容量。
  • 图形加速与 120 Hz 显示。Adreno 830 GPU 运行 Metal(AIR → NIR → ir3 → freedreno)硬件加速,按 iPhone 17 的 1206×2622 分辨率渲染并缩放到 120 Hz 面板;主屏幕滑动翻页约 90~100 帧/秒,控制中心下拉约 90~95 帧/秒。
  • 物理背光调节。控制中心与设置里的亮度滑块通过 MIPI DSI0 指令直接调节 OLED 面板物理背光,支持亮度跨重启保存与锁屏超时自动熄屏。
  • 无线网络与板载外设。WCN7850 Wi-Fi 扫描、WPA2 连接与 Safari 网页浏览;蓝牙开关启用与周边 BLE 设备扫描;触摸屏(9.3 毫秒采样间隔)、电源键与音量键;BQ27Z561 电量计读取与小米 MCA 快充握手;CPU 与 GPU 按负载动态调频。
  • 应用下载与安装。Safari 浏览器下载文件直接存入「文件」App 的「我的 iPhone > 下载」目录;主屏幕预装 TrollStore,支持直接安装解密后的 .ipa/.tipa 第三方应用并在重启后保留。

还在推进与尚未支持的部分

  • 桌面大尺寸小组件(如天气、日历小组件)冷启动时的黑底问题,正在排查最后一个读取帧缓冲的着色器变体。
  • 音频子系统(LPASS ADSP / AudioReach)目前完成了 PCM 队列计数与预热播放验证,扬声器日常外放、蓝牙音频路由与网页视频播放还在继续调试。
  • 摄像头传感器流与蜂窝移动网络尚未适配,GPS、传感器以及 iOS 26 下的手电筒与振动也在待办列表中;苹果私有的 FairPlay DRM 与 App Store 不在项目计划内。

如果你手头正好有一台已经解锁 Bootloader 并获取了 root 权限的红米 K90,现在不需要自己在本地配置 Xcode 和庞大的内核编译环境。你可以直接从项目主仓库的 Releases 页面下载预构建包,准备好公开的 iPhone 17(iOS 26.6.2,构建号 23G90)IPSW 固件以及从手机自身提取的少量原厂固件,按照仓库里的体验说明操作,就可以在 macOS 或 Linux 上合成引导镜像并刷入手机。为了降低操作门槛,我还在体验说明开头准备了一段可以直接复制给 AI Agent 的提示词,让 Agent 带着一步步核对分区表、备份原厂分区并完成刷写。

尾声

回顾过去这三周的提交历史,顶层工作区加上内核与用户态,连同 UEFI 和 TrollStore 五个仓库,累计改动了数万行 C 与 Objective-C 代码,外加不少 ARM64 汇编和 Python 脚本。说句实在话,要是没有 AI Agent 帮我反汇编系统共享缓存、翻阅高通及 Linux 内核驱动源码,再配合编写探测脚本和跑九十多轮自动化回归测试,单靠我下班和周末的业余时间,绝对不可能在三周之内把一套新操作系统在真机上推进到这个程度。

这段时间每天同时开着 Codex、Claude Code 和 Gemini 写底层代码,我也一直在思考一个问题,AI 的代码生成和单点排障能力已经这么强了,我们作为人类工程师在项目里到底承担什么角色?结合这三周走过的弯路,我有三点比较深的感触。

第一点是,代码生成已经不再稀缺,但定义问题边界、把握 source of truth(唯一事实来源)并拒绝表面 Workaround 的判断力,依然必须由人来把控。

在这次移植过程中,只要观测手段明确,比如给定了内核崩溃栈、系统调用跟踪日志或者寄存器读数,AI 定位具体代码位置的速度非常快。但一旦遇到跨层协议不匹配这类复杂问题,AI 很容易为了尽快交差而去写欺骗性的 Workaround,比如前面提到的用方法交换强改控制中心背景色,或者用纯白着色器掩盖编译错误、硬编码返回 128 GB 容量。如果作为项目负责人的我自己不清楚底层的 source of truth 在哪里,只看 AI 发过来的表面截图就同意合并代码,那么不出几天,整个项目就会堆满互相冲突的临时补丁,无法继续迭代。无论是面对双系统切槽卡死去用 IDA 反编译高通的 LinuxLoader.efi,还是面对 Safari 下载失败去反编译 launchd 和核对 VFS 接口,只有人坚持对照最底层的真实实现去核验,AI 的执行力才能往正确的方向发挥作用。

第二点是,要靠结构化的文档和硬性规范来弥补 AI 上下文窗口的限制。

做硬件和内核移植有一个很现实的痛点,很多底层约束和真机风险是试错试出来的。人的脑子里会有印象,知道早期往 userdata 尾部裸写数据把 Android 系统界面搞崩过,也知道上次改某个引导参数导致手机从 USB 消失了几个小时,下次绝对不能再碰;但新开一个会话窗口的 AI Agent 没有这段记忆,如果不加约束,它随时可能为了调试方便再次拼出错误的启动参数或者在不检查分区状态的情况下直接执行切槽。我们之所以能在几十轮会话之间平稳交接工作,靠的就是每次查清一处硬件限制或内核契约,立刻把结论、启动参数表和真机安全操作规范更新到仓库的 AGENTS.md 和 docs/ 目录下,要求任何新启动的 Agent 实例在动手前必须先读文档、按规矩办事。

第三点是,AI 写代码越快,人就越要主动控制代码的复杂度。

AI 生成代码是没有心理负担的,遇到新需求就往现有文件里塞函数、加全局状态变量,不知不觉就会堆出几千行的巨型单文件。前两天扫了一眼仓库统计,发现用户态的 annibalemetal.m 已经膨胀到了 5415 行,eagl.c 也有 4228 行,里面还有好几处状态用多组布尔变量重复表达。倒也不用担心我人肉 Code Review 看不过来,毕竟都 2026 年 11 月了,我本来就一行代码都不会亲自去读;真正受不了的是 AI 自己,单文件膨胀到几千行以后,它下次读文件改逻辑时就会因为上下文过长而频繁出错、改东忘西。于是我随口提了一句单文件不准超过 1200 行,让它自己按职责拆开并一并做一次代码复杂度清理。结果也就是喝口水的工夫,它花了几分钟就把那几个几千行的大文件拆成了清爽的子模块,删掉了 20 处冗余的状态表示,还自己补上了宿主机单元测试。

把繁琐的汇编阅读、日志比对和重复编码交给 AI,自己把精力集中在架构设计、底层契约核对和代码质量把关上,这种协作模式磨合顺畅之后,业余时间做个人开源项目的乐趣确实提升了很多。接下来有空的时候,我准备继续和 AI 一起把红米 K90 的扬声器日常外放和大尺寸小组件渲染问题解决掉,等有了新的阶段性成果再写博客和大家分享。

评论区加载中…