在我们的自定义CPU上运行Doom并走红
摘要
作者描述了在逻辑门级别设计自定义CPU、集成带有缓存的DDR3内存,并成功在FPGA上运行Doom的经历,该经历随后走红网络。
暂无内容
查看缓存全文
缓存时间: 2026/07/21 06:36
# 在自制CPU上运行DOOM并走红网络
来源:https://www.armaangomes.com/blogs/doom
DOOM\# DOOM是id Software于1993年发布的一款电子游戏。它是游戏界的一场革命,风靡全球,并定义了现代第一人称射击游戏。它的流行催生了"万物皆可运行DOOM"的说法。为了证明这一点,DOOM几乎被移植到了所有设备上,从微控制器到烤面包机,甚至细菌。
两周前,我们成功地在自制的CPU上(爬行般)运行了DOOM(之后发布的视频获得了数百万次观看)。老实说,我现在还不敢相信。但我们到底做了什么?嗯,我们在逻辑门级别设计了一款定制CPU,将其连接到外设,将DOOM的源代码适配到我们的机器上,并部署到FPGA上实时运行。在运行DOOM之前,我们只运行过自己编写的简单程序,比如Pong和Mandelbrot集。现在我们可以运行完整的商业游戏了,但达到这一步可谓荆棘满途。
## 需求(https://www.armaangomes.com/blogs/doom#the-requirements)
从之前文章(https://www.outercloud.dev/blogs/riscv-2/)中的流水线设计出发,我们将目光投向了更复杂的程序。Pong很棒,但它是70年代的产物。我们想跳到90年代。然而,摆在我们面前有两大难题:内存和速度。更大的程序需要更大的内存,而我们的设计只能利用FPGA的BRAM,容量不到1兆字节。DOOM的基本共享版(`doom1.wad`)就有14兆字节。这还不包括运行程序所需的内存,仅仅是存储它。第二个障碍是速度。虽然DOOM在现代PC上轻松运行,但对于我们的CPU来说却是一大挑战。我们只是需要更快。
因此,Liam和我决定各自攻克一个难题。Liam目前正在为基础乱序执行做准备,这将实现更高的并行度和流水线填充技巧。我则负责内存集成。听起来可能很简单:只需连接一个额外的内存芯片,但现实要复杂得多。
## 内存集成(https://www.armaangomes.com/blogs/doom#memory-integration)
在我们最初的CPU设计中,内存非常简洁。FPGA BRAM延迟为1个周期,接口非常简单。这种一致性意味着我们的流水线处理器从不因内存而停顿,因为一致的延迟直接融入了流水线。此外,BRAM是颗粒化的,我们可以逐字读取和编辑内存。
而DDR3内存则速度慢、延迟可变、总线宽度大。这使得内存操作更复杂、更不可预测。而且速度慢得多。如果每个内存操作都发送到DDR3内存,CPU会慢如龟爬。这时就需要缓存了。程序不会同时使用所有内存,因此缓存将活跃的内存区域存储在BRAM中,以提高访问速度。一个优化良好的缓存几乎可以消除DDR3带来的额外延迟。
## 设计(https://www.armaangomes.com/blogs/doom#the-design)
这个版本的CPU采用了较为标准的5级流水线:指令取指、译码、寄存器读取、执行、写回。此外,该设计将内存操作抽象为一个统一接口,以简化核心流水线阶段。
### 核心流水线(https://www.armaangomes.com/blogs/doom#core-pipeline)
`取指`阶段跟踪程序指针,并通过指令缓存(ICache)从内存中获取正确的指令。与之前的设计不同,它内部处理重定向和停顿。随着DDR内存的加入,重定向变得复杂得多。之前内存延迟为一个周期,因此你可以每周期取一条指令,并在重定向请求到来的那个周期切换内存请求地址。
然而,在新设计中,如果取指阶段收到重定向请求,内存可能已经在处理一个请求。我们现在需要跟踪内存的下一个响应是否无效,然后请求正确的地址。解决了这个问题后,取指阶段工作得完美无缺。
`译码`阶段很简单。它接收32位指令,将其分解成各个部分,并保存到一个指令包中,向前发送到流水线。这个阶段也有一些独特的问题,但我会在后面的章节中讨论。
`读取`阶段经过了重大修改。之前寄存器文件的一个问题是使用了组合逻辑读取,拖慢了CPU速度。这个版本采用了流水线读取,增加了一个周期的延迟,但缩短了关键路径延迟。另一个改动是`读后写`(`RAW`)冒险处理。`RAW`冒险是指在你读取寄存器时,有一个待处理的写入尚未完成,导致你得到错误的数据。之前的读取阶段内部跟踪最近通过的几次寄存器写入。这在逻辑上很高效,但很脆弱,并不总是有效。更严格的测试显示,某些特殊情况会导致它无法检测到冒险。新版本依赖于由周围核心计算并送入阶段的`寄存器使用映射`(`RUM`)。`RUM`是一个32位的映射,跟踪每个寄存器是否在使用中。这可以自然地扩展到可变长度的流水线,无需使用任何魔法数字。使用`RUM`,读取阶段决定是必须引发冒险标志并停顿前面阶段,还是让指令通过。
`执行`阶段决定如何处理每条指令。它将指令的部分路由到各个组件,调用ALU进行算术运算、解析分支、与内存通信。当它解析跳转时,会向后发送冲刷信号以清除错误的流水线阶段。当遇到内存指令时,它会进入一个特定的内存等待阶段,直到内存响应才继续。
`写回`阶段只是将值写回到寄存器文件。
### 内存处理(https://www.armaangomes.com/blogs/doom#memory-handling)
核心流水线说实话相当标准,真正有趣的是内存部分。在理想的流水线CPU中,指令取指阶段每周期取一个新字,执行阶段每周期获取新数据。相比之下,我们FPGA上的DDR3每30-60个周期只能返回2个字。这比我们期望的吞吐量慢得多,所以我们需要缓存。实际上我们需要两个缓存。取指和执行阶段可能在一个周期内请求两个不同的地址,因此为了同时服务于两者,我们需要独立运行的指令缓存(`ICache`)和数据缓存(`DCache`)。
#### 缓存(https://www.armaangomes.com/blogs/doom#cache)
`ICache`和`DCache`相对简单,彼此非常相似。实际上,`ICache`使用了与`DCache`完全相同的底层代码,只是去掉了写入逻辑。两个缓存都是简单的直接映射缓存。在当前版本中,每个缓存使用2048个缓存行,每行4个字。
当接收到内存请求时,缓存会隔离字地址(通过去掉最低两位),然后计算缓存索引。缓存索引是字地址的最低10位。然后它从BRAM中取出该缓存行。缓存行由三个组件组成:数据、标签、状态。数据是缓存跟踪的4个实际内存字。标签是字地址的高位部分,防止缓存别名。别名是指多个地址指向同一个缓存索引,我们需要确保不混淆它们。2位状态表示有效和脏信号。有效表示该行数据是实际数据而非初始化随机值,脏信号让我们知道CPU自该行从内存取出后是否修改过它。如果取出的缓存行有效且标签匹配,缓存会执行简单的读或读-改-写操作,无需发出内存请求。
然而,如果标签不匹配,缓存必须引用DDR3内存。有效的缓存行有两种可能状态:干净和脏。如果行是干净的,我们可以直接从内存读取新的缓存行,丢弃当前数据。如果行是脏的,缓存必须先写回修改过的行,然后请求新数据。考虑到DDR3延迟可能是30-100个周期,而缓存命中仅需2个周期,这非常慢。
缓存地址解码器直接映射·1路
缓存行
每行字数
地址宽度
标签0x0000218b
索引0x25C10b
字偏移12b
字节偏移02b
缓存行1024行·每行16 B
坦白说,这是一个低效的设计。更好的缓存会使用更长的缓存行来提高存储密度和空间重用,但4字行宽的选择是为了简化与原始FPGA板上128位宽MIG接口的连接。后来我们换了一块不同的板子,所以目前这只是一个遗留的低效问题。
#### 仲裁(https://www.armaangomes.com/blogs/doom#arbitration)
现在,你可能注意到了另一个问题。我们使用两个缓存来支持同时处理两个内存请求,但这只是把问题往后推了。如果ICache和DCache同时发出内存请求怎么办?在6.191(https://www.armaangomes.com/blogs/c191w/)课程中,这是通过为每个缓存配备一个内存芯片来解决的,但我们的板子只有一个芯片,所以这是不可能的。这时就需要内存仲裁器了。它对两个缓存假装是内存接口,通常只是将请求传递给实际的DDR3内存,但当另一个请求已经在处理时,它会假装内存已收到请求,但实际上只是将其排队,等到内存空闲时再发送。当然,这可能导致死锁,即一个缓存饿死了另一个缓存的内存访问。解决办法是优先考虑DCache,因为它在流水线中更靠后,最终会清理并完成内存请求。
#### 底层细节(https://www.armaangomes.com/blogs/doom#the-low-level)
有了上述所有组件,CPU在模拟内存下工作完美,但在真实硬件上却无法工作。首先,DDR3内存接口非常复杂,需要非常严格的时序和控制。这时Xilinx的内存接口生成器(MIG)就派上用场了,它将极其复杂的接口变成了仅仅复杂的接口。第二个问题是MIG运行在自己的时钟域上,所以如果我们将CPU直接连接到MIG,会遇到无法解决的时序问题并崩溃。第三个问题是我无法让原始FPGA板(带128位总线的那种)上的MIG工作,所以我借了我朋友Ryan Tang(https://turtlely.github.io/)的板子,上面有一个较小的DDR3芯片,带64位总线。
这些问题通过一系列时钟域交叉(CDC)、FIFO和协议包装器解决,具体细节太痛苦就不展开了,但可以在Github上找到。最重要的是,一切都能工作。
有了新内存,我们可以运行《蜜蜂总动员》了。### 接口与I/O(https://www.armaangomes.com/blogs/doom#interfacing-and-i-o)
在移植DOOM之前,我们还需要一些硬件。CPU可以工作,但没有办法与外界通信。我们需要外设:显示输出、硬件定时器、调试输出、键盘输入。我们决定对这些外设使用内存映射I/O(MMIO)。VGA控制器已经构建好了,但我将其扩展到了12位色,并连接到HDMI端口。硬件定时器也很简单。它只是以微秒为单位跟踪时间,并保存到内存中的一个字里。调试输出稍微有点棘手。简单版本只是将UART发送器连接到一个可写内存值。但是,如果CPU连续打印许多字符,就会丢失一些字符,导致输出乱码。因此,我使用了一个FIFO缓冲来防止溢出。键盘输入更糟糕。我最初的计划是与Ryan借给我的Urbana FPGA板上的USB主控芯片接口。然而,在编写了SPI驱动程序后,我很困惑地看到SPI总线上没有任何数据。似乎板上的USB端口不提供电源,因此无法给键盘供电。于是,我连接了一个UART接收器,并将按键从我的笔记本电脑转发到FPGA。虽然不完美,但能工作。这凑齐了我们运行DOOM所需的一切。
## 移植DOOM与调试(https://www.armaangomes.com/blogs/doom#porting-doom-and-debugging)
将DOOM移植到新芯片上颇具挑战性。你必须找到加载程序、访问正确外设、输入输出以及通常的修补工作来应对平台差异。将DOOM移植到自制的CPU上更具挑战性,因为当它崩溃时,你不知道是代码错了,还是你的CPU有问题。好消息是,Ozkl创建了doomgeneric(https://github.com/ozkl/doomgeneric),简化了移植过程。尽管有简化,我们还是遇到了许多挑战。我的朋友Liam(https://outercloud.dev/)主导了移植过程,可能会在他的博客上分享他的见解。在众多挑战中,有文件系统问题、渲染难题,以及`printf`的各种问题,以至于他最终自己写了一个版本。最奇怪的问题是,有时`printf`能工作,有时却不能。
首次渲染出的画面许多缺陷暴露了CPU的错误。例如,我们经常看到系统陷入跨越数百条指令和跳转的无限循环。其他时候,它似乎随机跳转,跳转到毫无意义的地方。然而,一次性调试整个DOOM程序是不可行的,所以我们将其分解成部分,逐步测试更复杂的程序。有时缓存仲裁器和MIG CDC会遇到问题并丢失写回请求。有时立即数计算错误,有时DCache会覆盖指令数据。其中一些问题源于我们最初的单周期处理器(https://www.outercloud.dev/blogs/riscv-1/),直到现在才被遇到。不过最终,我们在模拟中让一切运转起来。
DOOM在模拟中运行然而,在硬件上它就是不行。我们编写的任何程序都能工作,但DOOM就是不行。它似乎会在随机位置停止,跳转到随机地址,甚至开始读取半字指令。这让我困惑了很长时间,完全说不通。一定是MIG模拟的问题,因为那是唯一无法轻易模拟的部分,但我编写的每个内存测试在模拟和硬件中都能完美运行。在苦思冥想数小时后,我决定修改模拟,在加载程序前将DDR3内存初始化为随机值。这破坏了DOOM,但没有破坏其他程序。似乎DOOM假设未初始化的内存是零,而硬件中并非如此。在做了几次链接和汇编修复后,DOOM终于成功加载到硬件上。
之后,过程相对顺利,我们让DOOM以0.7 FPS的漂亮帧率运行,实际上应该是秒每帧。这本来是我计划结束这篇博客的地方:我们完成了既定目标。但我无法满足于0.7 FPS。例如,我坐下来打算学习线性代数,结果却开始优化CPU。我可能上瘾了。
## 通往30 FPS之路(https://www.armaangomes.com/blogs/doom#the-road-to-30-fps)
1 FPS的DOOM相当乏味。令人印象深刻,但不可玩。我们想要更流畅的体验。
相似文章
构建Clang后端并将Doom移植到我的自定义字节码虚拟机
作者复活了他的自定义字节码虚拟机(UVM),并借助AI解析文本LLVM IR构建了一个Clang后端,成功将Doom移植到该虚拟机上。
为什么fastDoom这么快
关于fastDOOM移植版相比原始Doom可执行文件实现显著性能提升的详细技术分析,涵盖了Doom源代码传承历史及具体优化技巧。
z386:基于原始微码构建的开源80386
本文详述了z386,一款基于原始Intel微码构建的开源FPGA 80386 CPU。它能引导DOS 6/7、运行保护模式程序,并玩经典游戏如Doom,既是一种教育性重构,也是一个可用的FPGA CPU。
56,000行DOOM代码,用我自创的语言编写
作者构建了一种名为bet的玩笑编程语言,通过LLVM编译,采用基于区域的内存管理,并且成功运行了完整的DOOM游戏(56,000行代码),无需代码审查,仅依赖测试。
在E. coli细胞上运行‘Doom’……非常非常缓慢
MIT研究员Lauren Ramlan发表了一篇论文,证明经典游戏Doom可以在由E. coli细胞制成的显示器上运行,但帧率极低,完成一次通关需要几个世纪。