如何看懂 Madeira 中 Wine 的 PE/Unix 分离架构:ntdll、kernel32 与 wineserver 协作全解析
【免费下载链接】MadeiraRun x86-64 Windows PC games on jailed iOS via FEX-Emu + Wine + DXMT项目地址: https://gitcode.com/GitHub_Trending/mad/Madeira
Madeira 是一个无需越狱即可在 iPhone 上运行 x86-64 Windows PC 游戏的开源项目,其核心秘诀在于 Wine 的PE/Unix 分离架构:Windows 那一侧(PE 侧)的 ntdll、kernel32 等 DLL 以"纯翻译"方式运行,而真正干活的内核层(Unix 侧)以线程形式塞进同一个 iOS 进程。本文带你从零看懂这套 ntdll、kernel32 与 wineserver 三方协作的完整设计。
一、为什么必须"分家":iOS 的三条铁律
普通 Wine 跑在 Linux 上时,wineserver是一个独立进程,Windows 程序是真正的子进程。但 iOS 把这条路全堵死了:
| iOS 限制 | 对 Wine 的影响 | Madeira 的对策 |
|---|---|---|
| App 不能启动其他程序 | wineserver 不能独立成进程 | wineserver 改造成一个线程(WineServerBridge.h) |
| 沙盒内 Unix Domain Socket 不可靠 | ntdll 与 wineserver 之间没法走常规 socket | 用socketpair()造一根"内存电话线" |
| 所有"Windows 进程"其实是伪进程 | 多个游戏进程共享一个 Mach 任务 | 每个"进程"只是一组线程 + 各自的 TEB |
一句话概括:PE 侧负责"像 Windows",Unix 侧负责"干 Unix 的活",wineserver 负责"当裁判"。
二、架构总览:谁住在哪一侧
┌──────────────────────── 单个 iOS App 进程 ────────────────────────┐ │ │ │ PE 侧(Windows API 层) Unix 侧(真正的实现) │ │ ┌────────────────────────┐ ┌─────────────────────────────┐ │ │ │ kernel32.dll (Win32 API)│ │ ntdll 的 unix 实现 │ │ │ │ ntdll.dll(内核边界) │─────▶│ virtual_ios / env_ios 等 │ │ │ │ user32 / gdi32 / ... │ │ wineserver 线程(事件循环) │ │ │ │ 由 FEX 实时翻译成 ARM64 │ │ 全部是原生 ARM64 代码 │ │ │ └────────────────────────┘ └─────────────────────────────┘ │ │ ▲ ▲ │ │ └── FEX-Emu JIT 池(RX/RW 双映射)──┘ │ └────────────────────────────────────────────────────────────────────┘- PE 侧:ntdll.dll、kernel32.dll 这些是真正的 Windows PE 二进制,放在 App 包的 arm64ec-windows/ 和 aarch64-windows/ 两个"DLL 农场"里。游戏的 x86-64 代码和这些 DLL 一起由 FEX-Emu 边跑边翻译成 ARM64(接口见 FEXBridge.h)。
- Unix 侧:ntdll 里那部分"必须接触操作系统"的代码被单独实现为 C 代码(虚拟内存、环境变量、进程/线程),原生 ARM64 运行,不经过 FEX——这是分离架构最大的性能红利。
- wineserver:Wine 的"中央调度员",负责对象所有权、跨进程消息、注册表等。在 Madeira 里它是一个被改名
wineserver_main()后跑在线程上的静态库。
三、启动顺序:一场精心编排的接力赛
读懂这套架构,最好的方式是按时间线看一次冷启动。全部逻辑集中在 WineProcessBridge.m 和 WineServerBridge.m 两个文件中:
| 步骤 | 动作 | 为什么必须这样 |
|---|---|---|
| 1️⃣ | 解压前缀模板(prefix template),铺好注册表 | wineserver 的init_registry()启动毫秒级就跑,若system.reg还没落盘,它会用空注册表覆盖模板里 17479 个键——所以播种必须先于服务器(madeira_seed_prefix_if_needed) |
| 2️⃣ | 创建 wineserver 线程,跑wineserver_main(--foreground) | 日志重定向到madeira-log.txt;fatal_error被重写为pthread_exit——原来会exit(1)杀掉整个 App |
| 3️⃣ | 创建socketpair(),一端注入 wineserver,另一端写入环境变量WINESERVERSOCKET | 绕过 iOS 上坏掉的 Unix socketaccept():ntdll 的server_connect()检查这个变量,直接拿到"已连接"的 fd |
| 4️⃣ | 创建 Wine 进程线程,读 PE 头判断目标位数 | madeira_pe_machine()从磁盘读MZ → e_lfanew → PE\0\0里的 Machine 字段,决定选哪个 DLL 农场 |
| 5️⃣ | 把农场里的 DLL软链接进drive_c/windows/system32 | 符号链接指向 App bundle,重装后路径变了,链接会自动重建(自愈) |
| 6️⃣ | 调用__wine_main(argc, argv),外面套一层setjmp | ntdll 的 iOS 出口 shim 用 longjmp 回到这里,实现"干净的进程退出"而不是exit() |
其中第 3 步是整个架构里最"妙"的一笔:
// 摘自 WineProcessBridge.m:用 socketpair 替代传统 socket 连接 int pair[2]; socketpair(AF_UNIX, SOCK_STREAM, 0, pair); setenv("WINESERVERSOCKET", fd_str, 1); // ntdll 侧:直接拿 fd wineserver_inject_client_fd(pair[0]); // 服务器侧:事件循环捡走它ntdll 拿到WINESERVERSOCKET后就不走server_connect(),直接在这根"电话线"上发第一个create_process请求——于是 PE 侧的加载器和 Unix 侧的 wineserver 在同一个进程里完成了握手。
四、ntdll:站在悬崖边的"双面人"
ntdll 是 Wine 里最特殊的模块,因为它正好骑在 PE/Unix 分界线上:
- PE 侧的 ntdll.dll:导出
NtCreateFile、NtWaitForSingleObject等Nt*/Zw*系统调用,是 kernel32 之下、系统之上的唯一入口。游戏调 kernel32 → kernel32 调 ntdll。 - Unix 侧的实现:真正的活儿(mmap、线程、信号量)由静态链入的
build/ntdll-unix/*_ios.c完成,对外只暴露__wine_main()这个入口(见 WineProcessBridge.m 第 285 行附近的声明)。
这种"一个模块、两种人格"的设计正是分离架构的精髓:PE 侧保持原汁原味的 Windows 调用链,Unix 侧随时可以针对 iOS 内核(XNU/Mach)定制优化,两边只在 ntdll 这一条缝上通信。
五、kernel32 与 DLL 农场:Windows 的"目录重定向"
kernel32.dll 提供CreateProcess、LoadLibrary、ReadFile这些经典 Win32 API。Madeira 里它和同门的 user32、gdi32 等一起住在 App 包里,靠软链接农场摆上"Windows 的货架":
| 农场目录 | 内容 | 何时使用 |
|---|---|---|
| arm64ec-windows/ | ARM64EC 混合模式系统 DLL(如madeira_d3d12.dll) | 真游戏(x86-64 目标) |
| aarch64-windows/ | 纯 ARM64 原生 Windows 模块 | ARM64 原生测试程序 |
| i386-windows/ | 32 位 WoW64 模块 | 32 位游戏 |
x86_64-vcruntime/ | 微软真版 VC++ 运行时 | 覆盖 Wine 的 stub 实现 |
启动时wine_process_thread()会把选定农场的全部 DLL 链接进system32,同时维护sysx64/sysaa64两个镜像农场,让"64 位启动器拉起 32 位子进程"这类混合场景也能按 Win32 路径正确解析到对应架构的二进制。
💡 小提示:为什么 ntdll 在"系统 DLL"里地位如此特殊?因为它是每个进程的第一个被加载的模块,加载器本身也用它。谁先有、谁加载谁——这就是为什么 ntdll 必须同时存在于 PE 侧和 Unix 侧。
六、wineserver:为什么必须跑在最高优先级线程上
wineserver 是 Wine 的"系统服务",PE 侧每个对象等待、消息泵调用都要和它往返一次。这里有一个很实在的工程教训(WineServerBridge.m 中有完整注释):
- 早期版本:wineserver 线程被降优先级,"免得挤占主线程"。
- 后果:游戏线程在每个 55ms 帧里大部分时间停在
read_reply_data等回复——教科书级的优先级反转。 - 修正:改用
QOS_CLASS_USER_INTERACTIVE,让服务器线程至少和客户一样"热"。
此外还有两处关键的"保命"改造:
fatal_error()被重定义为pthread_exit(NULL)——wineserver 崩了只死一个线程,App 还活着;- 游戏退出后必须主动
wineserver_stop()并 join 线程,否则空转的服务器会因"CPU 占用过高"被 iOS 杀掉。
七、WoW64 彩蛋:32 位游戏如何各占 4GB
32 位 x86 程序在 ARM64 上跑面临一个根本矛盾:XNU 给每个进程 4GB 的__PAGEZERO,而经典 WoW64 假设"32 位指针就是真实地址"。Madeira 的解法是guest window(详见 docs/WOW64.md):
- 每个 32 位伪进程独占一段 4GB 对齐的宿主区间
[B, B+4GB),x86 代码看到的地址a实际落在B + a; - Wine 自己的
wow64.dll以原生 aarch64 运行(不翻译),只有游戏自身的 x86 代码交给 FEX 的 WOW64 模块xtajit.dll; - 基准
B通过私有NtQueryInformationProcess查询下发——又是一个 ntdll 跨界的例子; - 不跑 32 位程序时什么都不预留(lazy windows),小内存设备也不会白白损失地址空间。
八、退出路径:用 longjmp 模拟"进程死亡"
iOS 上一个线程不能调用exit()(会杀死整个 App)。ntdll 的 iOS shim 给每个"进程"线程准备了线程局部的jmp_buf:
- 游戏进程调用
ExitProcess→ Unix 侧longjmp回到 WineProcessBridge.m 里__wine_main()外层的setjmp; - 若是 NTSTATUS 错误(
0xC...),ntdll 还会回调wine_launched_process_did_exit(),把崩溃码上报给 App 前端展示; - 主线程退出后清掉 pthread TSD 里的 TEB 镜像,避免线程销毁时触发 Objective-C 析构踩雷。
至此,一次完整的"进程生命周期"——启动、运行、退出——全部在单进程、多线程、无子进程的约束下闭环了。
九、总结:分离架构的收益与代价
| 维度 | PE/Unix 分离带来的收益 | 代价 |
|---|---|---|
| 性能 | 内核路径(内存、线程、同步)走原生 ARM64,零翻译开销 | ntdll 必须维护两套实现 |
| 兼容性 | PE 侧保持标准 Wine DLL,社区修复可直接复用 | 所有跨边界通信都要自己设计(socketpair、NtQuery 私有类) |
| 可移植性 | iOS 定制只集中在 Unix 侧文件 | 单进程多"进程"模型带来优先级反转、TSD 等微妙问题 |
| 可维护性 | 日志统一进madeira-log.txt,崩溃可归因到线程 | 调试复杂度显著高于原生 Wine |
如果你想继续深入,建议按这个顺序读源码:先看 WineServerBridge.m(最短,线程化 wineserver 的全貌),再看 WineProcessBridge.m 的wine_process_thread()(DLL 农场 + 启动编排),最后配合 docs/WOW64.md 理解 32 位路径。整个架构没有一行"魔法",每一条别扭的设计(socketpair、longjmp、软链接农场)背后都对应着 iOS 的一条具体限制——这正是读懂 PE/Unix 分离架构的钥匙。
【免费下载链接】MadeiraRun x86-64 Windows PC games on jailed iOS via FEX-Emu + Wine + DXMT项目地址: https://gitcode.com/GitHub_Trending/mad/Madeira
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考