Theseus: 将 win32 翻译为 wasm

Lobsters Hottest 工具

摘要

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

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

缓存时间: 2026/05/27 03:27

# 技术笔记:Theseus:将 win32 翻译为 wasm 来源:https://neugierig.org/software/blog/2026/05/theseus-wasm.html *本文是有关 Theseus(我的 win32/x86 模拟器)系列文章的一部分(https://neugierig.org/software/blog/2026/04/theseus.html)。* Theseus 现在可以生成 WebAssembly 输出,从而将 `.exe` 文件转换为可在 Web 上运行的内容。在这里试用(https://evmar.github.io/theseus/),但请注意它充满了漏洞(例如,扫雷在获胜时会崩溃)。 这相当直接,除了本文将讨论的一个主要细节。 x86 仿真部分只是用不同的 CPU 目标重新编译现有的 Theseus 输出。这是这种二进制翻译方法的主要优势之一。翻译后的代码几乎(除了 `main` 如何被调用外)完全独立于最终运行的环境。原则上,我现在可以相对免费地获得优化的 wasm 编译器输出。主要的挑战是弄清楚代码布局,以便让 Cargo 配合我的奇怪需求。 win32 部分则是将内容抽象为一个“Host”API,该 API 能够完成诸如获取鼠标事件和渲染像素等操作。现在该 API 为 SDL 和 Web 各实现了一次。这也相对直接,至少在我第一次尝试时是这样。 那么难点何在?这涉及到我之前没有很好探索的设计空间的一部分:模拟器是否允许阻塞。 ## 阻塞还是不阻塞 在 retrowin32 中,模拟器被设计为能够逐步执行一些指令,然后将控制权返回给调用者。这对于网络版本尤其关键,因为主线程不能阻塞。在我之前的文章“两种线程方式”(https://neugierig.org/software/blog/2024/05/threading-two-ways.html)中,我详细讨论了在浏览器中模拟线程的各种权衡,最终选择了单线程。 这有其优点,但在几个重要方面并不令人满意: - 主线程必须反复调用模拟器循环,每次将控制权交还给浏览器。 - 任何可能将控制权转移到模拟器的 Windows API 实现都必须异步,以便可以挂起和恢复。这对于需要回调的函数来说是显而易见的,但即使是像 `MoveWindow` 这样的函数也会同步发送与窗口移动相关的 Windows 消息,因此它相对于消息处理也是异步的。 - 最后,所有异步代码的常见问题:对象生命周期管理、堆栈跟踪被破坏、调试混乱等等。 本着探索设计空间的精神,当我在 Theseus 中重新考虑这个选择时,我改为使所有内容同步,并使用真实的 OS 线程实现线程。特别是因为 Theseus 将原始程序的代码映射为函数调用,这使得调试体验相当愉快:如果我设置断点或发生崩溃,我会得到一个跨越源程序和模拟器代码的堆栈跟踪。 调试器截图 *图片:原生调试器中的 Theseus 程序,堆栈跟踪中包含生成的 x86 地址(左侧),右侧有线程选择器显示 Windows "winmm" 多媒体线程。* 我主要关心开发者体验,但这种方法的另一个好处是性能。计算机非常擅长快速运行由嵌套函数调用组成的简单代码,并将数据存储在栈上。我的异步方法意味着即使在内层循环中也有很多控制开销。 ## 在 Web 上阻塞 总的来说,阻塞很棒。但在 Web 上,你不能阻塞主线程。即使是在单线程程序中,对诸如 `GetMessage` 的 Windows API 调用也应该阻塞直到有消息可用,但浏览器事件只会在你归还控制权后通过浏览器事件循环进入。这看起来似乎陷入了困境。 这真正意味着,从根本上说,如果你想要阻塞,你必须使用线程——即使你模拟的程序本身是单线程的——因为工作线程是允许阻塞的。因此,方法如下:我在 Web Worker 中运行模拟器的线程。当模拟器需要浏览器提供某些内容时,它可以通过 `postMessage` API 发送一条消息,该消息在主线程的事件循环中接收。在这里,我可以让工作线程阻塞,直到消息处理完毕。 这就是原子 API(https://devdocs.io/javascript/global_objects/atomics)发挥作用的地方。(哦,同步代码!我搞错的可能性极高;欢迎您对此提出反馈,我发布此文部分原因也是为了引出一位比我更了解的读者来纠正我。) 如果你在主线程和工作线程之间共享内存,你可以让工作线程在一个原子操作上阻塞,直到主线程完成。为此,工作线程在发布消息时发送一个局部变量的地址: ``` fn blocking_call() { let mut buf = 0i32; let msg = create_message( /* ... 一些 JavaScript 数据,指示要调用的函数 ... */ // ... 并包含上述 'buf' 变量的*地址* &mut buf as *mut _ as u32 ); post_message(msg); unsafe { // 等待 buf==0,直到收到关于它的 Atomic 通知 wasm32::memory_atomic_wait32(&mut buf, 0, -1 /* 永远等待 */); } } ``` 主线程接收这些消息,并在完成时通过修改共享内存来唤醒工作线程: ``` window.onmessage = (e) => { const msg = e.data; // ... 处理消息 ... // 将 msg.buf 解释为共享内存中的指针: const ints = new Int32Array(sharedMemory.buffer, msg.buf, /* 长度 */ 1); ints[0] = 1; // 设置上面 `buf` 标记为成功处理 // 唤醒等待的线程: Atomics.notify(ints, /* 索引 */ 0, /* 唤醒数量 */ 1); } ``` 注意,由于工作线程被阻塞直到其消息被处理完毕,我们知道局部栈变量的地址在主线程使用它之前保持有效。这意味着我们可以有效地将工作线程中任何局部变量的地址传递给主线程,主线程可以安全地按需修改它。 从以上草图你应能看到我如何扩展它以双向传递缓冲区。当工作线程生成像素时,它仅发送一个指向像素的指针,主线程可以直接从其内存中读取(无需复制!)。当工作线程阻塞等待事件时,它可以提供一个缓冲区,由主线程填充。 这种方法的主要限制是主线程无法将任何浏览器对象传输给工作线程,因为唯一的回传方式是通过共享内存缓冲区。对象只能通过附加到 `postMessage` 来传输,而这些通过浏览器事件循环到达。 ## 宿主中的 TypeScript? 你可能注意到上述代码切换到了 TypeScript 来展示主线程处理程序。起初我打算将所有内容编写为一个单一的 wasm blob,包含主线程和工作线程的代码。后来我转而使用 TypeScript,有几个原因。 因为主线程不能阻塞,这意味着如果涉及任何同步,它实际上无法与工作线程共享其内存。这将禁止使用 even a malloc 实现。我认为最好的方法是让主线程 wasm 使用它自己的私有内存运行,并向它传递对工作线程共享内存的引用。我认为由于该共享内存对象是不透明的,你需要调用浏览器 API 与其交互,而不是使用原生的 wasm 内存 API。 与主线程不同,工作线程尽管共享内存,仍然可以安全地使用 malloc,因为它们可以像普通程序一样使用锁。……但出于我不完全理解的原因,wasm 下的 Rust 标准库在编译时未开启原子支持。值得庆幸的是,有一个相对受支持但仍为夜间版的 Rust 路径,可以将标准库本身作为工作线程构建过程的一部分重新编译。(但这确实突显了在 Rust 中使用共享内存 web workers 仍然不是一个完全受支持的路径。) 我转而使用 TypeScript 的另一个主要原因是工作线程无法访问 DOM,虽然这可能很麻烦,但它也在 Rust 工作线程代码和浏览器宿主代码之间提供了良好的隔离。Rust/wasm 对 DOM 交互的支持比之前好,但仍然相当笨拙,例如任何你调用的 DOM 函数都会被封装在一个由 wasm 模块导入的 JS 辅助函数中。相反,我可以在不了解浏览器 API 的情况下编写 Rust 代码,并在 TypeScript 端完成所有 DOM 处理。 总的来说,使用 TypeScript 进行 Web 开发的体验很难被超越。调试和交互式检查对象等工具远优于 wasm 调试。(另外,最近用 Go 重写的 TypeScript 编译器效果很好,非常快!) 到目前为止的主要缺点是序列化。我还没有找到一个令我满意的机制来在宿主/工作线程边界传输更复杂的对象。我最近看到一个技术演讲,有人使用 Rust 的 rkyv 库(https://rkyv.org/)来实现这个目的,看起来相当不错。 ## 下一步是什么? 这些项目的最终目的只是学习我好奇的事情。 从这次探索中,我得出结论:使用 wasm 编写应用程序令人印象深刻,但还没有完全到位——我很高兴有我本机构建作为后盾,在需要部署更复杂的工具时可以依赖。这绝对是我在 Figma 学到的一种模式(他们也有其基于 wasm 应用的本机构建),我推荐给你。 同样,我得出结论,Rust 与共享内存工作线程的结合还处于早期阶段。我认为对于一个你真正关心的应用程序来说,它的工作效果相当不错,但“使用夜间编译器以便重新编译标准库”并不是一个好兆头。 对于 Theseus 本身,我对下一步有一些想法,但这些只能等到另一篇文章了!

相似文章

Theseus,一个静态的Windows模拟器

Lobsters Hottest

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

Wterm – 面向网页的终端模拟器

Hacker News Top

Wterm 是一款面向网页的终端模拟器,使用 Zig 构建并编译为 WASM,以实现接近原生的性能,同时具备原生文本选择、复制/粘贴、查找和无障碍功能。