本文链接:如何制作NES模拟器
前言
大约是一周半前,女朋友说她想要练习写Java。我想了一下,觉得要练一种编程语言,不如就用那种语言写点东西,在实践中学到这种语言的用法。于是我就提出,不如做一个NES模拟器吧。既练习了Java,又复习了一些底层相关的课程,又可以用来玩,岂不是一举三得?她欣然接受。然而,我没想到的是,编写模拟器并非如此简单,其中的坑非常多。
收集文档
毕竟是20多年前的主机,NES的相关资料并不难找,在网上搜一搜就能得到许多资料。我最终看得最多的是nesdev上的wiki,其中详细介绍了NES的各种硬件和他们的特性。虽然还是有一些语焉不详的地方,不过其实影响不大,可以用知识(脑补)来补足。
并不是有了这些资料就可以开始制作了,因为有可能发现根本看不懂。而如果学习过操作系统,汇编,计算机体系结构等课程的话,就会发现,PC和NES的主要硬件都是相通的,都存在中断,内存映射等概念。如果学过数字电路,就会了解信号上升沿触发中断等设计。所以,如果看不懂,不妨补一补以上课程。
加载ROM
现在玩游戏都是靠网上下载的ROM文件,其中大部分是NES格式的,这个格式有16字节的头部和其余的数据部分。根据nesdev上的资料,我们可以实现一个NesLoader
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public interface NesLoader { int getPRGPageCount(); int getCHRPageCount(); byte[] getPRGPage(int index); byte[] getCHRPage(int index); int getMapper(); boolean isHorizontalMirroring(); boolean isVerticalMirroring(); boolean isSRAMEnabled(); boolean is512ByteTrainerPresent(); boolean isFourScreenMirroring(); byte[] getTrainer(); } |
这部分并不需要硬件知识,只要了解文件操作和文件格式,就可以轻松载入ROM。
此时可以测试载入一个ROM,看它的Mapper
是什么,最好留用一些Mapper
是0
的ROM,备之后测试用。
实现CPU
从这个阶段开始就要进入模拟硬件的时间了。我首先从最熟悉的CPU开始,CPU内有若干寄存器,其中包括程序计数器PC,CPU每次从PC指向的内存中取出一条指令,执行,然后更新PC到下一条指令。执行指令需要要若干个周期,CPU每秒可运行的周期数一定,和CPU频率相等。所以,我初步设计出CPU为:
1 2 3 4 5 6 7 8 9 10 |
public interface CPU { void setMemory(Memory memory); Memory getMemory(); long execute(); // 执行PC指向的一条指令 long getCycle(); // 获得周期数 void reset(); // 重置 void powerUp(); // 启动 void nmi(); // 两种中断 void irq(); // } |
其中Memory
为内存:
1 2 3 4 5 |
public interface Memory { int getSize(); int getByte(int address); void setByte(int address, int value); } |
内存的设计,要考虑到内存映射和硬件寄存器这回事。我在实现时,也设计了多种不同的内存类。
之后就是苦力活了,实现CPU的各个方法,要对每一种指令编写其功能实现。取指和实现的方法可以有多种,不再详述。我自己是用了一个大switch
语句来完成。值得注意的是周期数一定要模拟好,不然就无法控制CPU的速度了。
简单Mapper
NES有200多种Mapper,用来将ROM的内容映射到CPU或是PPU的内存中,同时提供了切换内存映射的功能,也就是可以改变CPU同一块内存上对应哪一块的ROM。这里先不考虑太多,像超级马里奥这种比较简单的游戏,Mapper也是最简单的,所有ROM都固定映射到某一块地址。之后的几个步骤都只考虑这种Mapper。
基准测试
写好CPU和内存大概用了两天左右,之后我写了一个基准测试来检查我写的CPU是否足够有效率。这段程序尝试以1.78MHz的速度运行,执行200万条指令,最终结果是可以达到目标速度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
long time = System.nanoTime(); int count = 2000000; for (int i = 0; i < count; i++) { for (int k = 0; k < 100; k++, i++) { cpu.getCycle(); } double cps = cpu.getCycle() * 1e9 / (System.nanoTime() - time); while (cps > 1789772.5) { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } cps = cpu.getCycle() * 1e9 / (System.nanoTime() - time); } } double costTime = (System.nanoTime() - time) / 1e9; System.out.println("Cost time: " + costTime + "s"); System.out.println("Cycles: " + cpu.getCycle()); System.out.println("Instructions: " + count); System.out.println("CPI: " + cpu.getCycle() / (double) count); System.out.println("CPS: " + cpu.getCycle() / costTime / 1e6 + "M"); |
编写PPU
光有CPU根本就玩不起来,要有PPU这个输出图像的模块才行。PPU有自己的内存,也把自己的寄存器映射到CPU的内存空间中,这样CPU就可以操作PPU了,包括修改PPU内存中的内容。PPU在自己的运行周期中,根据自己内存中的内容,绘制图形到屏幕上。在特定时刻,PPU还会触发CPU的中断。根据以上的特点,PPU设计如下:
1 2 3 4 5 6 7 8 |
public interface PPU extends Memory { void setMemory(Memory memory); Memory getMemory(); void cycle(Screen screen, CPU cpu); // 运行一个周期 void powerUp(); void reset(); boolean inVerticalBlank(); // 是否在绘制两帧之间 } |
这里继承Memory
是因为要把PPU的寄存器映射到CPU的内存中,设置PPU有“内存”接口在实现上更加直接。
PPU的实现比较复杂,其内存主要分为四个部分:图形模式、背景、调色板、活动块。其中图形模式一般是从ROM中直接读入,是图块的形状,每个图块只能表示透明和3种颜色;背景是以8×8图块密集堆成的背景图;调色板用来选择图块的每种颜色的实际值;活动块是可以在表面任意活动的图块,比如人物等等。PPU根据这些信息,平均每个周期绘制屏幕上的一个点。
这里的Screen
类用来将抽象的坐标-颜色组合转换成可以显示的图像:
1 2 3 4 |
public interface Screen { void set(int x, int y, int color); BufferedImage show(); } |
合并测试
有了CPU和PPU,就可以合并起来测试了。如果查看文档,知道PPU的运行速度是CPU的3倍,所以将上面基准测试的核心代码改成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
for (int k = 0; k < 100; k++, i++) { int cycle = (int) (cpu.execute() - oldCycle); oldCycle = cpu.getCycle(); for (int j = 0; j < cycle; j++) { ppu.cycle(screen, cpu); ppu.cycle(screen, cpu); ppu.cycle(screen, cpu); if (!oldInBlank && ppu.inVerticalBlank()) { updateScreen(); } oldInBlank = ppu.inVerticalBlank(); } } |
有一些游戏,比如超级马里奥,即使没有输入,也可以自己行动,正好用来测试。
写到这个程度,基本上一定会有bug。没有其他好办法了,只好debug了。
debug的方式很多。首先,为了确定各个模块有没有写错,可以写一些测试。其中内存类内容较少,很容易测试通过。CPU类需要对每种可能的指令进行测试,覆盖所有可能。PPU因为要输出,而且流程比较复杂,不适合写测试,可以直接输出到界面上测试。
当然,如果这些都找不到问题,可能就是文档看错了或是理解错了。唯一的办法是看其他文档或是和已有模拟器对比。我最后是和VirtuaNES这个模拟器对比,发现了自己的实现错误。
我最后找到的大问题有两个:一是SBC指令实现错了,这都怪我看了错的文档。二是PPU的$2007寄存器实现不对。之后花屏的问题就消失了,剩下的一些就是小问题了。
因为要做模拟器,最好还是忠实地按照硬件的运行方法实现,不能自己想当然地改变做法,谁也不能保证一个游戏不会使用一些奇葩的硬件特性。我在这个上面吃了亏,没有仔细读文档说明,就想当然地实现了,最后当然出了问题,浪费了时间。吃一堑,长一智,之后我实现APU的时候就仔细按照文档的说明来一步一步完成,不再想当然。
输入
CPU通过读取4016~4017的内存地址来得知输入,类似上面PPU的逻辑,输入也可以看作实现了“内存”接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public abstract class Input implements Memory { protected abstract void writeRegister(int value); // 向下调用接口 protected abstract int get(int address); public int getSize() { return 2; } public int getByte(int address) { return get(address); } public void setByte(int address, int value) { if (address == 0) { writeRegister(value); } } } |
大部分游戏都使用标准的方向、A、B、Start、Select控制器作为输入,我也只实现了这一种输入,并为它写了一个类。这个类接收外来(我用的是Swing的KeyListener)的按下或抬起某个按键的调用,在CPU访问时,输出按键按下状态。
更多Mapper
如果做完了以上这些内容,就可以模拟一些游戏了,但是可以模拟的只有Mapper编号是0
的游戏。如果要玩更多的游戏,就要实现其他的Mapper。同CPU,PPU相同,Mapper也是实际存在的硬件,CPU通过写内存来控制Mapper,所以Mapper也要映射到CPU的内存空间中,也要实现“内存”接口。
我的做法是用一个MemoryFactory
,根据编号不同,给出不同的Mapper对象。每种Mapper,各自设计一个类,实现抽象的Mapper
和Memory
接口。Mapper的主要作用是在模拟开始前,映射好所有内存地址。同时,在模拟过程中根据CPU的控制,修改内存映射。
1 2 3 4 |
public interface Mapper extends Memory { void mapMemory(NesLoader loader, CPU cpu, PPU ppu, APU apu, Input input); void cycle(CPU cpu); } |
实现APU
现在我已经可以玩很多游戏了,但是还是没声音。俗话说,“没声音,再好的游戏都不行”。还是要模拟一下声音相关的硬件。在完成了以上这么多内容后,实现APU应该也不是难事,只要看着文档一步步来就好了。
APU有五个声音模块:2个方波,1个三角波,1个噪声,1个差值调制通道(DMC),每个模块都有4个寄存器映射到CPU的内存空间,它们之间相互独立,可以逐个完成。APU使用计数器来同步它们的行为,并将它们的输出混合。
因为有了之前的经验,我这里的实现很顺利,出现问题也能很快解决。
总结
以上就是我编写模拟器的过程。和自己写一个软件相比,编写模拟器要更多依照别人的标准,稍有差别就会模拟不正确。有时候文档不清楚,或是自己看不懂,也需要找更多文档,或者自己尝试更多可能。当然,做出来的那一刻,带来的成就感是相当大的,自己也在不知不觉中复习了一些课程的内容。
不过,我似乎忘了关注一下女朋友的进度……
题主牛x。现在完成了吗?能不能分享下源码。我现在做的本科论文是在一个模拟器的基础上完成多线程功能,但一头雾水,希望可以参考下题主的作品。
完成了啊。
链接: https://pan.baidu.com/s/1nvsZMXV 密码: v7mt
看了半天发现自己不是写模拟器的料,压根不懂底层的东西,还是找找别人写好的现成的移植一下吧。不过话说保存读取进度这块看哪里的资料
如果是即时保存,只需要把内存和寄存器值存起来就好了。
如果是游戏提供的保存,则需要实现相应的Mapper,并保存相应的内存段。一般是把$6000-$7FFF中的一段内存作为存盘记录下来。