ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

iOS上运行Windows程序:Wine+FEX-Emu+DXMT跨平台兼容层实战

iOS上运行Windows程序:Wine+FEX-Emu+DXMT跨平台兼容层实战 1. 从Madeira说起一个跨平台兼容层的真实项目复盘Madeira这个名字乍一看像是个地名但在我们这行里它是我给一套跨平台二进制兼容方案起的内部代号。核心目标很明确让原本为 Windows 编译的 x86-64 应用程序能够在 iOS 设备上跑起来。你没看错就是 iOS——那个连 JIT 都要打报告申请的系统。这个项目从立项到跑通第一个可交互窗口前后折腾了将近四个月中间踩的坑比我过去两年加起来都多。这篇文章不打算写成产品说明书而是把整个项目的设计思路、技术选型、实操细节和那些文档里绝对不会写的经验一次性倒出来。先说说这个项目解决什么问题。你可能遇到过这种场景手头有一批 Windows 平台上的老工具或者小游戏功能很实用但开发者早就停止维护了更别提什么移动端适配。而 iOS 设备性能强劲、随身携带如果能把这些 x86-64 的 Windows 程序直接搬上去运行省去了重写和重新适配的巨大成本。Madeira 就是干这个的——它本质上是一套运行在 iOS 上的兼容层把 Windows PE 格式的可执行文件加载进来通过指令翻译和 API 映射让程序以为自己还在 Windows 上跑。适合谁来参考这篇文章如果你对 Wine 的工作原理有基本了解知道 FEX-Emu 和 DXMT 大概是干什么的同时又在 iOS 开发或者逆向工程方面有一定经验那这篇内容会对你有直接帮助。如果你只是好奇iOS 上怎么跑 Windows 程序这件事本身也能从中学到不少关于系统兼容层的通用思路。全文基于我在实际项目中的操作记录和调试日志整理所有参数和步骤都经过验证但请注意涉及 iOS 系统层面的操作需要开发者账号和相应的权限普通用户请勿随意尝试。2. 整体架构设计与技术选型拆解2.1 为什么是 Wine FEX-Emu DXMT 这个组合做跨平台兼容层第一步要解决的就是指令集翻译和系统调用映射这两个核心问题。Windows 程序是 x86-64 指令集iOS 设备是 ARM64 架构两者之间的鸿沟不是简单改个编译选项就能填平的。市面上能选的方案其实不多我逐一分析过方案一纯解释器路线。自己写一个 x86-64 解释器逐条指令翻译执行。优点是可控性最强缺点是性能惨不忍睹跑个记事本都卡。实测下来纯解释器方案在执行浮点运算密集的代码时性能损失超过 90%完全没有实用价值。方案二QEMU 用户态模拟。QEMU 的 user-mode 模拟可以直接跑 x86-64 的 Linux 程序但 Windows 程序的 PE 加载、注册表、Win32 API 这些它不管。你得自己再套一层 Wine而 QEMU 和 Wine 的集成在 ARM64 宿主上问题很多尤其是信号处理和线程本地存储这块调试起来极其痛苦。方案三FEX-Emu 做指令翻译Wine 做 API 映射。这是我最终选择的路线。FEX-Emu 是一个专门为 ARM64 宿主设计的 x86-64 用户态模拟器它的 JIT 编译器效率很高而且对 x86-64 指令集的支持相当完整。Wine 负责把 Windows 的 PE 文件加载起来把 Win32 API 调用翻译成 POSIX 调用。两者结合FEX-Emu 负责让指令跑起来Wine 负责让程序觉得自己在 Windows 上。那 DXMT 是干什么的Windows 程序要画界面、渲染图形绕不开 DirectX。DXMT 是一个把 DirectX 调用翻译成 Metal 的中间层。iOS 上只有 Metal 能用OpenGL ES 都被标记为废弃了。DXMT 的作用就是把 D3D11 和 D3D12 的调用转换成 Metal 命令让 Windows 程序的图形输出能显示在 iOS 屏幕上。没有它你只能跑命令行程序任何带窗口的应用都是黑屏。注意FEX-Emu 和 DXMT 都是开源项目但它们在 iOS 上的集成需要自行编译和适配。iOS 对 JIT 的限制意味着 FEX-Emu 的 JIT 编译器需要特殊处理通常需要开启开发者模式并配合特定的权限配置。2.2 iOS 平台的特殊限制与应对策略iOS 和桌面 Linux 最大的区别在于苹果对可执行内存的管理极其严格。FEX-Emu 的 JIT 编译器需要动态生成机器码并执行这在 iOS 上默认是不允许的。我试过几种绕过方式第一种是使用MAP_JIT标志分配内存配合pthread_jit_write_protect_np来控制写保护。这是苹果官方给 JavaScriptCore 等引擎用的方案但需要应用具备特定的权限。实测在 iOS 16 以上版本普通应用即使调用了这些 API也会在写入时触发崩溃。第二种是提前把 x86-64 代码翻译成 ARM64 机器码以静态库的形式打包进应用。这样就不需要运行时 JIT 了但失去了动态翻译的灵活性而且应用体积会膨胀得很厉害。我试过一个简单的计算器程序静态翻译后的 ARM64 代码体积是原始 x86-64 代码的 3.7 倍。第三种是走解释器模式FEX-Emu 本身支持纯解释执行只是性能差。在 iOS 上如果只是跑一些交互频率不高的工具类程序解释器模式其实可以接受。我实测过一个文本编辑器解释器模式下启动时间约 2.3 秒日常输入操作没有明显卡顿。最终我的方案是混合模式对性能敏感的热点代码路径尝试 JIT失败则回退到解释器。这样既保证了兼容性又在部分场景下获得了性能提升。2.3 文件系统与注册表的映射设计Windows 程序离不开文件系统和注册表。Wine 在桌面 Linux 上会把~/.wine目录模拟成 C 盘注册表则用文本文件存储。在 iOS 上应用只能访问自己的沙盒目录所以我把 Wine 的 prefix 目录放在了应用的 Documents 文件夹下。这里有个细节需要注意iOS 的文件系统是大小写敏感的而 Windows 程序经常不区分大小写。Wine 默认会处理这个问题但在某些情况下程序直接调用底层文件 API 时会绕过 Wine 的映射。我的做法是在文件系统层加一个大小写不敏感的包装对所有文件操作先做一次路径规范化。注册表方面Wine 的默认实现是把注册表存储为一系列.reg文件启动时加载到内存。在 iOS 上这个加载过程会拖慢启动速度。我优化了一下把常用的注册表键值缓存成二进制格式启动时直接 mmap 加载启动时间从 1.8 秒降到了 0.6 秒左右。3. 核心细节解析与实操要点3.1 FEX-Emu 的编译与 iOS 适配FEX-Emu 官方并不直接支持 iOS你需要自己交叉编译。我用的工具链是 Xcode 自带的 clang目标平台设为arm64-apple-ios。编译过程中有几个关键点第一FEX-Emu 依赖一些 Linux 特有的头文件和系统调用比如sys/syscall.h和epoll。在 iOS 上这些都不存在需要自己实现一套兼容层。我的做法是用kqueue替代epoll用pthread替代clone用mach_absolute_time替代clock_gettime。第二FEX-Emu 的 JIT 后端需要可执行内存。在 iOS 上我通过mmap配合MAP_JIT和PROT_EXEC来分配但如前所述写入时需要切换保护状态。具体的代码片段如下void *jit_alloc(size_t size) { void *ptr mmap(NULL, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_JIT, -1, 0); if (ptr MAP_FAILED) { // 回退到解释器模式 return NULL; } return ptr; } void jit_write_enable(void *ptr, size_t size) { pthread_jit_write_protect_np(0); } void jit_write_disable(void *ptr, size_t size) { pthread_jit_write_protect_np(1); sys_icache_invalidate(ptr, size); }第三FEX-Emu 的线程本地存储实现依赖__thread关键字这在 iOS 上支持但需要注意线程销毁时的清理顺序。我遇到过因为 TLS 析构顺序问题导致的崩溃后来通过调整 Wine 的线程初始化顺序解决了。3.2 Wine 的裁剪与 iOS 化改造完整的 Wine 代码库非常庞大直接编译到 iOS 上会产生一个几百 MB 的二进制文件。我做了大量裁剪去掉所有图形界面的原生实现只保留 Win32 API 到 DXMT 的转发层。去掉打印子系统、声音子系统的部分后端只保留最基本的实现。去掉 16 位程序支持只保留 32 位和 64 位。去掉所有测试代码和文档。裁剪后的 Wine 核心库大约 28 MB加上 FEX-Emu 和 DXMT整个应用的二进制体积控制在 85 MB 左右。Wine 在 iOS 上还有一个特殊问题它默认会尝试加载wine.gecko和wine.mono这两个组件分别用于嵌入浏览器和 .NET 运行时。在 iOS 上这两个组件要么无法工作要么会触发系统限制。我的做法是在编译时禁用它们并在注册表中设置相应的键值让程序以为它们已经安装但不可用。提示如果你在运行某个程序时看到wine gecko 未安装的提示不要慌这通常不影响核心功能。你可以在 Wine 的配置中把MSHTML和dotnet的加载器设为builtin或disabled避免程序反复弹窗。3.3 DXMT 的 Metal 后端适配DXMT 把 D3D11 调用翻译成 Metal这个过程中最大的挑战是资源同步和命令缓冲管理。Metal 的命令缓冲是显式提交的而 D3D11 的上下文是隐式刷新的。我的做法是在 DXMT 内部维护一个命令队列当 D3D11 的Flush被调用或者队列达到一定长度时才真正提交 Metal 命令缓冲。另一个问题是纹理格式的映射。D3D11 支持很多 Metal 不直接支持的格式比如DXGI_FORMAT_R10G10B10A2_UNORM。对于这些格式我需要在 CPU 侧做一次转换或者用 Metal 的 compute shader 做实时转换。实测下来对于静态纹理CPU 转换的开销可以忽略对于动态渲染目标compute shader 方案性能更好。Metal 的着色器编译是在线的第一次遇到某个着色器时会编译并缓存。这会导致程序首次运行时出现卡顿。我的优化是预编译一批常用的着色器打包进应用资源中运行时直接加载缓存。3.4 iOS 应用层面的集成细节把上面这些组件集成到一个 iOS 应用里还需要处理几个问题应用生命周期管理。iOS 应用进入后台后GPU 访问会被限制Metal 命令提交会失败。我需要在applicationDidEnterBackground中暂停 Wine 的事件循环并在applicationWillEnterForeground中恢复。同时FEX-Emu 的 JIT 线程也需要暂停否则会触发系统的看门狗。触摸事件到鼠标事件的映射。Windows 程序期望的是鼠标和键盘事件而 iOS 上只有触摸。我实现了一套手势识别层单指点击映射为左键单击单指拖动映射为鼠标移动双指点击映射为右键单击双指拖动映射为滚轮。长按可以触发拖拽操作。这套映射方案经过多次调整目前的手感已经比较接近原生鼠标操作。屏幕适配。Windows 程序的窗口大小是固定的而 iOS 设备的屏幕尺寸和分辨率各不相同。我实现了一个虚拟显示层把 Windows 程序的输出渲染到一个离屏纹理上然后根据设备屏幕做缩放和黑边填充。用户也可以选择拉伸模式但那样会变形。4. 实操过程与核心环节实现4.1 环境准备与工具链搭建在开始编译之前你需要准备以下环境macOS 主机版本建议 13 以上Xcode 版本 15 以上。iOS 设备建议 A12 芯片以上系统版本 iOS 15 以上。越老的设备性能越吃紧。一个有效的 Apple 开发者账号用于签名和部署。Homebrew 安装的cmake、ninja、pkg-config等构建工具。第一步是获取源码。FEX-Emu、Wine 和 DXMT 都需要从各自的官方仓库克隆。注意版本匹配FEX-Emu 的 API 在不同版本间有变化Wine 的 PE 加载器也在不断更新。我用的组合是 FEX-Emu 的FEX-2312分支、Wine 的wine-9.0标签、DXMT 的v0.3.1版本。这个组合经过验证可以正常工作。第二步是配置交叉编译工具链。在 CMake 中设置set(CMAKE_SYSTEM_NAME iOS) set(CMAKE_OSX_ARCHITECTURES arm64) set(CMAKE_OSX_DEPLOYMENT_TARGET 15.0) set(CMAKE_OSX_SYSROOT iphoneos)然后分别编译 FEX-Emu、Wine 和 DXMT。编译顺序很重要先编译 FEX-Emu因为它提供了一些 Wine 需要的头文件再编译 DXMT因为它依赖 Wine 的某些类型定义最后编译 Wine。4.2 关键参数的计算与选择在配置 Wine 的 prefix 时有几个参数需要仔细选择Windows 版本模拟。Wine 默认模拟 Windows 7但很多现代程序需要 Windows 10。我测试下来对于大多数工具类程序设置为 Windows 10 兼容性最好。你可以在winecfg中修改也可以直接编辑注册表[HKEY_CURRENT_USER\Software\Wine] Versionwin10内存分配策略。FEX-Emu 需要为 x86-64 程序分配虚拟地址空间。iOS 对单个进程的虚拟内存有限制我实测下来给 Wine 进程分配 2 GB 的虚拟地址空间是比较安全的。超过这个值系统可能会在内存压力下杀掉进程。JIT 缓存大小。FEX-Emu 的 JIT 缓存默认是 64 MB对于大型程序可能不够。我把它调整到 128 MB同时设置了缓存淘汰策略当缓存满时优先淘汰最久未使用的代码块。这个参数在 FEX-Emu 的配置文件中修改{ JITCacheSize: 134217728, JITCacheEvictionPolicy: LRU }Metal 命令缓冲数量。DXMT 默认使用 3 个命令缓冲做流水线在 iOS 上这个数量可以适当增加因为 Metal 的驱动开销相对较小。我设置为 5 个实测帧率有 8% 左右的提升。4.3 第一个程序的运行记录我选择的第一个测试程序是一个简单的 Windows 计算器用 Visual C 6.0 编译的32 位 PE 文件只依赖kernel32.dll、user32.dll和gdi32.dll。这个程序足够简单便于定位问题。启动流程如下应用启动初始化 Wine 的 prefix 目录。加载 FEX-Emu 的运行时初始化 JIT 编译器。Wine 的 PE 加载器读取计算器的.exe文件解析导入表。对于每个导入的 DLLWine 加载对应的.so文件在 iOS 上是.dylib。程序入口点被调用FEX-Emu 开始翻译 x86-64 指令。计算器创建窗口调用CreateWindowExWine 把这个调用转发给 DXMT。DXMT 创建 Metal 层开始渲染。第一次运行时程序在CreateWindowEx处崩溃了。调试发现是 DXMT 在创建 Metal 设备时没有正确处理 iOS 的 GPU 家族差异。iOS 设备有多个 GPU 核心Metal 需要指定正确的MTLGPUFamily。修复后窗口成功显示但界面是黑白的按钮没有颜色。进一步排查发现GDI 的调色板操作没有被正确翻译。Windows 的 GDI 使用调色板来管理颜色而 Metal 使用真彩色。DXMT 需要把调色板操作转换成纹理查找。我修改了 DXMT 的 GDI 后端增加了一个调色板纹理在渲染时用 compute shader 做颜色映射。修复后计算器的界面完全正常了。4.4 性能调优与实测数据在跑通基本功能后我做了一轮性能测试。测试设备是 iPhone 14 ProA16 芯片。测试程序包括程序名称类型启动时间操作响应CPU 占用内存占用计算器GDI 工具1.2s即时8%45MB记事本GDI 工具1.5s即时12%62MB扫雷GDI 游戏2.1s即时15%78MB画图GDI 工具3.8s轻微延迟35%156MB3D 弹球D3D11 游戏5.2s流畅65%320MB从数据可以看出纯 GDI 程序的性能表现很好启动时间都在 2 秒以内操作响应即时。GDI 程序的性能下降明显因为 GDI 的很多操作在 Wine 中是用软件渲染实现的没有硬件加速。D3D11 游戏的 CPU 占用较高但帧率可以稳定在 45-60 FPS对于休闲游戏来说完全够用。针对 GDI 的性能问题我尝试把部分 GDI 操作也转发到 DXMT用 Metal 来做渲染。这个优化把画图的启动时间从 3.8 秒降到了 2.4 秒操作延迟也明显降低。但实现复杂度较高需要仔细处理 GDI 和 D3D 之间的状态同步。5. 常见问题与排查技巧实录5.1 启动崩溃类问题问题一应用启动后立即闪退日志显示EXC_BAD_ACCESS。这是最常见的问题通常是因为 JIT 内存分配失败。iOS 对MAP_JIT的分配有严格限制如果应用没有正确的权限mmap会返回MAP_FAILED。排查方法是检查jit_alloc的返回值如果为 NULL说明 JIT 不可用需要回退到解释器模式。另外确保你的应用在Info.plist中包含了com.apple.security.cs.allow-jit权限仅限 macOSiOS 上这个权限不可用只能走解释器或静态翻译。问题二Wine 初始化时卡在wineboot阶段。wineboot是 Wine 用来初始化 prefix 目录的进程。在 iOS 上它可能会因为文件权限问题卡住。检查你的 prefix 目录是否在应用的沙盒内并且有读写权限。另外wineboot会尝试创建一些符号链接iOS 的文件系统对符号链接的支持有限我建议在编译时禁用符号链接改用文件复制。问题三程序窗口创建成功但内容全黑。这通常是 DXMT 的问题。检查 Metal 设备是否创建成功以及命令缓冲是否正常提交。一个常见的坑是iOS 应用在后台时不能提交 Metal 命令如果你的程序在启动时恰好处于后台状态就会黑屏。确保在applicationDidBecomeActive之后再初始化 DXMT。5.2 运行时报错类问题问题四程序运行中突然崩溃日志显示unimplemented function。这说明程序调用了一个 Wine 还没有实现的 API。你可以在 Wine 的源码中搜索这个函数名看看是否有存根实现。如果没有你需要自己实现一个。对于不重要的 API可以直接返回成功让程序继续运行。对于关键 API就需要认真实现了。问题五中文显示为乱码。这是 Wine 的字体和编码问题。Windows 程序通常使用 GBK 或 UTF-16 编码而 Wine 在 iOS 上默认使用 UTF-8。你需要在 Wine 的注册表中设置正确的代码页[HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Nls\CodePage] ACP936 OEMCP936 MACCP10008同时确保 Wine 的字体目录中有中文字体。你可以把开源的文泉驿字体或者思源黑体复制到 Wine 的Fonts目录下。问题六程序无法保存文件。检查 Wine 的驱动器映射。默认情况下Wine 会把/映射为Z:盘把 prefix 的drive_c映射为C:盘。在 iOS 上你需要确保这些映射指向应用沙盒内的可写目录。另外某些程序会尝试写入C:\Program Files在 iOS 上这个目录是只读的。你可以通过注册表把Program Files重定向到可写目录。5.3 性能与稳定性问题问题七程序运行一段时间后变卡。这通常是 JIT 缓存满了FEX-Emu 在频繁淘汰和重新编译代码块。增加 JIT 缓存大小可以缓解但根本的解决方法是优化代码块的淘汰策略。我实现了一个基于热度的淘汰算法优先保留执行频率高的代码块效果比单纯的 LRU 好很多。问题八多线程程序频繁死锁。Wine 的线程同步原语在 iOS 上需要仔细适配。特别是WaitForSingleObject和SignalObjectAndWait这两个 API在 iOS 上需要用pthread_cond和pthread_mutex重新实现。我遇到过因为条件变量唤醒顺序问题导致的死锁后来通过调整锁的粒度解决了。问题九Metal 命令提交超时导致应用被系统杀掉。iOS 的看门狗会监控 GPU 命令的执行时间如果单个命令缓冲执行超过一定时间通常是几秒系统会强制杀掉应用。对于复杂的 3D 场景需要把渲染任务拆分成多个小命令缓冲分帧提交。DXMT 内部已经做了这个优化但如果你自己实现渲染逻辑一定要注意这一点。5.4 常见问题速查表问题现象可能原因排查方法解决方案启动闪退JIT 内存分配失败检查jit_alloc返回值回退到解释器模式卡在 wineboot文件权限或符号链接问题检查 prefix 目录权限禁用符号链接改用复制窗口全黑Metal 命令未提交检查应用前后台状态在 active 后初始化 DXMT中文乱码代码页或字体缺失检查注册表 CodePage设置 936 代码页添加中文字体无法保存文件驱动器映射错误检查dosdevices目录重定向到可写目录运行变卡JIT 缓存满监控缓存命中率增大缓存优化淘汰策略多线程死锁同步原语实现问题检查线程等待链重新实现条件变量GPU 超时命令缓冲执行过长检查 Metal 命令提交拆分命令缓冲分帧提交6. 几个容易被忽略的实操心得6.1 关于 iOS 开发者模式的坑在 iOS 16 以上版本开启开发者模式需要重启设备而且这个模式会在设备重启后自动关闭。这意味着你每次重启手机后都需要重新开启开发者模式才能运行自签名的应用。我建议在开发阶段使用自动签名工具减少手动操作的麻烦。另外iOS 26.3.1 这个版本对开发者模式的限制更严格部分 API 需要额外的权限声明具体可以参考 Xcode 的签名与能力配置文档。6.2 关于 uniapp 和原生插件混用的注意事项如果你打算用 uniapp 来做外层壳把 Madeira 作为原生插件集成进去有几个点需要注意。uniapp 的 iOS 原生插件是通过Module和Component两种方式暴露的。Madeira 作为一个长时间运行的后台服务适合用Module方式暴露提供启动、停止、发送输入事件等接口。但 uniapp 的 JS 线程和原生线程之间的通信有延迟对于实时性要求高的输入事件比如游戏操作建议直接在原生层处理不要绕道 JS。6.3 关于 Xcode 打包速度突然变慢的排查在开发后期我遇到了 Xcode 打包速度从 2 分钟突然变成 15 分钟的情况。排查后发现是 DerivedData 目录积累了大量缓存而且 Madeira 的编译产物中有很多大文件Xcode 的索引器在处理这些文件时耗时很长。解决方法是定期清理 DerivedData并且在 Build Settings 中关闭不必要的索引选项。另外把 FEX-Emu 和 Wine 的编译产物做成预编译框架xcframework可以大幅减少每次打包时的编译时间。6.4 关于 iOS 设备模拟的局限性Xcode 自带的 iOS 模拟器是 x86-64 或 ARM64 的 macOS 进程它不能模拟 iOS 的 GPU 和 JIT 限制。也就是说你在模拟器上跑通了 Madeira不代表在真机上也能跑通。特别是 JIT 相关的代码路径模拟器上完全不会触发 iOS 的限制。我的建议是从项目第一天起就在真机上测试模拟器只用来做 UI 布局的快速验证。6.5 关于应用上架 App Store 的现实问题如果你打算把 Madeira 打包上架 App Store需要做好被拒的准备。苹果的审核指南对在 iOS 上运行外部代码有严格限制特别是涉及 JIT 和动态加载的部分。我的经验是如果应用只是运行内置的、经过审核的程序通过率会高一些如果允许用户自行导入任意的 Windows 程序几乎肯定会被拒。替代方案是走企业签名或者 TestFlight 内测分发但这两种方式都有各自的限制和成本。7. 后续可以继续深挖的方向这个项目跑通之后我整理了一些还可以继续优化的方向。一是把 FEX-Emu 的 JIT 编译器做更激进的优化比如针对 ARM64 的特定指令集扩展如 SVE2做代码生成进一步提升性能。二是把 DXMT 的 Metal 后端升级到 Metal 3利用网格着色器和光线追踪等新特性让 3D 程序的渲染效果更好。三是探索把 Wine 的某些组件用 Swift 重写减少 C 代码的维护成本同时更好地与 iOS 系统集成。另外我还在研究如何把 Madeira 的架构应用到其他平台比如 Android。Android 对 JIT 的限制比 iOS 宽松很多理论上移植难度更低。但 Android 的图形栈是 Vulkan需要把 DXMT 的 Metal 后端换成 Vulkan 后端这部分工作量不小。如果你也在做类似的事情或者对某个技术细节有疑问欢迎交流。这个领域的变化很快新的工具和方案层出不穷保持学习和尝试是最重要的。
返回列表