前言
Gameboy作为任天堂最成功的一代掌机产品,其近乎成为了掌机标志性代表,自己完成一个Gameboy模拟器一直是我的一个梦想。早在一年前,我就开始试着读了下Gameboy的文档资料,惊讶的发现有了计组和汇编课程的知识储备,看这些文档似乎也没有想象中的那么难,不过离写出一个模拟器差的还是有点多。幸运的是,Gameboy是很受Hacker们欢迎的一台机器,网络上能找到不少相关资料,Reddit上甚至有专门的模拟器开发板块,基本在开发中碰到的砍都能找到相应的解决方案。从构思到编码,完成这款模拟器总共花费了我一个月的时间,编码过程中由近乎一半的时间都在DEBUG。不过最终总算是做出了一个能用的模拟器:
既然模拟器跑起来了,那就可以做一些有意思的扩展了。现在都说云游戏是未来,那咱也来凑波热闹吧,你可以通过命令行进行“云游戏”游玩Gameboy游戏:
总的来说,编写一个Gameboy模拟器并不是特别特别困难的事,大学里的汇编和计算机组成的知识完全能应付过来,国外的文档资料也十分全面,赶紧动手吧!在复习汇编计组的同时,真的能学到不少新知识!这篇文章就以尽可能易懂的方式阐述Gameboy模拟器的基本思路,希望能给有类似想法的同学一个参考。
硬件概况
广义上的Gameboy指的是Gameboy家族,其产品线包括初代厚砖头Gameboy、Color、Advance及其附属改良、变种机型,并且每代机型都能向后兼容前代卡带。我所模拟的是初代的Gameboy(以下简称Gameboy)。初代的架构与其后的Color很相似,所以完成初代模拟后不需要较大工作也可完成Color的模拟。Gameboy的基本硬件信息如下:
- CPU:改良后的Z80,主频4.194304 MHz
- 工作内存:8KB
- 显存:8KB
- 图形:160x144分辨率,4阶灰度
- 声音:4个通道,包括2个方波通道、1个噪声通道和一个采样通道
- I/O:手柄按键、串口通信、卡带插槽
编写模拟器的主要工作也就是完成对上述组件的模拟。
模块模拟思路
我采用的思路是将现实的实机的功能部分对应到代码逻辑上来,将模拟分为了CPU、内存、图形、声音、卡带、控制器、外部驱动实现(串口通信、键盘、GUI显示)。这里我将以模拟器从启动到运行的顺序对这些部分分别解释。
卡带
没法插卡带的游戏机是没有灵魂的。
卡带是游戏的载体,卡带中以只读形式存储着游戏的全部数据,不同游戏使用的卡带可能有很大的不同。有的游戏卡带支持使用电池驱动的可擦写存储,以便用于游戏存档;有些卡带内装有增强芯片,以便用于实现原始机器不能实现的画面或声音特效;更有像Gameboy Camera这种异型卡带装有摄像头用于拍照。
模拟器的启动过程与真机类似,不过真机是从内置的BIOS启动的,其用于检查卡带授权信息、展示任天堂LOGO。在模拟时我们可以略去这一步(如果您对重现Gameboy的经典开机画面不是特别执着的话),只需要将初始状态CPU寄存器的初始化为和BIOS启动后真机的一致就行。
模拟器在启动时,首先要做的是读取卡带信息,尤其是识别卡带的类型,不同类型的卡带在后期模拟时处理方式也有稍微的不同。在模拟器这里,卡带实体是以ROM文件呈现的(一般为gb
扩展名),这些ROM文件将卡带中记录的数据完整的DUMP出来,我们不必关注硬件连接,只需要读取ROM文件即可,这使得我们模拟的工作要轻松很多。在拿到ROM后,模拟器首先要做的是读取卡带的基本信息。
卡带头部
ROM文件中0x100
到0x14F
部分为卡带的头部信息,记录了卡带类型、游戏标题、版权标识等信息,模拟过程需要关注的主要有
-
0x100
-0x103
- 卡带入口BIOS程序结束后游戏会从此处开始执行,通常会有
JP
指令跳转至游戏程序真正的起点。 -
0x104
-0x133
虽然模拟器不需要关心这一部分,但因为很有意思,所以也拿来说一下。
这里记录的是任天堂LOGO的点阵图,与开机画面的那个一样。为什么要在卡带里包含一份呢?其实这是为了防止厂商未经任天堂许可私自生产卡带。因为Gameboy在启动时BIOS会检查卡带的这一部分数据,并与Gameboy自身存储的LOGO进行对比,如果不一致,游戏就无法启动。所以每个想要在Gameboy上运行的卡带都需要包含这一段数据,而使用任天堂LOGO又必须得到授权。这种做法与Oracle在代码里写诗有异曲同工之妙。
-
0x134
-0x143
- 游戏标题这里记录的时游戏大写标题的ASCII码。
-
0x147
- 卡带类型这一部分是模拟器重点关注的部分,其值具体含义如下:
00h ROM ONLY 13h MBC3+RAM+BATTERY 01h MBC1 15h MBC4 02h MBC1+RAM 16h MBC4+RAM 03h MBC1+RAM+BATTERY 17h MBC4+RAM+BATTERY 05h MBC2 19h MBC5 06h MBC2+BATTERY 1Ah MBC5+RAM 08h ROM+RAM 1Bh MBC5+RAM+BATTERY 09h ROM+RAM+BATTERY 1Ch MBC5+RUMBLE 0Bh MMM01 1Dh MBC5+RUMBLE+RAM 0Ch MMM01+RAM 1Eh MBC5+RUMBLE+RAM+BATTERY 0Dh MMM01+RAM+BATTERY FCh POCKET CAMERA 0Fh MBC3+TIMER+BATTERY FDh BANDAI TAMA5 10h MBC3+TIMER+RAM+BATTERY FEh HuC3 11h MBC3 FFh HuC1+RAM+BATTERY 12h MBC3+RAM
卡带类型的区别主要是体现在MBC (Memory Bank Controller)上,具体是啥后面再说。
MBC
虽然还没讲到内存部分,不过咱可以先预告一下,Gameboy的内存映射中,分给卡带ROM部分的内存地址空间值只有32KB!也就是说在原始情况下,Gameboy只能玩32KB的游戏。对于俄罗斯方块这种小游戏,32KB完全够用,但对于宝可梦这种游戏来说肯定是完全不够的。所以ROM容量为1MB的初代宝可梦是怎样实现在Gameboy上运行的呢?这里就要介绍一种名为Memory Banking的技术,它与计算机中内存的分页和交换机制很类似。Gameboy将内存分为了16KB大小的“bank”,也就是有两个bank大小的卡带ROM数据可同时映射到内存中。这样就可以实现用有限的地址空间访问更大范围的数据:游戏在运行时之将当前时刻需要的bank映射到内存中,需要其他数据的时候再将新的bank替换进来。对于ROM ONLY
类型的游戏,这个过程就不需要啦,32KB的地址空间正好完全对应32KB的卡带ROM。
除了ROM需要使用Bank以外,Gameboy映射给用于存档的卡带内置RAM的地址空间也很有限,所以大部分情况下,带有RAM功能的卡带也需要对RAM进行类似的Banking操作。
什么时候需要替换新的bank?在现代计算机中,内存分页、交换是由操作系统完成的,应用程序无需干涉,而在Gameboy中,这一过程是由游戏程序控制的。前面说过,卡带有不同的类型,不同类型的卡带主要体现在对Memory Banking控制逻辑的不同,常见的类型有MBC1、MBC2、MBC5等。下面以最最常见的MBC1为例说明。
前面说到,Gameboy内存中有32KB映射为卡带ROM,这一部分存放的主要是游戏程序、资源等等不可修改的数据,理论上这一部分内存是只读的。但在MBC中,游戏可以通过想这一部分地址写入数据来控制Memory Bank。Gameboy内存中,0x0000
-0x3FFF
是第一块ROM Bank,但这一块Bank不可操纵,总对应着卡带的第一块Bank,随后的0x000
-0x7FFF
为第二块,可通过MBC改变Bank。向这些地址写入产生的控制效果如下:
-
向
0x0000
-0x1FFF
写入 RAM Bank 使能有些卡带带有可读写的RAM用于存储游戏存档数据(后面会说到,这一部分在Gameboy内存中也有映射),原则上,在向卡带RAM写数据前要先启用RAM,写入完毕后立马禁用,以防止关闭Gameboy电源时对卡带RAM造成损坏。但对于模拟器来说,如果不追求精确模拟的话,这个其实无所谓,就当一直启用着就行。写入值的含义为:
00h 禁用RAM (默认) 0Ah 启用RAM
-
向
0x2000
-0x3FFF
写入 - 指定ROM Bank 序号被写入的值即为需要的ROM Bank编号的低5位(剩下的位怎么决定在下一段)。MBC会将需求的Bank序号从卡带映射到
0x000
-0x7FFF
。 -
向
0x4000
-0x5FFF
写入 - 指定ROM Bank 序号的高位或者RAM Bank序号向这一部分写入会产生不同的效果:指定ROM Bank序号的高位、或者指定RAM Bank序号,到底哪种影响,取决于下一段中的描述。
-
向
0x6000
-0x7FFF
写入 - 指定上一段中的操作是用来控制ROM Bank还是RAM BANK00h = ROM Banking Mode (up to 8KByte RAM, 2MByte ROM) (default) 01h = RAM Banking Mode (up to 32KByte RAM, 512KByte ROM)
在编写模拟器时,我们可以对写内存的操作,根据写入地址进行划分,如果在上面的范围内,就调用MBC相应的功能进行Memory Banking,具体会在内存章节中进行说明;对于读内存的操作,如果读取地址范围处于某个Memory Bank中,就根据当前选择的Bank序号去卡带ROM或RAM中取回相应的数据返回。
内存
模拟器获取到卡带的基本信息后,需要完成内存地址对卡带ROM和RAM的映射,并完成内存的初始化。内存是模拟Gameboy中很重要的部分,Gameboy采用“内存映射的I/O” (Memory-mapped I/O) 与外部硬件通信,对诸如LCD、声音处理单元的控制都是通过读写内存实现的。
在模拟内存部分时,我们主要需要实现两个接口:
WriteMemory(address word, value byte)
- 向指定内存地址写字节ReadMemory(address word) byte
- 读取指定内存地址一个字节的数据
Gameboy可用的工作内存只有8KB,但其总共可寻址的范围为0x000
- 0XFFFF
,所以我们可以简单粗暴的用一个数组来模拟全部的内存空间:
type Memory struct {
MainMemory [0x10000]byte
}
但实际上,Gameboy主机并没有这么多可用内存,这些地址空间中有很大部分都被映射给了外部的硬件,真正属于主机内存部分的只有8KB,所以在处理读写内存指令时,除了访问MainMemory
数组或其他外部存储设备外,模拟器需要根据目的地址所处范围执行不同的外部操作。
内存映射图大致如下:
0000-3FFF 16KB的ROM Bank 序号0 一直对应卡带的0号 ROM Bank
4000-7FFF 16KB的ROM Bank 序号1...N 可变的 ROM Bank
8000-9FFF 8KB的显存
A000-BFFF 8KB的外部RAM 如果卡带有RAM功能的话,这里也是可以进行 RAM Banking 的
C000-DFFF 8KB工作内存
E000-FDFF 内容与C000-DDFF一致(ECHO) (通常不被使用)
FE00-FE9F Sprite属性表 (OAM)
FEA0-FEFF 不可用
FF00-FF7F I/O 端口映射
FF80-FFFE High RAM (HRAM)
FFFF 中断使能寄存器
下面从上到下依次介绍。
-
0000-3FFF
这一部分始终对应卡带ROM的第0号Bank,对于这一区域的读请求,直接返回卡带ROM对应地址的字节就行了;写操作可能根据卡带类型的不同,交给卡带的MBC处理或者忽略掉。
-
4000-7FFF
在
ROM Only
的卡带中,这一部分对应着卡带ROM的相同位置,直接返回对应字节即可。对于带有MBC的卡带,模拟器需要根据当前选择的Bank序号计算出卡带中对应的真实地址并返回字节。 -
8000-9FFF
这一部分对应主机显存,直接对
MainMemory
数组的对应地址进行存取操作。至于游戏画面的处理,咱放在后面说。 -
A000-BFFF
这一部分时可读写的外部RAM,对于有MBC的卡带,存取前,模拟器需要进行RAM Banking操作。有些没有MBC的卡带也支持外部RAM,不过不需要Banking。
-
C000-DFFF
这里的处理很简单,直接对
MainMemory
数组的对应地址进行存操作. -
E000-FDFF
这部分的内容与
C000
-DFFF
一致,可以在处理C000
-DFFF
部分写指令的同时将字节写入到这部分对应的位置。 -
FE00-FE9F
这部分也可以放在
MainMemory
数组里,具体作用放到图形章节说。 -
FEA0-FEFF
不可用区域,直接忽略掉这部分的读写请求。
-
FF00-FF7F
这部分很重要,这里映射给了各种外部硬件的寄存器,也是CPU与外部硬件通讯的通道。这部分的很多读写请求都要单独处理,具体后面遇到了再说。
-
FF80-FFFE
这一部分内存的读写速度要比别处要高,不过模拟时可以忽略,放到
MainMemory
数组。 -
FFFF
这一个字节是中断使能位,具体放到CPU章节说。
CPU
目前,我们的模拟器已经读取了卡带信息、完成了内存部分的基本模拟,之后模拟器就要进入主循环了,后续的全部工作都是在这个循环中完成的,每轮模拟器循环只需要依次执行以下操作:让CPU运行一段时间、更新控制器状态。这里的每次循环都会让游戏画面更新一帧,而Gameboy实机的帧率为59.73
FPS,方便起见,模拟时可以假定为60
FPS。也就是说,模拟器循环的执行是需要控制为和帧率一致的速率的。下面是模拟器循环的实现代码:
// Start the emulation loop
func (core *Core) Run() {
// Execution interval depends on the FPS
ticker := time.NewTicker(time.Second / time.Duration(core.FPS))
for range ticker.C {
core.Update()
// Check controller input interrupt
if core.Controller.UpdateInput() {
core.RequestInterrupt(4)
}
}
}
其中core.Update()
是最重要的部分,Update
中主要的工作是让CPU运行指定的指令周期,并在每次Update
结束后绘制一帧游戏画面。所以每轮模拟器循环CPU要运行多少个指令周期呢?前面提到过,Gameboy CPU的时钟频率CLOCK
为4.194304 MHz
,而模拟器循环频率FPS
为60HZ
,所以每轮模拟器循环中CPU可用的时钟周期为CLOCK/FPS
。
指令周期
Update
中,CPU会一直运行,直到所花费的总时钟周期达到CLOCK/FPS
,我们可以把Update
大致的框架写出来:
func (core *Core) Update() {
cyclesThisUpdate := 0
for cyclesThisUpdate < core.Clock/core.FPS {
/*
执行一个指令周期,并把所花费的时钟周期加到cyclesThisUpdate
*/
}
core.RenderScreen()
}
在模拟器中,“指令周期”除了执行指令外,还要执行包括更新时钟、画面等更多的操作。模拟指令周期需要执行的操作依次为:执行下一条操作码、更新计时器、更新画面、更新串口I/O、检查中断。
在开始最耗时的指令码模拟之前,我们还需要将CPU的寄存器表示出来。
寄存器
Gameboy的CPU有6个16位寄存器,除了栈顶指针SP
、程序计数器PC
外,剩下的寄存器分别为AF
、BC
、DE
、HL
,这些寄存器可以被当作完整的16位使用,也可以把高低位拆分为两个8位寄存器使用。但是AF
的低位F
是状态标志寄存器,不可以直接使用。
在模拟时,你可以选择将每个寄存器表示为一个WORD,在需要拆分使用时再使用位运算拆分,或者使用BYTE表示每半个寄存器。我使用的组合的方式:
type Registers struct {
A byte
B byte
C byte
D byte
E byte
F byte
HL uint16
PC uint16
SP uint16
}
方便起见,我使用了另一个结构体来表示状态标志寄存器的值,以将各个状态拆分开来:
type Flags struct {
Zero bool //零位
Sub bool //加减位
HalfCarry bool //半移位
Carry bool //移位
InterruptMaster bool
PendingInterruptEnabled bool
}
在后期进行指令模拟时,寄存器的值需要根据具体情况进行更新。不过后面实际操作的时候,我发现这种分开表示的方法不怎么方便….更好的方式是想其他寄存器那样,用一个字节把F
寄存器、用位操作更改各个标志位的值。