自从我第一次写关于retrowin32(我的 win32 模拟器)以来已经四个月了。这是一份进度报告。
在我进入技术领域之前,我有一些更温和的生活观察。如果您只是在这里谈论计算机,请随时跳到下一部分。
首先,我离职很大程度上是因为没有空闲时间,但出乎意料的是,我的余生却在某种程度上扩展了,消耗了大部分时间。从某种意义上说,考虑到我的日程安排很少,我更好地理解了为什么我之前的时间压力如此之大。造成这种情况的根本原因是为人父母、我妻子重新开始她的职业生涯以及我自己缺乏组织能力,但我尽量不要对自己太苛刻,因为我没有取得尽可能多的进步。我从工作中休息了一段时间,明确的目标是尽量放松。
考虑到这一点,我也一直在思考这个项目的实际目标是什么。我不太可能做出比现有产品更好的产品,但正如我在Ninja 回顾展中提到的,“成功”可以如您所定义的那样。目前我认为成功就是追逐我的好奇心——我发现这个话题非常鼓舞人心——而不是任何特定的产品目标。
(虽然让任何东西明确有用不是我的目标,但我在 Figma 工作时发现有趣的是,经常会出现一些随机的技术挑战,我会说“哦,我曾经在这个领域做过一个项目,我知道一点关于它“……)
特别是在这个领域,我对这个项目的最后一件事是我知道的很少,这意味着我很容易发布关于它的错误信息。每当我在这里写东西并被互联网纠正时,我都将其视为免费教育,所以如果您有任何反馈(即使是真正的反馈),我很乐意听取您的意见。
同时针对 Wasm 和原生
经过一些重构手术后,该项目现在更加分层。有一个不了解 Windows 的 x86 模拟器,然后是一个位于其上的 Windows 层。然后,Windows 层公开了一组Host
接口,这些接口提供诸如“写入标准输出”和“创建窗口”之类的功能。
该分层允许我两次实现Host
接口。第一个直接调用操作系统,它允许您构建本机 retrowin32 可执行文件并从命令行运行retrowin32 foo.exe
以使用 SDL 显示窗口。第二种实现以 WebAssembly 为目标,并将 Host API 跨 WebAssembly 边界转发到使用 HTML DOM 显示窗口的 TypeScript 实现。
我最初并没有打算针对非 Wasm。事实证明它很有用,因为它执行起来更快(当你编写的模拟器和我的一样慢时,这很重要,哈!)而且本机分析工具更好。我觉得我现在已经多次学习这个特定的课程,所以在未来的 Wasm 项目中要牢记这一点。
但也因为我最初只考虑 Wasm 我没有考虑太多安全性,因为在 Wasm 环境中,出现严重错误的程序仍然只能破坏其自身的内存。考虑一下我是否可以设置模拟器,使其可以“安全地”运行不受信任的可执行文件,这可能会很有趣,特别是考虑到意图是从可执行文件到外部机器的主机接口相当狭窄。
翻译 x86
当我开始这个项目时,我曾想过我会做的一件事是构建某种直接从 x86 到 Wasm 的转换器,但到目前为止我还没有。您可能会天真地期望(我可能已经预料到了!)您甚至可以提前构建某种 x86 到 Wasm(或本机)转换器。由于一个有趣的原因,最终没有成功。
对于初学者来说,x86 程序生成更多 x86 代码的情况并不少见。明显的例子是 JIT 的程序。另一个例子是我开始这个项目时使用的演示场景类型的可执行文件通常包装在某种“打包程序”中,也就是说您运行的程序的第一步是将实际程序的代码解压缩到内存中然后从那里去。 (有关示例,请参见kkrunchy 。)
但是,即使您搁置这些类型的程序并仅考虑不动态生成代码的程序子集,从 x86 静态转换仍然是一个潜在的令人惊讶的挑战,因为很难识别代码实际上是什么!在 Windows 可执行文件中,通常有一个标记为可执行代码的字节块,但这些字节相对来说是非结构化的。 x86指令编码复杂、密集、充满惊喜。
举一个具体的例子,这是我正在查看的一个随机 exe 的片段。最左边一列是代码的地址。第二列是代码的原始字节——长度不同,因为 x86 是一种可变长度编码——后面是试图反汇编这些字节的行的其余部分。
004188e2 83e203 AND EDX,0x3 004188e5 ff2495f4884100 JMP dword ptr [EDX*0x4 + 4188f4] 004188ec ff248d0489410090 ?? 004188f4 04894100 // this is 00418904 written in little-endian 004188f8 0c894100 // this is 0041890c written in little-endian 004188fc 18894100 // this is 00418918 written in little-endian 00418900 2c894100 // this is 0041892c written in little-endian 00418904 8b44240c MOV EAX,dword ptr [...]
您在这里看到的是一个跳转表。在第一行中,EDX 被屏蔽为 <= 4。第二行中的数学计算一个地址并取消引用该地址,也就是说它读取第 4 行到第 7 行中看到的常量之一,然后跳转到该地址.
请特别注意,该指令流还包含原始数据。我的反汇编程序对第 3 行显示的字节甚至是什么感到困惑。代码中的其他一些点可能会计算这些字节内的一些任意偏移量并跳转到那里。
这是一种冗长的说法,给定一个任意的 x86 二进制文件,即使它本身不生成代码,您也不能只从顶部开始并反汇编到底部,因为您无法确定指令的边界在哪里;可能有数据被解析为指令并跳转到看起来像数据的地方。确定指令的实际位置是您只能在运行时确定的极限。
实际上,所有这些并不意味着不可能从 x86 生成 Wasm。相反,这意味着您还必须能够在运行时转换 x86 代码,这就是现有的 x86 到 Wasm 工具(如 v86 和 CheerpX)所做的。 (请参阅 v86 的“工作原理”文档。)
生成可执行文件
为了开始这个项目,我一直在使用我发现的一些随机.exe
文件,但为了测试,创建我自己的可执行文件很有用。
例如,我从其他一些 x86 仿真器中借鉴的一个技巧是制作一个程序,在受控环境中执行各种 x86 指令,然后打印出生成的 CPU 状态。然后我可以在真正的 x86 上执行这个程序,捕获它的输出,然后验证我的模拟器产生相同的输出。
那么如何从 Mac 创建 Windows exe? “这只是交叉编译,”我想,但事实证明这是一个充满繁琐细节的兔子洞。特别是交叉编译器倾向于生成对 Windows 库或 Windows 工具链具有大量依赖性的代码;例如,请参阅有关 Rust 工具链当前状态的这些详细信息,可以将其概括为“它还没有工作”。
Zig 语言在这方面很有前途。我对它的主要犹豫是语言还很早。我在尝试学习该语言时设法使编译器崩溃,甚至在 retrowin32 中,我发现我的旧 Zig hello world 无法使用较新的编译器进行编译(一些微不足道的标志更改)。但除此之外,它看起来非常理想——超快速编译器、无痛交叉编译、最少的可执行文件,甚至是asm
语言结构。
我目前所处的位置只是在本机 Windows 机器上构建的 C 代码。这种方法的一个很好的特性是它使用标准的 Windows 工具链,因此生成的可执行文件就像其他 Windows 可执行文件一样。有一点有点聪明:我设置了 GitHub 的 CI,这样当我修改 C 代码时,它会在 Windows VM 上重建关联的.exe
文件,并将生成的可执行文件附加到当前的 Git 分支,这样我就可以迭代(有点慢)从我的 Mac。 (PS:有一个秘密标志可以使 MSVC 的输出稳定…)
实施 Windows API
retrowin32 包括它自己的(部分)Windows API 实现。它的工作方式是当 x86 模拟器试图跳转到 Windows 函数时,它会探测到我编写的代码。这些调用中的每一个都需要从 x86 堆栈中弹出参数,然后解释它们,将数据适当地插入到 x86 内存中。
考虑 Windows 函数WriteFile
:
BOOL WriteFile( [in] HANDLE hFile, [in] LPCVOID lpBuffer, [in] DWORD nNumberOfBytesToWrite, [out, optional] LPDWORD lpNumberOfBytesWritten, [in, out, optional] LPOVERLAPPED lpOverlapped );
具体来说,签名表示接受一些数字和一些指针。通过适量的 Rust 黑客攻击,我对该函数的实现改为具有以下签名:
#[win32_derive::dllexport] pub fn WriteFile( machine: &mut Machine, hFile: HFILE, lpBuffer: Option<&[u8]>, lpNumberOfBytesWritten: Option<&mut u32>, lpOverlapped: u32, ) -> bool {
那里的dllexport
由一个代码生成器获取,该代码生成器从 x86 仿真机器生成管道(这也是附加Machine
参数的目的)并且还映射来自更高级的 Rust 类型的参数。例如,请注意缓冲区/长度对成为具有适当边界的切片,并且输出参数成为&mut
。此外,指针变为Option<...>
以模拟它们是否为空。
(在 Rust 的意义上,这仍然是不安全的,因为这个函数的调用者可以将lpBuffer
和lpNumberOfBytesWritten
指向同一内存……)
我强调这个函数也是为了指出 Hyrum 定律的一个更有趣的例子。根据 MSDN 文档, lpNumberOfBytesWritten
不能为 null,并且最初在我对上述函数的签名中,我没有将Option<>
包裹在它周围。
同时,我有一个我一直在使用的测试“最小 Windows”程序,它使用了这个函数,调用如下:
WriteFile(hStdout, buf, sizeof(buf) - 1, nullptr, nullptr);
…也就是说它为正式不能为空的参数传递了一个空值。这个程序当然在本机 Windows 上运行良好,但在我的模拟器下崩溃,直到我意识到发生了什么。
(从那时起,我改变了一些东西,使得每个指针都必须始终成为一个Option<>
,因为无论如何在 Rust 端.unwrap()
很容易。)
DOS怀旧
我的童年是在 DOS 时代。我记得我曾经修补过CONFIG.SYS
之类的东西,并试图弄清楚如何获得更多内存来运行视频游戏,但我并不真正理解发生了什么。阅读关于这个时代技术的维基百科文章真的是一种怀旧的乐趣,因为我接受了欣赏它所必需的计算机科学教育。
例如,你还记得“high memory”这个词吗?这实际上很有趣!在那些日子里,内存由segment << 4 + offset
寻址,其中 segment 和 offset 都是 16 位。 16 << 4 表示它包含 20 位,也就是说您可以寻址 1 兆字节。但是如果你看一下数学,你可以用该表达式表示的绝对最大地址是0xFFFF << 4 + 0xFFFF
,它只覆盖了一点点(65520 字节!)超过 1 兆字节。这就是“高端内存区域”,那个小小的额外区域!
…好吧,但模拟器实际上做了什么?
回想起来,我非常幸运地成功模拟了第一个 DirectDraw 二进制文件,因为该二进制文件没有使用太多 Windows API 或机器功能。
从那时起,我发现即使是一个微不足道的控制台 C 程序也会引入更多的 Windows API 调用。例如,C 程序期望使用argv
数组调用 main,但这根本不是 Windows 的看法,因此作为 C 启动的一部分,它最终会调用一堆 Windows 函数来进行设置。
多少?我一直在测试的一个小型 C++ 控制台程序可以拉入 83 个 Windows 函数。但是 Windows C 库似乎也在动态探测更多代码——我想一些关于初始化 C 分配器的事情会在TlsAlloc
(线程本地)上进行,然后尝试查看当前系统是否具有支持FlsAlloc
(纤程本地)的 DLL。
…这是一个很长的说法,尽管自从我上一篇文章以来有数百次提交,并且我在内部可以看到进展,但没有任何重要的新里程碑可以报告。自上次以来,我确实连接了本机执行位,如下所示:
$ cargo run -p retrowin32 — exe/zig_hello/hello.exe 2>/dev/null Hello, world!
并且可以类似地为 DirectDraw 演示生成本机窗口。但是我没有新的酷炫的基于网络的演示可以向您展示。对不起,也许下次吧!
原文: https://neugierig.org/software/blog/2023/02/retrowin32-progress.html