我在 Figma 之后的第一个爱好项目是一个我称之为retrowin32的 win32 模拟器。它现在几乎无法在浏览器中执行一些未经修改的 Windows exe 文件(请参阅该站点的一些链接)。
我想当我读到“win32 是稳定的 Linux 用户态 ABI”这句话时,我开始思考这个想法。也就是说,Linux 的变化如此之大,以至于今天编写的给定程序在一年内都无法运行。同时,在 Windows 95 32 位时代编写的程序保证永远不会再改变。您可以将今天的 win32(即 32 位 Windows)视为类似于 NES 的旧平台,您可以通过“仅”模拟所有芯片来模拟它。
还有其他项目可以运行旧的 Windows 程序。 WoW64是 64 位 Windows 中运行旧的 32 位 Windows 程序的系统的名称。 Wine将 Windows API 填充到您的主机系统上——请参阅伟大的Wine 工作原理,深入了解这意味着什么。像qemu这样的系统模拟器项目可以模拟完整的 x86 机器,这样您就可以在它们上面安装 Windows。但是 Wow64 需要运行 64 位 Windows,Wine 需要 x86 硬件,而 qemu 需要在模拟器中安装完整的 Windows 操作系统才能运行 Windows 程序。
相比之下,我的玩具模拟 x86 和足够的 Windows API 来获取一个普通的 exe 文件并直接在我的浏览器中运行它。它绝对仍处于“玩具”阶段,但我只在一个月内对其进行了修补,而且很有趣——这正是我希望能够从事的那种“不是很有用但令人发痒”的工作退休并度过我的日子。
这个怎么运作
模拟 Windows 程序有两个主要部分,x86 部分和 Windows 部分。
从根本上说,Windows 程序包含 x86 指令,因此您需要模拟 x86。我做了最愚蠢最慢的事情,它迭代指令流并运行它看到的操作。相比之下,qemu 为 x86 代码提供了一种与架构无关(!)的 JIT技术。 (我真的推荐这篇论文,它是一种易于阅读且非常受欢迎的方法——几乎是你对像 Bellard 这样的天才所期望的……)。我认为CheerpX的人已经针对 Wasm 做了类似的事情,尽管我找不到太多关于它的信息。我有兴趣研究这个领域,但我还没有。
运行 Windows 程序的另一部分是使 Windows 与其他 x86 操作系统不同的所有东西。一个 Windows exe 文件编码了一堆关于如何将文件加载到内存中的信息,特别是它在大量 Windows API 中的调用。 retrowin32 将这样的文件加载到其模拟的 x86 内存中,并提供 API 的实现(稍后会详细介绍)。
文件格式
看看 Windows 是如何工作的考古学有点令人惊奇,因为它是几十年来垃圾的积累。例如,每个 exe 文件首先以一个 DOS 程序开头,该程序打印“此程序无法在 DOS 模式下运行”,然后是更多的标题,然后由 Windows 解析。在各种格式中,有些地方感觉像是后来被改装的——例如,在 BMP 格式中,如果编码的图像高度为负,这意味着编码的像素行是自上而下的(与 BMP 的默认自下而上相比)。资源(菜单和图标等静态结构化数据)使用指向子块的块的通用嵌套目录结构建模,仅用于具有精确的三层嵌套。等等。
从今天的 JSON 和 protobufs 世界很容易说,文件格式受益于具有内置进化的统一结构的感觉更加明显。许多解析 PE 最终以一次性结束,例如“如果高位设置这意味着这是一个整数,否则它是相对于指向“字符串”类型编码的某个其他字段的偏移量。相比之下,例如 Figma 文件(大部分)采用kiwi格式(与 protobuf 基本相同),因此它们的解析器是一个简单的代码生成问题。
挂钩 Windows API
在高层次上,DLL 是这样工作的。程序的代码在不同的点会说“调用内存地址 X 的函数”对于某些特定的 X。然后在加载时有一个可用的表显示“在启动时,将 user32.dll 的 LoadIconA 函数的地址放在地址 X”。相反,retrowin32 所做的是在这些位置戳一个特殊的否则无法访问的地址。然后,如果 CPU 尝试跳转到这些地址之一,它会调用我对这些函数的自定义实现。 (您可以单击 UI 中的“导入”来查看这些内容。)
这是 Wasm 与主机系统如何工作的奇怪回声。我的函数是传递参数,例如地址,但这些地址指的是模拟器内存中的数据。同样,为了返回数据,他们必须将数据返回到模拟器的内存中。
还有一层间接性,因为其中许多调用的最终目标是运行网页的 TypeScript。因此,例如对传递 stdout 句柄的WriteFile()
的调用将首先跳转到我的WriteFile()
实现,然后解码参数并将它们向前(通过 Wasm 桥)转发到 TypeScript 接口。
COM 和 DirectDraw
迄今为止,其中最挑剔的部分之一是COM ,它(部分)大致是用于对 API 进行动态探测的 Windows 机制。特别是 DirectDraw(“快速”图形 API)使用它。
让我深吸一口气,试着描述一下DirectDrawCreate
函数:
- 它需要一个参数,一个指针。
- 实现将一个指向
IDirectDraw
的指针写入该指针。 -
IDirectDraw
结构以指向 vtable 的指针开始。 - 然后 vtable 具有指向更多函数的指针。
而这些函数本身可能会返回更多的 DirectDraw 对象! DirectDraw 表面本身被探测到最终映射到 HTML <canvas>
元素上。总之,有很多指针,以及多个不同的内存空间,要保持直线。
老糊涂
在获得基本的 DirectDraw 演示(请参阅项目站点上的链接)时,我对必须实现的 API 数量感到惊讶。事实证明,在内核加载可执行文件和 C 到达main()
函数之间,需要计算大量的东西——所有这些对命令行和环境的解析等等。有趣的是,即使在像 C 这样的低级语言中,复杂性也是如何积累的。
(如果您加载演示,请单击左下角的“导入”以查看该程序使用的所有 Windows 功能的转储。所有这些都显示了一辆旋转的汽车。)
调试
为了让一切正常工作(在某种程度上它确实很弱),我肯定花了一些时间在 Windows 方面的调试器上。特别是学习Ghidra并绘制出我对可执行文件尝试的最佳猜测非常有趣。
在此特别向我认识的最狡猾的黑客之一Dean大声喊叫,他帮助我解决了其中的一些问题。
同时,我还需要调试模拟器中发生的事情,所以在整个事物之上有一个 Web UI,让我可以单步检查状态。 (我知道 UI 不太好,抱歉!)所以在模拟器之上,我想我已经构建了 x86/win32 调试器的开端。
我认为拥有一个网络前端最终会是一种非常强大的方法,尽管这可能是由于我在网络事物方面的经验而产生的偏见。举个小例子,如果您单击 UI 中的内存选项卡,然后单击指令流中的一个地址,它会将视图跳转到该地址,并且对鼠标悬停有进一步的影响。使用 React 之类的工具实现这种 UI 是微不足道的。
另一方面,我在技术堆栈方面也非常深入,介于 x86 和 Rust 以及 Wasm 和 TypeScript 之间,所以可能更简单的东西更容易组合在一起。
(代码的结构方式应该相对容易为模拟器编写一个原生前端,一个根本不涉及任何网络内容的前端。有一个由 Wasm 桥实现的“主机”抽象,但它可以就像 SDL 一样。)
我要去哪里
让我开始走上这条道路的事情是看着我最喜欢的演示之一,并为我基本上不能再运行它而感到难过。我试图让它在运行旧 Windows 的 qemu 下运行,但它只会在启动时关闭,也许是 DirectDraw init 失败?我最终在本机 Windows 计算机上对其进行了调试,但那台计算机已经很旧了。
在 GPU 接管一切可能不需要大量 Windows API 并且可能已经足够老可以在这些快速的新 Mac 之一上模拟的软件(如演示和游戏)之前,感觉就像有一个甜蜜点.我是否能够真正成功地执行chillin.exe
还有待观察——尤其是我还没有考虑太多关于声音的问题,而且它可能使用线程,eek,也许它仍然会太慢——但总的来说我觉得即使我失败了,这里的总体思路是时机已到。
原文: https://neugierig.org/software/blog/2022/10/retrowin32.html