Synth Daily

在自制 CPU 上运行《毁灭战士》并走红网络

这篇内容讲述了从零开始设计一款自定义 CPU,将其部署在 FPGA 上,并成功运行 1993 年经典游戏《毁灭战士》的整个过程。项目的主要挑战在于解决内存容量和处理速度的瓶颈,这通过集成大容量但较慢的 DDR3 内存与一套自定义的缓存系统得以解决。整个过程充满了艰巨的调试工作,因为很难区分是软件代码错误还是自定义 CPU 的硬件缺陷。最终,通过一系列硬件和软件优化,团队成功将游戏性能从最初几乎无法运行的 0.7 FPS 提升到了流畅可玩的 15-20 FPS。

挑战:在自制 CPU 上运行《毁灭战士》

《毁灭战士》(DOOM)是 1993 年发布的一款革命性游戏,它定义了现代第一人称射击游戏。为了证明“任何设备都能运行毁灭战士”这句名言,我们决定在我们从零开始构建的 CPU 上运行它。

这个项目远比我们之前制作的《乓》(Pong)等简单程序复杂。具体来说,我们完成了以下工作:

  • 逻辑门级别设计了一款自定义 CPU。
  • 为 CPU 连接了显示、输入等外围设备。
  • 修改了《毁灭战士》的源代码以适应我们的机器。
  • 将整个系统部署到 FPGA(现场可编程门阵列)上实时运行。

技术障碍:内存与速度

要运行像《毁灭战士》这样的大型程序,我们面临两个核心问题:内存速度

  • 内存不足:我们最初的设计只能使用 FPGA 内部的 BRAM,其容量不足 1MB。而《毁灭战士》仅游戏文件(doom1.wad)就高达 14MB,更不用说运行所需的额外内存了。
  • 速度过慢:虽然现代 PC 运行《毁灭战士》轻而易举,但对于我们的简易 CPU 来说,它是一个沉重的负担。

为了解决这些问题,我们决定集成更大容量的 DDR3 内存。但这带来了新的复杂性,因为 DDR3 内存相比 FPGA 的内部 BRAM 速度慢得多,延迟也不稳定。

CPU 设计:核心流水线

为了应对新的挑战,我们设计了一个相对标准的 5 级流水线 CPU,它将内存操作抽象成一个统一的接口,以简化核心设计。

  • 指令获取 (Instruction Fetch):从内存中获取指令,并处理因 DDR 内存延迟带来的跳转和停顿问题。
  • 解码 (Decode):将 32 位指令分解成不同部分。
  • 寄存器读取 (Register Read):改进了读取方式,通过流水线化读取降低了延迟。同时引入了一个寄存器使用图 (RUM) 来更精确地处理 读后写 (RAW) 风险,避免读取到错误数据。
  • 执行 (Execute):处理算术运算、分支跳转和内存访问。当遇到内存指令时,它会进入一个专门的等待阶段,直到内存操作完成。
  • 写回 (Writeback):将计算结果写回寄存器。

内存解决方案:缓存与仲裁

为了弥补 CPU 核心与慢速 DDR3 内存之间的巨大速度鸿沟,我们引入了缓存机制。

DDR3 内存的延迟可能在 30-100 个时钟周期,而一次缓存命中的延迟仅为 2 个周期。一个设计良好的缓存几乎可以消除 DDR3 带来的额外延迟。

我们设计了两个独立的缓存:

  • 指令缓存 (ICache):用于指令获取。
  • 数据缓存 (DCache):用于数据读写。

这两个缓存都是简单的单路直接映射缓存。当 CPU 请求一个内存地址时,缓存会检查该地址的数据是否已在高速 BRAM 中。如果命中,则快速返回数据;如果未命中,则需要从慢速的 DDR3 内存中加载数据,如果缓存中的数据被修改过(即“脏”数据),还需先将其写回内存。

由于两个缓存可能同时请求访问 DDR3 内存,我们还设计了一个内存仲裁器。它负责处理冲突,通过优先满足 DCache 的请求来避免流水线后端的阻塞和死锁。

硬件接口与外设

为了让 CPU 与外部世界交互,我们通过内存映射 I/O (MMIO) 的方式连接了多个外设:

  • 显示输出:将原有的 VGA 控制器扩展至 12 位色深,并连接到 HDMI 端口。
  • 硬件计时器:用于跟踪时间。
  • 调试输出:通过 UART 接口向电脑发送调试信息,并使用 FIFO 缓冲区防止数据丢失。
  • 键盘输入:由于板载 USB 接口无法供电,我们最终通过 UART 将笔记本电脑的按键转发给 FPGA,作为一种临时的解决方案。

移植与调试的漫长过程

将《毁灭战士》移植到一个全新的平台上本身就充满挑战,更何况这个平台是我们自己设计的。

移植《毁灭战士》到一个定制 CPU 上更具挑战性,因为当它崩溃时,你不知道是代码错了,还是你的 CPU 有问题。

调试过程非常痛苦。我们遇到了各种奇怪的问题,例如系统陷入无限循环、程序跳转到无意义的地址等。这些问题最终暴露了我们从第一版单周期处理器开始就一直存在的硬件设计缺陷。

一个关键的突破是,我们发现《毁灭战士》的程序假设未初始化的内存值为零,但在真实硬件上,这些值是随机的。我们在仿真环境中模拟了这种情况,最终定位并修复了这个问题,成功在硬件上加载了游戏。

通往 30 FPS 之路:性能优化

初次在硬件上运行时,游戏的帧率仅有 0.7 FPS,基本上是幻灯片。为了让它变得“可玩”,我们进行了一系列优化。

  • 提高时钟频率:从 100MHz 提升到 125MHz。
  • 优化指令获取:修复了获取阶段的低效设计,使性能提升超过 300%,达到约 2.5 FPS
  • 添加新指令:实现了乘法指令,将性能提升至 3.5 FPS
  • 优化内存访问:通过直接写入 VGA 显存和流水化缓存读取,将性能提升至 6.7 FPS
  • 启用编译器优化:这是最关键的一步。最初,启用优化(-O2)会导致游戏崩溃。经过调试,我们发现是因为没有将硬件计时器的内存地址标记为 volatile,导致编译器错误地优化掉了相关代码。修复此问题后,游戏性能飙升至 15-20 FPS

总结与未来展望

将《毁灭战士》移植到自制 CPU 上的经历虽然有些“创伤”,但也让我们学到了大量关于内存、缓存以及大型系统调试的知识。有趣的是,一个 9 秒的运行视频在网上获得了数百万的观看量。

我们未来的计划是实现乱序执行 (out-of-order processing) 和更优的内存访问模式,目标是达到稳定的 30 FPS。在那之后,我们或许会尝试移植《雷神之锤 2》(Quake 2)。