“小霸王其乐无穷啊~”
FC对每个80、90后来说,都代表了一段难忘的时光。自从接触编程以后,应该很多人都有想编写一个FC模拟器的冲动,想要了解到底是什么魔法让屏幕上的马里奥穿梭于各个管道之间,变大变小,拯救公主的。但又不知从何做起,始终无法跨出第一步。
该文尽量用文字和简单代码来描述FC模拟器的编写,其中忽略一些细节问题,主要侧重于原理解释,大概的理清FC模拟器的工作流程。希望能跟我一样在理解了FC的设计之后,与前辈们的设计思想产生碰撞的火花。
模拟器如何工作?
FC主要依靠CPU、PPU、APU的协作。(CPU是中央处理单元、PPU是图像处理单元、APU是音频处理单元)
模拟器主要流程如下:
1 | load_rom(); |
ROM即游戏文件,是从卡带上面通过专业设备dump下来的,里面包含了一句一句的机器指令,首先需要把ROM加载到内存中。在cpu_work里面,我们要做的就是逐条读取指令、解析指令,并模拟内存操作。然后,ppu_work根据当前内存里的数据,将其转换成一幅图像(也就是我们从显示器上看到的内容)。
模拟内存操作,包含对内存、寄存器、栈的操作。这些都是以软件来模拟的,透过内存分配、变量分配即可完成硬件的搭建。在整个模拟器运行中,你会发现以上每次循环都会使得我们申请的内存或变量发生改变,在这个过程中,我们需要让内存里的图像显示到屏幕上,这就得根据当前平台相应语言写出UI,将ppu得到的图像显示出来。在这个连贯的画面呈现过程中,将控制器(例如键盘)的操作,写入对应内存中,就实现了角色的控制。
内存
FC内存大小为64KB,它是一个独立于CPU的存储硬件,它之所以能跟其他硬件紧密相连,是通过主板上的电路进行物理连接的。在模拟的时候成本就很低了,只需要使用指针来指向内存地址。
详见:https://wiki.nesdev.com/w/index.php/CPU_memory_map
其中连接到CPU,提供数据存储的内存只有2KB,也就是0x0000-0x07FF段,但FC的CPU具有16位寻址功能,可以完整访问64KB的内存空间。PPU有几个寄存器是映射在0x2000-0x2007和0x4014上的,通过这几个暴露在内存上的寄存器,CPU与PPU之间得以进行协作通信。
在模拟内存的时候,需要处理镜像问题
1 | struct Memory { |
加载ROM
写好了内存模块,就可以将ROM里的数据加载到内存。ROM包含一个文件头,需要先对其进行解析。
文件头长度是16字节,定义如下
- 0-3: Constant 45 1A (“NES” followed by MS-DOS end-of-file)
- 4: Size of PRG ROM in 16 KB units
- 5: Size of CHR ROM in 8 KB units (Value 0 means the board uses CHR RAM)
- 6: Flags 6 - Mapper, mirroring, battery, trainer
- 7: Flags 7 - Mapper, VS/Playchoice, NES 2.0
- 8: Flags 8 - PRG-RAM size (rarely used extension)
- 9: Flags 9 - TV system (rarely used extension)
- 10: Flags 10 - TV system, PRG-RAM presence (unofficial, rarely used extension)
- 11-15: Unused padding (should be filled with zero, but some rippers put their name across bytes 7-15)
通过第四字节可以知道16KB PRG ROM的个数。内存0x8000-0xFFFF的32KB连续空间,用来存储PRG ROM,如果只有一个16KB ROM,需要复制一份到0xC000处。
程序起始地址就是0x8000,也就是第一条指令是从0x8000开始的。
CPU(2A03)
FC使用的是一个8位处理器(2A03),使用6502指令集。
寄存器
将CPU比作一个结构体,寄存器就是其私有成员变量
1 | struct CPU_2A03 { |
对于这些寄存器,只需要知道它们参与对应指令的计算即可,主要是读写内存、把数据存取于栈上。
栈
栈也是一段内存,栈的读写只能依次推入/取出,于是需要栈指针SP来记录当前栈的下标。其长度为256字节,起始位置是内存的0x100,范围在0x100-0x1FF。
1 | regs.SP = 0xFF; // 栈是自上而下增加 |
入栈的时候SP指针上移,出栈的时候下移,初始值为0xFF。
指令解析
机器指令是8位的,数据格式是:指令(8bit) + 操作数(8 or 16bit)。
于是,指令解析可以写成下面这样:
1 | cmd = read_8bit(); |
这里先不介绍指令的具体解析,只需要明白这个操作即可,例如寄存器 A|X|Y的数学运算,栈SP指针的前后移动,PC指针的跳转等等。
这里有指令解析的完整实现:http://nesdev.com/6502_cn.txt
PPU(2C02)
PPU是图像处理单元,负责像素填充,类似显卡。PPU专用内存VRAM存储绘图所需的数据。其中名称表、图案表、属性表,构成整幅背景,通过索引的方式,每个点映射为系统调色板上的一个颜色。PPU的工作就是负责处理其中的转换。
在当时处理器受限、存储空间受限的情况下,为了让显示器呈现彩色画面,任天堂使用了这种巧妙的设计,在减小内存占用的同时,还支持变色功能。
至于背景如何滚动起来,是由PPU专门的寄存器来控制名称表的偏移坐标,由CPU执行相应指令的时候,更改该寄存器来实现的。
屏幕上的角色,是由精灵控制的,每个精灵代表一个瓦片(图案表单元),像马里奥这样的角色,是由多个精灵拼接而成的。
VRAM
VRAM是一个16KB大小,专门提供给PPU使用的内存,可以理解为显卡的显存,存储名称表、属性表、图案表。图案表是固定的,系统启动的时候,会从ROM里的固定位置读取图案表数据加载到VRAM。
详见:https://wiki.nesdev.com/w/index.php/PPU_memory_map
注意:属性表位于名称表未使用的最后64字节空间
模拟这部分内存,同样需要处理镜像问题
1 | struct VRAM { |
调色板
系统调色板
系统调色板构成了FC能够显示的所有颜色(一共64种),透过下标索引来使用,下面是具体RGB值
1 | 84 84 84 0 30 116 8 16 144 48 0 136 68 0 100 92 0 48 84 4 0 60 24 0 32 42 0 8 58 0 0 64 0 0 60 0 0 50 60 0 0 0 |
我们需要单独分配一个192字节的空间来存储调色板颜色
1 | const static uint8_t DEFAULT_PALETTE[192] = { |
背景调色板 & 精灵调色板
调色板内存位于0x3F00-0x3F1F(32字节),由两个16字节的调色板组成,一个用于背景调色(0x3F00),一个用于精灵调色(0x3F10)。其中每个字节表示一个0-63的数,索引到系统调色板,下面看到的颜色都来自系统调色板。
调色板内存有些地方存在镜像,见上图蓝色位置。这个问题留给大家探索了,我也只是猜测。
图案表
图案表是一个4KB的空间,PPU有两个图案表0x0000-0x0FFF和0x1000-0x1FFF,映射在VRAM里由背景和精灵共用。它的基本单元是瓦片,一个瓦片8x8像素(64个像素),8字节一组,共2组顺序排列,16字节。下面是一个瓦片的数据排列:
64bit: 11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
64bit: 11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
为了索引到调色板内存,需要一个4bit的数(0-15)。这里第一组构成了每个像素的低0位,第二组构成了每个像素的低1位。所以瓦片存在图案表的时候,实际上是无色的,因为每个像素只代表了2bit,还无法索引到具体调色板内存上的位置。
最后需要与属性表构成的高2bit组成一个4bit的数(0-15),才能索引到调色板内存。
名称表
FC有4个名称表,位于0x2000-0x2FFF,一共4KB,每个名称表1024字节。其中前960字节存储实际名称表,后64字节存储属性表。
一个名称表是32x30的尺寸(960字节),每个单元占1字节(0-255),用来定位一个瓦片(图案表单元),也就是上面介绍的图案表中的16字节数据。所以在计算瓦片索引的时候,记得按照16字节来做偏移。
有了名称表,我们就能构成整幅画面,因为32x30*8x8就等于256x240,刚好是FC显示尺寸。
现在,我们可以根据名称表用瓦片来填满每个像素,但是此时每个像素只有2bit。
属性表
每个属性表位于每个名称表的后64字节,其中前60字节有意义。
全屏是由32x30的瓦片构成的,为了填补每个瓦片缺失的2bit,属性表每个字节作用在每4x4的瓦片上。每字节(8bit)分成4x2bit,每2bit作用在2x2的瓦片上(4x4 = 4x2x2)。作为其中每个像素的高2bit,以此构成一个4bit数(0-15),索引到调色板内存。
知道了以上信息,我们就可以把每个名称表都画出来,像下面这样(2x2排列)。
名称表有个特性叫:镜像。目前实现竖直镜像即可(水平、竖直镜像开关由头文件里的一个标记控制)。
具体查看这里:https://wiki.nesdev.com/w/index.php/Mirroring
精灵
精灵也是图案表的瓦片引用,每个精灵引用一个瓦片,4字节。
1 | struct Sprite { |
info定义
1 | 76543210 |
info里低2bit构成了类似属性表的工作,构成调色板内存引用的高2bit,与瓦片自带的低2bit构成一个4bit数索引到调色板内存。
其中info还定义了翻转功能,以及在背景上还是背景下。还记得马里奥下水管时的样子吗?那个时候就是通过改变Priority值来实现的。
FC一共支持64个精灵在屏幕上显示,也就是说,需要准备一个256字节的存储空间来模拟精灵内存。
1 | uint8_t _OAM[256]; // 精灵内存, 64 个,每个4字节 |
OAM的数据是由PPU暴露在FC内存上的几个寄存器传入的,位于0x2000-0x2007。
这里详细介绍了这几个寄存器:https://wiki.nesdev.com/w/index.php/PPU_registers
实现之后,才能与PPU传输数据。
画面呈现
FC的画面呈现,涉及扫描线、绘制像素点、时间周期的准确同步,这里简单介绍PPU的工作流程。
FC并没有缓冲区一说,PPU会实时在屏幕上绘制每一个点。PPU的时钟周期是CPU时钟周期的3倍,当CPU运行了1个时钟周期(一条指令花费数个CPU时钟周期),PPU就在屏幕上画了3个点,一行画完后跳转到下一行,如此将整个显示器画完,再进行下一帧的绘制。画面呈现通常是60帧/秒,但这跟电视信号的制式有关,那个年代常用的制式有NTSC和PAL。
我们要用一块内存来模拟显示器,存储PPU绘制的像素点,最后才将其作为纹理显示。
NTSC制式数据,尺寸341x262像素,刷新率60fps。
FC可显示区域是256x240像素,我们在模拟的时候需要按照制式来绘制每一条扫描线,不在显示范围的就跳过,但仍然要算在PPU工作时间内(PPU的水平空白时间称为HBlank,竖直空白时间称为VBlank)。
VBlank
我们知道了PPU一直在屏幕上绘制像素点,又没有缓冲区,这不就是所谓的“禁用垂直同步”吗?为什么没有产生画面撕裂呢?
因为所有画面改变的操作都放到了VBlank里,也就是PPU画完最后一根可见扫描线之后,运行在不可见扫描线期间这段时间。
FC处理器只支持单线程,如何知道VBlank发生了呢?谁来通知并触发相应代码?这里就得提到中断了。
VBlank发生的时候,会触发NMI中断。
中断
中断,可以理解为系统事件,在程序执行中,遇到了特殊情况需要处理,系统将程序挂起来(把PC指向的下一条指令地址推入栈),然后将PC跳转到中断处理函数地址继续执行,返回的时候,也就回到之前程序的下一条指令。
中断类型有四种
NMI:此中断可以被0x2000的第7位屏蔽,0就是屏蔽
复位:此中断不可屏蔽
Break:此中断为代码产生,例如执行到机器码0x00,就需要模拟产生此中断,通常是执行错误才会执行到0x00
IRQs:此中断也叫软中断,由系统硬件发出,可以屏蔽。如果中断禁止标记为0,才可继续此中断
为什么中断有可屏蔽一说?
我们知道中断函数也是程序员写的,如果在这段时间代码量大,执行时间长,PPU可一直在运行,如果绘制到第二帧完成,再次触发了NMI中断,就会陷入中断嵌套,造成程序崩溃。
所以,在处理中断函数跳转的时候,要先屏蔽中断
1 | // 设置中断屏蔽标志(I)防止新的中断进来 |
而我们摁下FC机器上的复位键产生的复位中断,是不可屏蔽的。
如何处理中断?
中断应该放在cpu_work执行当前指令解析之前,首先检查是否有中断信号存在。
不同的中断,由不同的中断函数来处理,程序员需要把中断函数地址写在固定地址下。
1 | // 得到中断处理函数的地址 |
在汇编中是这样写的
1 | .bank 1 |
最终效果
了解完以上知识,我们就可以让超级马里奥显示出画面来了。绘图部分就是上面讲到的像素格式解析,可自行实现。
总结
篇幅有限,文章就写到这里,剩下的部分,例如6502汇编程序编写、屏幕滚动、碰撞检测、控制器、时钟周期同步、Mapper等,这些内容可以在编写模拟器的时候结合前面的知识查阅相关文档来逐步实现。所有资料都可以在 nesdev.com 上找到。