1. 项目缘起:为什么要在 iOS 上折腾 Wine 和 FEX-Emu
“Madeira”这个项目标题,乍一看像是个地名,但在我们这行里,它其实是一个把 Windows 应用搬到 iOS 上跑的技术验证项目代号。核心思路很直接:用Wine做 Windows API 的转译层,用FEX-Emu做 x86-64 到 ARM64 的指令翻译,最后打包成一个能在 iOS 上侧载的 App。说白了,就是想让 iPhone 或 iPad 直接运行那些原本只能在 Windows 上跑的 .exe 程序。
为什么选这个组合?因为 iOS 的设备端全是 ARM 架构,而大量 Windows 软件是 x86-64 编译的。Wine 本身不模拟 CPU,它只负责把 Windows 的系统调用翻译成 POSIX 调用,所以必须搭配一个 CPU 模拟器或翻译器。FEX-Emu 是目前在 ARM 上跑 x86-64 比较成熟的方案,性能损耗比 QEMU 全模拟低不少。DXMT 则是把 Direct3D 调用转成 Metal 的中间层,让游戏和图形程序有希望跑起来。
这个项目适合谁看?如果你是对 iOS 底层感兴趣、想了解跨架构兼容层怎么搭、或者单纯想在自己的设备上跑一些老 Windows 小工具的人,那这篇内容就是给你写的。我会把整个搭建过程拆开,从环境准备到编译、从配置到排错,尽量把每一步的“为什么”讲清楚。需要提前说明的是,iOS 上的侧载和签名机制比较特殊,本文只讨论技术实现路径,不涉及任何绕过平台规则的操作。
2. 整体架构设计与技术选型逻辑
2.1 为什么是 Wine + FEX-Emu + DXMT 这个组合
先拆开看这三层各自干什么。Wine负责 PE 加载、注册表模拟、Windows 系统调用到 POSIX 的映射。它不翻译指令,所以纯 ARM 设备上跑 x86 程序必须再加一层。FEX-Emu就是这层,它把 x86-64 指令动态翻译成 ARM64 指令,并且做了大量优化,比如块缓存、寄存器映射、SMC 检测等。DXMT是 Direct3D 到 Metal 的转换层,因为 iOS 上没有 Vulkan 也没有原生 D3D,Metal 是唯一能用的图形 API。
这个组合的优势在于:Wine 和 FEX-Emu 都是开源且活跃的项目,DXMT 也在快速迭代。相比用 QEMU 全系统模拟,FEX-Emu 的用户态翻译性能好很多,尤其是在跑 32 位和 64 位 Windows 程序时。另一个关键点是 iOS 的沙盒限制——你不能直接 fork 一个完整虚拟机,所以用户态翻译是更现实的选择。
选型时也考虑过 Box64,它在 ARM Linux 上表现不错,但在 iOS 上的移植成熟度不如 FEX-Emu。而且 FEX-Emu 对 x86-64 的覆盖更完整,特别是对 SSE4.2、AVX 等指令集的支持更好。DXMT 相比 DXVK 的优势在于它直接输出 Metal,不需要经过 MoltenVK 再转一层,延迟更低。
2.2 iOS 侧载与签名的基本约束
在 iOS 上跑任何非 App Store 分发的代码,都绕不开签名和描述文件。开发者模式、免费证书、企业证书这几条路各有各的限制。免费证书每 7 天要重签一次,而且最多只能装 3 个 App。开发者模式需要设备上手动开启,并且要信任对应的开发者证书。这些机制直接影响了 Madeira 的打包方式——你必须把 Wine、FEX-Emu、DXMT 以及目标 Windows 程序全部塞进一个 App bundle 里,或者做成动态下载的形式。
另一个约束是JIT(即时编译)权限。FEX-Emu 的动态翻译需要可执行内存,而 iOS 默认不允许普通 App 申请可执行内存。这就涉及到com.apple.security.cs.allow-jit这个 entitlement,但它在非越狱设备上通常拿不到。所以实际可行的方案有两种:一是用解释模式跑 FEX-Emu,性能差但不需要 JIT;二是在越狱设备上启用 JIT 权限。本文主要讨论第一种,因为它的适用范围更广。
2.3 目录结构与文件布局
一个典型的 Madeira 项目目录大概长这样:
Madeira/ ├── App/ │ ├── MadeiraApp.swift │ ├── Info.plist │ └── Entitlements.plist ├── Wine/ │ ├── bin/ │ ├── lib/ │ └── share/ ├── FEX/ │ ├── libFEXCore.dylib │ └── fex-emu ├── DXMT/ │ ├── libdxmt.dylib │ └── d3d11.dll └── Prefix/ ├── drive_c/ └── system.regWine 的 prefix 目录是运行时生成的,里面模拟了 C 盘的结构。FEX-Emu 的 dylib 需要和 Wine 的 loader 放在一起,DXMT 的 dll 则要覆盖到 Wine 的 system32 目录下。这个布局不是随便定的,它决定了运行时动态库的搜索路径。
3. 核心组件编译与配置实操
3.1 Wine 的交叉编译与裁剪
在 macOS 上编译 iOS 能用的 Wine,需要一套 ARM64 的 iOS 工具链。我一般用 Xcode 自带的 clang 加上-target arm64-apple-ios14.0这样的参数。Wine 的 configure 阶段要关掉一堆不需要的模块,比如 X11 驱动、OSS 音频、CUPS 打印等,只保留 CoreAudio、Metal 相关的后端。
./configure \ --host=arm64-apple-ios14.0 \ --enable-win64 \ --disable-winedbg \ --without-x \ --without-oss \ --without-cups \ --with-coreaudio \ --with-metal编译过程中最容易卡住的是ntdll和kernel32这两个模块,因为它们涉及大量底层系统调用。iOS 的沙盒不允许直接访问/dev下的设备节点,所以 Wine 的server进程需要做特殊处理——通常的做法是把wineserver编译成静态库,直接链接进主 App,而不是作为独立进程启动。
注意:Wine 的 32 位支持在 iOS 上基本不可用,因为 iOS 从 11 开始就只支持 64 位。所以只能跑 64 位的 Windows 程序,32 位程序需要额外的 wow64 层,但那个在 ARM 上还不成熟。
3.2 FEX-Emu 的移植要点
FEX-Emu 原本是为 Linux ARM 设计的,移植到 iOS 主要改三个地方:一是内存分配,iOS 没有mmap的MAP_FIXED_NOREPLACE标志,需要用mach_vm_allocate替代;二是线程本地存储,iOS 的 TLS 模型和 Linux 不同;三是信号处理,FEX 依赖 SIGSEGV 来捕获 SMC 写入,iOS 上这个信号的行为有差异。
编译 FEX 需要先装好 CMake 和 Ninja,然后指定 iOS 的 toolchain 文件:
set(CMAKE_SYSTEM_NAME iOS) set(CMAKE_OSX_ARCHITECTURES arm64) set(CMAKE_OSX_DEPLOYMENT_TARGET 14.0) set(CMAKE_C_COMPILER clang) set(CMAKE_CXX_COMPILER clang++)编译出来的libFEXCore.dylib要嵌入到 App 的 Frameworks 目录下,并且用install_name_tool改掉它的 install path,否则运行时找不到。
3.3 DXMT 的 Metal 后端配置
DXMT 的编译相对简单,因为它主要依赖 Metal 和 Objective-C 运行时。但要注意的是,iOS 上的 Metal 版本和 macOS 有差异,比如MTLResourceOptions里的一些选项在 iOS 上不可用。DXMT 的d3d11.dll需要和 Wine 的d3d11模块做替换,同时要在 Wine 的注册表里把Direct3D的渲染器指向 DXMT。
配置注册表可以用wine reg add命令:
wine reg add "HKCU\\Software\\Wine\\Direct3D" /v renderer /t REG_SZ /d dxmt /f这一步不做的话,Wine 会默认走它自带的 OpenGL 后端,而 iOS 上 OpenGL 已经废弃了,结果就是黑屏或者崩溃。
3.4 打包与签名流程
把所有组件塞进 Xcode 工程后,需要配置Entitlements.plist。如果设备越狱了,可以加com.apple.security.cs.allow-jit和com.apple.security.cs.allow-unsigned-executable-memory。非越狱设备只能靠免费证书,那就要把 FEX 切成解释模式,在fex-emu的配置里设Core = "Interpreter"。
签名用codesign命令:
codesign --force --sign "Apple Development: xxx" \ --entitlements Entitlements.plist \ --deep Madeira.app--deep会递归签名所有嵌套的 dylib 和 framework。签完之后用ios-deploy或者 Xcode 直接安装到设备上。
4. 运行时排错与常见问题实录
4.1 Wine 乱码问题的根因与修复
Wine 在 iOS 上跑起来后,最常见的现象就是菜单和对话框里的文字全是方块或者问号。这个问题的根源是字体缺失和字符集映射错误。Wine 默认会去找系统字体,但 iOS 的字体目录和 Linux/macOS 都不一样,它没有~/.wine/drive_c/windows/Fonts这个路径。
解决办法是手动把字体文件复制到 prefix 的 Fonts 目录下,并且在注册表里指定字体替换:
cp /System/Library/Fonts/PingFang.ttc Prefix/drive_c/windows/Fonts/ wine reg add "HKCU\\Software\\Wine\\Fonts\\Replacements" /v "MS Shell Dlg" /t REG_SZ /d "PingFang SC" /f另一个坑是wine gecko和wine mono的安装。很多 Windows 程序依赖 .NET 或者 HTML 渲染,如果没有 gecko 和 mono,程序启动就会报错。但 iOS 上没法直接跑 gecko 的安装程序,需要提前把 gecko 的 msi 解包,把文件放到对应的目录里。
4.2 FEX-Emu 崩溃的排查思路
FEX-Emu 在 iOS 上崩溃通常有几个典型原因。一是 JIT 权限不足,日志里会出现Failed to allocate executable memory。这时候要么切解释模式,要么检查 entitlements 是否生效。二是 SMC(自修改代码)检测失败,很多老程序会动态修改自己的代码段,FEX 需要捕获这个行为并重新翻译。如果崩溃日志里有SMC write to non-writable page,那就要在 FEX 配置里打开SMCChecks = "mtrack"。
三是线程数超限,iOS 对单个进程的线程数有限制,Wine 加 FEX 很容易超过。可以在fex-emu的配置里限制Threads = 4,或者用pthread_setname_np给线程起名方便排查。
4.3 DXMT 黑屏与性能调优
DXMT 跑起来后如果黑屏,先检查 Metal 设备是否创建成功。可以在代码里加一行MTLCreateSystemDefaultDevice()的日志,如果返回 nil,说明当前设备不支持 Metal,或者 App 没有 Metal 权限。另一个常见问题是纹理格式不匹配,Windows 程序常用的DXGI_FORMAT_B8G8R8A8_UNORM在 Metal 里需要转换成MTLPixelFormatBGRA8Unorm,DXMT 内部会做这个转换,但如果程序用了不常见的格式,就可能渲染失败。
性能方面,DXMT 的MaxFrameLatency参数可以调低来减少输入延迟,但会牺牲一些帧率稳定性。在 iPhone 上跑 2D 游戏基本流畅,3D 游戏就要看具体负载了。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 启动即闪退 | 签名无效或 entitlements 缺失 | 查看设备日志idevicesyslog | 重新签名,补 JIT 权限 |
| 文字乱码 | 字体缺失或字符集错误 | 检查 Fonts 目录和注册表 | 复制字体,设替换规则 |
| FEX 报内存错误 | JIT 不可用或 SMC 检测失败 | 看 FEX 日志的 error 行 | 切解释模式或调 SMC 配置 |
| DXMT 黑屏 | Metal 设备创建失败 | 加 Metal 日志 | 检查设备兼容性 |
| 程序卡死 | 线程数超限或死锁 | 用sample抓调用栈 | 限制线程数,检查 Wine server |
5. 实操心得与进阶方向
5.1 我踩过的几个坑
第一个坑是Wine 的 wineserver 在 iOS 上没法作为独立进程启动。因为 iOS 的 App 沙盒不允许 fork 出新的可执行文件,所以必须把 wineserver 的逻辑编译成静态库,在主进程里用线程模拟。这个改动涉及 Wine 的server/目录下大量代码,我当时的做法是只保留最核心的请求队列和对象管理,把进程间通信改成线程间通信。
第二个坑是FEX-Emu 的块缓存目录。FEX 默认会把翻译后的代码块缓存到磁盘上,但 iOS 的沙盒对缓存目录的写入有限制。需要把缓存路径改到NSCachesDirectory下,并且设置合理的缓存上限,否则设备存储很快就会被占满。
第三个坑是DXMT 的着色器编译。Metal 的着色器编译是异步的,如果程序在着色器还没编译完就提交绘制命令,就会丢帧或者黑屏。DXMT 内部有等待机制,但在低端设备上编译时间较长,需要适当增加超时。
5.2 性能优化的几个方向
如果想让 Madeira 跑得更流畅,可以从这几个方面入手。一是FEX 的块缓存预热,把常用程序的翻译结果提前缓存好,减少首次运行的卡顿。二是Wine 的 DLL 裁剪,把不需要的模块(比如wininet、mshtml)去掉,减小内存占用。三是DXMT 的管线缓存,把 Metal 的MTLRenderPipelineState缓存到磁盘,避免每次启动都重新编译。
另外,iOS 设备的内存管理比较激进,后台程序容易被杀。可以在 App 的Info.plist里加UIApplicationExitsOnSuspend为 false,并且用beginBackgroundTask延长后台存活时间,但效果有限。
5.3 后续可以扩展的方向
这个项目目前只能跑一些简单的 Windows 工具和老游戏。如果想支持更复杂的程序,比如需要 .NET 或者 DirectX 12 的,还需要做很多工作。.NET 方面可以尝试把 CoreCLR 移植到 iOS ARM64,但工作量很大。DirectX 12 方面,DXMT 目前只支持到 D3D11,D3D12 需要等上游的 vkd3d 或者新的 Metal 后端。
另一个方向是iOS 设备模拟,也就是在 Mac 上模拟 iOS 环境来跑 Madeira,这样可以方便调试。但 iOS 模拟器是 x86-64 的,和真机的 ARM64 差异很大,FEX 在模拟器上跑不起来,所以这条路暂时走不通。
5.4 一些实用的小技巧
如果你也在折腾类似的项目,有几个小技巧可以省时间。一是用idevicesyslog实时看设备日志,比 Xcode 的控制台更全。二是用lldb远程调试,在 Mac 上连到设备上的进程,可以直接断点看 FEX 的翻译过程。三是把 Wine 的WINEDEBUG环境变量设成+all,虽然日志量很大,但排查问题时非常有用。
最后再提一句,iOS 的开发者模式在较新的系统版本里藏得比较深,需要在设置里连续点几次版本号才能出来。如果找不到,可以搜一下对应系统版本的开启方法。这个模式不开的话,侧载的 App 根本没法启动。