实现一个FC模拟器

前言

前段时间无意中浏览到了描述FC(Family Computer)游戏的一些工作原理的博客,瞬间勾起了儿时对小霸王游戏机如痴如醉的过往,看到网上从以前游戏卡带中导出来的游戏:超级玛丽、魂斗罗等才几十k大小,大的也不过几百k,极少数超过1M的,而这点空间现在的一张普通质量的图片可能都存不下。所以想到了不如自己实现一个FC模拟器,一探它背后神秘的魔法。即是重新追忆一下那些逝去的时光,又是对计算基础知识很好的一个实践。然现实很骨感,网上能找得到的相关硬件资料太少,资料比较全面的就是NesDev,但是全英文,而且很多东西过于详细,花了很长时间读可能还找不到重点在哪里,除非做过类似的东西否则很难就此上手。所以写篇博客记录一下整个实现的流程。

总览

所谓模拟器,实际就是我们在软件层面来模拟硬件的工作,也就是说实现一个FC程序的执行环境。要实现这么一个模拟器,首先就是要清楚FC主要由哪些硬件构成以及各个硬件是如何协作的,反正现代的计算机基本都是基于冯洛伊曼体系啦。与我们一般的电脑一样,主要就是CPU、内存、显卡、输入设备、输出设备几部分。具体到FC,以前游戏机都是插卡的,所以卡带也要算一部分。接下来描述一下大致的过程,主机通电后

  • 1.卡带的加载
    游戏的数据主要包括图像和程序,所以第一步首先就是根据固定的头部解析nes文件(卡带硬件导出的),拿到相关的数据后,就可以开始将程序和图像分别装载到主内存和显存,而CPU主要就是和主内存打交道,包括其它的硬件也都是通过CPU内部的IO寄存器映射到主内存,IO寄存器可以当作是CPU与外部设备通过总线连接的端点。当然具体实现时,直接通过操作具体的某个内存地址来实现与其它硬件的通信就可以了。
  • 2.CPU运行程序代码
    CPU(Center Processing Unit)对计算机而言,始终是最核心的硬件,其它组件的运行都是通过它来带动的。对于FC,通电重置后会触发一个RESET中断,也就是会将CPU的指令指针寄存器PC(Program Counter)跳转到RESET中断存储的地址,因为PC总是存储的程序下一条要执行指令的内存地址嘛,所以程序也就从这里开始执行了。执行的过程也就是[取指令]->[指令译码]->[取操作数]->[计算]。
  • 3.PPU开始读取图像数据并进渲染
    图像处理单元PPU(Picture Processing Unit)也就是我们常说的显卡,主要就是用来处理图形的渲染、窗口的显示,一般显卡会有自己单独的一块内存,主要用来存储图像以及相关的信息。对于FC,PPU会定期从显存中抓取游戏背景、精灵的数据,并渲染到窗口上。这里说的定期,实际上就是需要与CPU的时钟周期进行同步啦,这样才能保证获取到的数据是正确的。一般来说,PPU需要有比CPU更快的执行速度,CPU除了执行指令,还有对各个硬件进行协调的功能,对于PPU而言,也是通过几个IO寄存器来进行的,一般CPU执行一次,屏幕至少要抓取几个像素点。
  • 4.输入与输出
    输入设备主要包括手柄、光枪之类的,和现在的计算机不太一样,我们用键盘输入时一般都是通过中断来通知CPU,键盘的中断处理程序接着再根据输入的扫描码翻译成键盘上对应按键值。对于FC而言,是程序通过代码定期(一般是在一帧绘制完成后)按手柄按键顺序从内存映射的IO寄存器中读取输入的值。输出主要就是包括屏幕的像素点以及声音。

    各个组件的协作见上图,接下来就依次介绍一下各个部以及一些额外的扩展。

游戏卡带

这部分需要读取nes文件并按部就班的解析和存储相关的信息,nes文件实际就是FC平台的可执行文件,与Windows上的PE(常见的.exe)、Linux上面的ELF可执行文件一样,都不是纯二进制程序,额外包含了一些固定的头部信息。这是平台所规定的,需要从中解析出实际的程序才能放到CPU上面执行。现在需要关注的信息

  • 1.Mapper Id
    FC卡带上自带的额外扩展的芯片Mapper的id,后面再详细介绍。
  • 2.CHR-ROM/VROM
    即Charater-ROM, 这部分就是存储的游戏中所需要用到的图像信息,或者换个说法,也就是常说的字体库。程序包那么小的体积,存图片肯定是不现实的,它是存储游戏中背景和精灵需要引用的图案的点阵,需要装载到显存。
  • 3.PRG-ROM
    即Program-ROM,这部分存储的就是游戏程序编译后的二进制代码,需要装载到主内存。
  • 4.Mirroring-Type
    镜像类型,主要是决定了程序运行过程显存中存储的背景渲染信息的那部分内存是如何规划,在PPU部分再详细解释。

知道大概需要哪些东西了,就可以先定义一个获取该文件信息相关的接口了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface INesLoader {
// 获取16KB PRG(程序)数据的页数
int getPRGPageCount();
// 获取8KB CHR(图像)数据的页数
int getCHRPageCount();
// 通过索引获取对应的PRG数据块
byte[] getPRGPageByIndex(int index);
// 通过索引获取对应的CHR数据块
byte[] getCHRPageByIndex(int index);
// 获取屏幕的镜像类型
int getMirroringType();
int getMapperId();
String getFileMD5();
}

具体文件格式可以看看这篇博客
https://zhuanlan.zhihu.com/p/44035613

CPU

FC使用的是2A03 CPU,主要在6502 CPU的基础上扩展了对音频处理(pAPU)的支持,所以CPU使用的其实仍是6502的汇编指令集。前面说过,模拟器主要模拟的是硬件,是FC程序的执行环境。FC程序也是直接使用的6502汇编进行的编程,不过它本身用什么也不重要,关键点在于编译后的程序最后是在什么硬件上运行的,因为编译后的都是二进制,我们需要解决的是把这些二进制机器码对应到CPU所支持的指令集,这样程序才能正常在该CPU上运行。(这里需要与Win上面的PE、Linux的ELF可执行文件区别开的是,Win和Linux上面编译后的程序虽然都是CPU可识别的机器码,但它们毕竟是运行在操作系统上,程序运行需要的资源的分配与管理、相应的系统调用都依赖该操作系统的内核,所以即使最终都是同一套机器码、在同样的硬件上面运行,也很难做到跨平台,重点在于这些程序需要另一套程序(内核)来进行管理),而FC的程序不需要额外的管家,直接在硬件上面裸奔,所以直接将程序装载到主内存就可以跑了。因为要模拟CPU和内存,所以基本的思路就是对二进制的FC程序进行解释执行就可以了。接着先看看CPU直接访问的主内存各部分是怎么划分的

主内存布局

内存划分

RAM

实际就是程序运行期间可以完全供自己操作的内存,不过前面1kb(0-0x200)也是有固定用途的,ZeroPage指内存的第一页,临时存放一些数据,CPU可以用来快速寻址和执行;栈就是用来存放计算时需要临时保存的一些值,或者子程序(函数)调用和触发中断时需要将PC的下一条要执行指令的地址、状态寄存器等信息保存在栈中,等待执行完后再恢复现场; RAM(0x0200-0x0800)就是没有固定用途可任意操作的了。而0x0800-0x2000内存地址实际都是前面0-0x800的镜像,也就是说访问地址0x800实际是访问到了地址0,以此类推。所以可以看到,供程序自由发挥的也就2KB(0-0x0800)。

I/O Regesters

0x2000-0x4020主要包含了PPU、APU(Audio Processing Unit)、手柄等输入设备的IO寄存器的内存映射,直接对映射的内存地址进行读写就可实现对这些设备的控制以及状态信息的获取。可以看到,前0x4020个字节对所有程序的内存都是这样规划的。

Expansion ROM 与 SRAM

Expansion ROM留作卡带程序的扩展空间;SRAM(Save RAM)主要用来给某些存在存档的游戏预留的空间,这两部分暂时都不用管。

PRG-ROM

游戏卡带那部分提过,0x8000-0xFFFF这32KB空间用来存储游戏程序代码。

关于CPU,还有几点需要了解的。

  • 1.之前图已经给出了,2A03 CPU拥有16位的地址总线 ,可寻址的范围是2字节,即0x0000-0xFFFF,主内存总共空间大小为64KB,默认字节序采用小端序;而数据和控制总线都是8位的,所以具体操作内存的时候实际都是以字节为单位进行的。
  • 2.实现CPU首先需要实现CPU的寄存器,寄存器主要包括PC(Program Counter)寄存器、SP(Stack Poninter)寄存器、A(Accumulator)累加器、X和Y索引寄存器以及处理器状态寄存器。栈指针寄存器就是始终指向当前栈顶的位置。CPU指令实现的过程中也需要对状态寄存器的标志位进行对应的改变。
  • 3.寻址模式 ,也就是说看汇编指令(机器码)使用什么样的方式寻址,一共寻址模式包含10来种,也都是和机器码一起已经定义好的,至于程序使用哪种方式寻址程序开发人员自己发挥。所以具体实现时可以先完成各个寻址模式,然后再看各个指令的机器码分别对应哪种就行了。不管哪种寻址模式,最终目的都是拿到内存地址最终的数值,进行运算。这部分也纯粹是一个体力活,前面说过具体操作都以字节为单位,指令的机器码也是1字节,所以最多也只能有256个指令。实际6502CPU的指令只有几十个,剩下的要么是组合了不同的寻址模式(同一指令,寻址模式不同对应的机器码也不同),要么是留作扩展。另外,指令包括官方指令和非官方指令,暂时实现官方记载的指令就可以支持大部分游戏了。根据文档,指令的排列还是有一定规律的,所以最好参照已有的模拟器代码来。测试可以写单元测试,可以用专门的测试Rom,倒比较好看执行结果。
  • 4.时钟周期,既然模拟CPU,必须得控制好它的时钟周期。2A03CPU的主频才1.78MHz,要知道现在的CPU基本都是GHz起步了,这差了成百上千倍了,要是不加以控制,那么游戏里面指令执行的速度就会快到飞起,那样根本没法玩。所以运行过程中需要计算一下主频,首页要清楚的是主频(也称CPS, Cycle Per Second)实际是指CPU每秒度过的时钟周期数,一般一条指令需要1-几个时钟周期不等,看看以下的公式
    1
    2
    平均每个时钟周期花费的时间 = 1 / 每秒度过的时钟周期数
    程序运行时间 = CPU指令总的时钟周期数 * 每个周期花费的时间

所以要算出当前的主频

1
主频 = CPU指令总的时钟周期数 / 程序运行时间

而CPU指令的周期数和程序运行的时间都是运行过程中需要进行统计的, 算出当前的主频后,直接一个While循环,当前的大于目标主频就直接sleep(),先空闲一段时间,接着计算。对于程序的主循环直接这样

1
2
3
4
5
6
7
8
9
10
long time = System.nanoTime();
while (true) {
cpu.execute();
long timeDiff = System.nanoTime() - time;
cps = cpu.getCycle() * 1e9 / timeDiff;
while (cps > Emulator.TARGET_CPS) {
sleep(1);
cps = cpu.getCycle() * 1e9 / (System.nanoTime() - time);
}
}

看到这里,应该也有了想法,一般的模拟器都有几倍加速的,其实加大下目标主频就行了,反正怎么调还是比现代的CPU速度慢得多。可以看看CPU执行的伪代码

1
2
3
4
5
6
7
8
9
public long execute() {
int opcode = mainMemory.readByte(register.getPC());
increasePC();
switch (opcode) {
case 0: return brk();
case 1: xxx;
case 2: xxx;
}
}

就是从内存读取操作码,然后对应到指令,如果指令还需要操作数,就继续在PC指向的地址取值,并移动PC指针到下一个地址。至于内存,因为多个地方要用到,所以也可以抽象出一个接口来。

1
2
3
4
5
6
7
public interface IMemory {
// 从地址address读取1字节数据
int readByte(int address);
// 写1字节数据到地址address
void writeByte(int address, int value);
int getSize();
}

总的来说,CPU没有那么多弯弯绕绕,具体的指令无非就是一些基本的运算以及内存、寄存器的复制与读写,对着文档来就好。指令的实现参考
http://nparker.llx.com/a2/opcodes.html
https://wiki.nesdev.com/w/index.php/CPU_unofficial_opcodes
http://www.6502.org/tutorials/6502opcodes.html

PPU

PPU主要用来做图形渲染。要清楚图形是怎么渲染的,首先需要了解的是,以前的大头电视机是怎么工作的。借一张图看看

图像其实是从屏幕左上角开始从左到右一个一个像素点进行渲染的,实际过程是电视机背后的电子枪发射出一个电子,而电视里面都是有一个大线圈,通电后产生了磁场,接着电子经过磁场的偏移打到了屏幕上的荧光材料从而产生了可见图形。而屏幕显示的彩色,是由红、绿、蓝RGB三基色进行混合。即三支电子枪发射出不同的电子,轰击到屏幕的三色荧光粉上,进行混合后就能产生不同的颜色。

图像经电子枪的扫描线从左到右渲染,一行完成后又要回到下一行的最左边,回到左边的这个时间段没有像素点被渲染,这个过程称为H-Blank(Horizontal Blank)。而整个屏幕被渲染完成后,又需要从右下角回到左上角开始绘制下一帧,这个时间段也没有像素点渲染,称为V-Blank(Vertical Blank)。FC的图像是以Tile(像素块)为基本单位的,一个Tile为8x8的像素块。
PPU有单独的一块显存VRAM(Video RAM),接下来看看VRAM的内存布局

VRAM内存布局

VRAM内存视图

调色板

即PaletteRAM indexes(0x3F00-0X3F1F)。系统调色板构成了FC能显示的64种颜色,而分别存储在VRAM中0x3F00-0x3F0F和0x3F10-0x3F1F(后面的0x3F20-0x3FFF都是镜像)位置的是16字节的背景调色板与16字节的Sprite调色板的索引,通过1字节来索引到系统调色板。调色板是在系统中写死的,不同模拟器颜色的差异也就是从这里来的。具体实现时,直接获取到调色板颜色对应的RGB值,再进行渲染。

图案表(字体库)

即PatternTable(0-0x2000),图案表存储的是游戏中背景和Sprite需要用到的图案,分为两个4kb,由PPU的控制寄存器指定是给背景还是Sprite使用。图案表以16字节的方式步进的。看看下图,是冒险岛3首页的图案表

8x8的像素块一共64个像素点, 那如何确定像每个素点的颜色呢? 答案就是这16字节, 16字节分成2个8字节,即两个64位,从这两个64位各拿出1位来组成了4位中的低两位. 这里的4位是干啥用的呢?前面说过0x3F00-0x3F1F的两个16字节的调色板索引,用来索引到系统调色板. 所以对于背景与精灵的颜色,就需要用至少4位,(总共2^4=16)才能访问到这16字节. 整个过程就是

  • 1.用4位确定到调色板索引的地址
  • 2.通过调色板索引的地址读取到1字节的调色板索引
  • 3.最后再用该索引找到系统调色板中对应的颜色

名称表

即NameTable,FC总共有4个名称表,位于0x2000-0x2FFF,一共4kb,每个名称表占用1024字节。前面说过图像基本单位是8x8的像素块,FC使用的屏幕分辨率是256x240,刚好可以分成32x30个像素块,而名称表每1个字节存储的是像素块在图案表中的编号,总共需要32x30=960个字节。同样看看冒险岛3的名称表

上下个各2个名称表,问题来了,屏幕像素不是只有256x240,应该只要一个名称表就够了吧?这就是FC神奇的地方了,这样设计的目的是为了方便做屏幕滚动,现在的游戏屏幕滚动一般都是直接对同一块空间进行操作,也就是整块图像缓存空间重新刷新填充。而FC是直接通过修改PPU内部的寄存器在名称表上面进行偏移来达到滚动的效果,所以整块空间不需要频繁改动,后面再详细说明。最后剩余64个字节就是给属性表所使用的。

属性表

属性表位于名称表的最后64字节,分成8x8个字节,前面说过分辨率是256x240, 除以8x8就是32x30,即属性表每1个字节分配给1个32x30的像素块。 而现在前面所说的4位还缺少2位,,这里1字节,分成4个2位, 于是将32x30的像素块再分成4块,可以分成4个8x8(实际有一个像素块不完整)的像素块,,每个8x8像素块就再使用图案表中的低2位+这个作为高2位,去确定到一个调色板索引的地址。到这里就可以发现了一个问题,,就是没办法为每个像素点确定到所有的调色版索引的地址,因为8x8像素块每个点中的高两位其实都是一样的,但前面说过了实际FC也是以8x8像素块为基本单位,确定了图案形状后,每个像素块中的像素点还能有几种变化就够了。另外,这里的属性表是给背景使用的,而精灵的属性表存储在SPR-RAM中。

SPR-RAM

即Sprite-RAM,是PPU给精灵使用的单独一块256字节的空间,每个精灵占用4字节,也就是说屏幕上最多显示64个精灵。精灵是指的屏幕上面的活动块,比如游戏的角色或者状态栏一直需要变化的部分一般就是使用的多个精灵组合成的。看看马里奥就是由8个8x8的精灵像素块组成的

再看看4字节主要储存了哪些信息

  • Byte0存储的是精灵左上角的y坐标-1。
  • Byte1存储了精灵像素块在图案表中的编号。
  • Byte2存储了资源信息
    bit0-1存储的就是确定调色版索引地址的高2位
    bit5决定了精灵的显示对于背景的优先级。
    bit6表明精灵是否是要水平翻转
    bit7表明精灵是否是要存储翻转
  • Byte3存储了精灵左上角的X坐标

PPU渲染

前面介绍了相关的内存布局,现在来看看具体的渲染是怎样的。屏幕渲染的规则和采用的制式有关,FC大部分资料都是使用NTSC制式的,所以优先选取这个,毕竟对于了解工作原理来说,这都不是重点。渲染过程中,每帧扫描线一个有262条,每秒渲染60帧。

  • 0-239这240条是屏幕上可见的扫描线(屏幕是256x240分辨率,高为240),在这个过程中需要进行屏幕像素的渲染。
  • 240-260为V-Blank,这个时间段是不可见扫描线,主要用来生成nmi、获取手柄的状态信息、为下一帧的渲染做好准备。
  • 第261条是预处理扫描线,在这个扫描线开始时需要结束V-Blank,清除其它的一些状态信息。这条扫描线和可见扫描线一样需要更新相关的寄存取信息,但不做任何像素的渲染。

    时钟周期

    每条扫描线一共需要花费341个PPU时钟周期,而1CPU周期=3PPU周期。这里就需要与CPU周期进行同步了,同步有很多种方式,可以直接渲染完一条扫描线,CPU就走341/3个时钟周期;或者渲染完一帧,CPU走261*341/3个时钟周期。采用精度最高的方式就是走1个CPU周期,PPU走3个时钟周期。每个周期渲染一个像素点,当然按照tile也就是1行8个像素点为单位来渲染比较方便,每隔8个时钟周期,一次渲染8个像素点。屏幕宽是256个像素点,渲染完背景的一行就需要256个时钟周期,接下来257-320是HBlank,也可以不进行任何渲染,再往后可以提前渲染下一行的前两个8像素的块。完整的渲染过程
  • 1.抓取当前渲染到的像素块的编号(当前屏幕左上角名称表地址+当前位置在屏幕内像素块个数的偏移),用编号去获取到图案表中的像素块(16字节步进)。
  • 2.像素块是8x8,所以还要获取到当前扫描线在8x8像素块里面偏移的行和列是多少。这样就可以获取到像素点的低2位。
  • 3.抓取当前像素块所在资源表的地址,获取到高2位,与前面的低2位一起就能组合出调色板索引的地址,最后根据调色板索引获取到系统调色板的RGB值。

PPU滚动

上面所述的偏移,其实就是PPU实现屏幕滚动的关键,下图来自NesDev
屏幕滚动
滚动其实就是修改在名称表上面的偏移来进行的,具体实现时按理来说可以直接根据PPU寄存器的内存映射来(0x2000-0x2007),但关键在于游戏程序不按你想的来,就会出现需要抓取的资源没有更新。可以看看这篇博客
https://gridbugs.org/zelda-screen-transitions-are-undefined-behaviour/

所以最佳的方式是实现PPU内部的寄存器v、t、x、w,在进行PPU的内存映射地址操作过程中对这几个寄存器进行维护即可。接下来看看这几个寄存器

  • v: 当前的VRAM地址。
  • t: 临时的VRAM地址,也可以被看作是屏幕窗口左上角的地址。
  • x: 精准x滚动(3bit)。
  • w: 第一次或第二次写时触发(1bit)。

CPU使用主内存的0x2007读写数据时,PPU使用的就是当前的VRAM地址,它也被用来获取名称表的数据以绘制到屏幕上。在用名称表的数据进行渲染时,也会更新当前的VRAM地址,保证获取的数据是正确的。v和t寄存器由15位组成

  • 0-4bit: 模糊x滚动(名称表中当前像素块位置的x轴偏移)。
  • 5-9bit: 模糊y滚动(名称表中当前像素块位置的y轴偏移)。
  • 10-11bit: 名称表选择。
  • 12-14bit: 精准Y轴滚动。

而x寄存器和v、t寄存器的12-14bit就是当前通过名称表的像素块编号找到的8x8像素块具体的像素点偏移的位置。知道了这几个,剩下的直接根据wiki来就可以了
http://wiki.nesdev.com/w/index.php/PPU_scrolling

具体渲染的时候将当前屏幕每个像素点的RGB值放到一个缓冲区,一帧填充完后,再交给系统的api进行绘制。至于精灵的渲染,过程也是一样的,只是精灵的渲染要完全按照文档来,还是有些繁琐了,而且文档有些地方语焉不详,见过其它几个模拟器也都是不同的实现。之前的做法是直接在预渲染扫描线填充到缓冲区一次,大部分游戏都没有问题,直到忍者神龟2,精灵没有显示出来,后面发现原因是精灵的图案表在可见扫描线渲染过程中才填充,按理来说一般图案表在一帧渲染前提前准备好了,但这游戏偏偏不这么干就没办法了,解决部分就是在可见扫描线再进行精灵像素的拉取。PPU这一块主要关键点就这些了。

APU

即Audio Processing Unit,音频处理单元实际是2A03CPU的一部分,不过实现时还是当作单独的硬件。要实现声音处理的硬件,还是要先清楚声音是怎样产生和传播的。人耳能听到声音是因为物体振动影响到了空气的波动,进而影响到了耳膜振动,接着耳膜发出信号传输到大脑的听觉神经,这样人就感知到了声音。对于计算机,因为只能识别二进制,想要听到人的声音,首先是人肺部流出的空气影响到声带的振动,带动空气的振动,从而影响到麦克风等设备内部的声音传感器内的薄膜振动导致产生了电压的变化,这样也就把自然界中的模拟信号转换成了计算机可以识别的电信号(数字信号)。可以看到,这个过程中计算机只是感知到了信号的变化,但它根本不知道这是干嘛的。所以还是由人来控制,将计算机收集到的信号保存下来。接着将保存的电信号再传输到音响等设备。以扬声器为例,电信号使得线圈通过电流后产生了磁场,而设备一般会携带一个固定的磁铁,两个磁场互相作用影响了线圈的振动,最后使与线圈连接在一起的鼓膜振动发出了声音。然后看看实现APU模块需要的过程。

  • 1.将几个声音通道产生的数字信号转换为模拟信号->混音器混合模拟信号产生声音
    如果是原来的硬件到这里就完了,但模拟器还需要一个过程,因为不同音频设备驱动方面都有较大差异,所以也没办法简单的直接使用自己的硬件输出声音。
  • 2.对混音器输出的模拟信号进行采集(当然这个模拟信号也是直接根据公式算出的0.0-1.0之间的小数,不算真正意义的模拟信号)。也就是一个完整的【采样->量化->编码】,因为模拟信号是在一段连续的时间里不停的产生,采样就是在这断时间内间隔的采集声音样本,将时间离散化;接着就是量化,采集到的声音在相邻的样本之间,声音的幅度还是可以有无数个,所以需要将幅度也进行离散化,也就是将声音的幅度变化控制在一个具体有限的范围内;最后就是编码,也就是将样本按特定的规则组织。这样采集到的数据就可以交给本机的硬件去进行播放了。

具体到FC,一共有5个声音通道,2个方波(Pulse)、1个三角波(Triangle)、1个噪音(Noise)、1个增量调整通道(DMC)。方波和三角波用来控制游戏的背景声音和主旋律,噪音一般用作打击音效,DMC可以用来输出DPCM的声音样本,一般用作特殊音效。

时钟周期

同样模拟硬件少不了的就是时钟的控制,APU里面各个组件用的比较多的是Divider,可以把它当作一个定时器,倒计时完成后,会触发各个组件内部产生一个时钟周期的变化。APU内部有一个帧计数器(Frame Counter),用来控制其它组件的时钟周期,注意不要和图形渲染的帧搞混了,两者没有关系。一帧为14915/18641(取决于寄存器的控制位是4步还是5步模式)个APU周期,而1APU周期=2CPU周期,所以整个APU还是跟随着CPU指令的执行来进行时钟周期的控制。至于其它的,文档基本写得挺详细了,就不多说了。
https://wiki.nesdev.com/w/index.php/APU

中断

前面说过,CPU其实就相当于一个死循环,通电后总是在做【取指令->指令译码->执行指令】这样重复的过程。 CPU内部的指令指针寄存器PC保存的就是下一条要执行的指令的地址。那问题来了,程序该怎么把起始地址信息告诉CPU呢?答案就是中断。首先必须明确的,机器再高级始终是机器,只会按固定的规则办事。对于6502CPU,通电启动时会主动触发一个RESET中断,接着CPU会从固定的内存地址来读取中断处理程序并把该地址放到PC寄存器,所以只要在这个地方保存程序加载到内存后的起始地址,接下来CPU就会从程序的起始地址开始执行。直接按字面意思,中断就是打断当前执行的指令。

6502中断一共有4种,RESET、IRQ、BRK、NMI。RESET前面已经说过了。IRQ一般是硬件所产生的,比如APU(音频处理单元)、Mapper(游戏卡带上面携带的用来作内存映射的额外芯片),可通过设置CPU的标志位来屏蔽;BRK一般是软件所产生的中断,对应着6502汇编指令BRK;NMI(No-Maskable Interrupt )不可屏蔽中断,是在PPU的不可见扫描线期间即V-Blank时产生的,可通过设置PPU的控制寄存器进行屏蔽。

不同的中断实际是有额外的引脚连接到CPU的,但我们这里是模拟硬件,不用管这些,只实现发送中断时要做的事情就可以了。从硬件层面来说,CPU执行指令的时候,其它的硬件可以直接通过不同的线路发送信号给CPU,其它硬件的工作以及产生中断更像是并行的,用多线程模拟合理一点,但这就增加了编码的难度。而现在的CPU速度已经比6502高了成百上千倍了,使用单线程模拟完全没任何问题,只需要每次执行指令前检查一下是否有新的中断即可。 另外,因为中断产生时需要打断当前CPU下一条要执行的指令,和函数调用一样,所以程序中一般会先保存当前的PC、状态寄存器等信息到内存,等中断程序完成后再回到之前的位置。

Mapper

以前的FC游戏从主内存0x8000-0xFFFF,显存0-0x1FFF,顶多就只能存储40kb程序相关的资源了,早期的游戏也确实够了。但可以了解到的是,后面的一些游戏,无论是声音,游戏的画面,游戏丰富的内容,这些只有40kb是远不够的。但FC硬件也固定了,所以后面任天堂就提供了游戏卡带的扩展,称为Mapper。也就是说游戏卡带上有额外的一个芯片来进行内存映射,对于CPU和PPU来说,看到的内存空间始终那么大,但Mapper可以进行内存切换,也就是在某个时刻,将原来分配好的内存地址的程序或者图案表切换成卡带上面其它的,这样就解决了40kb的限制。这一做法,使得FC游戏的体验大大的提升,有些卡带更是会扩展音源。Mapper大概有两百多种,不过一些是某个Mapper的变种。FC也满足二八原则,实现少量的Mapper就可以兼容大部分游戏了,游戏占比比较大的就是Mapper0-4了。

输入设备

其它组件都已经实现了,输入设备肯定不能少,不然游戏都玩不起来。输入设备实际也是经过内存映射IO寄存器到0x4016和0x4017,分别对应玩家1和玩家2。游戏读取手柄的状态,是定期地按照手柄顺序A、B、选择、开始、上、下、左、右不断的获取8个按键按下的状态。所以实现普通的手柄控制,只需要用额外的空间存储8个按键的状态,按下是1,释放是0,最后将键盘上的按键映射到手柄的按键即可。参考
https://wiki.nesdev.com/w/index.php/Standard_controller

https://wiki.nesdev.com/w/index.php/Controller_reading_code

扩展

调试

写模拟器毕竟不像普通的程序,调试起来还是没那么容易的。所以可以实现一个辅助的6502汇编指令调试器进行调试,主要就是将一块程序内存的机器码反汇编成6502汇编程序,再实现名称表、图案表、SpriteRAM的可视化以及内存的dump。

存档与读档

以前玩真机,毕竟头疼的是,有些游戏关卡太长或者难度太大,就经常玩不到最后,电源就发热严重了或者游戏机会偶尔抽风,一断电啥都没有了,每次都得重来。所以自己实现模拟器,存档与读档是肯定是必须的。所谓存档,实际就是把当前的内存保存现场,读档就是恢复现场。具体到FC,主要就是把主内存、VRAM与各个硬件的寄存器状态、以及绘图用到的缓冲区、Mapper都存储下来。只是直接暴力的存储占用空间有点大,一个游戏才几十k,存档却多好几倍了,不过对于现在的机器来说这点空间完全无关紧要。

画质增强

这也是一个比较令人头疼的问题,FC使用的不过是256x240分辨率的屏幕,而现在的屏幕基本1、2k分辨率起了,强行地拉伸像素块边缘就会有明显的方块感,这可不是我们的童年。经过调研,发现比较可靠实用的就是xBRZ图像缩放算法,整体还是不错的。

结尾

对于实现一个模拟器,主要就是对硬件要有足够的理解,控制好各个组件之间时钟周期的同步,通过以前的这么一个平台,也可以一窥现在一些平台的工作。原理了解了,具体的实现过程中可以有多种不同的方式。对于FC,由于硬件本身的资料不是完全开放的,而且比较有意思的是实现Mapper的时候,几个不同地方的文档有不同的实现。不过总有一些游戏你不知道会使用哪些奇葩特性,所以很难有模拟器能完美支持所有的游戏,一些模拟器也不是完全模拟硬件,对特殊的游戏会使用一些trick,不过这些对理解平台的工作都无关紧要了。更多资料,英文基本就NesDev了,里面有个不算长的NesDoc写得还可以。
http://nesdev.com/NESDoc.pdf
然后下面是我找得到的有用的中文资料。
http://rexq.me/2020/03/22/%E5%A6%82%E4%BD%95%E7%BC%96%E5%86%99%E4%B8%80%E4%B8%AAFC%E6%A8%A1%E6%8B%9F%E5%99%A8/
https://www.cnblogs.com/chunyueye/p/12261027.html
https://zhuanlan.zhihu.com/p/34144965
https://zhuanlan.zhihu.com/p/43999178
https://blog.chaofan.io/archives/如何制作nes模拟器
http://www.360doc.com/content/18/0116/09/33564766_722316244.shtml

×

纯属好玩

扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦

文章目录
  1. 1. 前言
  2. 2. 总览
  3. 3. 游戏卡带
  4. 4. CPU
    1. 4.1. 主内存布局
      1. 4.1.1. RAM
      2. 4.1.2. I/O Regesters
      3. 4.1.3. Expansion ROM 与 SRAM
      4. 4.1.4. PRG-ROM
  5. 5. PPU
    1. 5.1. VRAM内存布局
      1. 5.1.1. 调色板
      2. 5.1.2. 图案表(字体库)
      3. 5.1.3. 名称表
      4. 5.1.4. 属性表
      5. 5.1.5. SPR-RAM
    2. 5.2. PPU渲染
      1. 5.2.1. 时钟周期
      2. 5.2.2. PPU滚动
  6. 6. APU
    1. 6.0.1. 时钟周期
  • 7. 中断
  • 8. Mapper
  • 9. 输入设备
  • 10. 扩展
    1. 10.0.1. 调试
    2. 10.0.2. 存档与读档
    3. 10.0.3. 画质增强
  • 11. 结尾
  • ,