java_jdk

实现一个FC模拟器

2020-06-02  本文已影响0人  丶legend

前言

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

总览

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

游戏卡带

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

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

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 / 每秒度过的时钟周期数
程序运行时间 = CPU指令总的时钟周期数 * 每个周期花费的时间

所以要算出当前的主频

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

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

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执行的伪代码

public long execute() {
        int opcode = mainMemory.readByte(register.getPC());
        increasePC();
        switch (opcode) {
            case 0: return brk();
            case 1: xxx;
            case 2: xxx;
       }
}

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

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字节. 整个过程就是

名称表

即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字节主要储存了哪些信息

PPU渲染

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

时钟周期

每条扫描线一共需要花费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像素的块。完整的渲染过程

PPU滚动

上面所述的偏移,其实就是PPU实现屏幕滚动的关键,下图来自NesDev

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

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

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

而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模块需要的过程。

具体到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

上一篇 下一篇

猜你喜欢

热点阅读