Theseus,一个静态的Windows模拟器

Lobsters Hottest 工具

摘要

Theseus是一个新型的静态Windows/x86模拟器,它在编译时翻译程序,而不是在运行时解释或即时编译,代表了一种不同于传统模拟架构的方法。

<p><a href="https://lobste.rs/s/dfe90i/theseus_static_windows_emulator">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/04/20 14:44

# Theseus,一个静态 Windows 模拟器 来源:https://neugierig.org/software/blog/2026/04/theseus.html *这篇文章很可能是我关于 retrowin32 系列(https://neugierig.org/software/blog/2023/09/retrowin32.html)的终结篇。* 我带来的是:Theseus(https://github.com/evmar/theseus),一个全新的 Windows/x86 模拟器,它通过*静态*翻译程序来解决一系列模拟问题——当然也势必引入一些新问题。 ## retrowin32 后来怎么样了? 我很久没碰 retrowin32(https://github.com/evmar/retrowin32)了,部分因为生活琐事,部分因为我不确定方向。然后,一位曾为其做过贡献的人发布了 retrotick(https://github.com/lqs/retrotick),一个基于网页的 Windows 模拟器,看起来比我多年的成果还好,还在 HN 上评论说只用了一小时和 Claude 就做成了。 这不是一篇关于 AI 的文章,一来这类文章已经太多,二来我自己也还没完全想清楚。但有件小事我一直琢磨:(1)AI 正缓慢但坚定地沿着初级到高级工程师的阶梯攀升;(2)高级工程师的核心能力之一,是更清楚*应该*构建什么,而不仅仅是*如何*构建。 (这是否就是“创新者困境”中的“向高端市场撤退”,套用在我作为人的效用上?不确定。所幸我做这些是为了求知、满足好奇心,这意味着我不会像企业那样感受到生存威胁。正如Benny Feldman(https://www.youtube.com/watch?v=2rAMOlXu_Eg)所说:“我在赌场作弊,因为我暗暗对物质财富毫无执念!”) 那么,高级工程师先生,我们该构建什么?模拟器到底要解决什么问题?我们的方法又如何应对?我最终想到了一个非传统的方案,想跟你们聊聊。 ## 模拟器与 JIT 最简单的 CPU 模拟器非常类似解释器。输入程序解析后变成 x86 指令,例如: `` mov eax, 3 add eax, 4 call ... ; 某个 Windows 系统 API `` 解释型模拟器就是一个大循环,逐条执行指令: `` loop { let instr = next_instruction(); match instr { // 例如 `mov eax, 3` Mov => { set(argument_1(), argument_2()); } // 例如 `add eax, 4` Add => { set(argument_1(), argument_1() + argument_2()); } ... } } `` 和解释器一样,这种方式很慢。 从高层次看,解释器慢是因为每条指令都要做大量动态工作。想象一下模拟一个循环执行相同`add`指令的程序;上述模拟器循环每次都要通过一系列函数调用反复问“我现在运行的是什么指令?”,检查参数,最终每次迭代又只做同样的加法。x86 内存引用尤其痛苦,因为它们非常灵活(https://blog.yossarian.net/2020/06/13/How-x86_64-addresses-memory)。 此外,x86 的`add`指令不仅做加法,还要计算六个派生值,包括奇偶标志:结果中 1 的个数是否为偶数(!)。一个正确的模拟器要么也得计算所有这些值,要么对代码做某种旁路分析来决定如何高效运行。 有很多有趣的技巧可以改进模拟器(http://www.emulators.com/docs/nx25_nostradamus.htm)。但要想快,真正需要的是分析代码并生成原生机器码——即 JIT。JIT 是出了名的难写!它们本质上是优化编译器,包含优化和生成机器码的全部复杂性,而且编译本身的运行时间也处于关键性能路径上。我喜欢这篇文章关于 JIT 为何难写的讨论(https://tratt.net/laurie/blog/2026/retrofitting_jit_compilers_into_c_interpreters.html),其中提到 Python JIT 的尝试不下 15 次。 ## 静态二进制翻译 那么,假设你想生成高效的机器码,但又不想写 JIT。你知道吗,真正擅长分析代码并生成高效机器码的东西是什么?编译器! 所以核心思路是这样的。对于像上面那个 x86 片段,我们可以将其处理成类似这样的*源代码*: `` regs.eax = 3; regs.eax = add(regs.eax, 4); windows_api(); // 被调用 API 的原生实现 `` 然后把这个代码喂给优化编译器,生成当前架构的原生程序,x86 就不再需要了。 换句话说,我们不直接把`.exe`文件交给会 JIT 代码的模拟器,而是用某种编译器(中间借助另一个编译器)将`.exe`静态翻译成“原生”可执行文件。 (我用引号写“原生”,因为虽然生成的可执行文件是原生二进制,但它内部携带了一个表示 x86 状态的虚拟机器,比如上面代码中的`regs`结构。稍后会详细说。) 我觉得这个基本想法是我自己想出来的——只是仔细思考到底想达到什么目的——但后来发现这种方法被称为“静态二进制翻译”,已有大量研究。它有一些很好的特性,也有一些大问题。 ## 反编译 我先讲这些,但先绕个小弯子说说我是怎么走到这一步的。 你听说过反编译(https://decomp.wiki/)吗?这些疯狂的人(或者该叫他们“疯狂人”?)正逐个函数地手动重建老游戏的源代码。他们拿游戏二进制文件,提取一个函数的机器码,然后用一个花哨的 UI(https://decomp.me/)(点击“Recent activity”下的某个条目),迭代调试,重现能生成完全相同机器码的高级语言代码。这挺神奇的。 (为此,他们甚至需要运行与目标游戏编译时相同的原始编译器。这些编译器往往是 Windows 程序,这意味着实现上述花哨 UI 需要在他们的 Linux 服务器上运行老旧的 Windows 二进制。这就是我第一次听说他们的原因——他们需要一个 Windows 模拟器!) 反编译不仅是一项奇怪而迷人(可能还很枯燥?)的人类活动。它还让我意识到一个重要问题:我其实并不那么在乎一个能运行任何随机程序的模拟器,我在意的是运行几个非常特定的程序,而且我愿意甚至手动做些努力来帮忙。 实际上,你看别人构建 Windows 模拟器,最终他们都会像外科医生一样,需要手动伸进去给目标程序的心脏泵血,包括调试目标程序并绕过它个别的 bug(https://corteximplant.com/@aaronsgiles/116044322660755992)。模拟器通常还会手动整理一份已知可运行或失败的程序清单。 ## 一个老想法 静态翻译机器码并不是新想法。那为什么它没有更流行呢?我阅读相关资料时的印象是,它常常被认为“行不通”而被抛弃,但至少到目前为止它运行良好。也许我还没遇到某些被忽视的无法解决的问题? (写这篇文章查相关工作的时候,我看到了这个尝试静态翻译 NES 的(https://andrewkelley.me/post/jamulator.html),结论是做不到,但后来又看到这些人似乎成功了(https://1379.tech/nesrecomp-from-faxanadu-to-4-supported-commercial-titles/),所以很难说。) 我认为主要有两个问题:一个技术性的,一个更文化性的。 技术部分,简单的想法包含复杂的细节。首先,任何在运行时生成代码的程序(比如自身包含 JIT)都不行,但我很容易就把这类程序列为范围之外。还有控制流如何工作等方面的挑战,但这些都比较小且有趣,我可能会在以后的文章中讨论。 一个常见的研究课题是,即便在不运行时生成代码的程序中,静态地找到所有可能执行的代码在极限情况下也是不可能的,因为虚表或跳转表会导致动态控制流(https://scholar.google.com/scholar?q=jump+target+identification)。尤其是,虽然有技术能找到*大部分*代码,但没有哪种方法能保证完美。这就是反编译改变了我的看法的地方:如果我愿意在特定程序上手动帮一点忙,那这个问题也许就没事了? 我认为二进制翻译没有更常见的另一个主要文化原因是,它不如一个通用的能处理大部分程序的模拟器方便。用户不太可能想运行编译器工具链,不过我见过有些项目直接内嵌编译器(比如 LLVM)来避免这个问题。 另一个文化问题是,如果你想分发翻译后的程序,会有法律后果。每个视频游戏模拟器都依赖“首先,复制你已拥有的物理拷贝上的游戏数据,并将其作为输入”的法律虚构,这样它们才能合理地保持非衍生作品。 但我不是在为用户解决问题,我是在为自己兴趣解决问题。这些文化问题对我不重要。 ## 优势 再考虑一下上面的片段,它把 3 和 4 相加。在静态翻译的世界里,我们提前解析指令流,所以编译器可以看到我们想把 3 放到 eax 中,而不会(像解释器那样)花费运行时时间考虑我们在读写什么值、写在哪里。 编译器不仅会为目标架构生成正确的机器码,它甚至能优化上面的代码,直接存储结果值 7(https://godbolt.org/z/xK84Tv1s3)。而且如果你框架得当,编译器能消除不需要的代码,比如奇偶计算。因为 Theseus 的代码生成是“离线”完成的,与程序执行分离,我可以比 JIT 更不用操心在运行时花时间分析代码来优化。 我开始时以为性能会是这种方法带来的全部好处,但后来发现开发也更容易,因为它引入了所有其他的开发者工具: - 翻译后的指令在输出程序中以常规代码形式出现,这意味着原生调试器可以单步执行翻译后的指令,它们看起来就像普通源代码。 - 如果程序崩溃,原生堆栈跟踪可以回溯到(翻译后汇编形式的)原始程序。 - 我还没试过,但 CPU 分析应该也有同样的好处。 在 retrowin32 中,我最终构建了一个完整的调试器 UI 来帮助定位问题,但在 Theseus 中,我目前只用系统调试器,效果也不错。 在 retrowin32 中,我还花了很多时间调整模拟器与原生代码之间的桥接(https://neugierig.org/software/blog/2024/09/retrowin32-syscalls.html)。这个边界在 Theseus 中仍然存在,但规模小得多,因为翻译后的代码可以直接调用我原生的 win32 系统 API 实现(只需少量胶水代码在内部机器的表示之间移动数据)。 在 MacOS 上,retrowin32 可以在 Rosetta 下运行,但这意味着整个可执行文件必须是 x86-64 二进制,因此需要交叉编译的 SDL。Theseus 二进制是原生代码,直接调用原生 SDL。 总之,它简单得多。从有这个想法到让我一直摆弄的测试程序(https://neugierig.org/software/blog/2022/10/retrowin32.html)运行出第一个场景(包括 DirectX、FPU 和 MMX),只花了我几周时间。 ## 部分求值 你可以将解释器、JIT 和静态二进制视为一个频谱,表示你在提前做多少工作,而在运行时做多少。Theseus 将“这是什么类型的 mov”这个动态问题移到了提前编译步骤,部分求值通用指令处理器,将其变成带有固定参数的具体指令。(我再次链接到关于元追踪 C 代码的精彩博客(https://tratt.net/laurie/blog/2026/retrofitting_jit_compilers_into_c_interpreters.html)。读一读关于Futamura 投影(http://blog.sigfpe.com/2009/05/three-projections-of-doctor-futamura.html),这个概念被推向了极致!) 再举一个例子,典型的 Windows 模拟器必须在启动时解析并加载 PE 可执行文件,但 Theseus 在编译时做这件事,只写出执行所需的数据结构。输出中不需要 PE 解析代码。 类似地,可执行文件的启动涉及链接和加载任何引用的 DLL,包括系统的那些,但 Theseus 必须看到所有将要运行的代码,所以它提前做了这个链接。下面是在调用某个 Windows API 附近的一些输出,编译时它直接解析了 IAT 引用(`ds:[...]`地址)到我自己写的 Rust 实现: `` // 004012a0 push 4070A4h push(ctx, 0x4070a4u32); // 004012a5 push 8 push(ctx, 0x8u32); // 004012a7 call dword ptr ds:[4060E8h] call(ctx, 0x4012ad, Cont(user32::CreateWindowExA_stdcall)) `` 从某种意义上说,Theseus 在编译时部分地运行了系统二进制加载器,而输出的源代码就是就绪状态的快照。这让我想起解包可执行文件的问题(https://neugierig.org/software/blog/2025/04/unpacking.html)。 ## WebAssembly Theseus 应当可以轻松扩展到 WebAssembly 下的 Web 运行环境;大部分工作就是把生成的程序用 wasm 作为目标架构来编译。(我最初实现了这个功能,后来决定目前不需要额外的复杂性,所以现在还没实现。) 另外,Theseus 的输出程序的灵感来自于 WebAssembly 的执行方式。两者都有一个外部宿主程序,其内部携带一个“机器”,拥有自己的代码和内存概念。该机器内的代码只能读写自己的内存,并且必须调用提供的钩子来桥接到宿主机。与 WebAssembly 类似,Theseus 输出的可执行代码与数据隔离,具有这样的优点:任何无意/恶意的内存写入都无法创建新代码。 一个 wasm 的 Theseus 会是一个机器套娃: 1. 原生宿主机的 WebAssembly 实现(例如 Chrome 运行时),有其内存概念,运行着 2. 一个包含 Theseus wasm 二进制的 WebAssembly 虚拟机,有它自己的内存概念(例如我的 Rust 实现的 Windows API 分配内存的位置),而在这个虚拟机内部又有 3. x86 虚拟机和 Windows 程序的内存概念(例如,可能会说“从内存偏移 $x 处的静态数据表读取”)。 在思考时,很容易想尝试混合一些机器层次,让 WebAssembly 程序的内存与输入 Windows 程序的内存概念一一对应。也就是说,如果输入程序写入某个地址 $x,你可以将其翻译成直接写入 WebAssembly 内存地址 $x。(你需要调整中间层,将其数据结构藏在 x86 程序不使用的地方。)为了让 retrowin32 能在 x86 模拟器下工作,我不得不做类似的事情(https://neugierig.org/software/blog/2023/08/x86-x64-aarch64.html)。WebAssembly 甚至让我可以直接从二进制文件中布局内存(https://webassembly.github.io/spec/core/syntax/modules.html#syntax-data)。我不认为这能带来多大好处,它只是——

相似文章

Theseus: 将 win32 翻译为 wasm

Lobsters Hottest

将 Windows 可执行文件 (win32/x86) 翻译为 WebAssembly 以在浏览器中运行,讨论诸如阻塞与异步设计等挑战。

Unicorn – 终极CPU模拟器

Hacker News Top

Unicorn 是一个轻量级、多平台的CPU模拟器框架,支持多种架构(ARM、x86、RISC-V等),提供简洁的API、JIT编译以及多种语言的绑定。它在网络安全和学术研究领域被广泛使用。