Madeira的TSD/TLS槽位之战:ARM64EC模块为何不能再硬编码TSD
【免费下载链接】MadeiraRun x86-64 Windows PC games on jailed iOS via FEX-Emu + Wine + DXMT项目地址: https://gitcode.com/GitHub_Trending/mad/Madeira
Madeira是一个让越狱 iOS 设备运行 x86-64 Windows PC 游戏的项目,技术路线为 FEX-Emu(x86-64 模拟器)+ Wine(Windows API 层)+ DXMT(DirectX 转 Metal)。而在整个栈最底层,有一场无声的"槽位之战":Wine 的线程环境块(TEB)存放在 pthread 的 TLS 线程局部存储槽中,而 ARM64EC 模块 FEX 曾硬编码了这个槽位——一次 Apple 框架的"抢位"让所有游戏进程集体阵亡。本文讲清楚 Madeira 是如何把硬编码 TSD 换成自发现 + 运行时补丁的。
一、背景:每个线程的"信箱",以及谁拥有第 275 号
在 Apple Silicon 上,线程局部存储(TLS)的物理形态是一条原始 TSD 数组:基址放在TPIDRRO_EL0寄存器里,第 N 号槽位就在基址 + N×8 处。可以把它想象成每个线程都有一排信箱,各组件往自己的信箱里放东西。
Madeira 里有两方要用信箱:
- Wine:把每个线程的 TEB(线程环境块,Windows 线程的"根指针")放进一个 pthread key 对应的槽里;
- FEX(ARM64EC 模块):模拟器在 ARM64EC 混合环境里运行 x86 代码,需要从 TLS 里读 TEB 才能切换上下文。
旧的 FEX 汇编里直接写死了ldr xN, [xN, #0x898]——即第 275 号槽(偏移 0x898)。问题在于:275 号是一个动态 pthread key,谁先调用pthread_key_create()谁就拿到它,而且赢家因设备而异、因加载的框架而异。Wine 根本不拥有这个槽。
二、灾难现场:M4 iPad 上的"抢位"
真实事故(完整记录在 thread_ios.c 的注释里):
- 一台 M4 iPad 上,首次
nextDrawable唤醒的Metal 图形栈抢先拿到了 275 号 key; - 它把槽位重置为 NULL;
- 此后所有 FEX 的 x18 跳板从该槽读 TEB,读到的是
TEB = 0; - 进程死在读
TEB->PEB的那一瞬间。
更隐蔽的是另一面:275 号 key 若带着ObjC 析构函数被 Apple 框架分配,线程退出时 pthread 的清理逻辑会拿 TEB 指针当对象调用objc_release——非对象被释放,winhttp 的解析线程一退出就崩。
一个"看起来能用"的魔数,变成了随设备漂移的地雷。这就是硬编码 TSD 槽位的原罪:你写的地址,不属于你。
三、新打法:哨兵自发现,发现失败就自杀
Madeira 在 loader_ios.c 中重建了整套信任链,分四步:
1. 哨兵探测真实槽位
启动早期(任何 PE 模块加载之前),Wine 创建自己的 pthread key,写入一个唯一魔数0x5445425F534C4F54(即字符串 "TEB_SLOT"),然后扫描 TSD 数组的 512 个槽,看哨兵落在哪里——那个位置就是本进程里真正属于该 key 的原始槽。
为什么不用"扫描 TEB 值"?因为 TEB 本身能通过 Wine 自己的
teb_key找到,扫 TEB 会误匹配到别人拥有的槽。哨兵值只属于这次探测,不会撞车。
2. 校验 + 绝不降级
找到槽后必须验证"槽里现在装的确实是 TEB",且基址与跳板将来使用的一致。验证失败直接abort()——源码注释写得很直白:错误的偏移不是"降级模式",而是"每个被补丁模块里必定发生的 TEB=0 故障"。宁可当场死,不要无声地错。
3. 通过 ntdll 导出表发布唯一权威值
发现完成后,load_ntdll_functions 把偏移写进 ntdll 的导出变量ios_teb_tsd_offset。ARM64EC 模块(FEX)导入这同一个值,而不是各自硬编码——全系统从此只有一个事实来源。
4. 二进制补丁器:执行前改写汇编
FEX 模块里那三句手写的汇编:
mrs xN, TPIDRRO_EL0 ; 取 TSD 基址 and xN, xN, #~7 ; 对齐 ldr xN, [xN, #slot*8] ; 读槽 —— 槽号是占位符它的槽号在汇编期不可能知道。于是 virtual_ios.c 的 "Pass 0" 在镜像还是 RW 别名、尚未执行时,精确匹配这条三指令序列(三条指令寄存器必须一致,顺带的 LDR 撞不上),把第三条的立即数重写为已发现的槽偏移。两个防坑设计值得学习:
- 字面池保护:先用数据地图排除 literal pool 中的"假指令",避免把数据当指令打补丁;
- FATAL-BY-SILENCE 哨兵:如果某镜像在槽位"尚未发现"时就被打补丁,占位符会原样存活,后续所有 TEB 读取都变成读别人的数据且不会触发任何故障——所以此路径会被显式大声报告,而不是保持安静。
四、线程退出:用"自己的钥匙"清自己的槽
灾难解决后,收尾也必须对称。线程退出时不再去捅 275 号裸槽,而是通过 Wine 自己创建、自己持有的 key 把槽置 NULL(thread_ios.c):槽归谁管,谁负责清理。同理,server_ios.c 的线程采样器也不信任 libpthread 私有头文件里写死的偏移 224,而是用"写哨兵 → 扫描"的方式自我标定TSD 数组在struct pthread里的真实位置。
五、给新手的三条经验
- 不要硬编码你不拥有的东西。动态 key、私有结构布局、魔数偏移,都是会随设备和系统版本漂移的暗雷;
- 自发现优于信任假设:写入哨兵、扫描、校验、发布单一权威值——这套流程在 loader_ios.c 里就是一个可复用的模板;
- 让失败大声。最危险的不是崩溃,而是"占位符存活 + 读到别人的数据 + 毫无故障信号"。把静默路径变成显式 FATAL,是嵌入式/系统层开发的老练标志。
总结
Madeira 的这场 TSD/TLS 槽位之战,本质是一次从"魔法数字"到"运行时事实"的架构升级:Wine 启动时用哨兵自发现真实 TLS 槽,经 ntdll 导出表发布唯一权威偏移,二进制补丁器在 FEX 镜像执行前重写其占位汇编。ARM64EC 模块因此不再依赖任何固定槽位——这也是 Madeira 能在不同越狱 iOS 设备上稳定跑起 Windows 游戏的底层基石之一。想深入了解构建与运行细节,可参阅项目文档 BUILDING.md 与 WOW64.md。
【免费下载链接】MadeiraRun x86-64 Windows PC games on jailed iOS via FEX-Emu + Wine + DXMT项目地址: https://gitcode.com/GitHub_Trending/mad/Madeira
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考