AIGC标识 Android开发笔记[21]-手机上通过qemu虚拟机运行windows系统

摘要

记录一个把手机变成 QEMU 虚拟机的开源分支 Vectras VM(v2.9.5-3dfx 分支的离线版):在 Android 上用 proot 套一层 Alpine Linux 用户态根文件系统,把离线打包的 QEMU 运行时装进这层根文件系统,再由前台服务在其中拉起 qemu-system-x86_64,用 TCG 纯软件全模拟跑 x86_64 的 Windows PE 环境 WePE,最后通过 unix domain socket 上的 VNC 把画面送回手机屏幕。整个链路运行期零联网:最小 Alpine 根文件系统(busybox + apk)、QEMU 二进制运行时、168 个 Alpine 依赖包仓库、UEFI/BIOS 固件、WePE ISO 全部内嵌在 APK 的 assets 里,首次启动自动完成安装。同时记录权限闸门、离线安装哨兵、proot 单线程串行队列、命令注入校验、VNC 本地 socket 等关键实现细节与踩坑点。

工程仓库

关键信息

  • 开发语言:Java(app 主体,XML 布局 + ViewBinding),Kotlin 插件与 kotlin-stdlib-jdk8:2.0.20 仅作为依赖存在
  • 模块::app、:terminal-emulator、:terminal-view、:shell-loader、:shell-loader:stub(settings.gradle,rootProject.name = "Vectras VM")
  • Gradle:distributionUrl=https://services.gradle.org/distributions/gradle-8.4-bin.zip
  • com.android.application:8.1.2 / org.jetbrains.kotlin.android:1.9.0 / com.google.gms:google-services:4.4.0 / firebase-crashlytics-gradle:2.9.9
  • buildToolsVersion 34.0.0 / compileSdk 34 / targetSdk 28 / minSdk 23(root build.gradle 的 ext 默认值,可由 COMPILE_API / TARGET_API / MIN_API 覆盖;README 对外宣传口径是 Android 5.0+)
  • applicationId com.vectras.vm,versionCode 21,versionName v2.9.5-3dfx,Java 11,multiDexEnabled true,minifyEnabled false
  • aaptOptions { noCompress 'gz', 'tgz' }:故意不压缩 .gz/.tgz 资产(构建注释:Keep .tar.gz assets intact (aapt2 otherwise strips the .gz suffix)),保证 asset 原样存储、能直接 tar -x
  • 签名:根目录 vectras.jks(alias vectras),debug/release 共用;ABI splits 关闭(出一个全 ABI 的 APK)
  • 依赖:firebase-bom 33.0.0(analytics/auth/database/storage/crashlytics)、play-services-auth、glide 4.16.0、gson 2.11.0、okhttp 4.12.0 + retrofit 2.9.0、lottie 6.3.0、commons-compress 1.25.0、zt-zip 1.16、guava 33.1.0-jre
  • 原生库:app/src/main/jniLibs/<abi>/libXlorie.so(Termux-X11 的 Lorie X 显示服务,X11 模式用)
  • assets(离线包的全部家当):
资源 本副本实测 作用
bootstrap/{abi}.tar ~4MB:armeabi-v7a 3.6MB / x86 3.7MB / x86_64 4.3MB(各 891 条目,实测);arm64-v8a 走 Git LFS(.gitattributes 仅对它与 *.iso 开了 lfs),克隆后需 git lfs pull 才有真实内容 Alpine 最小根文件系统:distro/bin/busybox、distro/bin/sh、distro/sbin/apk、distro/etc/apk/*
setup/vectras-vm-arm64-v8a.tgz 大包,约 153MB,不在仓库里(从 Release 下载) QEMU 运行时二进制,解开落到 rootfs 的 /(构建时要放的就是它,放 app/src/main/assets/setup/)
roms/WePE_64_V2.3.iso Git LFS 指针,声明 227,559,424 字节(约 217MB) Windows PE 镜像,作为光驱引导
roms/QEMU_EFI.img / QEMU_VARS.img 各 64MB ARM64 guest 的 UEFI 固件(pflash 两块)
roms/bios-vectras.bin 256KB x86 guest 的 SeaBIOS 固件
apks/aarch64/ 97MB / 168 个 .apk,已入 git 离线 Alpine 包仓库,运行期补装 QEMU 依赖
3dfx/3dfx-wrappers-2.9.5.iso — 3DFX Voodoo 包装器 ISO(上游 3dfx 补丁配套资源)

两个 tar 别搞混:bootstrap/<abi>.tar 是 ~4MB 的 Alpine 最小根文件系统(解出来才有 distro/bin/busybox、distro/sbin/apk,给 proot 当 rootfs,也是 routeToNextScreen() 判断"装没装好"的依据);QEMU 运行时是另一个 ~153MB 的大包,本分支代码读的位置是 app/src/main/assets/setup/vectras-vm-arm64-v8a.tgz。二者都要准备,缺一不可。克隆后先 git lfs pull(arm64 的 bootstrap 与 WePE ISO 都走 LFS,不拉只是几百字节的指针文本)。

  • 仓库根目录有 26 张流程截图:screen-01-launch.png ~ screen-24-boot2.png、s25.png、s26.png(screenshots/ 下有同名副本)
  • 无单元测试:app/src/test、app/src/androidTest 目录均不存在;lint 在 release 构建中被关闭(checkReleaseBuilds false、abortOnError false)

原理简介

手机上为什么必须套一层 proot

Android 应用既没有 root,也没有 CAP_SYS_CHROOT,没法像 Linux 服务器那样 chroot /opt/rootfs 把一个发行版根目录切进去。proot 的办法是用 ptrace 拦截系统调用:把 open("/etc/apk/world") 这样的调用在用户态改写成 open("/data/data/com.vectras.vm/files/distro/etc/apk/world"),再伪造 geteuid() == 0,让进程"以为"自己站在一个完整发行版的根目录上、而且是 root。

于是 Android 的应用私有目录就能扮演 Linux 根文件系统:

/data/data/com.vectras.vm/files/
├── distro/          ← proot 的 rootfs(Alpine 最小系统 + 后续装进来的 QEMU)
│   ├── bin/busybox、bin/sh、sbin/apk
│   ├── etc/apk/{keys,world,repositories}
│   └── usr/local/bin/qemu-system-x86_64   ← "装没装好"的判据
└── usr/tmp/         ← 宿主侧临时目录(guest 内 /tmp 的 bind 来源)

代价是每条指令都要过 ptrace 中转(慢),以及同一份 rootfs 上不能并发跑多个 proot 实例——它们会互相踩 PROOT_TMP_DIR 里的 loader/trace 状态,表现成 --login is not a valid option、客人系统卡死这类玄学问题。项目因此给 proot 命令建了单线程队列串行执行,并且每次调用都新开一个临时目录(见"核心代码 4")。

Alpine + apk:为什么是"最小系统 + 离线包仓库"

bootstrap tar 是个 4MB 上下的压缩包(armeabi-v7a 实测 891 个条目,tar tf 看进去只有 distro/bin/busybox、distro/sbin/apk、distro/etc/apk/* 这类 busybox 时代的最小 Alpine),连 qemu 和图形库都没有。QEMU 是动态链接的,缺 libX11、pixman、glib、SDL2、mesa 等一长串依赖。上游的做法是联网 apk add,这个离线分支的做法是两步:

  1. 把 QEMU 运行时整体打成一个 tar(setup/vectras-vm-arm64-v8a.tgz),解开就得到 usr/local/bin/qemu-system-*;
  2. 把 Alpine 依赖闭包(168 个包,约 97MB)作为本地仓库放进 assets/apks/aarch64/,缺什么就 apk add --no-network --allow-untrusted /apks/aarch64/*.apk 离线补。

--no-network 是"不许联网拉索引",--allow-untrusted 是"本地 .apk 文件不校验仓库签名"(Alpine 的 RSA 公钥其实也随 bootstrap 放在 distro/etc/apk/keys/ 里)。这样从首启到能开机,一个包都不下载。

TCG:arm64 手机跑 x86_64 Windows 靠什么

现代安卓机的 CPU 是 arm64,要跑的 Windows 是 x86_64——指令集完全不同,只能靠 QEMU 的 TCG(Tiny Code Generator)做二进制翻译,把 x86 指令块翻成 host 的 arm64 指令再执行。这是纯软件模拟,速度通常是原生的百分之几到百分之十几,所以:

  • 在 WePE 里点点鼠标、分区、修复引导是可行的;
  • 安装并流畅使用完整 Windows、玩游戏不要指望;
  • WePE 这条 ROM 的参数写死了 -m 4096 -smp 4 -accel tcg,thread=multi——多线程 TCG 能吃满多核;32 位 arm 机没这个待遇,代码会弹窗把 thread=multi 降级成 thread=single(MainActivity.java:976-994)。

内存默认取"当前可用内存 − 100MB",可在设置里改成固定值(见"核心代码 11")。

WePE:一个能引导的 Windows PE

WePE_64_V2.3.iso 是常见的 Windows PE 维护盘(64 位),本身就是个可引导的迷你 Windows,带分区工具、引导修复、密码清除等。对这个项目而言它是内嵌的、开箱即用的"Windows 系统镜像":不用自己找 ISO、不用配光驱路径,ROM 列表里就有一条 WePE 64 V2.3,点一下即开机——这也是"手机上运行 Windows"最现实的演示形态(完整 Windows 安装镜像动辄几十 GB,还受 TCG 性能限制)。

UEFI(pflash)与 BIOS

QEMU 启动 guest 需要固件,代码按架构分三档(StartVM.java:175-197):

  • ARM64 guest → -pflash QEMU_EFI.img -pflash QEMU_VARS.img + -M virt:EDK2 的 UEFI 固件,必须两块 pflash(一块只读固件镜像、一块可写变量存储),缺了变量存储固件起不来;
  • x86 guest → -bios bios-vectras.bin(仓库自带 SeaBIOS)+ -M pc;
  • PPC guest → -L pc-bios 用 QEMU 自带固件目录。

WePE 这条 x86_64 走中间档:SeaBIOS + -boot(见"核心代码 7")。

VNC over unix socket:画面怎么回手机

QEMU 把客人系统的画面按 VNC 协议吐出来,最常见做法是监听 5900 端口,但在 Android 上开放 TCP 端口既麻烦又不安全。这里的做法是(把 StartVM.env() 拼出的参数摆在一起看,示意):

qemu-system-x86_64 ... -vnc unix:/data/user/0/com.vectras.vm/cache/vncsocket -monitor vc -qmp tcp:localhost:4444,server,nowait

QEMU 监听一个 unix domain socket 文件,App 内置的 VNC 客户端(fork 自 androidVNC)用 LocalSocket 直接连这个文件(RfbProto.java:344-349),全程不进网络栈。QMP(QEMU Machine Protocol)留在 localhost:4444,用于暂停、截图、按键注入等控制。设置里还有"外部 VNC"开关,打开后 QEMU 改成 -vnc localhost:1(即 TCP 5901),把画面从 unix socket 挪到 TCP——注意它绑的是 localhost,所以能接入的是这台手机上的其它 VNC 客户端,不是电脑直连。

三段式启动:闸门 → 安装 → 开机

SplashActivity(权限闸门)
   ├─ setupFiles():  建目录 / 初始化 roms-data.json / 把 roms 资产装到 .qemu/ / 注册 WePE
   └─ routeToNextScreen(): distro/usr/*/qemu-system-x86_64 在不在?
        ├─ 在      → MainActivity(ROM 列表)
        └─ 不在    → SetupQemuActivity(首次离线安装)
                       ├─ extractBootstraps(): bootstrap/<abi>.tar → filesDir/(得到 distro/)
                       ├─ checkabi() → setupVectrasOffline(): assets/setup/vectras-vm-<abi>.tgz → proot 内 tar -xzf -C /
                       ├─ 哨兵 echo "installation successful! xssFjnj58Id"
                       └─ 回 SplashActivity → 再次路由 → MainActivity

MainActivity 点 "WePE 64 V2.3"
   └─ StartVM.env() 拼 QEMU 命令行
      └─ MainActivity.startVM(): 校验命令安全 → 起 MainService(前台服务)
            ├─ Terminal.executeShellCommand2(env) → PROOT_EXECUTOR → proot 内执行 qemu-system-x86_64
            └─ 2 秒后打开 MainVNCActivity → RfbProto 走 LocalSocket 连 vncsocket

(任意时刻缺库)LibraryChecker → assets/apks/aarch64 → apk add --no-network ...

实现

核心代码

1. 启动闸门:权限 → 落盘 → 按"装没装好"路由

SplashActivity 是唯一的 launcher Activity,先卡"所有文件访问权限"(Android 11+ 的 MANAGE_EXTERNAL_STORAGE)与"通知权限"(Android 13+),两样都给了才开始写外部存储(配置文件、内置 ROM/ISO 注册):

app/src/main/java/com/vectras/vm/SplashActivity.java:223 的 tryProceed():

    private void tryProceed() {
        if (proceeded) {
            return;
        }
        if (!hasStoragePermission() || !hasNotificationPermission()) {
            return;
        }

        proceeded = true;
        permissionPanel.setVisibility(View.GONE);
        preparingPanel.setVisibility(View.VISIBLE);

        // External storage writes (config files, bundled ROM/ISO registration)
        // happen only after all required permissions have been granted.
        new Thread(() -> {
            try {
                setupFiles();
            } catch (Exception e) {
                Log.e(TAG, "setupFiles failed", e);
            }
            runOnUiThread(this::routeToNextScreen);
        }).start();
    }

"装没装好"的判据非常朴素——看 QEMU 可执行文件在不在,两个路径命中任一即进主界面(SplashActivity.java:247):

    private void routeToNextScreen() {
        String filesDir = activity.getFilesDir().getAbsolutePath();
        if ((new File(filesDir, "/distro/usr/local/bin/qemu-system-x86_64").exists())
                || (new File(filesDir, "/distro/usr/bin/qemu-system-x86_64").exists())) {
            startActivity(new Intent(this, MainActivity.class));
        } else {
            startActivity(new Intent(this, SetupQemuActivity.class));
            if (Build.VERSION.SDK_INT >= 34) {
                MainSettingsManager.setVmUi(this, "VNC");
            }
        }
        finish();
    }

注意 else 分支最后一句:Android 14+ 强制把显示模式切成 VNC(X11/SPICE 在新系统上不可靠),这是本分支的硬行为。

setupFiles() 幂等地补齐每次启动都要有的东西:usr/tmp(chmod 0771)、distro/、SharedFolder/、Downloads/、roms-data.json(空数组 []),然后调 FileInstaller.installFiles() 把 assets/roms/ 里的固件资产拷到 AppConfig.basefiledir(<externalFiles>/data/Vectras/.qemu/,-bios/-pflash 引的就是这里),最后注册内置 WePE(见第 7 节)。

2. 解压 Alpine bootstrap:不走 Java 解压,直接调系统 tar

app/src/main/java/com/vectras/vm/SetupQemuActivity.java:167:

    public void extractBootstraps() {
        String filesDir = getFilesDir().getAbsolutePath();
        String abi = getDeviceAbi();
        String assetPath = "bootstrap/" + abi + ".tar";
        String extractedFilePath = filesDir + "/" + abi + ".tar";

asset 先整包复制到 filesDir,再用系统自带的 tar 解开(SetupQemuActivity.java:184-190):

                // Step 2: Run tar extraction
                String[] cmdline = {"tar", "xf", extractedFilePath, "-C", filesDir};
                Process process = null;
                try {
                    process = Runtime.getRuntime().exec(cmdline);

能用系统 tar 是因为 Android 的 toybox 自带 tar,而这个 tar 的条目恰好以 distro/ 开头(tar tf 实测:distro/bin/busybox、distro/sbin/apk…),-C filesDir 一放就是 filesDir/distro/...。触发条件在 onCreate:只有 filesDir/distro/bin 不存在时才解压,重复安装不会重来。

附带一个坑:tar 里还混着 data/data/com.vectras.vm/files/... 这种"当年从根目录打包"的条目,解到 filesDir 下会多出一层无用目录——不影响功能,但看文件树时容易懵。更要紧的是这个 tar 必须真的是 Alpine bootstrap:arm64 那份若没跑 git lfs pull,拿到的只是几百字节的指针文本;若把 QEMU 运行时大包误放到了 bootstrap/ 路径,解完同样没有 distro/bin——两种情况都会让后面 routeToNextScreen() 的判据不成立,安装原地打转(见常见问题)。

3. 离线安装 QEMU 运行时 + 完成哨兵

本分支把上游的"联网自动安装 / 手动选文件"改成无条件从 assets 装(SetupQemuActivity.java:710):

    private void checkabi() {
        // This build embeds the QEMU runtime matching the device ABI (arm64).
        // Skip the manual/auto setup dialog and install from embedded assets directly.
        setupVectrasOffline();
    }

真正的安装(SetupQemuActivity.java:669):

    private void setupVectrasOffline() {
        inBtn.setVisibility(View.GONE);
        progressBar.setVisibility(View.VISIBLE);
        simpleSetupUIControler(1);
        String filesDir = activity.getFilesDir().getAbsolutePath();
        String abi = getDeviceAbi();
        String assetPath = "setup/vectras-vm-" + abi + ".tgz";
        String localTarPath = filesDir + "/vectras-vm-" + abi + ".tar.gz";

后台先把 asset 拷成宿主侧文件,成功后交给 proot 里的 shell 解包(SetupQemuActivity.java:695):

                    // Extract QEMU runtime in proot environment
                    executeShellCommand("set -e;" +
                            " echo \"Installing QEMU from local package...\";" +
                            " tar -xzf " + localTarPath + " -C /;" +
                            " rm " + localTarPath + ";" +
                            " echo export PULSE_SERVER=127.0.0.1 >> /etc/profile;" +
                            " mkdir -p ~/.vnc && echo -e \"555555\\n555555\" | vncpasswd -f > ~/.vnc/passwd && chmod 0600 ~/.vnc/passwd;" +
                            " echo \"installation successful! xssFjnj58Id\"");

三个细节值得记:

  1. asset 扩展名是 .tgz(assetPath),落盘却叫 .tar.gz(localTarPath)——照 README 表格放成 vectras-vm-arm64-v8a.tar.gz 会直接报 QEMU runtime not found in assets(见常见问题);
  2. tar -xzf ... -C / 是在 proot 的 rootfs 里执行的,所以 QEMU 落到 distro/usr/local/bin/,宿主进程看不到它、只有 proot 内能跑;
  3. 顺手写好了 PULSE_SERVER=127.0.0.1(音频走 PulseAudio)和 VNC 密码文件(密码写死 555555)。

安装完成的判据是终端里出现哨兵字符串,appendTextAndScroll() 逐行扫描日志文本(SetupQemuActivity.java:327):

        if (textToAdd.contains("xssFjnj58Id")) {
            startActivity(new Intent(this, SplashActivity.class));
            finish();
        } else if (textToAdd.contains("libproot.so --help")){
            libprooterror = true;
        }

也就是说"安装是否成功"不是靠退出码,而是靠往 stdout 里 echo 一个约定 token 再回读。同一函数下面还有一长串 textToAdd.contains("(50/") / "100/" / "Downloading Qemu..." 的分支,用来把 apk 解包进度映射成百分比文案——进度条本质上是在解析 shell 输出。

4. proot 通道:单线程队列 + 每次调用新临时目录

所有 rootfs 内命令都从 Terminal 走。app/src/main/java/com/vectras/vterm/Terminal.java:50:

    // proot instances sharing the same rootfs must NOT run concurrently:
    // their ptrace/loader state races, causing broken argv ("--login not a
    // valid option"), hung guests and failed commands. All one-shot commands
    // are funneled through this single-thread queue.
    private static final ExecutorService PROOT_EXECUTOR =
            Executors.newSingleThreadExecutor(r -> {
                Thread t = new Thread(r, "proot-cmd");
                t.setDaemon(true);
                return t;
            });

配套的 newProotTmpDir()(Terminal.java:72)保证每次调用独享一个 PROOT_TMP_DIR,并清理一小时前的崩溃残留:

    private static File newProotTmpDir(File baseTmp) {
        long now = System.currentTimeMillis();
        File[] old = baseTmp.listFiles();
        if (old != null) {
            for (File f : old) {
                String n = f.getName();
                // remove leftovers from previous crashes (older than 1 hour)
                if (n.startsWith("proot-") && now - f.lastModified() > 3600_000L) {
                    deleteRecursive(f);
                }
            }
        }
        File dir = new File(baseTmp, "proot-" + now + "-" + android.os.Process.myPid()
                + "-" + System.nanoTime());
        dir.mkdirs();
        return dir;
    }

真正起 proot 的命令数组(Terminal.java:277,executeShellCommand2()):

                String[] prootCommand = {
                        TermuxService.PREFIX_PATH + "/bin/proot", // PRoot binary path
                        "--kill-on-exit",
                        "--link2symlink",
                        "-0",
                        "-r", filesDir + "/distro", // Path to the rootfs
                        "-b", "/dev",
                        "-b", "/proc",
                        "-b", "/sys",
                        "-b", "/data/data/com.vectras.vm/files/distro/root:/dev/shm",
                        "-b", "/sdcard",
                        "-b", "/storage",
                        "-b", "/data",
                        "-b", "/data/data/com.vectras.vm/files/usr/tmp:/tmp",
                        "-w", "/root",
                        "/bin/sh",
                        "-l"// The shell to execute inside PRoot
                };

-0 伪造成 uid 0,--link2symlink 让符号链接在不支持 symlink 的文件系统上也能建,--kill-on-exit 保证 shell 退出时不留野进程。Android 的目录树通过 -b 绑进 guest:/sdcard、/data 全部可见(共享文件夹、下载镜像就靠这个),/tmp 指向宿主的 usr/tmp。

环境变量里另有两条与显示/音频直接相关(Terminal.java:273 起):PULSE_SERVER=127.0.0.1、SDL_VIDEODRIVER=x11,DISPLAY=:0(静态字段 Terminal.java:48)——X11 模式下 QEMU/SDL 画到 Lorie X server 上,VNC 模式下这两个变量只是摆着。

5. 缺库自动补装:内置 168 个 apk 的本地仓库

QEMU 的依赖不是一次性装完的(不同显示路径缺的库不同),所以每次进主界面都会比对一遍。app/src/main/java/com/vectras/vm/utils/LibraryChecker.java:27:

    // Offline Alpine apk repository bundled in assets, extracted into the
    // proot rootfs (files/distro/apks) and installed without any network access.
    private static final String ASSET_REPO_DIR = "apks/aarch64";
    private static final String INSTALL_CMD =
            "apk add --no-network --allow-untrusted /apks/aarch64/*.apk";

需要装的包是一份写死的清单 AppConfig.neededPkgs(AppConfig.java:80,38 个包:alsa-lib bzip2 cairo curl libepoxy mesa-gbm gdk-pixbuf gtk+3.0 glib mesa-gl mesa-dri-gallium mesa-egl rdma-core gettext-libs libiscsi jack libjpeg-turbo ncurses-libs libnfs pipewire pixman libpng libpulse pulseaudio cyrus-sasl sdl2 libseccomp spice liburing libusb libusbredirparser virglrenderer libx11 libxxf86vm zlib zstd tar libstdc++)。比对逻辑(LibraryChecker.java:35):

    public void checkMissingLibraries(Activity activity) {
        queryInstalled(activity, installed -> {
            String[] requiredLibraries = AppConfig.neededPkgs.split(" ");

            StringBuilder missingLibraries = new StringBuilder();
            for (String lib : requiredLibraries) {
                String name = lib.trim();
                if (!name.isEmpty() && !installed.contains(name)) {
                    missingLibraries.append(name).append("\n");
                }
            }

            if (missingLibraries.length() == 0) {
                return;
            }
            autoInstallOffline(activity, missingLibraries.toString());
        });
    }

queryInstalled 就是在 proot 里跑 apk info(LibraryChecker.java:168),装完再 apk info 复查一次,仍缺就弹"Retry / Cancel"。把仓库从 asset 拷进 rootfs 的 ensureOfflineRepo()(LibraryChecker.java:116)有两处值得抄:

            // remove stale files from older app versions
            File[] existing = repoDir.listFiles();
            if (existing != null) {
                for (File f : existing) {
                    if (f.isFile() && !assetNames.contains(f.getName())) {
                        f.delete();
                    }
                }
            }
                File outFile = new File(repoDir, name);
                if (outFile.exists() && outFile.length() > 0) {
                    continue;
                }

——先删掉旧版本遗留的包(防止版本混装),再只补"缺失或 0 字节"的文件(97MB 不必每次重拷)。整个过程不弹确认框,只有失败才显示错误对话框,文案是中英双语的"正在安装运行时组件…"。

6. 注册 WePE:把 Windows 引导参数写进 ROM 列表

WePE 不是"用户手动添加的镜像",而是每次启动时由 SplashActivity 自动注册(SplashActivity.java:323):asset 里有 WePE* → roms-data.json 里还没有 WePE → 把 ISO 拷到 <externalFiles>/roms/ → 追加一条 JSON 记录:

            // Parse and append new entry
            String newEntry = String.format(
                "{\"imgName\":\"WePE 64 V2.3\",\"imgIcon\":\"\",\"imgArch\":\"X86_64\",\"imgPath\":\"\",\"imgCdrom\":\"%s\",\"imgDrv1\":\"\",\"imgExtra\":\"-M pc -accel tcg,thread=multi -cpu qemu64 -smp 4 -m 4096 -vga std -net nic,model=e1000 -net user -usb -device usb-tablet\",\"vmID\":\"wepe_official\"}",
                destIso.replace("\\", "\\\\")
            );

这条 imgExtra 就是 WePE 的完整 QEMU 启动参数,逐个拆开看:

参数 含义
-M pc i440FX 机型(x86 经典 PC 结构,兼容性最好)
-accel tcg,thread=multi 纯软件模拟 + 多线程翻译(32 位 arm 会被降级为 thread=single)
-cpu qemu64 通用 x86_64 CPU 模型
-smp 4 -m 4096 4 核 / 4GB 内存
-vga std 标准 VGA(WePE 的显示驱动认这个)
-net nic,model=e1000 -net user e1000 网卡 + SLIRP 用户态网络(能上外网,但只在 guest 内)
-usb -device usb-tablet USB 鼠标(绝对坐标,触屏操作不会漂)

imgPath 留空表示没有硬盘(StartVM 里 if (!img.isEmpty()) 才加 -hda),光驱由 imgCdrom 提供——即"光盘引导、无盘启动"。

追加 JSON 用的是最朴素的字符串拼接(去掉末尾 ]、补逗号、再接上),而不是解析库——能用但脆:文件里只要有一个脏字符,整份 ROM 列表就废了。

7. QEMU 命令行拼装:StartVM.env()

列表项点击后(AdapterMainRoms.java:145)先设架构与光驱路径,再调 StartVM.env():

    public static String env(Activity activity, String extras, String img, String cpu) {

        String filesDir = activity.getFilesDir().getAbsolutePath();
        ...
                if (!img.isEmpty()) {
                    if (ifType.isEmpty()) {
                        hdd0 = "-hda";
                        hdd0 += " '" + img + "'";
                    } else {
                        hdd0 = "-drive";
                        hdd0 += " index=0";
                        hdd0 += ",media=disk";
                        hdd0 += ",if=" + ifType;
                        hdd0 += ",file='" + img + "'";

(StartVM.java:21 与 StartVM.java:51 起;其中 ... 对应原文略去的 24~50 行:String[] qemu = new String[0];、bios/finalextra 声明、按 MainSettingsManager.getArch() 选择 qemu-system-i386/x86_64/aarch64/ppc 二进制、以及 ifType/cdrom 处理)光驱、第二块盘 hdd1.qcow2、共享文件夹同样逐段拼:

                if (MainSettingsManager.getSharedFolder(activity)) {
                    String driveParams = "-drive ";
                    if (ifType.isEmpty()) {
                        driveParams += "media=disk,file=fat:";
                    } else {
                        driveParams += "index=3,media=disk,file=fat:";
                    }
                    driveParams += "rw:"; //Disk Drives are always Read/Write
                    driveParams += FileUtils.getExternalFilesDirectory(activity).getPath() + "/SharedFolder,format=raw";
                    params.add(driveParams);
                }

(StartVM.java:144)file=fat:rw: 是 QEMU 的 VVFAT 虚拟文件系统:把一个真实目录直接伪装成一块 FAT 磁盘挂进 guest,guest 里看到的是"本地磁盘",Android 这边往 SharedFolder/ 扔文件 guest 立刻可见,不需要任何协议或驱动。

固件与机型(StartVM.java:175):

                if (MainSettingsManager.useDefaultBios(MainActivity.activity)) {
                    if (MainSettingsManager.getArch(activity).equals("PPC")) {
                        bios = "-L ";
                        bios += "pc-bios";
                    } else if (MainSettingsManager.getArch(activity).equals("ARM64")) {
                        bios = "-pflash ";
                        bios += AppConfig.basefiledir + "QEMU_EFI.img";
                        bios += " -pflash ";
                        bios += AppConfig.basefiledir + "QEMU_VARS.img";
                    } else {
                        bios = "-bios ";
                        bios += AppConfig.basefiledir + "bios-vectras.bin";
                    }

                    String machine = "-M ";
                    if (Objects.equals(MainSettingsManager.getArch(activity), "X86_64")) {
                        machine += "pc";
                        params.add(machine);
                    } else if (Objects.equals(MainSettingsManager.getArch(activity), "ARM64")) {
                        machine += "virt";
                        params.add(machine);
                    }
                }

显示与控制通道(StartVM.java:236):

        if (MainSettingsManager.getVmUi(activity).equals("VNC")) {
            String vncStr = "-vnc ";
            params.add(vncStr);
            // Allow connections only from localhost using localsocket without a password
            if (MainSettingsManager.getVncExternal(activity))
                params.add(Config.defaultVNCHost + ":" + Config.defaultVNCPort);
            else {
                String qmpParams = "unix:";
                qmpParams += Config.getLocalVNCSocketPath();
                params.add(qmpParams);
            }

            //if (!MainSettingsManager.getArch(activity).equals("PPC") || !MainSettingsManager.getArch(activity).equals("ARM64")) {
            params.add("-monitor");
            params.add("vc");
            //}
        } else if (MainSettingsManager.getVmUi(activity).equals("SPICE")) {
            String spiceStr = "-spice ";
            spiceStr += "port=6999,disable-ticketing=on";
            params.add(spiceStr);
        } else if (MainSettingsManager.getVmUi(activity).equals("X11")) {
            params.add("-display");
            params.add("gtk");
        }

        params.add("-qmp");
        params.add("tcp:localhost:4444,server,nowait");

        return String.join(" ", params);

注意 -boot 的取法(StartVM.java:163):只有当用户参数里含 ".iso " 时才用设置里的启动顺序,否则一律 -boot c(从硬盘启动)。WePE 的 imgExtra 不含 .iso ,光驱是程序拼出来的,所以启动顺序靠 SeaBIOS 的默认回落(无盘时自动尝试下一个设备)。

最终命令是一整个字符串,用空格 join 出来,后面还要 split("\\s+") 打进日志——这也是为什么路径必须手工加单引号(-hda '/path/with space/x.qcow2'),以及为什么安全校验只能靠子串黑名单(见第 10 节)。

8. 起虚拟机:前台服务托管 QEMU,2 秒后再开 VNC

app/src/main/java/com/vectras/vm/MainActivity.java:963 是所有开机动作的总入口:

    public static void startVM(String vmName, String env, String itemExtra, String itemPath) {

        if (!VMManager.isthiscommandsafe(env)) {
            VectrasApp.oneDialog(activity.getResources().getString(R.string.problem_has_been_detected), activity.getResources().getString(R.string.harmful_command_was_detected), true, false, activity);
            return;
        }

        if (VectrasApp.isThisVMRunning(itemExtra, itemPath)) {
            Toast.makeText(activity, "This VM is already running.", Toast.LENGTH_LONG).show();
            activity.startActivity(new Intent(activity, MainVNCActivity.class));
            return;
        }

先过安全校验,再用 ps -e 的输出判断"这台 VM 是不是已经在跑"(VectrasApp.isThisVMRunning,靠比对命令串与磁盘路径是否出现在进程列表里)。之后是两条弹窗式降级:32 位 arm 遇到 tcg,thread=multi 降级为 thread=single;ARM64 架构遇到 ifType=ide 提示不能用 IDE 硬盘类型。

真正启动发生在 handler.postDelayed(..., 2000) 的回调里(MainActivity.java:1024 起):

                        Intent serviceIntent = new Intent(activity, MainService.class);
                        MainService.env = env;
                        MainService.CHANNEL_ID = vmName;
                        if (SDK_INT >= Build.VERSION_CODES.O) {
                            activity.startForegroundService(serviceIntent);
                        } else {
                            activity.startService(serviceIntent);
                        }

                        if (MainSettingsManager.getVmUi(activity).equals("VNC")) {
                            if (MainSettingsManager.getVncExternal(MainActivity.activity)) {
                                extVncLayout.setVisibility(View.VISIBLE);
                                appbar.setExpanded(true);
                                progressDialog.dismiss();
                            } else {
                                progressDialog.show();
                                Handler handler = new Handler();
                                handler.postDelayed(
                                        new Runnable() {
                                            public void run() {
                                                MainVNCActivity.started = true;
                                                activity.startActivity(
                                                        new Intent(
                                                                activity, MainVNCActivity.class));
                                                progressDialog.dismiss();
                                            }
                                        },
                                        2000);

QEMU 命令串通过静态字段 MainService.env 递给服务,再等 2 秒才打开 MainVNCActivity——因为 VNC 客户端连的是 QEMU 建好的 socket,QEMU 没起来就连不上。X11 模式则是等 3 秒打开 X11Activity。这两次 postDelayed 是纯时序凑合,没有"等端口/socket 就绪"的探测,慢机器上仍可能撞上连接失败(所以 MainActivity.onResume() 里还留了兜底:VNC 模式且 MainVNCActivity.started && VectrasApp.isQemuRunning() 时重开画面,MainActivity.java:1103 附近,注释写着 TEMPORARY FIX FOR VNC CLOSES)。

服务侧(app/src/main/java/com/vectras/vm/MainService.java:48):

        if (env != null) {
            if (service != null) {
                String filesDir = MainActivity.activity.getFilesDir().getAbsolutePath();
                Terminal vterm = new Terminal(this);
                vterm.executeShellCommand2("dwm", false, MainActivity.activity);
                vterm.executeShellCommand2(env, false, MainActivity.activity);
            }
        } else
            Log.e(TAG, "env is null");

        startForeground(NOTIFICATION_ID, notification);

executeShellCommand2(env) 会把整个 QEMU 命令写进 proot 里那个 /bin/sh -l 的 stdin,于是 QEMU 成了主服务所在进程树的子进程,退到后台有通知栏常驻托底、不会被系统随手回收。注意 PROOT_EXECUTOR 是单线程的,所以 QEMU 没退出之前,下一条 proot 命令(比如 killall qemu-system-x86_64、ps -e)都会排队——isQemuRunning() 这类检测天然带阻塞。另外这里先发了一条 dwm,是 rootfs 里的辅助命令;它和 QEMU 共用同一条串行队列。

9. VNC 连接:LocalSocket 直连 QEMU 的 socket 文件

app/src/main/java/android/androidVNC/RfbProto.java:344:

      if(!allow_external) {
          localSocket = new LocalSocket();
          String localVNCSocketPath = Config.getLocalVNCSocketPath();
          LocalSocketAddress localSocketAddr = new LocalSocketAddress(localVNCSocketPath, LocalSocketAddress.Namespace.FILESYSTEM);
          localSocket.connect(localSocketAddr);
          is = new DataInputStream(new BufferedInputStream(localSocket.getInputStream(),
                  32768));
          sos = localSocket.getOutputStream();

      } else {
          sock = new Socket(host, port);
          is = new DataInputStream(new BufferedInputStream(sock.getInputStream(),
                  16384));
          sos = sock.getOutputStream();
      }

Config.getLocalVNCSocketPath() 返回 getCacheDir() + "/vncsocket"(Config.java:156),正好对上 StartVM 里 -vnc unix:<该路径>。也就是说VNC 客户端与 QEMU 之间的链路是一对文件系统 socket:不需要 root、不开监听端口、不走 loopback TCP,全程是同应用内的文件通信。allow_external = true 时才退回标准 TCP Socket(host, port),配合设置里的"外部 VNC"(-vnc localhost:1 → TCP 5901)。

显示默认值在 Config 里集中定义:defaultVNCHost = "localhost"、defaultVNCPort = 1(新版 QEMU 的相对端口号,即 5900 + 1)、defaultVNCColorMode = C24bit、bitmapConfig = RGB_565(省一半显存带宽)。

10. 命令安全校验:黑名单式防注入

因为最终是一整串 shell 文本、还要 split 打日志,注入风险最直接的封堵方式就是:必须以 qemu 开头,且不含任何 shell 元字符。app/src/main/java/com/vectras/vm/VMManager.java:618:

    public static boolean isthiscommandsafe(String _command) {
        if (_command.startsWith("qemu")) {
            if (!_command.contains("&")) {
                if (!_command.contains("\n")) {
                    if (!_command.contains(";")) {
                        if (!_command.contains("|")) {
                            return true;
                        }
                    }
                }
            }
        }
        return false;
    }

覆盖了 &、换行、;、| 四种最常用的"串起第二条命令"的写法,$()/反引号/重定向 > 仍不在黑名单里——但能进入这条路径的字符串本来就只有程序自己拼出来的参数(imgExtra 来自 ROM 列表 JSON,用户可编辑),所以这是一道"防呆"而非"防攻击者"的闸门。MainActivity.onStart() 里对 pendingCommand(快捷启动命令)也走同一校验(MainActivity.java:1140)。

11. 内存分配:默认吃掉"可用内存 − 100MB"

app/src/main/java/com/vectras/qemu/utils/RamInfo.java:22:

    public static int vectrasMemory() {
        ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo();
        ActivityManager activityManager = (ActivityManager) activity.getSystemService(ACTIVITY_SERVICE);
        activityManager.getMemoryInfo(mi);
        long freeMem = mi.availMem / 1048576L;
        long totalMem = mi.totalMem / 1048576L;
        long usedMem = totalMem - freeMem;
        int freeRamInt = safeLongToInt(freeMem);
        int totalRamInt = safeLongToInt(totalMem);
        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
        if (prefs.getBoolean("customMemory", false)) {
            return Integer.parseInt(prefs.getString("memory", String.valueOf(256)));
        } else {
            return freeRamInt - 100;
        }
    }

默认策略是"当前有多少可用内存就给 guest 多少,留 100MB 给 Android"——这在 8GB 手机上合理,在 3GB 手机上就危险(guest 吃满后系统会疯狂杀后台,QEMU 自己也可能被 LMK 杀掉),所以 README 把"3GB 内存、至少 1GB 可用"写成最低要求。设置里可以打开 customMemory 改成固定值;PPC 架构另有上限 2048(StartVM.java:157)。

构建与测试

# 1) 大文件走 Git LFS,先拉真实内容
git lfs pull

# 2) 从 Release 取 QEMU 运行时,放入 assets(下载件是 .tar.gz,代码读 .tgz,改个名)
mkdir -p app/src/main/assets/setup
# vectras-vm-arm64-v8a.tar.gz → 改名为 vectras-vm-arm64-v8a.tgz → app/src/main/assets/setup/
# WePE_64_V2.3.iso            → app/src/main/assets/roms/

# 3) 构建(release 用根目录 vectras.jks 签名)
./build-with-timestamp.sh release      # 或 ./gradlew assembleRelease

build-with-timestamp.sh 的套路和本系列上一篇一致:clean → assembleRelease → 找到 app/build/outputs/apk 下的 APK → 复制成 VectrasVM-release-YYYYMMDD_HHMMSS.apk 放到仓库根目录并打印大小。构建没有测试环节(无 src/test),lint 也在 release 构建里关掉了(checkReleaseBuilds false、abortOnError false),验证只能靠真机装包跑一遍首启流程。

复刻时三个容易翻车的点:

  • bootstrap/arm64-v8a.tar 和 setup/vectras-vm-arm64-v8a.tgz 是两回事:前者是 ~4MB 的 Alpine 最小 rootfs(arm64 走 LFS,git lfs pull 后用 tar tf ... | head 核对开头是不是 distro/),后者才是 QEMU 运行时大包(~153MB)。放错位置的后果是首启 tar 解不出 distro/,安装流程卡在 extractBootstraps 这一步;
  • aaptOptions.noCompress 'gz','tgz' 必须保留,否则 asset 里的 tar.gz 会被压缩存储,tar -xzf 读到的是二次压缩的数据;
  • assets/setup/ 目录默认不在 git 里(大文件只放 Release),漏放时首启会弹 Offline setup failed: QEMU runtime not found in assets for arm64-v8a。

另外 git lfs pull 需要本机装了 git-lfs,否则 bootstrap/、roms/WePE_64_V2.3.iso 拉下来的只是几百字节的指针文本,解压必失败。

使用

  1. 安装 APK,进启动页点授权:所有文件访问权限(Android 11+ 会跳到系统设置页)+ 通知权限(Android 13+),两项都绿了才继续;
  2. 首次自动进安装页:解压 Alpine bootstrap(几秒)→ 从 assets 释放 QEMU 运行时(README 标包体约 153MB,进度按 shell 输出百分比显示)→ 出现 installation successful! xssFjnj58Id 后自动回到启动页并进入主界面;
  3. 主界面 ROM 列表里点 WePE 64 V2.3 → 弹 "Booting up..." → 约 2 秒后进入横屏 VNC 画面,即 Windows PE;
  4. 画面内用手指当鼠标(左键 = 点按/拖动),工具栏/菜单可开软键盘、切输入模式(触控板/鼠标)、发 Ctrl+Alt+Del 与方向键、调缩放配色;若打开设置里的"外部 VNC",则不再自动进全屏,而是显示 LOGIN --> localhost:5901 提示——QEMU 改为监听 localhost:1(即 5900+1 = 5901,Config.defaultVNCHost = "localhost"),交由本机 VNC 客户端接入,换掉应用内查看器;
  5. 想跑自己的镜像:用"新建 ROM"指定 ISO/磁镜像与架构参数(CustomRomActivity),或直接编辑 roms-data.json 里的 imgExtra;共享文件夹固定为 <externalFiles>/SharedFolder/,guest 内以 FAT 硬盘形式出现;
  6. 缺库导致 qemu-system-x86_64 起不来时,主界面会触发 LibraryChecker 静默补装(MainActivity.java:149);实在不行用应用内终端手动 apk add(见常见问题)。

常见问题

Q: 照 README 把 vectras-vm-arm64-v8a.tar.gz 放进 assets/setup/ 了,首启还是报 Offline setup failed: QEMU runtime not found in assets for arm64-v8a?

A: 代码读的 asset 名是 .tgz(SetupQemuActivity.java:675 的 "setup/vectras-vm-" + abi + ".tgz"),README 表格写的是 .tar.gz——以代码为准,把文件重命名成 vectras-vm-arm64-v8a.tgz 即可(落盘时才用 .tar.gz 这个名字,两者不一致是历史遗留)。同时确认 app/build.gradle 的 aaptOptions { noCompress 'gz', 'tgz' } 还在,否则 asset 被二次压缩,tar -xzf 一样解不开。

Q: 安装页一直卡在 Extracting data...,或弹 Extraction Failed?

A: 两步排查:① 先确认 git lfs pull 跑过——app/src/main/assets/bootstrap/arm64-v8a.tar 如果只有几百字节,那是指针文本,tar xf 必然失败;② 用 tar tf app/src/main/assets/bootstrap/arm64-v8a.tar | head 看开头是不是 distro/——该文件应该是 ~4MB 的 Alpine 最小根文件系统,别把 QEMU 运行时大包(~153MB)放进来,那个要放的位置是 app/src/main/assets/setup/vectras-vm-arm64-v8a.tgz。核对无误后再进安装页即可(详见"关键信息"下的说明)。

Q: 安装走完了,一点 WePE 就黑屏 / 提示 qemu-system-x86_64: not found?

A: 先按 README 已知问题排查运行库是否装齐:部分 ROM 上 proot 的登录 shell PATH 不完整,apk add 直接失败(/bin/sh: no such command 'apk' 或登录参数被拒),于是 libX11、pixman、SDL2、mesa 等一个都没装上。手工补救是在应用内终端执行:

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
apk add --no-network --allow-untrusted /apks/aarch64/*.apk

跑完再回主界面重开 VM。已观测到的 WePE 黑屏绝大多数是这个原因,而不是 ISO 或固件有问题。

Q: 为什么非要 proot?root 或 chroot 不行吗?

A: chroot(2) 需要 CAP_SYS_CHROOT,普通 Android 应用拿不到,也就没法把一个发行版根目录切进去。proot 用 ptrace 在用户态改写路径并伪造 uid,代价是每次系统调用都要中转(慢),以及同一 rootfs 上不能并发——项目因此做了单线程队列 + 每次调用独立 PROOT_TMP_DIR(第 4 节)。上游 v4.4.x 的 Play 商店版改用原生 QEMU 10(无需 proot),本分支保留 proot 方案是为了覆盖老设备(README 标 Android 5.0+)且构建更轻。

Q: arm64 手机跑 x86_64 Windows 到底能不能用?

A: 靠 TCG 二进制翻译,性能只有原生的一小部分。内嵌的 WePE 是维护型 PE 环境(分区、引导修复、拷文件),这类轻量操作没问题;装完整 Windows、跑 Office/游戏不现实。要更好的体验只有两条路:换用 ARM 架构的 guest 镜像(走 -M virt + UEFI,转译开销小很多),或用上游 v4.4.x 的原生 QEMU 分支。

Q: 哪些机器不能用?最低配置要求?

A: README 明确列出华为、荣耀、OPPO、realme、vivo 在 Android 14+ 不支持(部分旧版本上厂商内核也会限制 proot 的 ptrace);已确认可用的是三星、Pixel、小米 HyperOS、红魔。最低 3GB 内存(至少 1GB 可用),推荐 8GB + 骁龙 855 以上——因为 guest 内存默认按"可用内存 − 100MB"分配,机器小很容易把系统挤爆。

Q: 为什么 Android 14+ 强制 VNC?X11 / SPICE 模式还能用吗?

A: SplashActivity.routeToNextScreen() 走"未安装"分支(要进安装页)时,若 SDK_INT >= 34 就把 vmUi 写死成 VNC(SplashActivity.java:255,偏好存盘、此后每次都沿用),因为 X11(依赖 libXlorie.so 的 Lorie 显示服务)和 SPICE 在新系统上更难伺候。X11 模式代码仍在(x11/X11Activity,-display gtk + SDL_VIDEODRIVER=x11),SPICE 分支只拼了 -spice port=6999,disable-ticketing=on、消费端是注释掉的;另外 X11 若报 x11 not available / gtk initialization failed,运行期也会弹窗确认后切到 VNC(VMManager.java:561)。

Q: 别的 App 能不能连上我虚拟机的画面?

A: 默认不能。VNC 走 unix socket 文件(位于应用私有 cache/vncsocket),QMP 绑的是 localhost:4444,都不开放网络端口;只有主动打开"外部 VNC"才会出现 localhost:5901 这个 TCP 监听(此时同机其它应用理论上可连)。命令注入方面,进入执行链路的字符串必须过 isthiscommandsafe() 的子串黑名单(第 10 节)。

Q: 首启为什么非要"所有文件访问权限"?全程真的不联网吗?

A: 该分支把 MANAGE_EXTERNAL_STORAGE 当成硬闸门(tryProceed() 不给权限就一直停在启动页),而实际上它写固件、镜像、ROM 列表用的都是 getExternalFilesDir 这类应用专属目录,API 19+ 本不需要该权限——属于"宁可多要一次"的保守做法(targetSdk 28 + requestLegacyExternalStorage)。联网方面:bootstrap 解压、QEMU 运行时安装、依赖补装全程零联网;App 内的商店、更新检查、博客页仍会请求网络,不点就不影响离线使用。

效果

仓库根目录自带 26 张流程截图可作参考:权限页 screen-17-perm.png / screen-21-perm2.png,离线安装 screen-04-offline-setup.png / screen-16-freshsetup.png,依赖补装 screen-06-install-libs.png,主界面与列表 screen-07-main.png、screen-08-mainlist.png、screen-09-welist.png、screen-11-vmlist.png,开机与 VNC screen-14-boot.png、screen-15-vmboot.png、screen-22-vncboot.png、screen-24-boot2.png。发布前需在真机重截并上传图床,本文暂不预置外链。

界面特征:启动页是"权限状态面板"(绿/红圆点 + 中英双语逐条状态,如 ● 所有文件访问权限(已授权)),授权按钮直达系统设置;安装页是进度条 + 随进度切换的标题文案(Getting ready for you... → It won't take long → Don't disconnect → Almost there);主界面是带侧滑抽屉的 ROM 卡片列表,长按/点菜单可编辑、导出、删除,点卡片直接开机;VNC 画面为横屏全屏,底部是键盘按钮 + 虚拟摇杆(默认 GONE),菜单里是键盘开关、输入模式(触控板/鼠标/硬件鼠标/方向键等多种)、缩放与配色、Ctrl+Alt+Del、方向键与文本输入。

posted @ 2026-10-06 12:16  qsBye  阅读(6)  评论(0)    收藏  举报