我如何在 2013 年修复臭名昭著的 Basilisk II Windows “黑屏” Bug

Downtown Doug Brown

来自 Apple/Linux/Windows 极客的思考。

5月 14

我是如何在 2013 年修复臭名昭著的 Basilisk II Windows “Black Screen” bug 的

Doug Brown Bug fixes, Windows 2025-05-14

最近我注意到很多关于旧软件中的 bug 突然出现在较新版本的 Windows 中的有趣故事。例如,这里有一篇 Silent 的精彩文章 关于 Grand Theft Auto: San Andreas 中的一个 bug,该 bug 在 Windows 11 24H2 发布之前一直处于休眠状态。MattKC 最近还发布了一个很酷的视频 关于反编译 LEGO Island 的大型项目,该项目还解决了发生在较新版本 Windows 中的“退出故障”之谜。Nathan Baggs 再次出手,修复了 Sid Meier’s Alpha Centauri 的现代兼容性问题

我不会剧透这些故事,但它们都让我想起了 12 年前我在 Basilisk II 中修复的一个 bug,但直到现在才写出来。Basilisk II 是比较流行的 68k Mac 模拟器之一,允许你在现代机器上运行旧的 Mac 系统。现在,你甚至可以使用 Infinite Mac 在浏览器中运行它!这是 Basilisk II 在我的 Windows 10 机器上运行的屏幕截图。

这个 bug 是:当你启动它时,模拟的 Mac 只是坐在那里显示黑屏,而不是启动。它不是每次都发生,这真的让每个人都感到困惑。这个问题在较新的 Windows 版本上似乎更为常见,当时是 Vista 和 7,但人们偶尔也会在 XP 上看到它。它肯定在我使用 Windows 7 的时候大部分时间都失败了。在 Mac OS X 或 Linux 上没有人看到这个问题。

为了重新熟悉这个 bug,以便写这篇文章,我从 Internet Archive 下载了损坏的版本 并在一些虚拟机中进行了测试。Windows 2000 和 XP 第一次尝试就运行了它,没有任何问题,但 Vista 和 7 没有:

Basilisk II 有一个 UI 怪癖,在这种特殊情况下非常烦人:关闭按钮不起作用。你必须干净地关闭模拟机器才能退出,当你陷入黑屏时,这是不可能做到的。此功能对于在正确工作时保护你免于丢失模拟器中的数据非常有用,但这意味着每当它以黑屏启动时,我都必须进入任务管理器,右键单击列表中的进程,然后选择“结束任务”。多么令人恼火!我尝试了大约 10 次才能够说服它在没有黑屏的情况下正常运行。难怪我在 2013 年用这个 bug 修复折磨自己。

在过去,用户发布了关于这个问题的各种有趣的理论和解决方案。有人将问题归咎于与蓝牙相关的“BTTray.exe”服务。其他人发现在运行 Basilisk 之前使用 HFVExplorer 打开硬盘映像 可以使其工作。另一个人观察到以管理员身份运行它 可以解决问题。兼容模式设置 也是一种常见的解决方法。有人甚至使用安全模式 来绕过它。人们早在 2005 年 就一直在抱怨这个问题。鉴于有这么多不同的解释,并且成功程度各不相同,因此似乎它们都不可能是真正的答案。

对每个人都有效的唯一解决方案是恢复到 2001 年的 Basilisk II 的过时版本,称为“build 142”。这有时被称为 pre-JIT 版本,因为它是在将即时编译器添加到 68k 模拟中以大幅提高性能之前发布的。旧版本工作正常,但它缺少所有现代(当时)的改进,例如 JIT。

无论如何,在 2013 年,我的 Windows 7 计算机也受到了这个问题的影响,并决定尝试修复它。这个消灭 bug 的故事不像上面链接的三个那么史诗,因为 Basilisk II 是开源的。我可以访问所有代码来查看发生了什么。但是,即使有源代码,我也不知道从哪里开始查找。为什么行为会在运行之间随机变化?也许是一个未初始化的变量?为什么现代 Windows 版本更有可能导致它失败?

由于我能够重现失败案例和成功案例,因此我向 Basilisk II 添加了大量的调试跟踪输出。我想看看成功运行和不成功运行之间发生了什么变化。我专注于视频代码。视频实际上是否在内部工作并且无法在窗口中显示,或者更深层的东西是否搞砸了视频和/或导致模拟机器无法启动?

这个迭代测试和调试跟踪过程显示,在失败和成功期间都会定期调用 BasiliskII/src/SDL/video_sdl.cpp 中的 redraw_func(),但只有当视频正常工作时,才会在 video_refresh_window_vosf() 中发现显示是“脏的”并且需要更新。当发生黑屏 bug 时,显示永远不会脏。

当然,这导致我继续向后追踪,试图弄清楚为什么显示没有被标记为脏。我添加了更多检查以查看所有正在运行的代码。我的重大发现最终是,如果出现黑屏,则只调用一次 SDL_monitor_desc::video_open(),但如果视频正常工作,则调用三次。从那里向后推,我发现这一直追溯到 BasiliskII/src/video.cpp 中的 VideoDriverControl()。它在“黑屏”运行期间从未被调用。

VideoDriverControl() 函数很特别。它仅从 68k CPU 模拟器的操作码解析中调用!它连接到 CPU 操作码 0x7119。在这个相同范围内的操作码中,有一个关于磁盘、CD、软盘、显示器、声音、外部文件系统访问等等的大表,从 0x7100 开始。Basilisk II 源代码 将这些标识为“扩展操作码(非法 moveq 形式)”

如果你查看 Motorola M68000 Family Programmer’s Reference Manual,可以肯定的是,0x71xx 是一系列无效的 MOVEQ 指令。位 15-8 为 0x71 使其看起来像一个涉及寄存器 D0 的 MOVEQ,但位 8 应该是 0,所以它是无效的 - 指令格式特别说明它应该是 0。

你可以在你最喜欢的 68000 系列反汇编器中亲自查看这一点。0x7119 无法反汇编,但 0x7019 解码为 MOVQ #25, D0

这个分析的重点是展示 Basilisk II 如何巧妙地使用这个无效的指令范围作为在模拟 CPU 和主机之间进行通信的机制。CPU 模拟查找这些无效的操作码,并调用代码库中的各种函数来处理磁盘、音频、显示器等等。在某些方面,它有点类似于经典 Mac 程序用于与操作系统通信的 A-line 指令机制,只是它只在这个特定的模拟器中有效。

因此,在视频启动成功的过程中,模拟 CPU 在某个时候执行了 0x7119 指令。在出现黑屏的情况下,则没有。换句话说,模拟机器本身是问题的根源。哎呀!

等一下。这说不通。模拟机器如何首先知道调用这样的指令?我甚至没有向 Basilisk II 提供启动盘,那么它怎么可能加载执行 Basilisk II 的自定义 0x7119 指令的驱动程序?

这就是 Basilisk II 与 MAME 等其他模拟器的不同之处。它并没有试图完美地重现库存机器的运行方式。相反,它修补了你提供的 ROM(我使用了 Quadra/LC/Performa 630 ROM),以便它绕过会导致模拟环境中崩溃的事情,并且还注入了自己的代码来告诉模拟机器如何处理视频、音频、键盘和鼠标输入等等。所以这确实有道理。

所有这些都在 BasiliskII/src/rom_patches.cppBasiliskII/src/slot_rom.cpp 中详细说明。特别是,我正在处理的代码部分涉及 InstallSlotROM() 函数,该函数创建一个包含两个驱动程序的 declaration ROM:Display_Video_Apple_Basilisk 和 Network_Ethernet_Apple_BasiliskII。它将此 DeclROM 放在 Mac ROM 的末尾,以便在启动时自动检测到它。

我验证了 InstallSlotROM() 确实被调用了,无论成功还是失败。因此,驱动程序肯定已添加到 ROM 中。问题是由模拟机器内部的某些东西执行不同的操作引起的。当视频正常工作时,Display_Video_Apple_Basilisk 驱动程序正在加载并运行。当出现黑屏时,则不是。此外,通过查看 CPU 跟踪可以明显看出,在黑屏故障期间,模拟机器的其他部分运行良好!它只是没有任何视频。

那么,为什么模拟机器通常无法在较新版本的 Windows 中加载驱动程序?为什么 Windows 的版本对这个有什么影响?这只是一个模拟器,看在上帝的份上。每次运行它时,内部状态不应该相同吗?

这个问题的重大突破来自于我更详细地检查 InstallSlotROM() 函数,并添加更多调试输出以尝试识别差异。我注意到每当发生黑屏问题时,InstallSlotROM() 使用的变量 ROMBaseHost 的值看起来与成功时大不相同。

ROMBaseHost 的成功值| ROMBaseHost 的失败(黑屏)值
---|---
0x04C90000| 0x02970000
0x04C40000| 0x02730000
0x04C80000| 0x02720000
0x04CA0000| 0x02710000
0x04C50000| 0x025C0000

这很奇怪。ROMBaseHost 是 从主机角度来看 模拟机器的 ROM 所在的地址。ROM 的主机地址为什么在模拟机器内部也很重要?这只是巧合吗? (叙述者:不,不是。)

我查看了 在平台特定的 Windows 目录中分配 ROMBaseHost 的代码。首先,它为模拟 RAM 分配空间,然后为 ROM 分配 1MB:

1234567| // Create areas for Mac RAM and ROM``RAMBaseHost = (uint8 *)vm_acquire_mac(RAMSize);``ROMBaseHost = (uint8 *)vm_acquire_mac(0x100000);``if (RAMBaseHost == VM_MAP_FAILED || ROMBaseHost == VM_MAP_FAILED) {``ErrorAlert(STR_NO_MEM_ERR);``QuitEmulator();``}
---|---

vm_acquire_mac() 是一个经过几层的函数,但最终它会调用 VirtualAlloc() 在 Windows 上完成其工作。Unix 端口中的相同代码部分 看起来像这样:

1234567| uint8 *ram_rom_area = (uint8 *)vm_acquire_mac(RAMSize + 0x100000);``if (ram_rom_area == VM_MAP_FAILED) {    ``ErrorAlert(STR_NO_MEM_ERR);``QuitEmulator();``}``RAMBaseHost = ram_rom_area;``ROMBaseHost = RAMBaseHost + RAMSize;
---|---

不同之处在于,此代码同时分配 RAM 和 ROM,而不是通过两个单独的分配调用。为了更好地理解这一点,在我上面列出的所有测试用例中,无论成功还是失败,RAMBaseHost 都在 0x3xxxxxx 范围内。这里有两个例子:

成功| 失败(黑屏)
---|---
RAMBaseHost| 0x03C90000| 0x03B80000
ROMBaseHost| 0x04C90000| 0x02970000

就这么简单吗?主机内存空间中 ROMBaseHost 低于 RAMBaseHost 阻止了模拟计算机加载视频驱动程序?等效的 Unix 代码阻止了这种情况的发生。

事实证明,是的。这就是问题所在。我的修复最终是将 Unix 版本的代码移植到 Windows

我有点紧张,因为单独的 vm_acquire_mac() 分配是 Windows 端口上有意为之的事情,但是当我将它们组合成一个时,黑屏消失了,并且一切都完美运行。

为了更详细地解释修复,对 vm_acquire_mac() 的单独调用用于分配 RAM 和 ROM,这意味着有时从主机角度来看 ROM 的地址低于 RAM,有时高于 RAM。每当 ROM 低于 RAM 时,它都会失败。这就是导致问题如此随机的原因。这也可能很好地解释了为什么较新版本的 Windows 似乎更经常遇到这个问题。我的理论是,在 Vista 左右的某个时候,Windows 的内存分配器的行为发生了变化,并且第二次分配的地址低于第一次的可能性大大提高。实验表明,XP 在运行此代码时通常只是继续向上增加地址。

但是,到底是怎么回事?ROM 的主机地址为什么对开始有什么影响?模拟计算机不是无论如何都有自己的完全独立的地址空间吗?

查看源代码附带的一些文档,你可以看到 Basilisk II 有几种不同的寻址模式。我检查了一下,Windows 版本使用 DIRECT_ADDRESSING:

Emulated CPU, “direct” addressing (EMULATED_68K = 1, DIRECT_ADDRESSING = 1):As in the virtual addressing mode, the 68k processor is emulated with the UAE CPU engine and two memory areas are set up for RAM and ROM. Mac RAM starts at address 0 for the emulated 68k, but it may start at a different address for the host CPU. Besides, the virtual memory areas seen by the emulated 68k are separated by exactly the same amount of bytes as the corresponding memory areas allocated on the host CPU. This means that address translation simply implies the addition of a constant offset (MEMBaseDiff). Therefore, the memory banks are no longer used and the memory access functions are replaced by inline memory accesses.

这意味着主机地址很容易通过减去一个偏移量 (MEMBaseDiff) 转换为模拟机器中的虚拟地址,而 MEMBaseDiff 只是与 RAMBaseHost 相同的值。同样,要从模拟器地址转换为主机地址,你可以改为添加 MEMBaseDiff。这有效地使 RAM 始终映射到虚拟地址 0,而 ROM 最终映射到 ROMBaseHost – RAMBaseHost 的虚拟地址空间。

我发现整个设置非常令人困惑,但我承认我在编写模拟器代码方面没有太多经验。我假设这种设置背后的原因,而不是仅仅使用“if”语句来检查虚拟地址是否在 RAM 或 ROM 内部,与性能有关。我没有花太多时间进一步研究它。我确实注意到没有黑屏 bug 的旧的 pre-JIT 版本没有这种直接寻址模式,这就是它没有这个问题的原因。

让我们考虑一下这种偏移量减法在我上面列出的示例成功和失败场景中的含义。在成功的情况下,RAMBaseHost 为 0x03C90000,而 ROMBaseHost 为 0x04C90000。这意味着虚拟 ROM 地址为 0x04C90000 – 0x03C90000 = 0x01000000。该结果实际上很有意义,因为我已将 Basilisk II 设置为使用 16 MB 的 RAM,因此 Windows 的分配器完全按照你可能期望的方式执行操作,并在 RAM 之后直接分配 ROM。这也是我的补丁保证行为始终在 Basilisk II 的 Windows 版本中的行为。说得通。

另一方面,在失败的情况下,RAMBaseHost 为 0x03B80000,而 ROMBaseHost 为 0x02970000。用于确定虚拟 ROM 地址的减法最终会环绕到 0 以下:0x02970000 – 0x3B80000 = 0xFEDF0000。因此,ROM 被映射到模拟机器内部的非常高的虚拟地址。以下是一个视觉辅助工具,用于显示发生了什么:

这与模拟机器的角度有很大的不同。但是,它仍然没有真正解释 为什么 它在这种情况下失败了。它只是一个地址,对吗?Mac 的 ROM 是可重定位的。谁在乎它是在 0xFEDF0000 而不是 0x01000000?我跟踪了模拟 CPU 指令,找到了它们开始不同的地方。问题出在 ROM 的 Slot Manager 代码中。在失败期间,ROM 的虚拟地址为 0xFExxxxxx,这是插槽 E 的标准插槽空间。另一方面,当它成功时,由于高位为 0,因此它位于插槽 0 中。归根结底是 ROM 不希望将其自身映射到 0xFExxxxxx,因此 Slot Manager 在尝试加载 Basilisk II 放在 ROM 末尾的 DeclROM 时失败了。

基本上,允许 Mac 的 ROM 放置在模拟机器的地址空间中的_任何位置_都是有风险的,并且较新版本的 Windows 恰好以导致模拟 Mac 的 Slot Manager 不喜欢其 ROM 的虚拟地址的方式分配内存。这导致它在应该加载视频驱动程序时放弃。

为了真正确认我已经弄清楚了问题,我修改了 Linux 版本的 Basilisk II 以强制它将 ROM 放在 RAM 以下,就像我在 Windows 版本无法工作时看到的那样。这导致视频每次都在 Linux 中成功失败。有了这个,我相信我已经追踪并永久消除了这个 bug。在我提交修复程序的两天后,我的拉取请求 被合并,并且 Basilisk II 从那时起在 Windows 上运行良好。

有趣的是,这实际上曾经是 Unix 版本中的一个 bug,并且它已经在 2005 年修复了 - 我什至不是第一个追踪到这个问题的人。为 Unix 修复它的人没有将相同的修复程序应用于 Windows 版本。今天回顾这一点,我意识到我在 2013 年在 Windows 端口中修复它时,我忘记更新相应的取消分配代码,使其只是对 vm_release() 的单个调用。哎呀!这个小错误可能无害,但我应该提交另一个 PR 来修复它以保持一致性。

TL;DR: 像往常一样,这个与较新 Windows 版本的兼容性问题不是 Windows 的