Back
Featured image of post 从零开始的 Game Boy 模拟器 (一)

从零开始的 Game Boy 模拟器 (一)

可能不会有 (二) 了 🕊🕊🕊

前言

Gameboy作为任天堂最成功的一代掌机产品,其近乎成为了掌机标志性代表,自己完成一个Gameboy模拟器一直是我的一个梦想。早在一年前,我就开始试着读了下Gameboy的文档资料,惊讶的发现有了计组和汇编课程的知识储备,看这些文档似乎也没有想象中的那么难,不过离写出一个模拟器差的还是有点多。幸运的是,Gameboy是很受Hacker们欢迎的一台机器,网络上能找到不少相关资料,Reddit上甚至有专门的模拟器开发板块,基本在开发中碰到的砍都能找到相应的解决方案。从构思到编码,完成这款模拟器总共花费了我一个月的时间,编码过程中由近乎一半的时间都在DEBUG。不过最终总算是做出了一个能用的模拟器

GUI模式模拟
GUI模式模拟

既然模拟器跑起来了,那就可以做一些有意思的扩展了。现在都说云游戏是未来,那咱也来凑波热闹吧,你可以通过命令行进行“云游戏”游玩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这种异型卡带装有摄像头用于拍照。

一个最简单的Gameboy游戏卡带
一个最简单的Gameboy游戏卡带

模拟器的启动过程与真机类似,不过真机是从内置的BIOS启动的,其用于检查卡带授权信息、展示任天堂LOGO。在模拟时我们可以略去这一步(如果您对重现Gameboy的经典开机画面不是特别执着的话),只需要将初始状态CPU寄存器的初始化为和BIOS启动后真机的一致就行。

模拟器在启动时,首先要做的是读取卡带信息,尤其是识别卡带的类型,不同类型的卡带在后期模拟时处理方式也有稍微的不同。在模拟器这里,卡带实体是以ROM文件呈现的(一般为gb扩展名),这些ROM文件将卡带中记录的数据完整的DUMP出来,我们不必关注硬件连接,只需要读取ROM文件即可,这使得我们模拟的工作要轻松很多。在拿到ROM后,模拟器首先要做的是读取卡带的基本信息。

卡带头部

ROM文件中0x1000x14F部分为卡带的头部信息,记录了卡带类型、游戏标题、版权标识等信息,模拟过程需要关注的主要有

  • 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操作。

Memory Banking示意图
Memory 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 BANK

     00h = 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.73FPS,方便起见,模拟时可以假定为60FPS。也就是说,模拟器循环的执行是需要控制为和帧率一致的速率的。下面是模拟器循环的实现代码:

// 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的时钟频率CLOCK4.194304 MHz,而模拟器循环频率FPS60HZ,所以每轮模拟器循环中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外,剩下的寄存器分别为AFBCDEHL,这些寄存器可以被当作完整的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寄存器、用位操作更改各个标志位的值。

Licenced under CC BY-NC-SA 4.0
views