给自编译的纯 AOSP 15 焊上内核级 Root(SukiSU-Ultra GKI 内建,Pixel 9 Pro XL)

📚 《从源码长一台 Pixel:自编译纯 AOSP 15 日用机》系列

  1. 打通中国移动 VoLTE 电话
  2. 焊上内核级 Root(SukiSU-Ultra GKI 内建) ←(本篇)

本系列记录把一台 Pixel 9 Pro XL(komodo)刷成”从源码自编译、除必要闭源组件外尽量纯净”的 AOSP 15 日用机的全过程。

前言

接着上一篇打通中国移动 VoLTE 的 Pixel 9 Pro XL(代号 komodo,自编译纯 AOSP 15)继续折腾。这台机器我想要的是一个全功能日用机:电话、蓝牙、WiFi、GApps、双击熄屏,外加一个内核级 Root

为什么非要内核级(KernelSU/SukiSU 这一类),而不是 Magisk 那种基于 init 的方案?因为既然内核都是我自己从源码编译的,root 就应该焊死在内核里——不依赖 ramdisk patch、不怕 OTA、不怕全量擦除,开机即生效。

听起来很美。实际上,从”内核里编进了 KSU”到”管理器真的拿到 root”,中间隔着五道坎,每一道都足够让人卡一整天。这篇就是完整记录。剧透结局:最后在 SELinux enforcing 下,SukiSU-Ultra 管理器显示绿色的 Workingdmesg 里那行 Crowning manager 稳稳出现,而且整套被烤进了 ROM 镜像,全量擦除重刷也不丢。

注:本文所有设备序列号、签名密钥指纹、内网地址、主机名等均已脱敏。涉及的 GKI 版本号、KMI 代号、内核配置项均为公开技术信息,予以保留。文中”编译服务器”指一台远程 Linux 构建机。


零、先把”内核级 root 到底怎么生效”想清楚

走弯路之前,得先理解 KernelSU 系(SukiSU-Ultra 是其分支)的工作模型。内核里编进了 KSU 代码 ≠ 你有 root。真正的链条是:

  1. 内核里的 KSU 通过 hook execve,监视进程启动;
  2. 检测到 zygote 启动(/system/bin/app_process 第一次拉起),触发 on_post_fs_data()
  3. on_post_fs_data 启动一个 throne_tracker,去扫 /data/app 里安装的 APK;
  4. 对每个 APK 调 is_manager_apkcheck_v2_signature,把它的 V2 签名证书 跟内核里硬编码的 apk_sign_keys[] 比对;
  5. 命中 → 内核打印 Crowning manager(加冕管理器),给这个 UID 装上 su 能力;
  6. 此后管理器 App 发的 IOCTL 才被内核认,root 才真正可用。

任何一环断了,管理器都会显示”未安装/无 root”。下面五道坎,本质上就是这条链上五个不同的断点。


第一道坎:KPM 把内核拖进硬死锁

SukiSU-Ultra 支持 KPM(Kernel Patch Module),一个很诱人的特性。我一开始 CONFIG_KPM=y 编进去了。

结果一开机,进系统几十秒后必崩,dmesg/last_kmsg 里是:

1
kernel_panic - hard_hang ... cpu X by ehld

ehld 是 Exynos 的硬锁检测看门狗。每次都在 keystore / adb 授权 相关动作时触发——KPM 的运行时 patch 跟 SoC 的某些路径冲突,直接把 CPU 拖进 hard lockup。

排查方向一度跑偏(以为是 SELinux、以为是 HAL),最后用控制变量法:关掉 KPM 单独编一版,硬死锁立刻消失。

教训:CONFIG_KPM 在这颗 SoC(zumapro/Exynos 系)上不稳,直接去掉。内核级 root 本身不需要 KPM。


第二道坎:蓝牙和 WiFi 全废——元凶是一个 rfkill.ko

去掉 KPM,能进系统了。但蓝牙打不开,WiFi 也连不上

dmesg 里一堆模块加载失败。顺藤摸瓜发现核心是 rfkill.ko

  • WiFi 驱动 bcmdhd、蓝牙的电源/共存驱动 nitrous 都依赖 rfkill
  • 而 GKI 把 rfkill.ko 列为受保护模块gki_aarch64_protected_modules),用的是 Google 预编译的那一份;
  • 我自己编的内核 vermagic 跟预编译 rfkill.ko 对不上,内核拒绝加载它 → 连锁导致 nitrous/bcmdhd 全部起不来。

修法是把 rfkill 从模块改成内建,绕开 vermagic 校验:

  1. 内核配置 CONFIG_RFKILL=y(直接编进内核,不再是 .ko);
  2. modules.bzlgki_aarch64_protected_modules删掉 net/rfkill/rfkill.ko 那一行
  3. ROM 侧 system_dlkmmodules.load / modules.dep 里把 rfkill 的引用清掉。

重编、刷入,蓝牙能开、WiFi 能连了。模块加载数从个位数恢复到几百个,BT/WiFi 驱动正常 attach。

教训:自编译 GKI 内核 + 厂商预编译模块的组合里,任何”受保护模块”只要你动了内核就会 vermagic 失配。要么把它编成内建(=y),要么保证它跟你的内核一起重编。


第三道坎:内核根本不认我的管理器签名

蓝牙 WiFi 好了,root 还是没有。管理器装上去显示”未安装/工作不正常”,dmesg完全没有 Crowning manager

回到第零节那条链:内核 apk_sign_keys[]硬编码的是 SukiSU 官方发布版管理器的签名证书

1
2
3
4
// kernel/manager/apk_sign.c
static struct sign_key apk_sign_keys[] = {
{ 0x35c, "947ae944..." }, // 官方 release 证书
};

而我的管理器是用本机的 Android debug keystoreandroiddebugkey)签的(后面会解释为什么必须自签)。证书对不上,check_v2_signature 直接判否,自然不加冕。

check_v2_signature 的逻辑是:读 APK 的 V2 签名块里”证书长度”字段,先比长度,再比证书的 SHA-256。所以我要做两件事:

  1. 算出自己 debug 证书的长度和 SHA-256:

    1
    2
    3
    4
    keytool -exportcert -keystore ~/.android/debug.keystore \
    -alias androiddebugkey -storepass android | wc -c
    # → 744 = 0x2e8(证书长度)
    # 再对同样的输出取 sha256 即证书指纹
  2. 把内核 apk_sign_keys[] 第一项换成我自己的:

    1
    { 0x2e8, "<本机 debug keystore 证书 SHA-256,已脱敏>" },

重编内核。这下 dmesg 里开始出现签名比对的日志:

1
KernelSU: sha256: <…>, expected: <…>

两个 hash 一致了。但是——还是没有 Crowning manager。说明链条断在更前面:on_post_fs_data 压根没被触发过(dmesg 里搜不到 on_post_fs_data!)。这就引出了最难的第四坎。


第四道坎(最难):钩子模式不对,zygote 永远检测不到

这一坎卡了最久。现象:内核日志里有 KernelSU: LSM hooks initialized(说明 KSU 起来了),但永远不出现 on_post_fs_data!,因此管理器永远不被扫描、不被加冕。

逐行读源码后定位到根因,分两层:

第一层:kprobe 模式拿不到 argv

on_post_fs_data() 只在一个地方被调用——runtime/ksud.cksu_handle_execveat_ksud() 里,检测到 zygote 启动时:

1
2
3
if (first_zygote && !strcmp(path, "/system/bin/app_process") && argv_has(...)) {
ksu_on_post_fs_data(); // 整条加冕链的起点
}

注意它要读 argv。而我当时用的是 kprobe hook 模式——kprobe 挂在 execve 上时,拿到的 argv 指针往往是 NULL/不可靠。条件判断永远不成立 → zygote 检测不到 → on_post_fs_data 永不触发。

第二层:手动 hook 的 patch 又没调对函数。

于是改用 manual hook 模式(CONFIG_KSU_MANUAL_HOOK=y),它需要你在内核 execve 路径上手动插入 KSU 的钩子调用。但我参考的那份 scope-minimized patch 只插了 ksu_handle_execve_sucompat()(处理 su 兼容),从来不调 ksu_handle_execveat_ksud()——也就是说,触发 on_post_fs_data 的那个函数根本没被挂上。

定位到这里,修法就清晰了。在内核的 fs/exec.cdo_execveat_common() 的入口处,亲手把那个关键调用加回去

1
2
3
4
// fs/exec.c, do_execveat_common() 开头
#if defined(CONFIG_KSU) && defined(CONFIG_KSU_MANUAL_HOOK)
ksu_handle_execveat_ksud(&fd, &filename, &argv, &envp, &flags);
#endif

extern 声明用 void* 形参,避免跟 struct user_arg_ptr 的内部定义冲突。)

重编、刷入。dmesg 终于走通了完整链条:

1
2
3
4
KernelSU: exec zygote, /data prepared
KernelSU: on_post_fs_data!
KernelSU: sha256: <…>, expected: <…> ← 签名命中
KernelSU: Crowning manager: com.sukisu.ultra(uid=…) ← 加冕!

管理器 App 打开,绿色 Working、Manual Hook、Enforcing。这一刻 root 真正活了。

教训:kprobe 模式在这套内核上拿不到可靠 argv必须用 manual hook;而且要确认你用的 hook patch 真的调用了 ksu_handle_execveat_ksud,而不仅仅是 su_compat 那一支。这是整件事最隐蔽的断点。


第五道坎:boot.img 一刷就进 recovery

root 通了,但在把内核打包成 boot.img 实刷时又踩一个坑:用 mkbootimg 从厂商那个约 51MB 的预编译 boot 重新打包出来的镜像,fastboot boot 或刷入后会进 recoveryro.boot.mode=recovery),起不到正常系统。

换了一种打包方式就好了:用 magiskbootAOSP 自己 out/ 目录里那个 64MB 的 boot.imgunpack → 换内核 → repack。这样出来的 boot 正常进系统。

两者的差别在于 ramdisk / 镜像结构:厂商 51MB 预编译 boot 的 header 配置不适合直接拿来塞我的内核,而 AOSP out/ 的 64MB boot 结构正确。

教训:自换内核打 boot 时,基准镜像选错会静默地把你导进 recovery。用 magiskboot 基于本机 AOSP 产出的 boot 来 repack,比 mkbootimg 从厂商预编译 boot 重打更稳。


六、烤进 ROM:让全量擦除也不丢

到这里,root 已经能用、重启也在(boot 分区刷进去了)。但还差临门一脚:全量擦除(fastboot -w)重刷出厂 img.zip,会回退到没有 manual-hook 的旧内核。要真正”焊死”,必须把这个内核烤进 ROM 镜像本体。

这台设备的 ROM 走的是 BOARD_PREBUILT_BOOTIMAGETARGET_NO_KERNEL=true):ROM 不从 Image 现场组装 boot,而是直接打包内核目录里那个预编译 boot.img。所以思路是:把那个预编译 boot 换成”已验证可用”的版本,再重新出包。

最稳的”已验证可用”基准是什么?——就是设备上此刻正在跑的那个 boot 分区本身。因为是 userdebug,adb 直接是 root,干脆 dd 把它整个拉回来当 ground truth:

1
2
3
adb shell 'dd if=/dev/block/by-name/boot_a of=/data/local/tmp/live_boot.img'
adb pull /data/local/tmp/live_boot.img
# 确认:64MB、内核串含 6.1.99-android14-11、且自带 AVBf footer

这份 live_boot.img 是 100% 跑通过的,连 AVB footer 都是现成正确的。把它覆盖到内核预编译目录的 boot.img,然后 m dist 重新出包。

构建产物是一个约 2GB 的 *-img-*.zip。验证它内部的 boot.img 确实是 manual-hook 内核(64MB、版本串对、AVBf 在)后,做一次非破坏性整刷(不带 -w,保留数据和已装管理器):

1
fastboot update --force <baked-img>.zip   # 注意:不带 -w

重启,dmesg 自动出 Crowning manager,蓝牙 / SIM(中国移动)/ 内核模块全在。

这意味着 root 现在是 img.zip 的一部分——以后无论怎么刷(包括 -w 全量擦除),开机内核都会自己把管理器加冕。root 焊死了。


七、收尾:版本号红条

最后一个小尾巴:管理器顶部一直挂着一条红色警告——

Manager version (40781) and KernelSU driver version (40798) mismatch!

虽然不影响使用(已经 Working),但看着难受。根因挺有意思:管理器和内核驱动来自两个不同的 git 仓库,各自按自己的提交数算版本号,公式是:

1
versionCode = 4 * 10000 + commitCount - 2815
  • 管理器仓(浅克隆、git 传输被墙)里 commitCount 被硬编码成 3596 → 40781
  • 内核驱动所在的 builtin 仓有 3613 个提交 → 40798

把管理器构建脚本里那个硬编码的 commit 数从 3596 改成 3613,重编 assembleDebug(debug keystore 会自动用我那个已被内核认可的证书签名),装上去——红条消失,管理器和驱动都是 40798


总结

从”内核里有 KSU”到”管理器真正 Working”,这五道坎复盘下来:

# 现象 修法
1 KPM keystore 时 EHLD 硬死锁 去掉 CONFIG_KPM
2 rfkill 受保护模块 蓝牙/WiFi 全废 CONFIG_RFKILL=y + 从 protected modules / modules.dep 移除
3 管理器签名 内核不认、无 Crowning apk_sign_keys[] 植入自签证书的长度+SHA-256
4 钩子模式(最难) on_post_fs_data 永不触发 manual hook + 在 do_execveat_common 手动调 ksu_handle_execveat_ksud
5 boot 打包 一刷进 recovery magiskboot 基于 AOSP out/ 的 64MB boot 重打

外加把内核烤进 BOARD_PREBUILT_BOOTIMAGE、用设备 dd 出的 live boot 当基准,做到全量擦除不丢 root

最大的收获其实是第零节那个心智模型:内核里编进 KSU 只是起点,真正决定 root 能不能用的是”加冕链”上的每一环——execve 钩子能不能拿到 argv、zygote 能不能被检测到、on_post_fs_data 有没有被触发、管理器签名认不认。把这条链在脑子里跑通,每一个”管理器显示没 root”的现象,都能精确定位到链上的某个断点。

至此,这台自编译纯 AOSP 15 的 Pixel 9 Pro XL 集齐了:中国移动 VoLTE、蓝牙、WiFi、GApps、双击熄屏、以及焊进 ROM 的内核级 Root,全部在 SELinux enforcing 下永久生效。一台真正意义上”自己从源码长出来”的日用机。