如何制作NES模拟器

如何制作NES模拟器

前言

大约是一周半前,女朋友说她想要练习写Java。我想了一下,觉得要练一种编程语言,不如就用那种语言写点东西,在实践中学到这种语言的用法。于是我就提出,不如做一个NES模拟器吧。既练习了Java,又复习了一些底层相关的课程,又可以用来玩,岂不是一举三得?她欣然接受。然而,我没想到的是,编写模拟器并非如此简单,其中的坑非常多。

收集文档

毕竟是20多年前的主机,NES的相关资料并不难找,在网上搜一搜就能得到许多资料。我最终看得最多的是nesdev上的wiki,其中详细介绍了NES的各种硬件和他们的特性。虽然还是有一些语焉不详的地方,不过其实影响不大,可以用知识(脑补)来补足。

并不是有了这些资料就可以开始制作了,因为有可能发现根本看不懂。而如果学习过操作系统,汇编,计算机体系结构等课程的话,就会发现,PC和NES的主要硬件都是相通的,都存在中断,内存映射等概念。如果学过数字电路,就会了解信号上升沿触发中断等设计。所以,如果看不懂,不妨补一补以上课程。

加载ROM

现在玩游戏都是靠网上下载的ROM文件,其中大部分是NES格式的,这个格式有16字节的头部和其余的数据部分。根据nesdev上的资料,我们可以实现一个NesLoader

这部分并不需要硬件知识,只要了解文件操作和文件格式,就可以轻松载入ROM。

此时可以测试载入一个ROM,看它的Mapper是什么,最好留用一些Mapper0的ROM,备之后测试用。

实现CPU

从这个阶段开始就要进入模拟硬件的时间了。我首先从最熟悉的CPU开始,CPU内有若干寄存器,其中包括程序计数器PC,CPU每次从PC指向的内存中取出一条指令,执行,然后更新PC到下一条指令。执行指令需要要若干个周期,CPU每秒可运行的周期数一定,和CPU频率相等。所以,我初步设计出CPU为:

其中Memory为内存:

内存的设计,要考虑到内存映射和硬件寄存器这回事。我在实现时,也设计了多种不同的内存类。

之后就是苦力活了,实现CPU的各个方法,要对每一种指令编写其功能实现。取指和实现的方法可以有多种,不再详述。我自己是用了一个大switch语句来完成。值得注意的是周期数一定要模拟好,不然就无法控制CPU的速度了。

简单Mapper

NES有200多种Mapper,用来将ROM的内容映射到CPU或是PPU的内存中,同时提供了切换内存映射的功能,也就是可以改变CPU同一块内存上对应哪一块的ROM。这里先不考虑太多,像超级马里奥这种比较简单的游戏,Mapper也是最简单的,所有ROM都固定映射到某一块地址。之后的几个步骤都只考虑这种Mapper。

基准测试

写好CPU和内存大概用了两天左右,之后我写了一个基准测试来检查我写的CPU是否足够有效率。这段程序尝试以1.78MHz的速度运行,执行200万条指令,最终结果是可以达到目标速度。

编写PPU

光有CPU根本就玩不起来,要有PPU这个输出图像的模块才行。PPU有自己的内存,也把自己的寄存器映射到CPU的内存空间中,这样CPU就可以操作PPU了,包括修改PPU内存中的内容。PPU在自己的运行周期中,根据自己内存中的内容,绘制图形到屏幕上。在特定时刻,PPU还会触发CPU的中断。根据以上的特点,PPU设计如下:

这里继承Memory是因为要把PPU的寄存器映射到CPU的内存中,设置PPU有“内存”接口在实现上更加直接。

PPU的实现比较复杂,其内存主要分为四个部分:图形模式、背景、调色板、活动块。其中图形模式一般是从ROM中直接读入,是图块的形状,每个图块只能表示透明和3种颜色;背景是以8x8图块密集堆成的背景图;调色板用来选择图块的每种颜色的实际值;活动块是可以在表面任意活动的图块,比如人物等等。PPU根据这些信息,平均每个周期绘制屏幕上的一个点。

这里的Screen类用来将抽象的坐标-颜色组合转换成可以显示的图像:

合并测试

有了CPU和PPU,就可以合并起来测试了。如果查看文档,知道PPU的运行速度是CPU的3倍,所以将上面基准测试的核心代码改成了:

有一些游戏,比如超级马里奥,即使没有输入,也可以自己行动,正好用来测试。

写到这个程度,基本上一定会有bug。没有其他好办法了,只好debug了。

debug的方式很多。首先,为了确定各个模块有没有写错,可以写一些测试。其中内存类内容较少,很容易测试通过。CPU类需要对每种可能的指令进行测试,覆盖所有可能。PPU因为要输出,而且流程比较复杂,不适合写测试,可以直接输出到界面上测试。

当然,如果这些都找不到问题,可能就是文档看错了或是理解错了。唯一的办法是看其他文档或是和已有模拟器对比。我最后是和VirtuaNES这个模拟器对比,发现了自己的实现错误。

我最后找到的大问题有两个:一是SBC指令实现错了,这都怪我看了错的文档。二是PPU的$2007寄存器实现不对。之后花屏的问题就消失了,剩下的一些就是小问题了。

因为要做模拟器,最好还是忠实地按照硬件的运行方法实现,不能自己想当然地改变做法,谁也不能保证一个游戏不会使用一些奇葩的硬件特性。我在这个上面吃了亏,没有仔细读文档说明,就想当然地实现了,最后当然出了问题,浪费了时间。吃一堑,长一智,之后我实现APU的时候就仔细按照文档的说明来一步一步完成,不再想当然。

输入

CPU通过读取4016~4017的内存地址来得知输入,类似上面PPU的逻辑,输入也可以看作实现了“内存”接口:

大部分游戏都使用标准的方向、A、B、Start、Select控制器作为输入,我也只实现了这一种输入,并为它写了一个类。这个类接收外来(我用的是Swing的KeyListener)的按下或抬起某个按键的调用,在CPU访问时,输出按键按下状态。

更多Mapper

如果做完了以上这些内容,就可以模拟一些游戏了,但是可以模拟的只有Mapper编号是0的游戏。如果要玩更多的游戏,就要实现其他的Mapper。同CPU,PPU相同,Mapper也是实际存在的硬件,CPU通过写内存来控制Mapper,所以Mapper也要映射到CPU的内存空间中,也要实现“内存”接口。

我的做法是用一个MemoryFactory,根据编号不同,给出不同的Mapper对象。每种Mapper,各自设计一个类,实现抽象的MapperMemory接口。Mapper的主要作用是在模拟开始前,映射好所有内存地址。同时,在模拟过程中根据CPU的控制,修改内存映射。

实现APU

现在我已经可以玩很多游戏了,但是还是没声音。俗话说,“没声音,再好的游戏都不行”。还是要模拟一下声音相关的硬件。在完成了以上这么多内容后,实现APU应该也不是难事,只要看着文档一步步来就好了。

APU有五个声音模块:2个方波,1个三角波,1个噪声,1个差值调制通道(DMC),每个模块都有4个寄存器映射到CPU的内存空间,它们之间相互独立,可以逐个完成。APU使用计数器来同步它们的行为,并将它们的输出混合。

因为有了之前的经验,我这里的实现很顺利,出现问题也能很快解决。

总结

以上就是我编写模拟器的过程。和自己写一个软件相比,编写模拟器要更多依照别人的标准,稍有差别就会模拟不正确。有时候文档不清楚,或是自己看不懂,也需要找更多文档,或者自己尝试更多可能。当然,做出来的那一刻,带来的成就感是相当大的,自己也在不知不觉中复习了一些课程的内容。

不过,我似乎忘了关注一下女朋友的进度……

如何制作NES模拟器”有1条评论

  • hai

    题主牛x。现在完成了吗?能不能分享下源码。我现在做的本科论文是在一个模拟器的基础上完成多线程功能,但一头雾水,希望可以参考下题主的作品。

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*