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 等关键实现细节与踩坑点。
工程仓库
- 上游项目: https://github.com/xoureldeen/Vectras-VM-Android,本分支基于上游 v2.9.5-3dfx
- 官方文档: https://vectras.vercel.app/how.html
- 大体积资源从 https://github.com/qsbye/vectras-vm-zulu/releases 下载、构建前放入(README "复刻本构建"清单):
vectras-vm-arm64-v8a.tar.gz→app/src/main/assets/setup/、WePE_64_V2.3.iso→app/src/main/assets/roms/;离线 apk 仓库apks/aarch64/的 168 个包已入 git(另有单文件归档alpine-apks-aarch64.tar.gz同在 Release)。README 也点明该离线 QEMU 运行时只面向 arm64,x86_64 设备走自动/手动安装 - 致谢对象: QEMU、3DFX QEMU PATCH、PRoot、Alpine Linux;仓库
LICENSE为 GPL-2.0
关键信息
- 开发语言: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(aliasvectras),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,这个离线分支的做法是两步:
- 把 QEMU 运行时整体打成一个 tar(
setup/vectras-vm-arm64-v8a.tgz),解开就得到usr/local/bin/qemu-system-*; - 把 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\"");
三个细节值得记:
- asset 扩展名是
.tgz(assetPath),落盘却叫.tar.gz(localTarPath)——照 README 表格放成vectras-vm-arm64-v8a.tar.gz会直接报QEMU runtime not found in assets(见常见问题); tar -xzf ... -C /是在 proot 的 rootfs 里执行的,所以 QEMU 落到distro/usr/local/bin/,宿主进程看不到它、只有 proot 内能跑;- 顺手写好了
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 拉下来的只是几百字节的指针文本,解压必失败。
使用
- 安装 APK,进启动页点授权:所有文件访问权限(Android 11+ 会跳到系统设置页)+ 通知权限(Android 13+),两项都绿了才继续;
- 首次自动进安装页:解压 Alpine bootstrap(几秒)→ 从 assets 释放 QEMU 运行时(README 标包体约 153MB,进度按 shell 输出百分比显示)→ 出现
installation successful! xssFjnj58Id后自动回到启动页并进入主界面; - 主界面 ROM 列表里点
WePE 64 V2.3→ 弹 "Booting up..." → 约 2 秒后进入横屏 VNC 画面,即 Windows PE; - 画面内用手指当鼠标(左键 = 点按/拖动),工具栏/菜单可开软键盘、切输入模式(触控板/鼠标)、发
Ctrl+Alt+Del与方向键、调缩放配色;若打开设置里的"外部 VNC",则不再自动进全屏,而是显示LOGIN --> localhost:5901提示——QEMU 改为监听localhost:1(即 5900+1 = 5901,Config.defaultVNCHost = "localhost"),交由本机 VNC 客户端接入,换掉应用内查看器; - 想跑自己的镜像:用"新建 ROM"指定 ISO/磁镜像与架构参数(
CustomRomActivity),或直接编辑roms-data.json里的imgExtra;共享文件夹固定为<externalFiles>/SharedFolder/,guest 内以 FAT 硬盘形式出现; - 缺库导致
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、方向键与文本输入。

浙公网安备 33010602011771号