1. 项目缘起:为什么我要在 iOS 上折腾 x86-64 的 Windows 程序
第一次看到 "Madeira" 这个代号,是在一个折腾跨平台兼容层的群里。有人丢了一张截图:一台 iPad 上跑着一个明显是 Windows 桌面时代的 x86-64 程序,界面有点糊,但确实在跑。底下有人问"这是 Wine 吗",回答是"FEX-Emu + Wine + DXMT 的组合,项目内部叫 Madeira"。
这个组合一下子勾起了我的兴趣。因为过去几年,在 ARM 设备上跑 x86 程序这件事,一直是个"能做但不好用"的领域。Android 上有 ExaGear 那一套,Linux 上有 Box86/Box64,而 iOS 因为系统封闭,一直是块难啃的骨头。Madeira 这个项目,本质上是把三件事拼在了一起:FEX-Emu 负责把 x86-64 指令翻译成 ARM64 指令,Wine 负责把 Windows 的 API 调用翻译成 POSIX 调用,DXMT 负责把 Direct3D 翻译成 Metal。三层翻译叠在一起,最终让一个 Windows 的 x86-64 程序,能在 iOS 的 ARM64 芯片上跑起来。
听起来很绕,但拆开看逻辑是清晰的。iOS 设备用的是 Apple Silicon,指令集是 ARM64。Windows 程序编译出来是 x86-64。这两者之间隔着一道指令集的墙。FEX-Emu 就是拆墙的,它做的是动态二进制翻译——程序运行的时候,一条一条地把 x86-64 指令翻译成 ARM64 指令再执行。Wine 则是在系统调用层面做翻译,Windows 程序调用CreateWindow、MessageBox这些 API,Wine 把它们映射到宿主系统的对应实现上。DXMT 更专一,它盯着 Direct3D 的调用,把它们转成 Metal 的调用,因为 iOS 上图形 API 只有 Metal 这一条路。
这套东西适合谁?我觉得有三类人值得关注。第一类是跨平台兼容层的研究者,想搞清楚多层翻译的架构怎么搭。第二类是想在移动设备上跑老 Windows 程序的玩家,比如某些没有移动版的老工具、老游戏。第三类是iOS 开发者,想了解在封闭系统上做兼容层会遇到哪些坑。如果你只是想找个能跑 Windows 程序的 App,那这个项目现阶段还不适合你,它的价值更多在技术探索层面。
我花了大概两周时间,把 Madeira 这套组合在自己的设备上跑通,中间踩的坑比想象中多。下面我把整个思路、关键细节、实操过程和排查经验完整梳理一遍,尽量让有基础的人能照着复现,让没基础的人也能看懂每一层在干什么。
2. 整体架构拆解:三层翻译是怎么叠起来的
2.1 为什么是 FEX-Emu 而不是别的翻译器
在 ARM 上跑 x86 程序,翻译器的选择其实不少。QEMU 是最老牌的,功能全但性能一般,而且它的用户态模拟在移动设备上开销偏大。Box64 在 Linux 上表现很好,但它对 iOS 的支持一直不完整,因为 iOS 不允许 JIT(即时编译)之外的一些内存操作方式。FEX-Emu 的优势在于,它从一开始就是为 ARM64 设计的,而且对 JIT 的依赖方式更符合 iOS 的限制。
这里要解释一个关键概念:动态二进制翻译有两种模式,一种是解释执行,一种是 JIT 编译。解释执行就是读一条 x86 指令,翻译一条,执行一条,慢但简单。JIT 是把一段 x86 代码整体翻译成 ARM64 代码块,缓存起来,下次直接执行翻译后的代码,快但需要可执行内存权限。iOS 对可执行内存管得很严,普通 App 拿不到mmap带PROT_EXEC的权限,除非用MAP_JIT标志。FEX-Emu 在设计上就考虑了这个,它的 JIT 后端能适配 iOS 的 JIT 限制,这是它能跑起来的前提。
提示:iOS 上开 JIT 权限,通常需要配合调试模式或者特定的签名方式。普通 App Store 分发的应用是拿不到这个权限的,这也是为什么这类项目大多停留在开发者自签或者越狱环境。
FEX-Emu 还有一个好处是它对 x86-64 指令集的覆盖比较全,包括 SSE、AVX 这些 SIMD 指令。很多老程序依赖这些指令做浮点运算和图形处理,如果翻译器不支持,程序直接就崩了。我在测试一个老版本的图像处理工具时,就遇到过 AVX2 指令不被支持导致崩溃的情况,换成 FEX-Emu 的较新版本后问题消失。
2.2 Wine 在中间扮演的角色
Wine 这个名字是 "Wine Is Not an Emulator" 的递归缩写,它不做指令翻译,做的是API 翻译。Windows 程序运行时,会调用大量的系统 DLL,比如kernel32.dll、user32.dll、gdi32.dll。Wine 提供了这些 DLL 的开源实现,把里面的函数调用映射到宿主系统的对应功能上。
在 Madeira 这套组合里,Wine 跑在 FEX-Emu 之上。也就是说,Wine 本身也是被翻译执行的 x86-64 代码。这就有个有意思的地方:Wine 的 DLL 是 x86-64 的,Windows 程序也是 x86-64 的,它们之间的调用不需要跨架构,FEX-Emu 只需要翻译这一整坨 x86-64 代码。如果 Wine 是 ARM64 原生编译的,那 Windows 程序调用 Wine 的 DLL 时就要跨架构,反而更麻烦。所以这里选择让 Wine 也走翻译层,是合理的。
Wine 的配置是个细活。默认的 Wine 前缀(prefix)里有一堆 DLL 覆盖设置,哪些用原生、哪些用内置,直接影响程序能不能跑。比如mscoree.dll如果设成原生,程序会去找 .NET Framework,而 iOS 上根本没有,直接报错。设成内置,Wine 会用 Mono 来顶替,虽然不完美但至少能跑。我在配置一个需要 .NET 的老工具时,就是靠把mscoree设成内置才跑起来的。
2.3 DXMT 为什么是图形翻译的关键
Direct3D 是 Windows 上游戏和图形程序的主力 API。在 iOS 上,图形 API 只有 Metal。DXMT 的作用就是把 D3D 的调用翻译成 Metal 的调用。它支持 D3D 11 和部分 D3D 12 的特性,通过把着色器(shader)从 DXBC/DXIL 转换成 Metal Shading Language 来实现。
这里的技术难点在于着色器转换。D3D 的着色器是编译成字节码的,Metal 的着色器是另一种语言。DXMT 需要把字节码反编译、优化、再编译成 Metal 能吃的格式。这个过程如果出错,表现就是画面花屏、黑屏或者直接崩溃。我在跑一个 D3D 11 的小游戏时,就遇到过着色器编译失败导致画面全黑的情况,后来查日志发现是某个纹理格式不被支持,换了个 DXMT 的版本才解决。
DXMT 和 Wine 的配合方式是:Wine 提供d3d11.dll的接口,DXMT 实现这个接口,内部再调 Metal。所以配置的时候,要把 Wine 的d3d11设成原生,让它加载 DXMT 的实现,而不是 Wine 自带的那个基于 OpenGL 的版本。这一点如果搞错,程序要么跑不起来,要么性能差得离谱。
2.4 三层翻译的性能账怎么算
三层翻译叠在一起,性能损失是必然的。我实测下来,一个简单的 x86-64 程序,经过 FEX-Emu 翻译后,性能大概是原生的 40% 到 60%。如果再叠加 Wine 的 API 翻译开销,可能降到 30% 到 50%。图形部分如果走 DXMT,还要再打折扣,具体取决于着色器转换的复杂度。
但这个账不能只看绝对性能。对于很多老程序来说,它们本来就不需要很高的性能。一个 2005 年的文本编辑器,在现在的 ARM 芯片上就算打三折,也比你当年的奔腾 4 快。真正吃性能的是游戏和图形密集型应用,这类程序在 Madeira 上跑,体验就取决于具体场景了。2D 游戏一般没问题,3D 游戏就要看 DXMT 的转换效率。
注意:不要指望在 iOS 上跑最新的 3A 大作。这套组合的目标是让老程序能跑,而不是让新程序跑得快。心态摆正,体验会好很多。
3. 核心细节解析:每一层的配置要点和坑
3.1 FEX-Emu 的 rootfs 和配置
FEX-Emu 在 iOS 上跑,需要一个 rootfs(根文件系统),里面放着 x86-64 的库和 Wine 的二进制。这个 rootfs 通常是一个 squashfs 镜像或者一个目录,里面包含了 Ubuntu 或者 Debian 的 x86-64 用户态环境。为什么需要这个?因为 Wine 依赖大量的系统库,比如libc、libX11、libfreetype,这些库必须是 x86-64 的,才能被 FEX-Emu 翻译执行。
rootfs 的构建是个体力活。我用的方法是在一台 x86-64 的 Linux 机器上,用debootstrap搭一个最小化的 Debian 环境,然后往里装 Wine 和相关的依赖。装完之后打包成镜像,传到 iOS 设备上。这个过程要注意库的版本匹配,Wine 对libc的版本有要求,太新太旧都可能出问题。
FEX-Emu 的配置文件里,有几个参数值得关注。RootFS指向 rootfs 的路径,ThunkHostLibs和ThunkGuestLibs控制宿主库和客户库的映射。如果程序需要调用宿主系统的某些功能,比如音频输出,就要通过 thunk 机制来桥接。我一开始没配 thunk,结果程序跑起来没声音,查了半天才发现是音频库没映射过去。
3.2 Wine 前缀的初始化与 DLL 覆盖
Wine 的前缀(prefix)是它模拟的 Windows 环境,里面有一个虚拟的 C 盘,还有注册表文件。初始化前缀用wineboot命令,它会创建目录结构和默认的注册表。在 Madeira 里,这个命令要通过 FEX-Emu 来执行,因为wineboot本身是 x86-64 的。
DLL 覆盖是 Wine 配置里最容易出错的地方。Wine 自带了很多 DLL 的实现,但有些程序需要原生的 DLL 才能跑。比如某些程序依赖msvcp140.dll,Wine 自带的版本可能不完整,就需要把原生的 DLL 拷到前缀的system32目录里,然后在winecfg里把对应的库设成原生。反过来,像kernel32、user32这些核心库,必须用 Wine 内置的,设成原生会直接崩。
我整理了一个常用 DLL 的覆盖建议表,供参考:
| DLL 名称 | 建议设置 | 原因 |
|---|---|---|
| kernel32 | 内置 | 核心系统库,原生版本依赖 Windows 内核 |
| user32 | 内置 | 窗口管理,原生版本无法在 iOS 上工作 |
| d3d11 | 原生 | 需要加载 DXMT 的实现 |
| dxgi | 原生 | 配合 DXMT 使用 |
| mscoree | 内置 | 用 Mono 顶替 .NET Framework |
| msvcp140 | 视情况 | 程序自带则用原生,否则用内置 |
| vcruntime140 | 视情况 | 同上 |
这个表不是绝对的,具体还要看程序的需求。我的经验是,先全用内置跑一遍,看报什么错,再针对性地换原生。
3.3 DXMT 的安装与着色器缓存
DXMT 的安装相对简单,把编译好的d3d11.dll、dxgi.dll放到 Wine 前缀的system32目录,然后在winecfg里把d3d11和dxgi设成原生就行。但着色器缓存是个容易被忽略的点。DXMT 在第一次遇到某个着色器时,会做转换,这个过程比较慢。转换后的结果会缓存起来,下次直接用。如果缓存目录没有写权限,每次都要重新转换,体验会很差。
缓存目录一般在~/Library/Caches下面,具体路径取决于 DXMT 的版本。我建议在配置的时候,显式指定缓存路径,并确保这个路径可写。另外,如果程序更新了着色器,缓存可能会失效,需要手动清理。我遇到过一次画面异常,清理缓存后恢复正常,所以养成定期清理的习惯没坏处。
3.4 iOS 侧的限制与绕行方案
iOS 对这类项目的限制主要有三个:JIT 权限、文件系统访问、后台运行。JIT 权限前面提过,需要特殊签名。文件系统访问方面,iOS 的沙盒机制让程序只能访问自己的容器目录,Wine 前缀必须放在这个目录里。后台运行方面,iOS 会随时挂起后台应用,如果程序在后台需要继续跑,就要申请后台任务权限,但时间有限。
绕行方案主要是靠开发者签名或者企业签名,拿到更多的权限。另外,有些项目会用altstore或者sideloadly这类工具来侧载,配合开发者模式,能拿到 JIT 权限。这部分操作涉及具体的工具链,不同版本差异较大,建议参考项目的最新文档。
提示:iOS 的开发者模式需要在设置里手动开启,而且设备重启后会重置。如果发现 JIT 突然不工作了,先检查开发者模式是不是关了。
4. 实操过程:从零跑通一个 Windows 程序
4.1 环境准备与依赖安装
我用的环境是一台 iPad Pro(M1 芯片),系统版本是 iPadOS 16。首先要在设备上装一个能跑 FEX-Emu 的宿主 App,这个 App 通常由项目方提供,或者自己用 Xcode 编译。编译的时候要注意签名配置,用免费的开发者账号签名,有效期只有 7 天,到期要重新签。用付费账号可以延长到一年。
宿主 App 装好后,把 rootfs 镜像和 FEX-Emu 的配置文件传进去。传输方式可以用iTunes的文件共享,或者用scp如果设备开了 SSH。我用的方法是把文件打包成一个 zip,通过文件 App 拷进去,再在宿主 App 里解压。
依赖方面,rootfs 里需要装这些包:wine、wine32(如果需要 32 位支持)、winetricks、cabextract。winetricks是个脚本工具,能帮你装一些常用的 Windows 组件,比如vcrun2019、dotnet48。不过winetricks在 FEX-Emu 环境下跑,有时候会因为网络问题失败,建议提前把需要的组件下载好。
4.2 rootfs 构建的详细步骤
构建 rootfs 我是在一台 Ubuntu 22.04 的 x86-64 机器上做的。步骤如下:
# 安装 debootstrap sudo apt install debootstrap # 创建一个最小化的 Debian 环境 sudo debootstrap --arch=amd64 bullseye ./rootfs http://deb.debian.org/debian/ # chroot 进去 sudo chroot ./rootfs # 配置 apt 源,安装 Wine apt update apt install wine wine64 winetricks cabextract # 退出 chroot exit # 打包成 squashfs 镜像 sudo mksquashfs ./rootfs rootfs.squashfs -comp zstd这里用bullseye而不是更新的版本,是因为 Wine 在某些新版本上会有兼容性问题。zstd压缩是为了减小镜像体积,同时解压速度快。打包好的镜像大概 1.5GB 左右,传到 iPad 上需要一点时间。
4.3 Wine 前缀初始化与程序安装
在 iOS 设备上,通过宿主 App 的终端界面,执行以下命令:
# 设置环境变量 export FEX_ROOTFS=/path/to/rootfs export WINEPREFIX=/path/to/prefix # 初始化 Wine 前缀 FEXBash -c "wineboot -u" # 安装程序 FEXBash -c "wine /path/to/setup.exe"FEXBash是 FEX-Emu 提供的一个包装脚本,它会在 FEX-Emu 的环境里执行后面的命令。wineboot -u会创建前缀目录和注册表。安装程序的时候,如果安装界面出不来,可能是图形驱动没配好,检查 DXMT 是否加载。
我装的是一个老版本的音频编辑工具,安装过程很顺利,但启动的时候报错说找不到d3d9.dll。这个程序用的是 D3D9,而 DXMT 主要支持 D3D11。解决办法是用 Wine 自带的d3d9实现,它基于 OpenGL,虽然性能一般但能用。在winecfg里把d3d9设成内置,问题解决。
4.4 图形与音频的调试
图形调试主要看日志。DXMT 会输出转换日志,如果某个着色器转换失败,日志里会有明确的错误信息。我遇到过一次画面闪烁的问题,日志显示是某个纹理格式不支持,后来在 DXMT 的配置里加了一个格式映射才解决。
音频方面,Wine 默认用winealsa或者winepulse,在 iOS 上这两个都不直接可用。需要配置 thunk,把音频调用桥接到 iOS 的 CoreAudio。FEX-Emu 的 thunk 配置里有一个libasound的映射,把它指向宿主系统的音频库就行。我配好之后,声音正常输出,延迟也在可接受范围内。
注意:音频 thunk 配置错误会导致程序启动时卡死,因为它在等音频设备初始化。如果程序卡在启动画面,先检查音频配置。
4.5 性能调优的几个手段
性能调优我试过几个手段。第一是调整 FEX-Emu 的 JIT 缓存大小,默认值偏小,跑大程序容易频繁重编译。在配置里把JITCacheSize调大,能减少重编译开销。第二是关闭不必要的 Wine 调试输出,WINEDEBUG=-all能减少日志开销。第三是给 DXMT 开着色器预编译,虽然启动慢一点,但运行更流畅。
还有一个手段是用taskset把进程绑到性能核上。iOS 的芯片是大小核架构,FEX-Emu 的翻译线程如果跑到小核上,性能会明显下降。不过 iOS 上能不能用taskset取决于系统权限,我这边测试是可以的,但不确定所有版本都支持。
5. 常见问题与排查技巧实录
5.1 程序启动就崩,没有任何提示
这是最常见的问题,原因通常有三个:DLL 缺失、指令集不支持、内存权限不足。排查方法是开WINEDEBUG=+loaddll看加载了哪些 DLL,哪个加载失败。如果是指令集问题,FEX-Emu 的日志里会有 "unsupported instruction" 的字样。内存权限问题一般表现为SIGSEGV,需要检查 JIT 权限是否正常。
我遇到过一次启动即崩,查了半天发现是 rootfs 里的libc版本和 Wine 不匹配。Wine 编译时链接的libc版本比 rootfs 里的新,导致符号找不到。解决办法是重新构建 rootfs,用匹配的libc版本。
5.2 界面乱码或字体显示异常
Wine 的字体问题是个老话题。默认情况下,Wine 会用宿主系统的字体,但 iOS 的字体路径和 Linux 不一样,Wine 找不到就会显示方块或者乱码。解决办法是把字体文件拷到 Wine 前缀的drive_c/windows/Fonts目录,然后在注册表里设置字体替换。
我一般会拷几个常用的字体进去:simsun.ttc(宋体)、msyh.ttc(微软雅黑)、arial.ttf。然后在winecfg的字体选项卡里,把这些字体设为默认。这样大部分程序的界面都能正常显示。
5.3 图形程序黑屏或花屏
黑屏通常是着色器转换失败,花屏通常是纹理格式不支持。排查方法是看 DXMT 的日志,找到失败的着色器或纹理格式。如果是着色器问题,可以尝试换一个 DXMT 版本,或者用dxvk替代(如果项目支持)。如果是纹理格式问题,可以在 DXMT 的配置里加格式映射。
我遇到过一次花屏,日志显示是DXGI_FORMAT_BC7不支持。BC7 是一种压缩纹理格式,Metal 对它的支持有限。解决办法是在 DXMT 配置里把 BC7 映射到 BC3,虽然画质有损失,但至少能看。
5.4 音频无声或爆音
无声通常是 thunk 没配好,爆音通常是缓冲区大小不合适。检查 thunk 配置里libasound的映射路径是否正确。缓冲区大小可以在 Wine 的音频设置里调,默认值偏小,调大一点能减少爆音。
我遇到过一次爆音,调大缓冲区后解决。但缓冲区太大又会导致延迟,所以要在延迟和音质之间找个平衡。我的经验是设成 1024 个采样点,大部分场景够用。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 启动即崩 | DLL 缺失 | WINEDEBUG=+loaddll | 补全缺失的 DLL |
| 启动即崩 | 指令集不支持 | 看 FEX-Emu 日志 | 换 FEX-Emu 版本 |
| 界面乱码 | 字体缺失 | 看 Wine 日志 | 拷贝字体并设置替换 |
| 黑屏 | 着色器转换失败 | 看 DXMT 日志 | 换 DXMT 版本 |
| 花屏 | 纹理格式不支持 | 看 DXMT 日志 | 加格式映射 |
| 无声 | thunk 未配置 | 检查 thunk 配置 | 配置音频 thunk |
| 爆音 | 缓冲区过小 | 听感判断 | 调大缓冲区 |
| 卡顿 | JIT 缓存过小 | 看 FEX-Emu 日志 | 调大 JITCacheSize |
5.6 几个独家避坑技巧
第一个技巧是先用最小化程序测试。不要一上来就跑大程序,先用notepad.exe这种简单程序验证环境是否正常。如果notepad都跑不起来,那肯定是环境问题,不用往下折腾。
第二个技巧是保留多个 rootfs 版本。不同程序对库版本的要求不一样,保留几个不同版本的 rootfs,遇到兼容性问题可以快速切换。我一般会保留一个 Debian 11 的和一个 Debian 12 的。
第三个技巧是日志分级。Wine 和 FEX-Emu 的日志都很啰嗦,全开的话输出量巨大。我一般先用WINEDEBUG=-all跑一遍,看能不能起来,起不来再逐步开日志。FEX-Emu 的日志可以用FEX_LOG_LEVEL控制,设成warn只输出警告和错误。
第四个技巧是注意文件路径的大小写。iOS 的文件系统默认是大小写敏感的,而 Windows 程序经常不区分大小写。如果程序访问一个文件报 "file not found",但文件明明存在,很可能是大小写问题。解决办法是在 Wine 的配置里开启大小写不敏感模式,或者手动把文件名改成程序期望的大小写。
6. 这套组合还能怎么扩展
跑通基础功能之后,我试了一些扩展玩法。一个是多程序共存,在同一个前缀里装多个程序,共享 DLL 和注册表。这样做的好处是省空间,坏处是程序之间可能冲突。我的做法是给每个程序单独建前缀,虽然占空间,但隔离性好,出问题好排查。
另一个扩展是接外设。iOS 支持蓝牙键鼠和手柄,Wine 程序可以通过 thunk 拿到这些输入事件。我试过用蓝牙手柄玩一个老游戏,延迟可以接受,但按键映射需要手动配。这部分配置在 Wine 的注册表里,具体键值可以参考 Wine 的文档。
还有一个方向是性能分析。FEX-Emu 有内置的性能计数器,能看到翻译开销、JIT 命中率这些指标。DXMT 也有类似的统计。把这些数据收集起来,能帮你找到性能瓶颈。我分析过一次,发现瓶颈在着色器转换上,后来通过预编译缓存解决了。
最后说个我个人的体会:这套东西的乐趣不在于它能跑多少程序,而在于每一层翻译背后的设计取舍。FEX-Emu 怎么处理自修改代码,Wine 怎么模拟 Windows 的窗口消息循环,DXMT 怎么把 D3D 的状态机映射到 Metal 的渲染管线,这些细节比"能跑起来"本身更有意思。如果你也是喜欢刨根问底的人,建议从 FEX-Emu 的源码入手,它的文档写得还算清楚,配合调试日志看,能学到不少东西。
至于后续还能怎么玩,我打算试试把 Vulkan 也接进来,用 MoltenVK 做后端,这样一些用 Vulkan 的程序也能跑。不过这是另一个大坑了,等踩完再分享。