
1. 项目缘起为什么要在 iOS 上折腾 Wine 兼容层第一次看到 Madeira 这个项目名很多人会以为是那个葡萄牙的旅游海岛但在我们这群喜欢折腾跨平台兼容层的人眼里它指向的是另一件事把 Windows 应用搬到 iOS 设备上跑起来。这个方向听起来有点反常识毕竟 iOS 的沙盒机制、签名体系、架构限制摆在那里但恰恰是这些限制让 Madeira 这类项目变得有意思。我做跨平台兼容这块差不多有十来年从早期在 Linux 上编译 Wine 源码到后来接触 FEX-Emu、DXMT 这些新工具链踩过的坑能写满一个笔记本。Madeira 这个标题背后核心诉求其实很明确让 iOS 设备能够运行 x86-64 架构的 Windows 程序。注意这里有两个关键点一是 x86-64 指令集的翻译二是 Windows API 的兼容。前者靠 FEX-Emu 这类模拟器解决后者靠 Wine 解决而 DXMT 则是把 DirectX 调用翻译成 Metal让图形渲染能在 iOS 的 GPU 上跑起来。这套组合拳解决的是什么问题简单说就是让那些只有 Windows 版本、没有 iOS 版本的老软件、小工具、独立游戏能在 iPhone 或 iPad 上跑起来。适合谁来参考如果你是对兼容层技术感兴趣的开发者或者手头有一堆 Windows 小工具想在 iPad 上用的折腾党再或者你是做 iOS 开发想了解底层架构翻译原理的工程师这篇内容都能给你一些可以直接抄作业的东西。我先把话说在前面这不是一个点一下就能用的成品方案中间涉及签名、架构翻译、图形 API 转换、输入映射等一堆环节任何一个环节出问题都会导致黑屏或者闪退。但正因为难搞通之后的成就感也完全不一样。2. 核心技术栈拆解Wine、FEX-Emu、DXMT 各自扮演什么角色2.1 Wine 在 iOS 上的定位与限制Wine 的本质是一个 Windows API 兼容层它把 Windows 程序发起的系统调用翻译成宿主系统的调用。在桌面 Linux 上Wine 已经相当成熟但到了 iOS 上情况完全变了。iOS 不允许动态生成可执行代码不允许 fork 进程文件系统访问被严格限制在沙盒内这些限制直接砍掉了 Wine 的一大半能力。所以在 iOS 上跑 Wine你不能指望它像在 Linux 上那样直接加载 exe 就跑。通常的做法是把 Wine 的组件编译成静态库或者动态库嵌入到一个宿主 App 里然后由这个 App 来加载 Windows 程序的 PE 文件。这个过程里Wine 的 winserver 进程模型需要被改造因为 iOS 不允许你起一个独立的服务进程。常见的做法是把 winserver 的逻辑塞进主进程的一个线程里用 Mach 端口或者共享内存来模拟进程间通信。还有一个绕不开的问题是乱码。热词里提到的 wine 乱码 和 wine 栏是乱码本质上都是字符编码和字体缺失导致的。Wine 默认使用 UTF-8 和 Unicode但很多 Windows 程序内部用的是 GBK 或者 ANSI 编码如果宿主环境没有对应的字体和 locale 配置菜单栏、按钮文字就会变成方块或者问号。解决办法是在 Wine 的注册表里把字体替换规则配好同时把中文字体文件放进宿主 App 的资源目录让 Wine 能加载到。2.2 FEX-Emu 如何把 x86-64 指令翻译成 ARM64iOS 设备清一色是 ARM 架构而绝大多数 Windows 程序是 x86 或 x86-64 架构。要让这些程序跑起来必须做指令集翻译。FEX-Emu 就是干这个的它把 x86-64 指令动态翻译成 ARM64 指令翻译的粒度是基本块级别翻译结果会缓存起来下次遇到同样的代码块就直接用缓存不用重新翻译。FEX-Emu 的工作流程大致是这样的首先加载 x86-64 的 PE 文件解析出代码段和数据段然后从入口点开始逐块读取 x86-64 指令翻译成等价的 ARM64 指令序列翻译过程中需要维护一套虚拟的 CPU 状态包括通用寄存器、标志寄存器、浮点寄存器等这些状态在翻译后的代码里用 ARM64 的寄存器和内存来模拟。这里有个关键点FEX-Emu 需要处理自修改代码。有些 Windows 程序会在运行时修改自己的代码段这在 x86 上很常见但在 ARM 上代码段通常是只读的。FEX-Emu 的做法是监控代码段的写操作一旦发现某块代码被修改就把对应的翻译缓存失效掉下次执行时重新翻译。这个机制会带来性能开销但为了保证兼容性不得不这么做。2.3 DXMT 把 DirectX 调用翻译成 Metal图形渲染是另一个大坑。Windows 程序通常调用 DirectX 来做渲染而 iOS 只认 Metal。DXMT 的作用就是在中间做翻译把 Direct3D 的调用转换成 Metal 的调用。这个翻译不是简单的函数映射因为 Direct3D 和 Metal 的渲染管线模型有差异资源管理方式也不同。DXMT 的实现思路是拦截 Direct3D 的 API 调用比如 CreateDevice、CreateTexture、DrawPrimitive 这些然后在内部维护一套 Metal 的资源对象。当程序调用 DrawPrimitive 时DXMT 会把当前的渲染状态、顶点缓冲、索引缓冲、着色器都转换成 Metal 对应的对象然后提交一个 Metal 的 draw call。着色器的翻译是最复杂的部分因为 HLSL 和 Metal Shading Language 语法不同需要做源码级别的转换或者用 SPIR-V 做中间表示再转成 Metal。实测下来DXMT 对 Direct3D 9 和 Direct3D 11 的支持相对好一些Direct3D 12 的支持还在完善中。如果你要跑的程序用的是 Direct3D 12可能会遇到渲染错误或者直接崩溃。这时候可以试试在 Wine 的配置里强制指定用 Direct3D 11 或者 9 来渲染有些程序是支持回退的。3. 从零搭建iOS 上运行 Wine 兼容层的完整实操流程3.1 环境准备与工具链配置在开始之前你需要一台 macOS 电脑安装 Xcode版本建议 15 以上。然后需要准备以下工具FEX-Emu 源码从官方仓库拉取编译成 iOS 可用的静态库。注意要选对分支有些分支是针对 Linux 的编译到 iOS 上会缺头文件。Wine 源码同样从官方仓库拉取编译时需要用 iOS 的 SDK并且要打一些补丁来绕过 iOS 的限制比如禁用 fork、禁用动态代码生成。DXMT 源码这个相对独立编译成动态库后链接到宿主 App 里。iOS 签名证书如果你只是自己测试可以用免费的个人开发者证书但要注意免费证书的有效期只有 7 天过期后需要重新签名。热词里提到的 免费证书 ios 和 ios 开发者模式 就是在这个环节涉及的。编译顺序很重要先编译 FEX-Emu因为它不依赖其他组件然后编译 Wine链接 FEX-Emu 的库最后编译 DXMT链接 Wine 的库。每一步都可能报错常见的错误包括头文件路径不对、架构不匹配、符号未定义等。提示编译 FEX-Emu 时记得在 CMake 参数里加上-DCMAKE_OSX_ARCHITECTURESarm64否则默认会编译成 x86-64链接到 iOS App 时会报架构不匹配。3.2 宿主 App 的创建与 Wine 库的集成宿主 App 是一个普通的 iOS 应用用 Swift 或者 Objective-C 写都行。它的主要职责是初始化 Wine 运行环境、加载 Windows 程序的 PE 文件、提供图形渲染的宿主视图、处理输入事件。创建宿主 App 的步骤在 Xcode 里新建一个 iOS App 项目界面用 Storyboard 或者 SwiftUI 都行。把编译好的 Wine、FEX-Emu、DXMT 的静态库和动态库拖进项目里在 Build Phases 的 Link Binary With Libraries 里加上。在 Build Settings 里设置 Header Search Paths指向 Wine 和 FEX-Emu 的头文件目录。创建一个继承自 UIView 的类用来承载 Metal 的渲染层。DXMT 会往这个视图里提交渲染命令。在 AppDelegate 的 didFinishLaunchingWithOptions 里调用 Wine 的初始化函数传入沙盒内的路径作为 Wine 的 C 盘根目录。这里有个细节iOS 的沙盒路径每次安装都会变所以不能在代码里写死路径要用 NSSearchPathForDirectoriesInDomains 动态获取。Wine 的注册表文件、字体文件、临时目录都要放在沙盒内否则会因为没有写权限而失败。3.3 加载 Windows 程序并处理输入映射加载 Windows 程序的过程本质上是调用 Wine 的wine_init和wine_load_exe函数。你需要把 Windows 程序的 exe 文件和它依赖的 dll 文件一起放进沙盒的一个目录里然后把这个目录的路径传给 Wine。输入映射是另一个需要仔细处理的地方。iOS 的触摸事件和 Windows 的鼠标事件模型不同你需要把触摸事件转换成鼠标移动、左键点击、右键点击。如果程序需要键盘输入还要把 iOS 的软键盘事件转换成 Windows 的键盘消息。实测下来最简单的做法是提供一个虚拟鼠标指针用户手指在屏幕上滑动时指针跟着移动点击屏幕就是左键点击双指点击就是右键点击。注意有些 Windows 程序会检测鼠标是否在窗口内如果指针移出窗口范围程序可能会暂停渲染或者弹出提示。所以在做输入映射时要确保指针坐标始终在窗口范围内或者把窗口设成全屏模式。4. 常见问题与排查技巧实录4.1 启动就闪退签名与权限问题这是最常见的问题表现是 App 图标点进去白屏一秒然后闪退。原因通常是签名证书过期或者 App 没有开启必要的权限。iOS 对动态库的加载有严格限制如果你的 Wine 库是动态库需要在 App 的 entitlements 文件里加上com.apple.security.cs.allow-dyld-environment-variables和com.apple.security.cs.disable-library-validation。排查步骤用 Xcode 的 Devices and Simulators 窗口查看设备日志搜索 dyld 关键字看是不是动态库加载失败。检查证书的有效期如果过期了重新签名。检查 entitlements 文件是否正确配置可以用codesign -d --entitlements - YourApp.app命令查看。4.2 界面乱码字体与编码配置热词里反复出现的 wine 乱码 问题根源在于字体缺失和编码不匹配。Wine 默认会去找系统字体但 iOS 的字体目录和 Linux 不同Wine 找不到就会用内置的替代字体而内置字体往往不包含中文字形所以中文就变成了方块。解决办法分两步第一步是把中文字体文件比如 Noto Sans CJK 或者文泉驿复制到沙盒的 Wine 字体目录里第二步是在 Wine 的注册表里配置字体替换规则把 Tahoma、Arial、Microsoft YaHei 这些常用字体名映射到中文字体上。注册表配置可以用 reg 文件导入内容大概是这样的[HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes] ArialNoto Sans CJK SC TahomaNoto Sans CJK SC Microsoft YaHeiNoto Sans CJK SC导入之后重启 Wine 环境乱码问题基本就能解决。如果还有个别地方乱码可能是程序内部硬编码了字体名这时候需要用十六进制编辑器改 exe 文件里的字体名字符串或者用 API hook 的方式在运行时替换。4.3 图形渲染异常DXMT 的兼容性调优DXMT 虽然能把 DirectX 翻译成 Metal但并不是所有 DirectX 调用都能完美转换。常见的渲染问题包括纹理显示为黑色、模型缺失、画面闪烁、帧率极低。排查思路先看日志DXMT 会输出哪些 API 调用没有被正确处理根据日志定位问题。如果是纹理问题检查纹理格式是否被 Metal 支持有些 DirectX 的压缩纹理格式在 Metal 上没有对应需要做软件解码。如果是帧率低可能是着色器翻译开销太大可以试试开启 DXMT 的着色器缓存把翻译结果存到磁盘上下次直接加载。如果是画面闪烁可能是双缓冲或者垂直同步的问题可以在 DXMT 的配置里强制开启垂直同步。下面这个表格整理了我遇到过的典型渲染问题和对策问题现象可能原因解决方向纹理全黑纹理格式不支持检查格式必要时软件解码模型缺失顶点着色器翻译错误查看 DXMT 日志定位着色器画面闪烁缓冲策略不匹配强制垂直同步调整缓冲数量帧率极低着色器翻译开销大开启着色器磁盘缓存颜色偏差色彩空间转换错误检查 sRGB 配置4.4 性能调优让 x86-64 翻译跑得更快FEX-Emu 的翻译开销是性能瓶颈的主要来源。实测下来同样的程序在原生 ARM64 上跑和在 FEX-Emu 翻译后跑帧率可能差三到五倍。要提升性能可以从这几个方面入手开启翻译缓存FEX-Emu 支持把翻译后的代码块缓存到磁盘下次启动时直接加载省去重新翻译的时间。缓存文件放在沙盒的 Caches 目录里注意 iOS 可能会在存储空间紧张时清理这个目录。调整翻译块大小FEX-Emu 默认的翻译块大小是 1 个基本块可以调大一些减少翻译次数但会增加内存占用。需要根据设备的内存大小来权衡。关闭不必要的调试功能FEX-Emu 在编译时如果开启了调试符号和断言检查运行时会慢很多。发布版本一定要用 Release 模式编译关掉所有调试开关。使用多线程翻译FEX-Emu 支持多线程翻译可以把不同的代码块分配到不同的线程上翻译充分利用多核 CPU。但 iOS 对线程数量有限制不能开太多。提示在 iPhone 上跑 Wine 兼容层发热和耗电是不可避免的。建议插着电源用并且不要长时间连续跑大型程序否则设备会降频帧率会越来越低。5. 进阶玩法从单机运行到自动化与扩展5.1 用 iOS 自动化工具简化启动流程每次启动都要手动点好几步才能进到 Windows 程序里确实麻烦。iOS 自带的快捷指令Shortcuts可以帮上忙你可以创建一个快捷指令自动打开宿主 App并且通过 URL Scheme 传入要加载的 exe 文件路径。宿主 App 在启动时解析这个 URL直接加载对应的程序省去手动选择的步骤。如果要做更复杂的自动化比如定时启动、根据条件启动可以结合 iOS 的自动化触发条件比如连接电源时启动、到达某个位置时启动。热词里提到的 ios 自动化 和 ios 无感 就是这个方向的应用。5.2 多程序管理与文件系统隔离当你需要在 iOS 上跑多个 Windows 程序时文件系统的管理就变得重要了。每个程序可能需要不同的 dll 版本、不同的注册表配置如果全部混在一起很容易冲突。我的做法是给每个程序建一个独立的目录目录里包含一个精简的 Wine prefix只放这个程序需要的 dll 和注册表项。宿主 App 在加载时根据程序名切换到对应的 prefix。这样做的好处是隔离性好一个程序出问题不会影响其他程序。坏处是每个 prefix 都会占用额外的存储空间因为 Wine 的基础文件在每个 prefix 里都有一份。如果存储空间紧张可以用符号链接把公共文件链接到同一个位置但 iOS 对符号链接的支持有限需要测试确认。5.3 外设支持手柄与键盘iOS 支持蓝牙手柄和键盘如果你要跑的是游戏用手柄操作体验会好很多。Wine 本身不直接处理手柄输入需要宿主 App 把 iOS 的 GameController 框架的事件转换成 Windows 的 XInput 或者 DirectInput 消息。这个转换层需要自己写工作量不小但一旦写好兼容性会好很多。键盘支持相对简单iOS 的软键盘事件可以直接映射成 Windows 的键盘消息。但要注意有些 Windows 程序会检测键盘布局如果宿主环境没有正确设置键盘布局程序可能会把按键识别错。可以在 Wine 的注册表里设置键盘布局为 00000804中文简体或者 00000409美式英语根据程序的需求来定。6. 我个人在实际操作中的几点体会折腾这套东西差不多花了小半年时间中间有几次差点放弃。最大的感受是iOS 的沙盒限制不是靠技术手段能完全绕过的你只能在它的规则内找空间。比如动态代码生成被禁FEX-Emu 就只能用解释执行加静态翻译的混合模式性能损失是必然的。再比如进程模型被限制Wine 的 winserver 就只能塞进主进程里稳定性和桌面版没法比。但话说回来当你第一次在 iPad 上看到那个只有 Windows 版本的老工具跑起来菜单栏的中文正常显示鼠标指针跟着手指移动那种感觉还是挺爽的。如果你也在折腾类似的东西我的建议是先把最小闭环跑通能加载一个最简单的 Windows 程序能显示窗口能响应点击。这个闭环跑通之后再去解决乱码、图形、性能这些细节问题。不要一上来就想着跑大型游戏那样很容易卡在某个环节出不来。另外编译 Wine 和 FEX-Emu 的时候一定要有耐心。报错是常态不报错才是意外。每次报错都仔细看日志大部分问题都能在网上找到答案。实在找不到的就去翻源码看那个函数到底在做什么往往比搜半天更有效。