1. 项目缘起:为什么要在 iOS 上折腾 Wine 兼容层
第一次看到 "Madeira" 这个代号,是在一个折腾跨平台兼容层的群里。有人丢出一张截图,iOS 设备上跑着一个 Windows 程序的界面,底下配文"Madeira 项目,基于 FEX-Emu + Wine + DXMT 的 x86-64 转译方案"。当时我的第一反应是:这不是把三层重活叠在一起吗?x86-64 指令要转译到 ARM64,Windows API 要映射到 POSIX,DirectX 还要翻译成 Metal。任何一层出问题,整个链路就崩了。
但仔细想想,这件事的价值其实很明确。iOS 生态里长期缺一块拼图:大量经典的 Windows 桌面程序、老游戏、行业工具,在移动端根本没有原生版本,也没人愿意为它们重写。Wine 在 Linux 和 macOS 上已经跑了很多年,把 Windows 的 PE 可执行文件直接加载到非 Windows 系统上运行,靠的是把 Win32 API 用宿主系统的能力重新实现一遍。问题在于,iOS 的沙箱限制、没有 JIT 权限、图形栈完全不同于桌面,直接照搬桌面 Wine 是行不通的。
Madeira 这个项目要解决的,就是"在 iOS 上把 x86-64 的 Windows 程序跑起来"这一整套问题。它不是一个单一工具,而是一条完整的兼容链路:FEX-Emu 负责指令集转译,Wine 负责 API 层,DXMT 负责把 Direct3D 翻译成 Metal。适合谁来参考?如果你在做 iOS 上的模拟器、兼容层、跨平台运行时,或者单纯对"怎么让不同架构的程序互相跑起来"这件事感兴趣,这条链路里的每一环都值得拆开看。我下面会按我实际折腾的顺序,把每一层的原理、配置、踩坑点都摊开讲。
2. 整体架构拆解:三层转译链路是怎么串起来的
2.1 从 x86-64 到 ARM64:FEX-Emu 的角色定位
iOS 设备清一色是 ARM64 架构,而绝大多数 Windows 桌面程序编译出来是 x86 或 x86-64。这两者之间的鸿沟不是"重新编译一下"就能填的,因为你拿不到源码。FEX-Emu 干的事情就是动态二进制转译:在程序运行时,把 x86-64 的指令一条条翻译成等价的 ARM64 指令,然后执行。
这里有个关键点很多人会忽略。FEX-Emu 不是解释器,它是 JIT 编译器。解释执行是一条条读、一条条执行,慢得没法用;JIT 是把一段 x86-64 代码块整体翻译成 ARM64 机器码缓存起来,下次再执行到同一段就直接跑缓存。这就是为什么 FEX-Emu 的性能能做到"可用"级别,而不是"幻灯片"级别。
但 iOS 对 JIT 有严格限制。App Store 分发的应用不允许动态生成可执行代码,这是硬性规则。所以 Madeira 这类项目在 iOS 上落地时,JIT 权限的获取方式直接决定了它能走哪条分发路径。这是整个项目最敏感、也最需要提前想清楚的一环,我在第 4 节会专门展开。
FEX-Emu 的配置里,有几个参数直接影响兼容性和性能:
- TSO 模式(Total Store Order):x86 的内存模型比 ARM 强,开启 TSO 模拟能保证多线程程序的内存可见性正确,但会带来性能损耗。跑单线程老程序可以关掉换性能,跑多线程程序必须开。
- SMCC(Self-Modifying Code Cache):有些程序会自己改自己的代码,比如加壳的、带反调试的。开启这个检测能兼容这类程序,代价是额外的检查开销。
- Multiblock:把多个基本块合并翻译,减少 JIT 切换开销,对性能提升明显,但会稍微增加翻译延迟。
我实测下来,跑一个 2010 年前后的 2D 游戏,开 Multiblock + 关 TSO,帧率能比默认配置高 30% 左右。但换成带多线程物理引擎的程序,关 TSO 就会偶发崩溃,必须开回来。
2.2 Wine 层:把 Windows API 翻译成宿主系统调用
FEX-Emu 解决了"指令能跑"的问题,但程序跑起来第一件事就是调用 Windows API:创建窗口、读写文件、申请内存。这些 API 在 iOS 上不存在,Wine 的工作就是把它们重新实现一遍。
Wine 的实现思路很巧妙,它不是模拟 Windows,而是"假装自己是 Windows"。当程序调用CreateWindowEx时,Wine 内部把这个调用翻译成宿主系统的窗口创建逻辑。在桌面 Linux 上,这个宿主是 X11 或 Wayland;在 macOS 上是 Cocoa;在 iOS 上,就得翻译成 UIKit 那一套。
这里就出现了热词里提到的"wine 乱码"问题。Wine 的字体渲染依赖宿主系统提供的字体和字符集支持。iOS 上的中文字体、编码处理和桌面 Linux 差异很大,如果 Wine 的 locale 配置不对,或者字体映射表没配好,中文就会显示成方块或者乱码。这不是 Wine 本身的 bug,而是字符集和字体回退链路没打通。
Wine 在 iOS 上的另一个大坑是文件系统。iOS 的沙箱把每个应用的可见文件范围限制得很死,Wine 默认会去访问C:\这种 Windows 路径,需要把它重定向到应用沙箱内的一个目录,并且用WINEPREFIX环境变量指定这个映射根。这个前缀目录里会生成一整套假的 Windows 目录结构(drive_c、windows、Program Files等),所有 Windows 程序看到的文件系统其实是这个前缀目录。
2.3 DXMT:Direct3D 到 Metal 的最后一公里
程序能跑、窗口能开,接下来就是画面。Windows 程序画图要么走 GDI(老式 2D),要么走 Direct3D(3D 和现代 2D)。GDI 相对好办,Wine 自己就能用宿主绘图 API 实现。Direct3D 就麻烦了,它是一整套 GPU 编程接口,必须翻译成 iOS 能用的图形 API,也就是 Metal。
DXMT 就是干这个的。它把 D3D11 的调用翻译成 Metal 调用。为什么是 D3D11 而不是 D3D12?因为 D3D12 太底层,翻译工作量巨大,而 D3D11 覆盖了绝大多数实际会跑的老程序和游戏。DXMT 的翻译策略是"尽量直接映射":D3D 的 shader 编译成 Metal shader,资源绑定映射到 Metal 的 buffer 和 texture,渲染状态映射到 Metal 的 pipeline state。
这条链路里,性能瓶颈往往不在转译本身,而在同步。D3D 和 Metal 的同步模型不一样,如果翻译层处理不好,GPU 和 CPU 之间会频繁等待,帧率就上不去。DXMT 在这方面做了不少优化,比如命令缓冲的批量提交、资源的延迟释放,这些细节直接决定了实际体验是"能玩"还是"卡成狗"。
三层叠起来,完整链路是这样的:
| 层级 | 组件 | 职责 | 主要难点 |
|---|---|---|---|
| 指令层 | FEX-Emu | x86-64 转 ARM64 | JIT 权限、内存模型差异 |
| API 层 | Wine | Win32 API 映射 | 字体编码、文件系统沙箱 |
| 图形层 | DXMT | D3D 转 Metal | 同步模型、shader 翻译 |
3. 核心细节解析:每一层最容易翻车的地方
3.1 FEX-Emu 的 JIT 权限与 iOS 开发者模式
iOS 上跑 JIT,绕不开"开发者模式"这个开关。热词里反复出现"ios 开发者模式""ios 26.3.1 怎么开发者模式",说明这是很多人卡住的第一道坎。开发者模式的作用是放开一些普通用户模式下被限制的能力,其中就包括允许特定应用使用 JIT。
开启路径大致是:设置里找到隐私与安全性,往下翻能看到开发者模式选项,打开后设备会要求重启,重启后还要再确认一次。不同 iOS 版本这个入口的位置和措辞会有变化,但逻辑是一样的。需要注意的是,开发者模式一旦开启,设备的安全性会有所降低,这是系统明确提示的,自己权衡。
对于 Madeira 这类项目,JIT 权限的获取方式决定了分发形态。如果是自用或者小范围测试,通过开发者模式 + 自签名是可行的。如果要面向大量用户,就得考虑 App Store 的规则边界,这块的合规性需要项目方自己评估清楚,我不在这里展开。
3.2 Wine 前缀配置与中文乱码根治
Wine 的乱码问题,我踩过不止一次。表现是:程序界面能出来,但中文全是方块,或者变成一堆问号。根因通常有三个,按排查优先级排:
第一,locale 没设对。Wine 需要知道当前系统的语言环境,才能正确选择字符集。在启动脚本里设置LANG=zh_CN.UTF-8和LC_ALL=zh_CN.UTF-8是最基本的。如果这两个没设,Wine 默认走 C locale,中文直接歇菜。
第二,字体缺失。Wine 自己不带中文字体,它依赖宿主系统或者前缀目录里的字体。解决办法是把一个中文字体文件(比如思源黑体)复制到前缀目录的drive_c/windows/Fonts/下面,然后在 Wine 的注册表里把默认字体映射指向它。注册表路径是HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes,把MS Shell Dlg和MS Shell Dlg 2都指向你放进去的字体名。
第三,程序自己硬编码了字体。有些老程序写死了用"宋体"或者"MS Sans Serif",如果前缀里没有对应名字的字体,就会回退到默认字体,可能不支持中文。这时候要么装一个名字对得上的字体,要么在 FontSubstitutes 里做替换映射。
我一般的做法是:先把 locale 设好,再塞一个覆盖常见中文字体名的字体文件进去,最后用winecfg检查一遍字体替换表。三步走完,九成以上的乱码都能解决。
3.3 DXMT 的 shader 翻译与性能调优
DXMT 翻译 shader 的时候,最怕遇到程序用了 D3D11 里比较冷门的特性,比如 geometry shader、tessellation,或者一些非标准的纹理格式。这些在 Metal 里要么没有直接对应,要么需要绕路实现,翻译层处理不好就会渲染错误或者直接崩。
性能调优上,我总结了几条实测有效的做法:
- 限制帧率:很多老程序没有帧率上限,会疯狂刷帧,把 GPU 占满。用 DXMT 的帧率限制选项锁到 60 或者 30,能显著降低功耗和发热,帧率反而更稳。
- 关闭垂直同步再测:垂直同步开着的时候,帧率被锁在刷新率,看不出真实性能。调优阶段先关掉,看实际能跑多少帧,再决定要不要开回来。
- 纹理压缩格式对齐:如果程序用的纹理格式 Metal 原生支持,翻译就是零成本;如果不支持,DXMT 要软件转换,开销很大。这个没法从用户侧改,但知道这个原理后,遇到某个程序特别卡,就能判断是不是卡在纹理转换上。
3.4 文件系统沙箱与前缀目录规划
iOS 沙箱下,Wine 前缀目录的位置很关键。我建议把前缀放在应用的 Documents 目录下,因为这个目录在 iOS 上是用户可见、可备份的,方便你往里丢安装包、导出存档。前缀目录一旦建好,就不要随便挪动,因为里面很多配置是写死绝对路径的,挪了就得重新配。
前缀目录里几个关键子目录的作用:
drive_c:对应 Windows 的 C 盘,程序默认装在这里。drive_c/windows/system32:系统 DLL 的位置,Wine 的内置 DLL 和程序自带的 DLL 都在这。drive_c/users/:用户目录,对应 Windows 的C:\Users。dosdevices:盘符映射,c:通常软链到drive_c。
装程序的时候,我习惯把安装包放在前缀目录外面,用wine命令指定路径去跑,这样安装包不会污染前缀。装完再清理临时文件。
4. 实操过程:从零搭起一条可跑的链路
4.1 环境准备与依赖梳理
动手之前,先把要准备的东西列清楚。这套链路不是装一个 App 就完事,涉及多个组件的配合:
- FEX-Emu 的 ARM64 构建:需要针对 iOS 的 ARM64 目标编译,注意开启 JIT 相关选项。
- Wine 的 iOS 移植版:桌面版 Wine 不能直接用,需要针对 iOS 的图形栈和沙箱做适配的版本。
- DXMT:作为 Wine 的 D3D 后端,需要和 Wine 版本匹配,版本错配会直接崩。
- 一个中文字体文件:解决乱码用,思源黑体或者文泉驿都行。
- 测试用的 Windows 程序:建议从简单的 2D 程序开始,别一上来就挑战 3D 大作。
版本匹配是重中之重。Wine 和 DXMT 之间有接口约定,FEX-Emu 和 Wine 之间也有 ABI 约定。我踩过的坑就是拿了一个新版的 DXMT 配老版 Wine,结果 D3D 初始化直接失败,排查了半天才发现是版本问题。所以动手前,先把各组件的版本对应关系确认清楚。
4.2 前缀初始化与基础配置
第一步是初始化 Wine 前缀。用WINEPREFIX指定一个目录,然后跑wineboot让它生成初始的目录结构。这个过程会创建drive_c、注册表文件、默认配置等。
export WINEPREFIX=/path/to/prefix export LANG=zh_CN.UTF-8 export LC_ALL=zh_CN.UTF-8 wineboot -uwineboot -u是更新模式,如果前缀已经存在,它会补齐缺失的部分。第一次跑用wineboot -i做初始化也行。
初始化完成后,进winecfg检查几项配置:Windows 版本(建议设成 Windows 10,兼容性最好)、驱动器映射、字体替换表。Windows 版本设太低,有些程序会拒绝运行;设太高,有些老程序又会出兼容问题。Windows 10 是个比较稳的折中点。
4.3 中文字体注入与注册表调整
字体这块,我一般分两步。先把字体文件复制进前缀:
cp SourceHanSans.ttf $WINEPREFIX/drive_c/windows/Fonts/然后用注册表脚本做替换映射。写一个.reg文件:
REGEDIT4 [HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes] "MS Shell Dlg"="Source Han Sans" "MS Shell Dlg 2"="Source Han Sans" "SimSun"="Source Han Sans" "宋体"="Source Han Sans"用wine regedit font.reg导入。导入后重启一下 Wine 服务(wineserver -k再重新跑程序),字体替换才生效。
这里有个细节:字体名要用字体文件内部的名称,不是文件名。你可以用字体查看工具确认字体内部名称,填错了替换不生效。
4.4 程序安装与运行验证
装程序的时候,我建议先用一个绿色版(免安装)的小程序做验证,确认整条链路通了,再去装需要安装器的复杂程序。
wine /path/to/setup.exe安装过程中如果弹窗显示正常、中文不乱码,说明 Wine 层和字体层没问题。装完运行:
wine "$WINEPREFIX/drive_c/Program Files/YourApp/app.exe"如果程序能起来但画面异常,问题就在 DXMT 层。这时候可以开 DXMT 的日志,看 shader 翻译有没有报错。日志里如果出现大量 "unsupported feature" 之类的信息,基本就是遇到了翻译层不支持的 D3D 特性。
4.5 性能观测与参数微调
跑起来之后,别急着下结论说"能跑"或"不能跑",先观测一段时间。我一般看三个指标:帧率稳定性、内存占用、发热情况。
帧率用 DXMT 自带的统计或者系统级的性能监视看。内存占用如果持续上涨不回落,说明有资源泄漏,可能是翻译层没正确释放 Metal 资源。发热这个在移动设备上特别重要,如果几分钟就烫手,说明 GPU 一直在满负荷跑,得回去调帧率限制和渲染参数。
FEX-Emu 那边可以调的参数,我列个实测对照:
| 参数 | 开启效果 | 适用场景 |
|---|---|---|
| TSO | 内存模型正确,性能略降 | 多线程程序必开 |
| Multiblock | 性能提升明显 | 单线程或计算密集程序 |
| SMCC | 兼容自改代码程序 | 加壳、反调试程序 |
| HalfBarrier | 减少同步开销 | 多线程但同步不频繁的程序 |
调参的思路是:先用默认配置跑通,再针对具体程序的瓶颈逐个调。不要一次改一堆参数,那样出了问题根本不知道是哪个引起的。
5. 常见问题与排查技巧实录
5.1 启动即崩:从日志定位第一现场
程序双击就闪退,是最常见也最让人抓狂的问题。我的排查顺序是:先看 Wine 的 stderr 输出,再看 FEX-Emu 的转译日志,最后看 DXMT 的图形日志。
Wine 的报错通常会明确指出是哪个 DLL 加载失败,或者哪个 API 调用返回了错误。如果报的是缺 DLL,就去确认那个 DLL 是不是在前缀的 system32 里,或者程序目录里有没有自带。如果报的是 API 不支持,那就是 Wine 还没实现这个 API,得看有没有替代方案或者更新版本。
FEX-Emu 的日志如果出现 "unhandled instruction",说明遇到了它不认识的 x86-64 指令。这种情况比较麻烦,通常需要等 FEX-Emu 更新支持,或者找程序的旧版本试试。
5.2 画面异常:黑屏、花屏、贴图错乱
画面问题基本都出在 DXMT 层。黑屏可能是 shader 编译失败,花屏可能是纹理格式转换出错,贴图错乱可能是资源绑定映射错了。
排查这类问题,第一步是确认程序用的是 D3D 还是 GDI。如果是 GDI 程序还花屏,那问题在 Wine 的绘图层,不在 DXMT。判断方法很简单:看程序目录里有没有d3d11.dll之类的依赖,或者用工具查一下导入表。
确认是 D3D 问题后,可以试试切换 DXMT 的兼容模式。有些翻译层提供了"保守模式",牺牲一些性能换取更好的兼容性,遇到疑难画面问题可以先用保守模式确认是不是翻译层的问题。
5.3 中文乱码的三种典型表现与对应解法
乱码这事我单独拎出来说,因为它太常见了。三种典型表现:
- 方块:字体缺失,系统找不到能显示中文的字体。解法是注入中文字体并做替换映射。
- 问号:编码不对,字符集没匹配上。解法是检查 locale 设置,确保是 UTF-8。
- 乱码字符:字体找到了但映射错了,比如用了个不含中文的字体来显示中文。解法是检查 FontSubstitutes 表,确保中文相关字体名都指向了正确的中文字体。
这三种表现对应三个不同的根因,别混在一起调。我见过有人一遇到乱码就狂装字体,结果编码问题装再多字体也没用。
5.4 性能不达预期:定位瓶颈在 CPU 还是 GPU
性能问题要先定位瓶颈。方法很简单:看 CPU 占用和 GPU 占用。CPU 满载 GPU 闲,瓶颈在 FEX-Emu 的指令转译;GPU 满载 CPU 闲,瓶颈在 DXMT 的图形翻译;两个都满,那就是程序本身太重,得降画质或者降分辨率。
FEX-Emu 的转译瓶颈,能调的空间有限,主要是开 Multiblock、关不必要的检查。DXMT 的图形瓶颈,可以调分辨率缩放、关抗锯齿、降纹理质量。如果程序本身支持画质设置,优先在程序内调,比在翻译层调更有效。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动闪退 | DLL 缺失 / API 未实现 | 看 Wine stderr 日志 |
| 中文方块 | 字体缺失 | 注入字体 + 替换映射 |
| 中文问号 | locale 未设 | 检查 LANG/LC_ALL |
| 黑屏 | shader 编译失败 | 看 DXMT 日志 |
| 帧率低 | 转译瓶颈 / 渲染瓶颈 | 看 CPU/GPU 占用 |
| 发热严重 | 无帧率限制 | 锁帧 + 降画质 |
| 存档丢失 | 前缀目录被清理 | 备份 drive_c 用户目录 |
6. 我踩过的坑与几条实在建议
折腾这套链路,最大的体会是:别指望一次成功,要把它当成一个逐步收敛的过程。我最初想一步到位跑一个 3D 游戏,结果卡了整整两天,最后退回去从记事本这种最简单的程序开始,一层层验证,反而半天就把链路跑通了。
第二条建议是把日志当朋友。Wine、FEX-Emu、DXMT 都有日志输出,很多人嫌烦直接关掉,结果出了问题两眼一抹黑。我现在的习惯是调优阶段全程开日志,跑通之后再关。日志里那些看起来吓人的 warning,很多其实无害,但 error 级别的必须逐个查清楚。
第三条是版本管理要严格。这套链路的组件耦合很紧,我建议把每个组件的版本号记在一个文档里,升级任何一个之前先备份当前能跑的配置。我吃过亏,升级了 DXMT 之后整个前缀跑不起来,回滚又忘了之前是什么版本,只能从头配。
最后说个细节:iOS 上的存储空间和内存都比桌面紧张,前缀目录会随着装的程序越来越多而膨胀。定期清理drive_c/windows/temp和不再用的程序目录,能省不少空间。内存方面,如果程序跑起来频繁被系统杀掉,多半是内存超了,得考虑降画质或者换更轻量的程序。
这套东西目前还在快速演进,FEX-Emu 和 DXMT 都在持续更新,兼容性和性能每隔一段时间就有明显改善。我个人的做法是保持关注但不过度追新,等一个版本稳定跑通一批程序之后,再考虑整体升级。