我最喜欢的 PowerMac 是一台 Nintendo Wii
把经典 Mac OS 移植到 Wii 和 GameCube 上,从 OpenBIOS 与 Trampoline 一路补到伪造的 SCSI 驱动,最后第一次进了桌面。
中文
复制

(本文为 Rairii 撰写的客座文章)

今天,我的一个项目迎来了一个不错的里程碑。我一直在做的工作,是把经典 Mac OS 移植到搭载我戏称为 ArtX 芯片组的 PowerPC 系统上(Nintendo GameCube、Wii,最终还有 Wii U;GameCube 的 Flipper 集成芯片组基本就是 ArtX 设计的,ATI 收购这家公司时它实际上已经完工,而 Wii 的 Vegas(以及后来的修订版 Bollywood)和 Wii U 的 Latte 基本上就是它的直系后继)。这项工作第一次跑到了桌面上,而且很可能是自 CHRP 兼容机时代以来,第一次有 Apple 之外的人把经典 Mac OS 带到新系统的桌面上。尽管距离公开发布还差得远,我还是想聊聊这个项目到目前为止遇到的挑战。
但首先,为什么要费劲把经典 Mac OS 移植到这套硬件上?当然,我之前已经移植过 NT,做那项工作的过程中我搞到了几台 PowerPC Mac 笔记本,后来也把 NT 移植了上去,一个移植的进展会推动另一个,如此往复。这让我熟悉了经典 Mac OS,也让我爱上了它的“jank”(姑且这么说)。一层叠一层的 hack,一路追溯到 1980 年代,我绝对会管这叫“jank”。更别提 TBXI 里 m68k 那部分叫“SuperMario”,整个 PowerPC 时代都是如此。我很好奇,1990 年代初是不是有位开发者是它的粉丝?
另外声明一下,这个项目我是在 2025-06-22 开始的(那天我 fork 了上游 openbios),没有使用任何编码 agent 或 LLM,所有编码、逆向和调试工作都是我自己做的。我做这些项目是为了好玩、为了学习,如果用“AI”,那就不好玩了,我也学不到东西。
先从最初的启动说起。“New World”经典 Mac OS 以一个叫“Trampoline”的引导程序起步,它运行在 Open Firmware 环境上。所以我需要移植 OpenBIOS。幸运的是,差不多同一时间,Goldfish64 开始了他的 Mac OS X 移植到 Wii 的工作,他做了一个 OpenBIOS 移植,我可以从中取用一些驱动,不过出于种种原因,我并不想直接拿他的移植来用(它只针对 Wii/Wii U,而我还想支持 GameCube)。我还决定复用一些现有代码,让某些驱动的开发更省事(具体来说是显示驱动),于是把我现有的 NT ARC 固件加载器改成加载大端 ELF。
我需要少量磁盘驱动来完成最初的 bringup,于是决定移植我写的两个驱动,分别对应 EXI 总线(这只是 Nintendo 和 Macronix 对 SPI 的叫法)上的设备:“SD Gecko”(就是一个直通的 GameCube 记忆卡插槽转接器,让 SD 卡走 SPI),以及“EXI-IDE”(我的驱动其实并不符合真实硬件,而是对应我在自己的 Dolphin 分支里那套不精确的模拟,好在模拟器里测试,也因此能用上模拟器提供的底层调试支持)。
我不确定该用哪种磁盘上的格式(这两个驱动在我早先的 NT 移植版里完全靠 FAT 文件系统上的磁盘镜像运行,因为必须兼容现有的 homebrew 加载器(它们从 FAT 文件系统加载可执行文件),但这样自然就受限于 FAT32 的 4GB 镜像上限,而后期版本的经典 Mac OS 需要更大的空间),所以最初测试时,我直接把“Mac OS ROM”这个 TBXI 文件放到 FAT 文件系统上,命名为 \ppc\bootinfo.txt,让 OpenBIOS 在启动时加载它。
这样就能把 Trampoline 跑起来了。我决定尽量把尽可能多的补丁在接近运行时的阶段打到镜像上,这样最终就能用上基本未经改动的现有 CD 镜像。
遇到的第一个问题是,Trampoline 对物理地址空间布局做了一些假设,而在这套系统上并不成立。缓冲区的物理地址会被算成一个无效地址,于是我把它改成保证有效的地址,同时也改了对应的、为内核建立内存块列表/页表项的代码。
另外,Trampoline 还假设从 0x8000_0000 开始的物理地址对应内存映射 I/O(在它原本运行的那些系统上,这段地址对应 PCI 总线地址空间)。这里并非如此,实际上在 Wii U 上,这个地址对应的是 RAM 的一部分。还有另一个假设在这些系统上也不成立。物理地址空间中 RAM 各区域之间存在空洞,其中一些空洞被内存映射 I/O 部分填充。
我的修法是在内核内存块列表中修改这段 MMIO 映射的初始化,把它改成从物理地址 0x0100_0000(位于某段 RAM 中间)到虚拟地址 0xE000_0000 的映射,长度为 0x1000_0000。这样正好覆盖整个 MMIO,外加一段 RAM 的末尾,以及(对 GameCube 之后的机型)另一段 RAM 的开头。(以后我可能会改,因为在 Wii U 上,物理地址范围 0xD000_0000-0xEFFF_FFFF 映射到 Radeon R7xx GPU 中一块叫“HDP”的区域,可以用它把物理内存的某个视图按特定的 swizzling 和/或字节序交换映射出来,让某种 GPU 专有格式的纹理看起来像线性帧缓冲。要不要用这块,取决于我有没有这个需要。)Trampoline 里初始化映射的函数中途改过,我最后是检测出旧版本、再 hook 这个函数来绕过去(早期版本用 16 位页数,后期版本用 32 位)。另外,旧版 Trampoline 会无条件为 0x8000_0000 以上的物理内存建立 1:1 映射,这些必须去掉,因为它们和前面建立的映射冲突。
接下来是一堆假定特定硬件的补丁。包括屏蔽掉某些 bitflag 的设置,这些 flag 对应所有 PowerPC Macintosh 硬件上都有的一个微控制器;尝试用某个硬件定时器重新计算总线频率;以及针对某个特定中断控制器设备的一批寄存器写入和修正(它是小端的,因为挂在 PCI 总线上,而这里自然没有这个设备)。
最后,跳入已加载内核初始化代码的那次调用也被 hook 了,以便所有东西都载入内存之后再打后续补丁。
此外,设备树也得改一改,弄成 Trampoline 能接受的样子。中断控制器伪造成它认识的型号之一(就是上面那个小端的,因为在寄存器层面它最接近所有 ArtX 芯片组上都有的 Flipper 中断控制器,只不过在 Wii U 上做了些改动以支持多出来的 PowerPC 核心)。设备树里帧缓冲的地址改成未缓存的虚拟地址(在 0xExxx_xxxx 映射里),最后再往设备树里加一个伪造的 Mac99 兼容电源管理设备,好让 Trampoline 为它准备一个电源管理驱动,之后可以替换掉。
如前所述,内核初始化被挂钩,以便打上额外的补丁。内核这边只需要打一个补丁。内核包含多个中断控制器驱动,被选中的那个会被修改成与 Flipper 中断控制器匹配,同时牺牲一个用不到的驱动来腾出空间。这里的主要区别在于,被打了补丁的那个驱动所对应的中断控制器有一个 legacy 模式,只要中断状态发生变化(而不只是有中断被触发)就会发出 PowerPC 中断,因此需要绕开这一行为,先在内核里屏蔽被触发的中断,之后再在驱动的中断处理程序里解除屏蔽。
其他补丁打在“SuperMario”的 m68k 代码和 PowerPC 原生可执行文件里。m68k 代码中有若干条代码路径会直接读写原生中断控制器的寄存器,这些都需要打补丁。用于启用和禁用音频中断的函数指针需要替换成 stub(那张表里本来就有一项指向 stub,正好用得上)。启动早期会调用一个函数来初始化 ADB 和电源管理微控制器,它不检查硬件是否真的存在,硬件不存在时就会覆写低内存的一部分。还有一个初始化硬件中断的函数,因为复制粘贴时的疏忽带了个 bug。原本的意图是在串口 2 的硬件不存在时跳过初始化串口 2 中断的代码,但开发者从串口 1 复制代码时忘了改跳转标签,结果串口 2 不存在时就会陷入死循环。至于原生代码,还有更多读写中断控制器寄存器的代码需要正确打补丁。USBShimKeyboard 驱动的初始化代码没有处理 USB 控制器不存在的情况,此时会在 m68k 模拟器内部调用一个 NULL 指针。上面提到的电源管理驱动在这里也被整个替换掉了(目前我用的是 BlueBox 里的 stub 驱动,之后打算自己写一个,以正确支持关机和重启)。
这些做完之后,TBXI 已经能启动到闪烁的软驱图标了。随后我写了一个显示驱动(感谢 Elliott Nunn 的 virtio 驱动,帮了大忙),终于能正常看到画面;接着又写了 GameCube 手柄端口的驱动(从我为同一硬件写的 NT 驱动移植过来),这样不用 USB 也能移动鼠标光标。
到这一步,我开始实现块 IO 驱动(针对 EXI 设备),结果卡在了磁盘格式该怎么布局、驱动该怎么工作这两个问题上。后来 Mac OS for CHRP 被 dump 出来了,我就跑偏去给后续的 TBXI 镜像打补丁让它能启动,想着在 GameCube 上跑 7.x 也许有用,毕竟这台机器的内存连 8.0 的最低要求都满足不了。再之后又被别的项目带走了。差不多一年后我回来继续,在 OpenBIOS 里实现了我想要的磁盘格式,并决定写一个伪造 SCSI 的驱动(有些驱动就是这么干的,最典型的是经典 Mac OS 上各种 SATA 控制器的驱动)。写这个假 SCSI 驱动时也碰到几个问题。我得再给 m68k 代码加一个补丁,强制以较新的类型加载磁盘驱动(没错,就是从磁盘本身加载的驱动,本质上是在某个分区块设备之上实现真正的磁盘设备),而不是用旧类型(旧类型在第一批 PowerPC 机器发布时其实就已经废弃了,而且我的假 SCSI 驱动根本不支持旧式调用,这大概就是按旧类型加载它会崩溃的原因)。另外还发现一个有意思的怪癖。光盘驱动(同样是从块设备本身加载的)会在 2048 或 2352 字节扇区的块设备上模拟 512 字节扇区,做法是发起一次读调用、设置 3 个 scatter-gather 缓冲区,但它从不检查底层 SCSI 驱动是否真的支持 scatter-gather,所以我只好在读取路径上把这项支持实现出来。
不过我还是看到一个闪烁的软盘图标。调试之后我弄清了被拒绝的是什么。系统调用了一个 PowerPC 函数 StartLib!GetStartupDevice 来获取启动设备,这个函数对设备树做了一些假设,还假设存在某种需要单独处理的内置 SCSI 设备。第一个问题不难解决(在我的驱动初始化代码里修正设备树),第二个问题更有意思,我得钻进内部结构里,在三个不同的地方(包括设备树)修正同一个索引,确保它非零,好让正确的代码路径被执行。
做完这些,我第一次看到了笑脸 Mac 图标,但随后又被 HFS 引导扇区拒绝了(某处出错并返回了)。结果这是我自己的驱动里的 bug。我当时在 dolphin 里用一个临时拼凑的 EXI-IDE 设备调试;IDE 设备在 PIO 模式下读写数据以 16 位字为单位,而我没料到代码会收到一个完全未对齐的奇数缓冲区地址(低 4 位等于 1),所以我只好修改底层驱动,遇到这种地址时干脆不尝试 EXI DMA。这个 bug 的表现是有一个字节实际上没被写出去,后面的数据整体偏移了一个字节;好在它发生在读取资源分支头部的时候,失败的是对这批数据的有效性检查,调试起来容易得多(我最后把 RAM dump 出来,和我所用磁盘镜像里的预期数据做了对比)。
之后,Mac OS 9.2.2 在我那棵改得面目全非的 Dolphin 代码树里开始启动了,非常慢。过了一个小时它才终于崩溃,于是我又试了 7.6,同样崩溃,但崩得更早。我意识到它多半是在尝试加载 MacsBug 时崩溃的,就把它删掉了,大约 20 分钟后,我盯着桌面了。

第二天早上我在真机上做了测试(通过 SPI 访问 SD 卡)。基本上 40 秒内就启动进了 7.6,而这本该是 SD 卡里最慢的访问方式!不过我之前的经验是为 SPI 访问 SD 卡写 NT 驱动,那或许更像个特例。看这个视频,里面用这种块设备在 GameCube 上安装 NT,注意 NT 文本安装阶段的文件复制被加速了 64 倍,它非常慢。这里的区别在于,执行传输时我可以独占整个 CPU,事实上也没有别的选择,如果我返回一个表示传输是异步的值,调用方就会进入循环忙等中断,直到传输完成(不过这个忙等确实会检查系统上存在的所有控制器)。
后来我加了个临时拼凑的崩溃处理程序,想用 MacsBug 调试到底出了什么问题……结果发现是模拟错误,在真机上加载 MacsBug 并不会崩溃。接着我查明 Mac OS 9.2.2 启动时崩溃的原因是 Open Transport,把它从 Extensions 文件夹里移除后就能正常启动了。我猜是 Open Transport 处理不了完全没有串口或网络设备的情况。
如我所说,我的移植离发布还差得远。接下来要做的是把 Wii 的 SD 控制器跑起来,然后是 USB 控制器(这方面我已经做过一些逆向工作,我知道现在有个 vibe coding 出来的 EHCI 驱动,那大概只能对逆向工作有帮助),再然后把移植带到 Wii U 上。某个时候我还想研究一下补丁,至少突破一部分内存限制。