AI 声明:本文由 AI 助手撰写,实验也是它跑的——刷镜像、按电源键、读 dmesg 都是它; 结论仅供参考,出事了自己担。

上一篇解锁 bootloader 之后,我的目标很朴素:在这台手机上跑 Linux,并且要有 /dev/kvm——结果 /dev/kvm 这条路是死,真正啃的是厂商自己的 Gunyah hypervisor。

先给结论:这台机器给不了 /dev/kvm。不是配置问题,是固件里缺东西。 下面把三条证据、探测方法和踩过的坑都写清楚,免得自己(和别人)再从零撞一遍。

代码、探测桩和历史输出都在 https://github.com/light4/houji-linux。

为什么 /dev/kvm 需要 EL2

KVM 要用第二层地址翻译和 EL1 的敏感操作拦截,这两样只有 EL2 能做。 谁先占了 EL2,谁就决定下面能不能虚拟化。

这台手机从 XBL 起就把 EL2 交给了高通自己的 Gunyah hypervisor,Linux 只能在 EL1 起。 解锁 bootloader 只放开 boot/vbmeta 这类分区,xbl、hyp、TrustZone 依然过签名校验,换不掉。

顺带一提,内核侧其实是准备好的:CONFIG_KVM=y、cmdline 里带 kvm-arm.mode=protected。 只要能到 EL2,一行内核代码都不用改。

证据一:Secure Launch 没实现

业界通用做法是 slbounce:借 Windows 的 Secure Launch 流程让 hypervisor 交出 EL2。 它的 README 说 Android 设备出厂不打开这个开关,我这台更彻底——连代码都没编进去:

adb shell su -c 'strings /dev/block/by-name/hyp_a | grep -iE "mssecapp|hyp_manager_launch"'
# 空

hyp_a 里唯一的痕迹是一个字符串 hyp_secure_launch_enabled。 反汇编后能看到它被解析进全局 0x221194,然后:

69b98: str w8, [x9, #0x194]     ; 全镜像唯一一次写入

没有任何地方读它。 同组的 hyp_secure_mor_enabled 就有真实消费者(读到 0 就跳过、非 0 就申请安全内存), 这就是「活字段」和「死桩」的区别。devcfg 里连这个键都没写。

证据二:0x8600xxxx 不是 Gunyah

Radxa DRAGON Q6A 有个取巧办法:smc(0x2000121, 0, 0, 1) 让 Gunyah 在 ExitBootServices 时让位。这里不行。

我写了个 EL1 探测桩插在 boot 镜像的内核入口上,扫了 0x86000000..0x860000FF(每次 64 条,用位图记录):

0x86000000..3F: 300811f9 00000000   -> 只有 00 03 04 05 06 07 08 0C 13 1C 1D 应答
0x86000040..FF: 全 0

返回都是 0、1、0x1e 这种小状态码,没有一条把 CurrentEL 推到 EL2。 0x02000121 直接 NOT_SUPPORTED。

原因:0x86000000 是 Qualcomm SMCI(smcinvoke)的基址,这些 SMC 在 Gunyah 里被转发给 TrustZone (hyp_a 里有 forward SMC id … 的日志串)。扫这片其实是在问 TZ,不是在问 hypervisor。

证据三:Gunyah 的宿主 ABI 是 HVC,且没有交还 EL2 的调用

真正的接口藏在这里——EL2 的 trap handler:

240c: lsr  w30, w18, #26        ; ESR_EL2 的 EC
2410: cmp  w30, #0x16           ; HVC64
2414: ubfx x18, x18, #0, #16    ; 取立即数
2418: sub  x18, x18, #0x6000

立刻数上限 0x6069,然后走 0x2fc0 的 16 字节表。这是上游 Gunyah 的 hypercall ABI(hvc 0x6000 = hypervisor_identify)。

实测 hvc #0x6000:

x0 = 0x00008001   API v1,bit15=64 位 ABI
x1 = 0x100107ff   feature flags(partition/CSpace、doorbell、msgq、vGIC…)

Gunyah 应答正常,功能也很全——但整套 API 全是管理客户机的(partition、地址空间、内存、vCPU、doorbell、虚拟中断控制器), 没有任何「让出 EL2 / 接管」的入口。

于是两条出口都被堵死:Secure Launch 没编进去,Gunyah 自己又不交权。

顺便纠正一个流传的说法:AVF 也不行,但原因不同

网上对 8 Gen 3 的"终端 App 报错"有个流行解法:注入 androidboot.hypervisor.protected_vm.supported=1。 在这台机器上它不管用,因为这个 AVF 是 KVM-only 的:

ls /apex/com.android.virt/lib64/                                  # 只有 crosvm 那套,没有 libgunyah
strings .../virtualizationservice | grep -c gunyah                # 0

ro.boot.hypervisor.protected_vm.supported=false 只是第二道闸,打开它之后 VirtualizationService 转过头去要 /dev/kvm,还是撞同一堵墙。所以官方虚拟化框架 (终端 App、vm 命令、第三方 AVF 应用)在这里同样是死的。

反过来,高通自己的 QVT 通道是活的,而且能自己驱动(详见下一节):

[vendor.qvirtmgr.cpusys_vm.status]: [RUNNING]      ← 已经有个 Gunyah VM 在跑
/dev/gunyah  gh_rm_drv  gunyah_loader  /product/vm-system/trustedvm/boot

Gunyah 建 VM、加载镜像、RM 通信这套在本机正常工作,只是控制权在 qvirtservice_rs 手里、只认 OEM 自己的镜像,没有对外 API。也就是说"这台机器完全不能虚拟化"是错的, 准确说法是:能,但只给厂商自己用。

再往前一步:其实可以直接摸 Gunyah

写完上一段我又试了一下,结果比预期好——不需要那个厂商服务,root 就能推进:

open /dev/qgunyah:  0x3
GH_CREATE_VM:       0x4      ← 成功,返回一个 VM fd
GH_VM_SET_FW_NAME:  -ENOMEM
GH_CREATE_VCPU:     0x5      ← 成功
GH_VCPU_RUN:        -EINVAL  ← 因为 VM 还没 ready

几个实现细节(都在仓库 src/gh_probe.c 里,静态、freestanding、不依赖 libc):

  • VM 管理在 /dev/qgunyah(0600,root),不是 /dev/gunyah;后者对 0xB200..0xB2FF 的 ioctl 一律 ENOTTY
  • 0xB201 = GH_CREATE_VM,返回值就是 VM fd;0xB240 建 vCPU,0xB280 跑 vCPU,0xB241 设固件名
  • 设固件名必须用编码形式 0x4010B241,直接用 0xB241 一律 EINVAL
  • 只有 trustedvm 能被接受;oemvm、cpusys_vm 都 EINVAL,而本机只有 cpusys_vm.mdt 一份固件,/data/misc/vm-system/ 是空的

真正卡住的地方在 dmesg 里写着:

gh_secure_vm_loader: Couldn't allocate cma memory for trustedvm -12

CmaTotal: 589824 kB、CmaFree: 0 kB——整块 CMA 被相机/显示/DSP 吃光了,装不下 VM 镜像。 设固件名会触发 mdt_loader 加载 %s.mdt 并经 RM 验签,第一步分配就失败。

所以这条路现在的状态是:门是开的,只差内存。

后来重启手机(把被我们泄漏的 68 MB CMA 还回去)再试,整条链就走通了:

setfw:        0x0            ← 成功,固件真被加载了
vcpu_count:   0x2            ← 必须正好建两个 vCPU,少一个 run 就 EINVAL
create_vcpu:  0x5, 0x6
run:          阻塞           ← 客户机在跑

gunyah_loader: VM: 45 status 2 complete
gh_proxy_sched: vmid=45 cpu_index:0 virq_num=406 nr_vcpus:1
gh_proxy_sched: vmid=45 cpu_index:1 virq_num=407 nr_vcpus:2
gunyah_loader: VM:45 started running

也就是一个 1 KB 的静态二进制、不用任何厂商服务,把一台 Gunyah 虚拟机在小米 14 上跑起来了。

几个必须知道的细节:

  • 镜像不在 /vendor 而在 /product/vm-system/trustedvm/boot/,ueventd 通过 /vendor/bin/vmmgr -r 代加载; /vendor/vm-system/ 这版根本不存在,所以直接跑 vmmgr 会说找不到 VM
  • 只有 trustedvm 被接受,oemvm/cpusys_vm 都 EINVAL
  • setfw 会一次性吃掉 trust_ui_vm_region 整个 68 MB(该保留区共 71 MB),进程一死就还回去,所以一次开机基本只有一发
  • 少建一个 vCPU,run 直接 EINVAL

代价是:跑起来几秒后宿主会重启。客户机没有 virtio 设备(没有控制台),大概是在找设备的路上崩了,或者 gh_virt_wdt 看门狗到点复位。所以现在缺的已经不是权限、不是内存、也不是镜像,而是设备模型—— gunyah_loader 留了 gh_virtio_backend_ioctl(0xB261–0xB264:共享内存、ioeventfd、irqfd、等事件), 接一个 virtio-console 上去就能看见客户机输出,再给 vmid 45 喂看门狗,实验才稳得住。

自建 payload 仍然要过高通签名(gh_rm_vm_auth_image),而且 qcom,gh-secure-vm-loader 的槽位参数在 XBL 里, 不在内核 DT 上。

那能不能在上面跑 Arch Linux?

VM 能起来之后我接着看了客户机到底是什么。trustedvm 的固件包拆开是:Linux 6.1.68 内核 Image、 一个只有 verity.cmdline 的 initramfs、一份 TUI payload。那份 cmdline 写得很清楚:

root=/dev/dm-0 ro init=/sbin/init dm-mod.create="verity,... /dev/vda ... root_hash_sig_key_value=<Xiaomi VMSigner 证书>"

根文件系统是挂在 virtio 盘上的 dm-verity 镜像,root hash 带签名;而 virtio 盘对应的 /data/misc/vm-system/system.img 在本版是空的。

信任链是两段:内核/initramfs 在 trustedvm.mdt 的段哈希 → gh_rm_vm_auth_image → TZ 验签; 根文件系统另有 dm-verity 的签名 root hash。hypervisor 侧没有任何"关掉验签"的开关 (qcom,gh-secure-vm-loader 只有 firmware-name/vmid/label/pas-id 这些属性)。

于是做了个最小实验:把内核里 Linux version 的 v 改成 X,通过 bind mount 顶掉原目录再启动 (两个坑:bind mount 必须做在 init 的 mount namespace 里,文件还必须 relabel 成 vendor_vm_qti_system_file,否则 ueventd 读不到)。整块 68 MB 镜像都映射完了,最后一步:

gh_rm_vm_auth_image: VM_AUTH_IMAGE failed with err: -22

换句话说,"跑 Arch"现在是个签名问题,不是虚拟化问题。

三条路,从便宜到贵:

  1. 别用 VM。proot 那套在这颗 SoC 上确实能跑 Linux 用户态—— DroidDeck 就是这么干的(Steam 大屏 + 通过 FEX 跑 Proton, 不要 root,要求 Adreno 730 及以上)。没有 /dev/kvm、没有 Gunyah,但今天就能用。
  2. 搞到签名能力。高通 MBN 签名(SEC tools)加上小米接受的签名身份,07 那条路才会对任意 payload 有用。
  3. 找一个不验签的 loader。别的 ROM/SoC 上如果 gh-secure-vm-loader 关掉了 auth,或者 XBL 侧能加一个非安全 VM 槽位——两者都不在我们的可写范围内。

复现提示:每次失败都会泄漏那 68 MB CMA,所以一次开机只有一发;验签发生在 set_fw_name 那一步, 别指望 vcpu_run 报签名错误。

变通一下:用小米的内核,只在客户机里装 Nix?

帮助不大,而且是四重堵死:

  1. 根本没有根盘可启动。 厂商配置要 /data/misc/vm-system/system.img,这个目录在本版是空的; 只有第二块(persist)盘存在:/mnt/product/vm-persist/trustedvm/persist.img,29 MB,首块全零。
  2. 换成我们的根文件系统过不了 verity。 root hash 和它的 PKCS#7 签名都在被验签的 verity.cmdline 里, 签名方是 Xiaomi VMSigner;换盘就换哈希,没有私钥签不出来;改 cmdline 又要改 initramfs——同样在签名范围内。
  3. 宿主侧还没有块设备后端。 loop 设备和 qcom,svm_neuron_block 的资源授予都是 vmmgr 做的, 我们的 ioctl 工具一件都没做,客户机只会看到一个死的 virtio_blk。
  4. 客户机没有控制台。 内核里有 virtio_blk(8 处引用),但 virtio_console、virtio_gpu 都是 0 处。 就算启动了也是黑盒,得先自己搭一条 console/显示通路。

注意这个顺序:留着厂商内核只省一次内核构建,并不省签名——根文件系统哈希是单独签的。 真能签的话,不如连内核一起签。

那这台机器上怎么才能有 Nix?

不用 VM、直接跑在 Android 内核上:nix-on-droid(proot 沙箱、不要 root、F-Droid 有)或者 Termux 里装 Nix。 这是真的 aarch64 nixpkgs,今天就能用,上面四个障碍一个都不沾,而且和 Gunyah、/dev/kvm、整个仓库的调查完全无关。

如果只认硬件虚拟化、要启动一个定制系统

好消息:这个门是开的。 我原以为要过小米/高通的签名,结果 qtestsign 就被接受了。

判断依据是签名段(trustedvm.b04,段标志 0x2000000)里的证书链主体写着 Generated Xiaomi Root CA、SecTools Test User、Xiaomi VMSigner—— 「Generated」+「SecTools Test User」正是高通 SecTools 做测试签名的产物,不是烧进熔丝的生产证书。 格式也是公开文档描述过的 MBN/ELF(哈希段 0x8000007 + 签名段),有开源实现: qtestsign。

实验做了两次,都在刚重启、68 MB CMA 空着的时候:

  1. 原样重签 → set_fw_name 返回 0,vcpu_count 2,vCPU 建好,run 阻塞(客户机在跑)
  2. 把内核里 Linux version 改一个字节再重签 → 同样 set_fw_name 返回 0

对照是:同样改这一个字节但不重签 → gh_rm_vm_auth_image: VM_AUTH_IMAGE failed with err: -22。 所以验签是真的,而我们的签名它认。

产物要用 qtestsign -v 6 multi-image 签,再按新的 program header 切回 .mdt/.b00/.b01…(.mdt = 头部+哈希段+签名段,.b00 = ELF 头,.bNN = 第 N 个 PT_LOAD 段)。 投放别走 ueventd 的 fallback(10 MB 的内核段会让它 sendfile failed: Out of memory), 直接把 /vendor/firmware_mnt 重新挂成 rw,把包丢进 /vendor/firmware_mnt/image/,走固件直读。

剩下的就是工程活,按顺序:

  1. 自己的 cmdline——签自己的 initramfs,把 dm-verity 整个去掉(root=/dev/vda rw),根文件系统随便放
  2. 宿主侧 virtio-blk 后端——现在根本没有东西给盘做后端,vmmgr 的 loop 设备和 qcom,svm_neuron_block 授权都得自己补(gh_virtio_backend_ioctl 的 0xB261–0xB264)
  3. 一个 console——厂商内核里 virtio_console 是 0 处,自己编内核就打开
  4. 给 gh_virt_wdt 喂看活,否则几秒后宿主就复位

这条路已经不再是"能不能",而是"要写多少"。

顺手翻了原厂 ROM

本地有一份同版本国行 dumpset,翻了之后有两点收获和一点确认:

  • vm-bootsys.img(sparse → 20 MB ext4)里装的正是 /trustedvm/boot/ 那一套签名包, 并且带一份 sign_image_log.log,写着 Need use python3.6 or above version to exe sign_imagev2—— 厂商用的是高通的 SecTools(sign_imagev2),而我们的 qtestsign 居然也被接受, 说明这台平台的测试签名路径很松。设备上 vm-bootsys_a 分区与这份镜像逐字节相同(sha256 一致)。
  • vm-persist.img 是 35 MB 的 ext4,里面套着 trustedvm/persist.img(29 MB),和设备上 /mnt/product/vm-persist 挂的东西一致——以后写 virtio-blk 后端就有真实数据可测。
  • 确认了一件坏事:客户机根文件系统哪儿都没有。cmdline 要的是 138 MB 的 dm-verity 镜像, 它既不在 ROM 的 images/ 里,也不在 /product,/data/misc/vm-system/ 是空的; oemvm 的固件也完全没有——这正好对上 set_fw_name("oemvm") 返回 EINVAL。

所以原厂这套 TUI VM 靠现成东西是启动不了的,无论如何都得自己供盘;这也说明 「自己签 initramfs + 自己放根文件系统」才是正确目标,而不是去复活厂商的 VM。

换一条更好走的路:upstream crosvm

有个现成的项目 gunyah-on-sd-guide 在联想 8 Elite 平板上用 upstream crosvm + AOSP 测试密钥签的内核 + Google 的 Debian rootfs 跑起了 pVM。这台机器照这个路子能走很远,也踩到两个坑:

  • 这台机器的管理节点 ioctl 类型是 0x47xx(/dev/gunyah 上 0x4700 建 VM、返回 fd), 0xB2xx 那套在 /dev/qgunyah(vendor 的 gunyah_loader)。crosvm 用 0x47xx, 所以 /dev/gunyah 才是对的节点——我一开始把它 remap 到 /dev/qgunyah,只得到光秃秃的 EINVAL。
  • 重建节点要记得打标签:chcon u:object_r:vendor_gunyah_dev:s0。

改回正确节点后,crosvm 走得很顺:创建 hypervisor、挂载块设备,然后停在

failed to initialize virtual machine: EPERM
dmesg: gh_vm_mgr: Failed to configure pVM firmware

8 Elite 那条捷径 --protected-vm-without-firmware 在本平台直接 -ENODEV(Failed to start VM: -19), 必须走固件路径。而 --protected-vm-with-firmware <img> 是有大小的:

custom pVM firmware could not be loaded: Image size too large: 12177408

槽位是 pvmfw_region@0x824a0000,1 MiB。

pvmfw 是 pVM 的第一段代码:拿 ABL 给的配置验签、把启动度量进 DICE 链、然后才放行内核。 Pixel 和不少骁龙机器把它做成独立分区,这台机器的保留区在、镜像却没随 ROM 发布—— 这和 protected_vm.supported=false、AVF 从来起不来 pVM 完全对得上。

AOSP 的 packages/modules/Virtualization/guest/pvmfw/README.md 正好写了“不刷机、直接 adb push pvmfw 迭代”的流程, 但 pvmfw 没有 Cargo.toml,只能在 Android 构建系统里编。所以下一步按代价排:

  1. 拿一份信任 AOSP 测试密钥的预编译 pvmfw——Cuttlefish(aosp_cf_arm64_phone,ci.android.com)就是测试密钥编的,附配置与 DICE 素材
  2. 在 AOSP 树里 m pvmfw-vbmeta pvmfw-tool 自己拼
  3. 借别的机器的 pvmfw(wuxianlin/oneplus12_pvmfw,同 SoC 家族),但它的内嵌密钥大概率不认我们的测试签名内核

有 pvmfw 之后剩下的就照那篇指南:avbtool + testkey_rsa4096.pem 签内核和 initrd、--rwdisk disk1.img(pvmfw 要的 GPT+magic 技巧)、 外加一块 rootfs 盘,客户机就能用 root=/dev/vdb 起来。

AOSP 的 pvmfw README 里还留了两个现成的开关:pvmfw-tool custom_pvmfw <pvmfw.bin> <dice.dat> 可以把 DICE 链直接拼进镜像, setprop hypervisor.pvmfw.path 让 AVF 用它;另有调试用的逃生口 hypervisor.pvmfw.path none(不带 pvmfw 直接起 pVM)。

但这台机器上 vm run-microdroid --protected 在读 ro.boot.hypervisor.* 那一步就 panic 了 (Device doesn't support protected or non-protected VMs),根本到不了 hypervisor——所以只能像上面那样直接喂 crosvm。

pvmfw 这个文件已经拿到了,而且不用下 4 GB 全量包。

Google 的工厂镜像页是前端渲染的,但底下的 JSON 是公开的: content-flashstation-pa.googleapis.com/v1/builds?key=<flash.android.com 里的 39 位 key>&product=<codename>。 dl.google.com 支持 Range,外层 zip 里又套了一层未压缩的 image-*.zip,于是两次读中央目录 + 一次读 pvmfw.img 就够, 只花 1 MiB。拿到的正好是 1 MiB 的 Android boot image,和 Pixel 上那个分区的内容一致。

而且用 Pixel(Google 密钥)的 pvmfw 没关系。源码里写着:

// 如果 pvmfw loader(通常是 ABL)没有传 DICE handover,就跳过 DICE、AVB 和 RBP
if current_dice_handover.is_none() { warn!("Verified boot is disabled!"); }

这台机器的 ABL 根本不跑 pvmfw,没人追加 config,所以验签整段被跳过,Google 的密钥完全用不上,我们的内核连 AVB 尾巴都不是必需。

结果:

尝试结果
--protected-vm(不带固件)init 阶段 EPERM,dmesg gh_vm_mgr: Failed to configure pVM firmware
带 12 MB 的内核当固件Image size too large: 12177408
带 1 MiB 的 pvmfw固件被接受了,随后 failed to initialize virtual machine: ENODEV
去掉 4096 字节 boot header 再传同样 ENODEV
不加保护参数、--unprotected-vm-with-firmware同样 ENODEV
重启后再试同样 ENODEV

dmesg 只有一句 misc gunyah: Failed to start VM: -19。设备侧的每一环都在(hypervisor 找到了、块设备挂上了、固件体积被接受), 所以卡的是厂商 gh_vm_mgr 的启动路径,不是 crosvm。

顺手把原厂 ROM 翻了个底朝天,确认一件更根本的事:Xiaomi 根本没发 pvmfw。

  • images/ 里 93 个文件,没有任何 pvmfw* 镜像
  • vendor_boot.img 里那 12 处命中全是 DTB 里的 /reserved-memory/pvmfw_region@0x824a0000、pvmfw_mem
  • boot.img(内核)里的 pvmfw_load_addr、pvmfw_entry_vcpu 是 pKVM nVHE 的符号,在这台机器上跑不到(EL2 归 Gunyah)
  • 设备上既没有 pvmfw 分区,/vendor/firmware_mnt/image 里也没有

也就是说:平台留了 1 MiB 保留区、内核里塞了个 gh_vm_mgr,却没人发那个该躺在保留区里的固件。而且 vm_mgr 是编进内核的(/proc/kallsyms 里 T gh_dev_vm_mgr_ioctl,没有模块标记),部分开源的 kernel 转储里也没有它,live DT 里更没有 gh-vm-mgr 节点能指个固件名给它。这解释了为什么「固件会被接受、随后 start 报 ENODEV」——剩下那些期待在厂商那条路里,不在 crosvm。

顺手把第 ③ 条也试了,结果很有价值:官方 AVF 走到同一个错误。

要让它动起来得拆两道闸:resetprop 伪造 ro.boot.hypervisor.* 并指向 hypervisor.pvmfw.path; 以及 setenforce 0——否则 virtualizationservice 对我们这个 ksu 域做 setrlimit 会被 SELinux 拒 (avc: denied { setrlimit } scontext=u:r:virtualizationservice:s0 tcontext=u:r:ksu:s0)。

拆完以后,官方栈一路跑到 crosvm:建出 instance、写了 instance.img、挂了三个块设备,然后:

failed to initialize virtual machine No such device (os error 19)
VM ended: StartFailed
dmesg: misc gunyah: Failed to start VM: -19

和手搓 crosvm --protected-vm-with-firmware 一模一样。所以既不是我们的框架,也不是 crosvm 版本—— 这版固件本身起不了 pVM。

回内核里看,Failed to start VM: %d 是 built-in 的 gh_vm_mgr 打的,紧跟在一次 RM 启动调用(0xc39838,入参 vmid)返回非零之后。于是两道工序只到了第一道:

工序作用结果
configure pVM firmware通过 RM 把 pvmfw 登记给 hypervisorEPERM
startRM_VM_START(vmid)ENODEV

--protected-vm-with-firmware 只是让 crosvm 把 blob 放进客户机内存,那道"告诉 hypervisor"的工序被跳过了,RM 启动时找不到已登记的固件,就回 -ENODEV。

结论很清楚:pVM 路线现在依赖厂商发的 pvmfw(或把自编固件塞进厂商的登记路径),而 vendor loader 路线根本不需要 pvmfw——trustedvm 用 qtestsign 重签就能起来。那边剩下的活是设备模型(virtio 后端),完全在我们手里。

所以:回到 virtio-blk/console 后端,pVM 路线先搁置。

又往前挖了一层:客户机没有设备,是因为设备树里根本没有 virtio 节点

grep -rl virtio /proc/device-tree 一条都没有;VM 节点是光秃秃的 /hypervisor/qcom,gunyah-vm/{compatible,name,qcom,vendor,qcom,vmid}。 /sys/bus/platform/devices 和 /dev 里也没有任何 virtio 相关条目——因为 gh_virtio_backend_probe 根本没跑过。

驱动里写得很直白:它在 VM 节点上解析 virtio-backends 属性(No virtio-backends specified for vm %s), 属性是 u32 数组、每项是一个 label(代码把字节长度除以 4 后遍历),然后按 virtio_dev_%x 去找子节点, 读 qcom,vm、qcom,label、shared-buffers-size。所有 backend ioctl 都是 label 寻址的 (virtio_irqfd、virtio_event、virtio_queue_info 结构体第一个字段都是 __u32 label)—— 所以没有"创建设备"的 ioctl,设备必须事先在 DT 里存在。

这也解释了实测现象:VM 能建、固件能验、两个 vCPU 能起,但 GH_WAIT_FOR_EVENT 在 VM fd 上回 EINVAL(事件是按 backend fd 投递的), 客户机几秒后复位。

要推进就得往 DT 里补东西:

virtio-backends = <0x10 0x11>;
virtio_dev_10 { qcom,vm = <&gunyah_vm>; qcom,label = <0x10>; shared-buffers-size = <...>; };

dtbo 可以刷且不过签名校验,但它同时承载整个内核 DT,所以先备份再动手。刷的过程踩到两个坑,都记一下:

  1. dtbo 不是单个 blob,是一张表(DTBO magic + 表头 + 条目数组 + 数据区)。只有一条条目时数据刚好落在 0x40,而追加第二条后条目区变成两条、数据区必须挪到 0x60——重叠的那一版直接开不了机、掉回 fastboot。
  2. overlay 必须带板级 id(qcom,msm-id、qcom,board-id、xiaomi,miboard-id),否则引导程序静默跳过:能开机,但什么也不会出现。

带上 id 之后,只加一条 virtio-backends = <0x10 0x11>; 就够了。开机后驱动自己打日志:

gh_virtio_backend: trustedvm: Recognized VM 'trustedvm' shmem_size 108000
gh_virtio_backend: trustedvm: Recognized virtio backend device with label 11
gh_virtio_backend: trustedvm: Recognized virtio backend device with label 10

shmem_size 108000 是 hypervisor 自己给的——说明 label 10/11 在 hyp 侧真实存在,DT 只是把声明补上。 GH_GET_SHARED_MEMORY_SIZE 也从 EINVAL 变成正常返回 0x108000。

没有生成任何平台设备或 /dev 节点:backend 是按 label 在 VM fd 上寻址的 (virtio_event、virtio_irqfd、virtio_queue_info 第一个字段都是 __u32 label)。

还卡着一处:GH_WAIT_FOR_EVENT 即使预填 label 也回 EINVAL。驱动的处理函数会先拿每 VM 的锁、再走 label 状态, 多半是共享缓冲区得先 mmap(驱动里有 gh_virtio_backend_mmap),或要先经 GH_ACK_RESET/GH_SET_DEVICE_FEATURES 进入某个状态。

后来一路啃下去,整条 backend 序列真的通了:

GH_ACK_RESET            {label,0}          -> 0
GH_SET_DEVICE_FEATURES  {label,_,sel,feat} -> 0
GH_SET_QUEUE_NUM_MAX    {label,_,q,max}    -> 0
GH_GET_SHARED_MEMORY_SIZE                  -> 0x108000
mmap(shm, size, RW, SHARED, vm, 0)         -> ok
eventfd2                                   -> ok
GH_IRQFD      {label, flags=1, fd, 0}      -> 0
GH_IOEVENTFD  {label, flags=1, q, fd}      -> 0

三个坑:flags 必须非 0(给 0 一律 EOPNOTSUPP,头文件没写);label 是十六进制(hyp 是 0x10/0x11,dmesg 也按十六进制打印,十进制是 16/17);用到的 label 都要在 DT 里声明,否则该 label 的状态操作回 EOPNOTSUPP。

还有个存证的坑:普通 > 重定向只会留下长度正确、内容全零的文件(数据没落盘就被重启了),探测程序改成自己开文件、每次写后 fsync 才留得下结果。

接着把宿主侧配齐了(后端文件、features、queue max、设备配置、mmap、两个 label 的 irqfd/ioeventfd 全部成功):

disk fd 4  size 0x100000  sectors 0x800
cfg 0   shmsz 0   mmap ok
irqfd lbl 0x10 -> 0   ioevfd lbl 0x10 -> 0
irqfd lbl 0x11 -> 0   ioevfd lbl 0x11 -> 0
running
wait lbl16...          <- 阻塞,然后不再返回

两个容易踩的细节:GH_SET_DEVICE_CONFIG_DATA 的容量必须传用户态指针({label, size, ptr}),传内联值就是 EINVAL; features 要分两次、features_sel 0/1 分别设 VIRTIO_BLK_F_RO 和 VIRTIO_F_VERSION_1。

但客户机始终不来碰设备——没有 DRIVER_OK、没有 NEW_BUFFER,几秒后宿主复位。宿主侧齐了,客户机压根没探测到。

最可能的原因:枚举 virtio_mmio 的是客户机的设备树,而它里面没有这些节点。我们给子节点只写了 qcom,vm/qcom,label/shared-buffers-size, 没有 reg(MMIO 窗口)也没有 interrupts——要么驱动会从更多属性合成客户机节点,要么客户机 DT 来自 hypervisor 侧的 VM 配置(那本来是厂商服务提供的)。

继续查,答案是:客户机设备树就在固件包里。

trustedvm.b02 不是载荷,是一张 dtbo 表(magic 0xd7b7ab1e,22 个条目,custom 全 0), 每个条目是一份按板型选的客户机 DT。反编译匹配我们 SoC 的那条(qcom,msm-id = <0x22d … 0x241 …>、 qcom,board-id = <0x15 0x00>、Pineapple SVM RCM)就能看到全貌:

chosen { bootargs = "… console=hvc0 loglevel=8 …"; };

virtio-mmio@0 { vqs-num = <0x01>; memory { qcom,label = <0x11>; base = <0x00 0xda6f8000>; }; };
virtio-mmio@1 { vqs-num = <0x02>; memory { qcom,label = <0x10>; base = <0x00 0xda6fc000>; }; };

所以 label 的对应关系和厂商 JSON 给我的印象正好相反:

label设备队列数MMIO 窗口
0x11 (17)console(hvc0,客户机的启动控制台)10xda6f8000
0x10 (16)磁盘20xda6fc000

客户机一开机的输出就走 hvc0,所以控制台队列必须最先通;在此之前客户机是瞎的, gh_virt_wdt 几秒后就把宿主复位。

两个直接推论:

  1. 客户机 DT 是我们能改的:b02 在签名包内,而我们能重签(qtestsign)—— 窗口、label、甚至 bootargs(去掉 console 依赖、把 root= 指向磁盘)都归我们定。
  2. 两个 label 的 features/queue max/reset 都得准备好;控制台没配好,客户机卡在第一次打印。

现状有个值得记的反常:声明 backend 之后,VM 反而起不来了——加载器不再打 VM:45 started running, VCPU_RUN 一直阻塞;而没有 backend 时 VM 是能起、几秒后被看门狗复位的。也就是说启动被 backend 的"就绪"卡住,我们的准备还差一步。

按顺序试过、都没变化:给磁盘的两个队列都下发 queue max 和 ioeventfd、给控制台补 GH_SET_DEVICE_CONFIG_DATA(size 0)。

剩下的怀疑对象:hvc0 多用端口要 VIRTIO_CONSOLE_F_MULTIPORT(我们只设了 VIRTIO_F_VERSION_1); GH_ACK_RESET 也许得放在 mmap 之后、或要先轮询 GH_GET_QUEUE_INFO 的 queue_ready; 以及到底什么动作触发 start,得去驱动状态机里确认。

两个操作细节也记一下:加载器的 VM 生命周期日志是 dev_dbg,每次开机都要重新开 file *gunyah* +p; 看门狗一秒多就复位,dmesg 必须极早采,晚了拿到的是下一次开机的。

另外发现共享缓冲区是按 label 各自 mmap 的:mmap(size, RW, SHARED, vm, offset = label << 12) 能成功, 两个 label 拿到不同地址(一次运行里是 0x71820fc000 和 0x7181ff4000);单次 offset 0 只覆盖第一个 backend。 把 shared-buffers-size 从 0x108000 改成 0x4000 没有变化。

这里先纠一个我自己写错过的结论:"VM 从来没起来"是采样太晚造成的错觉。看门狗/panic notifier 在客户机崩后 0.1 秒就复位, 一次性的 dmesg 根本活不到写完。真正能留住日志的办法是边写边刷:

dmesg -w > /data/local/tmp/d.txt &
while true; do sync; done &

日志立刻把三件事摊开了:

  • VM 确实起来了——gunyah_loader: VM:45 started running 就出现在 VCPU_RUN 和全部 backend 准备之后,后面的复位是客户机自己崩出来的;
  • GH_ACK_DRIVER_OK 这个驱动根本没实现(cmd c010b26b not supported,0x4010B26B 同样);
  • 驱动给两个设备打的都是 base 0 size 4096,不管 reg 写 2/3/4 个 cell、还是加一个 memory { base } 子节点,都改不动——base 不从 Linux DT 读。

更关键的是:把原厂 dtbo 刷回去,加载器照样打 Recognized virtio backend device with label 11/10, 而 /proc/device-tree 里从来没有我们的 overlay(连厂商条目自带的 xiaomi,miboard-id 都没有)。 也就是说 dtbo overlay 压根没并进 Linux 的设备树,加载器是直接从签名包 trustedvm.b02 的 virtio-mmio@N/memory { qcom,label, base } 里读的设备清单——我们的 overlay 一直是无用功。

剩下真正的硬骨头有两条:set_features 1/0x1e failed ret 30(高 32 位特性被拒),以及 b02 里那些节点其实是模板 (generate = "/virtio-mmio"、带 dma_base/peer-default,却没有 reg、没有 interrupts),得有人把它变成真正的节点—— 厂商栈里干这事的是 vendor.qvirtmgr/qvirtservice。

特性这条已经量出来了:GH_SET_DEVICE_FEATURES 在 word 1 上对任何子集(0、1、2、4、8、0x10、0x1e)都返回 0x1e(那是设备支持掩码), 只有 0x1f 返回 0——所以高位特性应该是 0x1f。改成 0x1f 之后客户机依旧秒崩,特性不是主因。

接着翻进程表,翻出这一轮最值钱的东西:厂商自己的启动器就在机器上。 /system_ext/bin/qcrosvm(200 KB 的 Rust 小工具)加常驻的 vendor.qti.qvirt-service_rs(pid 1149), 它按 qcrosvm --disk=<img>,label=<n> --vm=<name> --vsock=... --mem=<mb> 起 VM, 会开 /dev/qgunyah、/dev/gh_virtio_backend_<label>,-l 模式下还写 /sys/kernel/load_guestvm_<vm>/boot_guestvm。 它的字符串里把我们手搓的那串调用(create vm、set fw name、vcpu 数、shmem 大小、每个盘的 ioeventfd/irqfd、vhost-vsock、vcpu 线程、boot)逐条列了出来。

但 trustedvm 在这台机器上根本没人起:qvirtmgr-vndr.json 里只有 oemvm(NOT_STARTED)和 cpusys_vm(RUNNING,且没有 virtio backend); /sys/kernel/load_guestvm* 不存在,qcrosvm -l 直接静默 rc=1;不带 -l 则在 Get reserved mem size 上吃 EINVAL 中止。 /vendor/bin/vmmgr 在本内核里只负责回报固件路径(ueventd 带 FIRMWARE= 调它),不负责起 VM。

也就是说所谓"厂商可用样例"只存在于 cpusys_vm,而它没有 virtio——我们手上的对手其实是这套二进制。 那就反汇编它。结果比预想的更有用:qcrosvm 的每条 backend 命令都是"基号 + 打包后缀",而且它有两套编号—— 老的 0xB2xx 和 0xBCxx(基号 + 0x9a0),按一个版本字段在运行时二选一。

把这张表量到我们自己的驱动上:0x4008B26D(ack_reset)返回 0、0x4008BC0D 是 EINVAL, 也就是这个内核只会说老的那套。而 ACK_DRIVER_OK 两个号(0xC004B26B / 0xC004BC0B)都是 EINVAL、 dmesg 直接写 cmd … not supported——它压根没实现,客户机的 DRIVER_OK 只能从 GH_WAIT_FOR_EVENT 拿。 GET_DRIVER_FEATURES 能通,但客户机启动前两个 word 都是 0。

顺带还看出我们漏了两步:0xB245(问驱动 vcpu 数)和 0x8008B261 返回的保留内存大小—— 我们只取了 shmem 那个值、看到 0 就忽略,然后硬编码 2 个 vCPU。正确的顺序是 create vm → 问 vcpu 数 → 问保留内存 → mmap → 每设备 ack_reset/features/qmax/config/irqfd/ioeventfd → 建 vcpu → run。

照单子量了一遍:0x8008B261 确实返回 0x108000(VM 的保留/共享内存大小,我们的 mmap 本来就用的这个值), 0xB243 返回 vcpu 数 2(qcrosvm 那个 0x4020B245 在这驱动上是 EINVAL),create vcpu 就是 0xB240 带索引、和它一致。 base 0 size 4096 那两行是在 mmap 之前打的,所以那是还没设的用户态地址,不是窗口没 backing。

真正的观察是:日志停在 VM:45 started running 就没了——没有 fault、没有 vcpu exit、WAIT_FOR_EVENT 一个事件都不给。 virtio 客户机开机第一件事就是写 status=0 复位设备,那会产生一个 reset 事件;事件全无,说明客户机死在 virtio 初始化之前, 跟特性和队列是否谈拢没关系。

剩下三种可能:客户机早期就 fault(栈/入口/fdt 交接——厂商压根没在这台机器上起过 trustedvm,包可能要求某个我们没设的启动模式); s02 模板需要被生成成真正的客户机 DT、拿到坏 DT 就死;或者 0xB201 的创建参数——qcrosvm 会传一个结构体,我们传的是 NULL。

创建参数也试了:GH_CREATE_VM 传 1 和传 0 没区别,都建得出来,不是它。 set_fw_name("cpusys_vm") 也不行——只有 trustedvm 能解析(/product/vm-system/trustedvm/boot/trustedvm), cpusys_vm 压根不在那下面,做不了对照组。

于是把驱动里到底有哪些 ioctl 全枚举了一遍:0xB200–0xB2C0 逐个打,除了 0xB243(返回 2,vcpu 数)之外, 裸号全部 EINVAL/ENOTTY——backend 那些只有打包形式存在,所以才显得"缺"。 也就是说实现面到 0xB243 为止,没有任何 vcpu/DT 的启动上下文接口: 客户机的入口状态完全由 hyp 从镜像和包自带 DT 里决定,用户态干预不了。

把这条和"0.3 秒内硬复位、连 panic 都不打"合起来,最可能的结论是:这台机器的 hyp/内核组合跑不动这个包里的客户机, 而不是我们的调用序列。

那就去动客户机的东西。把 trustedvm.b02 里对应的那份 DT 抽出来,给两个 virtio-mmio 节点补上 reg 和 interrupts(用 DT 自带的 gic-irq-ranges 0x13b/0x13c),重新拼回 dtbo 表、重新组装 MBN、用 qtestsign 重签、再拆回 mdt+bNN 推进 /vendor/firmware_mnt/image/——验签是过的(VM: 45 status 2 complete),VM 照样 started running。也就是说: 结构改动的 b02 重签没问题,但 reg/interrupts 原来不是病因——hyp 显然自己会从 memory.base/dma_base 生成。

顺手试的两条也都是否定结果:去掉 vm-attrs 里的 crash-fatal 之后宿主照样在 0.5 秒内复位;连 qcrosvm 要吃掉的 /dev/gh_virtio_backend_<label> 这些节点在客户机活着的时候也一个都没出现过——再加上 0x4020B245 和整套 0xBCxx 都是 EINVAL, 越来越像这 ROM 的驱动比它自带的 launcher 和这个包都老。

不过这一轮也算拿到了两把工具:tools/patch_b02.py 和 tools/rebuild_bundle.py——改一个 DT 条目、重签、按新程序头重拆, 一条命令一个,以后动客户机侧不用再手搓。

既然 DT 全都没用,那就怀疑内核本身。把 b01(内核段)直接换成四个字节的 aarch64:

14000000        b .

补零到原长度、重签、刷进去。结果很干净:

配置结果
空转客户机,不准备 backend(建 VM → 固件 → vcpu → run)宿主 8 秒还活着,约 15 秒后才复位
空转客户机,准备 backend0.5 秒内复位,和用真内核时一模一样

原来这张表下得有点急,按间隔重测之后要修正:空转包能活 约 10 秒,去摸一下窗口能活 15 秒上下, 而厂商内核是 0.5 秒。也就是说整个宿主流程(SET_FW → 建 vcpu → 装 backend → run)是好的, 是厂商客户机自己在早死,快一个数量级;读一下 0xda6f8000/0xda6fc000 也不 fault,窗口是通的。 另外空转客户机最终也会在 10 秒左右被放弃,说明有个"要进展"的机制——但那不是 0.5 秒的原因。

还顺带踩到一个必须写下来的坑:重签之后段索引会移位。qtestsign 把 hash 和签名段挪到最前面,交付出去的 文件名就跟原意对不上了——b01 变成 hash 块、b02 才是内核、b03 是 DT、b04 是 cmdline cpio。 加载器按段索引映射,所以两种布局都能过验签;手查的时候永远看 phdr[i].p_offset,别以为 b01 是内核。

至于让空转客户机写 status = 0 却没等来事件,这一轮把事件通道本身instrumented 了一遍:irqfd(驱动→客户机中断) 和 ioeventfd(客户机 kick→驱动)那两个 eventfd 才是真正的信号通路,qcrosvm 也是读它们而不是等 ioctl (顺带记一个坑:arm64 没有 poll(2),得用 ppoll 或者非阻塞 eventfd 读,我在这上浪费了一轮)。 结果是两个 eventfd 全程 EAGAIN,GH_WAIT_FOR_EVENT 同样一次都不返回——驱动根本没看见客户机。

再补一刀:让空转客户机去读一个没有 backing 的地址(0xbeef0000)再死循环,存活时间和读真窗口一样(8~10 秒), 也就是说那个读根本没 fault。这和"客户机一条指令都没执行"完全自洽:读对的读错的都不留痕迹。 (把内核段的 p_vaddr/p_paddr 改成客户机内存基址 0x28800000 也没测出区别——虽然这仍是最像样的猜测。)

所以现在所有现象都指向同一件事:镜像装了、验签过了、vcpu 建了、加载器也说了 started running, 但没有任何 vCPU 真的跑起来;空转 VM 十秒后被放弃,厂商内核因为多做了点事死得更快。 用户态的假设基本用完了,于是去读 gunyah_loader 里打 VM:%d started running 那段:它在 gh_vcpu_ioctl 的 VCPU_RUN 分支里、 紧跟在 hypercall 之后,所以 run 确实进了 hyp;而 vcpu fd 上 0xB240–0xB2C0 再没有别的命令,没有读寄存器/状态的接口, "到底跑没跑"从这个驱动里根本看不出来。

换个参照:这台机器上唯一真在跑的客户机是 cpusys_vm(vmid 50),它不走 proxy scheduling(dmesg 写着 Skip populating VCPU affinity info for VM=50、Proxy Scheduling isn't supported); 而我们的 VM 45 走 proxy——因为 DT 里的 vcpus { affinity = "proxy"; affinity-map = <0x05 0x06>; }。

于是把这两行从 b02 里删掉——真内核下有一次宿主撑到 8 秒(之前 0.5 秒就复位),当时以为是突破。

但按 3+3 重跑(两种变体各三次、50 ms 采样、1/3/6 秒查宿主)后,结论被推翻了:只要 crash-fatal 已经去掉, 两种变体宿主都活着、客户机都死,proxy scheduling 毫无影响。

更要紧的是——空转 b . 客户机也一样只活 0.25 秒左右。一条 b . 会让 vCPU 永远忙着,如果它真跑过,绝不会没区别。 配上"没有任何事件、读未映射地址也不 fault",能下的结论是:vCPU 从来没开始执行,而且在我们造得出的每种变体里都一样。

顺带纠正一个自己读歪的地方:宿主活得久不等于客户机活着——宿主能撑住是因为 crash-fatal 被去掉,VM 本身照样 0.2 秒就没了。 前面几篇里把"宿主存活"当成"客户机在跑"的地方,以这条为准。

那还剩唯一正常工作的样本可参照:cpusys_vm。它的固件包就摆在 /vendor/firmware_mnt/image/cpusys_vm.*, 配置段是一份普通 FDT(不是 dtbo 表),差别很集中:

属性cpusys_vmtrustedvm
vcpus.affinity"pinned",map <0..7>"proxy",map <0x05 0x06>
vm-attrs"no-shutdown", "crash-fatal", "ras-error-handler""crash-fatal", "context-dump"
vdevicesrm-rpc、chipinfo-shm、hw-fence-*——完全没有 virtiovirtio-mmio@0/1、swiotlb-shm

试了 "pinned"(map 改成 <0x00 0x01>):和整个删掉一样,客户机还是在同一瞬间死(样本数噪声大,探针进程会随 VM 一起被杀)。

不过这张表指出了更值钱的事:已知可用的那个 VM 一个 virtio backend 都没有。所以下一步不该再调参数, 而是把 trustedvm 的 virtio 整段去掉跑一次——能活就把问题切到 virtio 这一半,照样死也就把它洗清了。

于是把 trustedvm.b02 里全部 22 个变体的 virtio-mmio@0/1 和 swiotlb-shm 节点全删掉、重签、刷进去。 结果加载器照样打 Recognized virtio backend device with label 11/10,连 shmem_size 108000 都没变。 再把整张表砍到只剩一个条目,还是一样。

也就是说设备清单根本不在包里——是 hyp/RM 那边决定的,b02 只是给客户机用的那份 DT,不是设备清单。 顺带把前面两篇的结论也修正了(journal/18、19 说是从包里读的,错了):包里改设备名、改数量、改 label 都不会影响宿主提供什么, 所以"用 DT 二分 virtio"这条路走不通。这也让更早那些补 reg/interrupts 的尝试更彻底地无关。

客户机还是 0.2 秒没,没事件、不 fault,跟其他每种变体同一个签名。

再补一个之前漏掉的硬事实:VCPU_RUN 是阻塞的。用 timeout 跑一个不 fork、直接对 vCPU 0 调它的探针, 得到 RC=124(被 timeout 杀掉、调用一直没返回),日志停在 run0 里面。也就是说它不报错、不返回 EINVAL, vCPU 确实被投进了 hyp——进入是发生了的,从来没发生的是进展。而且就在调用阻塞期间,几秒后 VM 照样被拆掉, 说明拆解不依赖我们进程做什么。

(顺带记个把自己坑了的两篇:探针自己写 /data/local/tmp/o.txt 并 fsync,shell 重定向只截到 stdout、几乎是空的。 有两次拿重定向文件数行数,报出"只活 0.2 秒",其实真日志里有样本。以后只读 o.txt。)

接着查到一件本来就该早知道的事:cpusys_vm.mdt 里每个 load 段的 p_paddr 都等于它 DT 里的 memory/base-address (0x80e00000),段偏移 2 MB 对齐——段是按物理地址放进客户机内存区间的,p_paddr 是客户机 IPA,不是摆设。 而 trustedvm 的内核段 p_paddr 是 0,等于让客户机跑去 IPA 0 取指,而它的内存区从 0x28800000 才开始。 看着就是病根:把内核段的 p_vaddr/p_paddr 改成 0x28800000 再签再刷——客户机照样 0.2 秒没。

所以原包里这个地址确实是错的,但改对还不够。约定记下来了(以后构建一律 p_paddr = DT memory base、2 MB 对齐、PT_LOAD 用 0x6), 剩下的差异还得继续对 cpusys_vm 做:它的段清单、flags、无 virtio 的 vdevices、以及 no-shutdown/ras-error-handler 这些属性。

那就一次把能仿的全仿了:vdevices 里删掉全部 virtio 节点、vm-attrs 换成 "no-shutdown", "crash-fatal", "ras-error-handler"、 bootargs 改成 "nokaslr"、内核段 p_paddr 设成 DT 的 memory.base-address。

结果:还是 0.2 秒没,没有任何 backend 事件。

也就是说差异不在客户机 DT、不在段的落位、也不在我们能设的属性——至少不在任何我们够得着的组合里。

于是做那个"决定性测试":让厂商自己的 launcher qcrosvm 来起。它对本机和 trustedvm 都是同一个错: Get reserved mem size failed (EINVAL),连那个明明能启动的 cpusys_vm 也一样——所以不是我们的包有问题。

原因找到了,是调用顺序:0x8008B261(保留内存大小)在 GH_CREATE_VM 之后立刻调是 EINVAL,set_fw_name 之后调是 0。 而 qcrosvm 的字符串顺序就是 create → reserved mem → set fw name,这个驱动不认。第二个独立证据:qcrosvm -l 要 /sys/kernel/load_guestvm_<vm>/boot_guestvm,而这 ROM 里没有任何 .ko 提供那个 sysfs 节点(全量 grep 过)。

(这一段后来被推翻了,见下文「回头核对」。)结论很干脆:随包的 launcher 属于比这台机器的内核更新的一代驱动,在这版固件上它用不了。 我们手搓的探针不是重复造轮子,而是唯一能走到 started running 的那条路(而且必须把 set_fw_name 放最前面)。 哪天固件带上新 ABI(0x4020B245、0xBCxx、load_guestvm),launcher 才重新可用、也才值得拿来做对照。 剩下三样都是用户态碰不到的:hyp 拿什么 VM 模板建 VM、加载器在 VCPU_RUN 之后做了什么、 以及厂商那个 launcher 干了什么而我们没干(且没有以任何驱动实现了的 ioctl 形式露出来)。

下一步:让空转客户机真的去碰那个窗口(ldr 一下 0xda6f8000 的 virtio magic 再死循环),如果这样能活下来,就说明设备可达、只差握手; 再试只声明一个 label,把"任何 backend 都会触发"和"准备顺序"分开;顺便去加载器里找那个超时,看它到底等什么进展。接下来查"到底谁在复位"。宿主侧能想到的出口一条条被堵死:没有 /dev/watchdog(gh_virt_wdt 只是客户机侧的看门狗), gh_panic_notifier 卸不掉(Try again),开机理由是笼统的 reboot,bootinfo.pureason=0x80001; 内核倒是配了 oops 持久化(mtdoops 写到 /dev/block/by-name/oops、printk.always_kmsg_dump=1), 但我们的复位没写进新记录,翻遍记录也没有 Kernel panic。

也就是说这不是内核 panic、也不是用户态看门狗,更像 hyp/SCM 那侧在 VM 状态变坏后直接硬复位——这也能解释为什么去掉 crash-fatal 毫无变化。 另外顺手确认:GH_GET_QUEUE_INFO 在 dmesg 里是 cmd c010b269 not supported,不是结构写错,是驱动根本没实现。

宿主侧的观察通道到这就全断了:没控制台、没有 per-label 设备节点、没有队列查询、现在连 panic 痕迹都没有。 唯一能往前走的是给客户机一个不依赖 virtio 的早期通道——比如客户机 DT 里那个 dmesg-dump(label 0x07), 或者干脆换我们自己的客户机。 把 trustedvm.b02 改成真正的 DT 重签,或者干脆换上我们自己的内核 + initramfs 塞回包里——而不是继续考古 ioctl。 下一步:先试 GH_ACK_DRIVER_OK(只吃 label,可以预先置);核对客户机 DT 里两个节点的 interrupts 与 hyp 分配的 virq 是否对得上; 以及干脆把 chosen.bootargs 里的 console=hvc0 去掉重签,让客户机不依赖控制台也能往下跑。 下一步先查:窗口是否需要在 DT 里给 reg/base(驱动里有 gh_virtio_mmio_init)、 console 的 GH_SET_DEVICE_CONFIG_DATA(size 0)是否必须、以及要在同一个开机周期里核对 gh_proxy_sched 是否真的排上了 vCPU。

回滚就一句:fastboot flash dtbo_a ~/Downloads/houji_backup/dtbo_a.img(sha256 6264b8e6…,overlay 现在还在 dtbo_a 上)。细节都在仓库里的 docs/09。

想继续盯的话

两条命令,固件更新后各跑一次,分别对应两条路线:

adb shell su -c 'strings /dev/block/by-name/hyp_a | grep mssecapp'                       # KVM 路线
adb shell su -c 'strings /apex/com.android.virt/bin/virtualizationservice | grep -c gunyah'  # AVF 路线

第一条有命中说明 Secure Launch 上线了,仓库里的探测桩可以直接接着用——EL1→EL2 的入口骨架、 寄存器保存恢复、异常向量都已经写好并在真机验证过。第二条从 0 变非 0 说明 AVF 不再依赖 /dev/kvm,终端那条路会独立于 Secure Launch 重新打开。

顺便记下四个坑

这些是搭探测桩时真金白银换来的,做类似的活都会遇到:

  1. 内核入口处 MMU 是关的,所有数据访问按 Device 内存处理,不允许非对齐。8 字节常量必须 .align 3;字符串游标必须保持 4 字节对齐(记录长度取 4 的倍数)。
  2. BTI:不能用 br 跳回 0x1004(普通指令不是 landing pad),要在那里放一条直接 b,再用 br/eret 跳这条 b。
  3. x0 是 DTB 指针。探测循环把它当 fid 覆盖、返回前不恢复,start_kernel 会在 console 注册之前就崩——完全静默,pstore 里什么都没有。
  4. fastboot boot 在这台机器上是空操作,手机还是会启动 boot_a,所以迭代只能刷 boot;一定先备份 boot_a,回滚就是一条 fastboot flash boot 的事。 另外手机别挂在显示器 hub 上,一次长 getvar all 就能把 bootloader 卡死。

那还能怎么办

  • 想要「手机上的 Linux + KVM」:换 Pixel 6 及之后(pKVM,/dev/kvm 是真的存在)。
  • 想要 ARM 上的 KVM 练手:树莓派 5 / RK3588 板子,天生从 EL2 起。
  • 想要骁龙笔记本:X Elite + slbounce,但必须能关 Secure Boot 且需要旧版 tcblaunch.exe。
  • 只想要个 Linux 环境:Termux + proot-distro,不用碰 bootloader。

这台小米 14 就留着当日常机,等哪天 hyp 里出现 mssecapp 再回来。

回头核对:ROM 是原厂的,厂商 launcher 也一样挂

把手机分区和 ~/Downloads/houji_rom 里的 OS3.0.7.0 线刷包逐个比了 sha256:hyp(注意刷的是 hypvmperformance.mbn,不是 hypvm.mbn)、 tz、devcfg、xbl、abl、boot、vendor_boot、dtbo、vbmeta、vm-bootsys 全部一致;modem 分区哈希不同, 但里面 475 个固件文件逐个比对完全一致,差的只是我们临时拷包留下的 FAT 元数据;init_boot 不同是 KernelSU 打的补丁。 super 有 dm-verity(veritymode=enforcing、vbmeta 一致)兜底。所以这就是原厂 ROM。

那 cpusys_vm 为什么能跑?进程表里就写着:pid 1100 /system_ext/bin/qcrosvm --vm=cpusys_vm, 手里攥着 /dev/qgunyah、anon_inode:gunyah-vm、gh-vcpu:0,vCPU 线程停在 gh_vcpu_ioctl 里。 也就是说同一个驱动、同一个 launcher,在这台机器上是能跑 VM 的。前面说"qcrosvm 在这版固件上用不了"是我自己测错了: 传了 --mem,走进了原厂配置根本不用的保留内存路径;而 --vm=cpusys_vm 那次是因为它已经在跑。

再看 trustedvm:qvirtmgr-vndr.json 里其实有它(之前只看了文件尾巴),是 Trusted UI 按需拉起的 VM, 两块盘:label 0x10 是只读系统盘 /data/misc/vm-system/system.img,label 0x11 是可写 persist 盘——两个都是 virtio-block, 之前把 0x11 当成 hvc0 控制台也是错的。用一个小 NDK 程序直接调原厂的 vendor.qti.qvirt.IVirtualizationService (getVm=1,getState=1,start=3):服务拉起 qcrosvm,2 秒内以状态 1 退出——因为那块系统盘在这版 ROM 上根本不存在; 服务却照样把状态报成 RUNNING。

最后做真正的对照:手动跑原厂 qcrosvm、原厂未改的包、两块盘都给上。它把两个块设备配好(特性、队列、整块配置空间), 加载器打出 VM:45 started running——然后手机硬复位,那行之后什么都没有。和我们自己的探针一模一样。

所以结论要改写:/dev/qgunyah 本身没问题(cpusys_vm 证明了),我们的工具也没问题(厂商 launcher 同样挂); 挂的是这台机器上的 trustedvm 本身。最可疑的是它的身份:它是 Trusted UI VM,配置里要借走真实的显示/触控设备区间 (iomemory-ranges、gic-irq-ranges),产品流程里只会在 TUI HAL 把屏幕和触控交接出去之后才启动。没交接就硬起——很可能就是这个。 还没证实,但下一步的方向清楚了:要么走完整的 TUI 流程去起它,要么把那些借设备的配置从 b02 里删掉重签,要么照 cpusys_vm 的样子做一个不借设备的 VM。

更大的推翻:改过的包从来没被加载过,qtestsign 也不被接受

继续往"跑自己的系统"推的时候,dmesg 里一行一直被我忽略的日志露了馅:

gh_secure_vm_loader …: loading /vendor/firmware_mnt/image/trustedvm.mdt failed with error -13
ueventd: found /product/vm-system/trustedvm/boot/trustedvm.mdt …

SELinux enforcing 下,加载器直读 firmware_mnt 会吃 -13,然后静默回退到 ueventd,从 /product 读原厂包。 也就是说,前面所有"改过的包"——改 DT、换成 b . 空转内核、改 p_paddr、仿 cpusys_vm——跑的其实都是原厂包。难怪每种变体都一模一样。

把 SELinux 临时设成 permissive、确认真的从 firmware_mnt 加载之后:

放进去的包结果
原厂文件原样拷贝验签通过、started running,几秒后宿主复位
原厂内容、只用 qtestsign 重签、什么都不改VM_AUTH_IMAGE failed with err: -22
重签 + 去掉借用设备的配置同样 -22

qtestsign 不被接受。 证书名字看着像测试签名,但 hyp/TZ 的校验锚在小米自己的根上;更早那篇"重签被接受"同样是回退造成的假象,已在仓库里撤回。 结论因此彻底改写:/dev/qgunyah 这条路上每个 VM 都要小米签名,跑不了自己的代码。

剩下唯一不需要厂商签名的是 /dev/gunyah 上的非认证 VM(主线 ABI、crosvm、自己的内核)。不带任何 pVM 参数重测: 资源管理器接受了配置和初始化,但在 VM_START(0x56000004)上回 NORESOURCE。它确实解析 crosvm 生成的 gunyah-vm-config—— 把 vcpus 改成 pinned/static 会让初始化就失败——但 proxy 加不加 affinity-map、去掉 balloon/rng/usb/pmu 都不改变启动失败。 下一个怀疑对象是内存出借方式:crosvm 借的是宿主用户页,能跑的两个 VM 用的都是保留区。可惜这台生产版 hyp 不吐 RM 日志,只能继续盲测。

所以截至现在,这台固件上:没有 /dev/kvm、签名 VM 跑不了自己的代码、非认证 VM 被 RM 拒绝。要在这台手机上用 Arch/NixOS 的用户态,目前只剩 proot 这类方案。

把这条路试到尽头:NOMEM 不吃任何招

继续深挖时先纠了个错:RM 的拒绝码不是 NORESOURCE,内核里有错误名表,可以直接读出来——2 是 NOMEM,3 才是 NORESOURCE,10 是 MAP_FAILED。VM_START 回的是 NOMEM,hyp 侧分配不出来。

把用户态能动的全扫了一遍,全部还是 NOMEM:--mem 从 16 MB 到 1 GB、内存放置的基址换了四个、vCPU 1 个和 2 个、image-name 换成 hyp 认识的名字、vm-attrs、qcom,pasid、vcpus.config、cpu 加 enable-method、关掉 balloon/rng/usb/pmu、把十个 doorbell 全标 disabled,最后甚至 kill -9 掉唯一在跑的 cpusys_vm,让整套资源空出来——一样。

(有两条确实改变了结果,但只是把失败点提前:把 vCPU 亲和性改成 pinned/static,初始化阶段就 MAP_FAILED;加 vm-attrs = "crash-fatal",先 BUSY 再 NORESOURCE。说明配置确实被解析,只是资源不足发生在属性起作用之前。)

所以这不是内存多少、不是地址、不是设备、也不是和别的 VM 抢资源——这版 hyp 就是不给未声明的未认证 VM 分配。预算在签名的 hypvm* 和 devcfg 里,而这版正式 hyp 不打日志,宿主侧再没有能动的旋钮。

结论:这条路停在固件。等更新,届时先跑仓库里那几条 tripwire。这台机器上剩下的现实选项,只剩原生 Linux 当主系统(要重编内核补 DEVTMPFS/FHANDLE/namespace)或者真 chroot。

翻盘:内存要"借",不是"共享"——Linux 客户机起来了

上面那段结论是错的,错在两处。

第一,错误码读错了一位。 内核 rsc_mgr.c 和资源管理器(RM)源码里都是 NOMEM=1、NORESOURCE=2。日志里的 Error: 2 一直是 NORESOURCE,不是 NOMEM。从那次扫参数开始,我的解码就整体偏了一位。

第二,VMID 补丁和这件事无关。 用 tracefs 的 kprobe 把启动全程跟了一遍:客户机 VMID 是 0x80,厂商的 gunyah_qcom 本来就把它映射成 0x3A;宿主自己的 VMID 是 3,补丁改前改后完全一样。4990 次 qcom_scm_assign_mem 全部成功,内存共享、VM_CONFIGURE、VM_INIT 全部成功,唯独 VM_START 失败。网上那个"修复内核",编译配置里连 GUNYAH_QCOM_PLATFORM 都没开,补丁根本没被编进去。

转机在于 Gunyah 的 RM 是开源的(quic/gunyah-resource-manager),手机里 hypvm*.mbn 的字符串和源码一一对得上。VM_START 内部只要处理资源出错,一律回 NORESOURCE;而其中 process_image_memparcel() 有这么一句:

if (require_exclusive && (trans_type != TRANS_TYPE_LEND)) {
        err = RM_ERROR_DENIED;

这版 RM 要求客户机主内存是借出(lend),而我们一直用的 crosvm 走 GH_VM_SET_USER_MEM_REGION,内核对它做的是共享(share)。6.1 GKI 只有 GH_VM_ANDROID_LEND_USER_MEM 才会借出。DroidVM 的 crosvm 加上 --protected-vm-without-firmware 走的就是这条:

ALLOC_VMID → MEM_LEND → MEM_APPEND → VM_CONFIGURE → VM_INIT → VM_START ret=0

用 GKI 6.1.138 的 Image 当客户机内核,串口上出来了:

Booting Linux on physical CPU 0x0000000000 [0x000f0480]
Machine model: linux,dummy-virt
psci: PSCIv1.1 detected in firmware.
earlycon: uart8250 at MMIO 0x00000000000003f8
...
Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)

最后的 panic 正是没给磁盘时该有的结局。**原厂内核,不用任何补丁,Gunyah 客户机跑起了我们自己的 Linux 内核。**之前 trustedvm 内核"一声不吭"也说明不了什么:它压根没有 8250 串口驱动。

下一步:给它根文件系统,先用 initramfs,再接 virtio-blk 挂 Arch Linux ARM / NixOS。

Arch Linux ARM 跑起来了

有了"借出内存"这把钥匙,剩下的都是常规工作。

先进用户态。 把 Alpine 的 aarch64 minirootfs 打包成 initramfs,再写一个 /init 脚本,在 2 个 vCPU、464 MB 内存的客户机里打印出 NAME="Alpine Linux",然后干净关机(crosvm 记录的是 system shutdown,退出码 0)。有个小坑:GKI 内核只有 8250 earlycon,没有真正注册串口设备,用户态写 tty 的内容全被吞掉,只能借道 /dev/kmsg 输出。

再换一个像样的客户机内核。 用同一棵 ACK 6.1 源码,arm64 defconfig 再加上 virtio、DMA_RESTRICTED_POOL(受保护 VM 的 virtio 走受限 DMA 池)、FHANDLE(systemd 要用),去掉模块。还有个坑:ACK 的 pKVM 代码在关掉模块后链接不过,客户机本来也用不上 KVM,直接关掉。

最后上 Arch。 把 Arch Linux ARM 的官方 tarball 做成 3 GB 的 ext4 镜像,作为 virtio-blk 挂给客户机:

[  OK  ] Reached target Multi-User System.
Arch Linux ARM 6.1.138 (ttyS0)
houji-vm login: root (automatic login)
Linux houji-vm 6.1.138 #3 SMP PREEMPT aarch64 GNU/Linux
running

小米 14 上,原厂内核、原厂 hypervisor,在 Gunyah 客户机里跑起了完整的 Arch Linux ARM,systemd 全绿。

还剩一个限制:客户机内存给到 768 MB 时 VM_START 又会报 NORESOURCE,256 MB 没问题。DroidVM 有个选项会把借出的内存切成每块不超过 256 MB,看起来 RM 对单块内存的大小有上限。这是下一步要解决的。

顺便查了最新的国行 ROM(OS3.0.307.0):hypervisor 和 RM 跟现在用的是同一个构建,只有签名不同;变化只在宿主内核,从 6.1.118 升到 6.1.138。对这件事没有帮助,不用刷。

交给 DroidVM 管:联网、ssh、pacman

最后我没有 fork DroidVM。上游在 Gunyah 上默认就用 protected-vm-without-firmware,也就是我手工试出来的那个模式;大页、借出内存的方式、网络桥这些也都已经做好了。直接装它最新的 dev 版就够了。

装好后在 KernelSU 里给它授 root,加载了 GH Unmovable、Host Share、kvcalloc 三个运行时模块,建了一个 NAT + DHCP 的 Linux bridge。VM 用我们自己的内核和 Arch 磁盘,内存 256 MB,保护模式选 without firmware,一次就进了 systemd。

客户机通过 DHCP 拿到了 192.168.233.128,在手机上用 toybox nc 做个中继、再加一条 adb forward,Mac 就能 ssh 进去。手机本身没联网,就在 Mac 上起一个 HTTP 代理,经 adb reverse 转给客户机。之后 pacman 可以正常同步、装包:

OS: Arch Linux ARM aarch64
Packages: 167 (pacman)
CPU: dummy-virt (2)
Memory: 71.29 MiB / 129.82 MiB (55%)

路上踩了几个坑:pacman 7 的下载沙箱要求内核支持 Landlock;没有 NTP,时钟不对导致 TLS 证书校验失败;官方镜像的地理跳转在代理后面会无限循环。

还没解决的有:给客户机内核编进 Landlock 和 RTC;用 gh-hugepage-reserve 把内存上限提上去;DroidVM 默认的 pseudo-unprotected 模式跳进我们的内核后会触发异常。

手机连上 Wi-Fi 之后,DroidVM 的网桥 NAT 直接就通了,Mac 上的代理可以拆掉。客户机内核又补了三项:PL030 RTC(crosvm 模拟的是 PL030,defconfig 里只开了 PL031)、Landlock、Yama。pacman 的下载沙箱因此恢复正常,时钟也由 timesyncd 自己同步。

磁盘扩到 8 GB(关机后 truncate 镜像,开机后在线 resize2fs)。第一次 pacman -Syu 因为内存只有 129 MB,被 OOM 杀掉了,好在一个包都还没动。加了 2 GB swap 再跑,78 个包全部升级完,systemd 262、pacman 7.1、glibc 2.43。客户机里 systemctl reboot 之后,DroidVM 会自动把 VM 拉起来,没有失败的单元。

在小米 14 上,一台能用、能 pacman -Syu 的 Arch Linux ARM 虚拟机就这样跑起来了。

内存上限最后是靠 gh-hugepage-reserve 解决的。它作为 KernelSU 模块,在开机早期内存还没碎的时候,先预留一批 2 MB 大页。注意 pool_want 的单位是 2 MB 页而不是 MB:我一开始设成 2048,实际上预留了 4 GB,后来在线改回了 1024,也就是 2 GB。客户机内存设到 1536 MB 后,大页池显示 served=768,正好 768 个 2 MB 页。VM_START 不再报 NORESOURCE,客户机里 free 显示有 1.4 GB 可用。

日常就在 DroidVM 里管:VM 页面负责启停,Console 里的 Text Console Serial 1 就是客户机的串口终端,会自动以 root 登录,屏幕下方有 ESC/CTRL 等按键。从电脑连就用 adb forward 加 ssh。

DroidVM 默认的 pseudo-unprotected 模式之前一跳进我们的内核就出异常。对照 System.map,出错地址落在 swapper_pg_dir 里,也就是 CPU 在把页表数据当代码执行。加上 nokaslr 之后就能正常启动了,问题出在 KASLR 早期重定位,和 shim 的交接配合不上。这个模式不需要 swiotlb,同样配 1536 MB,客户机能用的内存从 1384 MB 涨到了 1442 MB,virtio 的数据也不用再经过缓冲区中转。顺手卸掉了用不上的 linux-firmware,根分区少占了 1.4 GB。