相信中大龄 90 后们都有一段难忘的和「小霸王学习机」相伴的时间吧。只要在 PC 上装过模拟器,就知道它对应的游戏 ROM 格式是 NES,也就是 Nintendo Entertainment System 了。而现在,连手机都有了模拟 32 位 PSP 的能力,要模拟这台老掉牙的 8 位机自然不在话下。下面就是一只连 x86 汇编循环都写不利索的菜鸟入门笔记。在本篇和后续的笔记中,将会一步步记录模拟器开发过程中的细节,并整理出有用的工具与资料。希望对有兴趣的同学有所帮助。

模拟器入门

下面描述中的术语,都可以直接在文末的基本概念一节中找到,所以请放心地看吧 XD

模拟器最基本的运作方式,就是在一个 while 循环中不停地取指令并执行了。CPU 每次取一条指令,根据这条指令,执行算术运算 / 寻址 / 读写等操作。模拟器要做的,就是模拟这个过程的执行。

那么,对模拟器来说,CPU 每次取的指令在哪里呢?理论上我们都知道,现在的计算机内存空间很大,程序加载的时侯一般将机器码载入内存,CPU 再从内存取每个时钟周期所用到的指令。不过对 NES 这种上古遗迹来说,并没有这么大的内存,所有的指令都是直接从 ROM 里读取的。

ROM 可以用 UltraEdit 之类的二进制文件查看器来查看。而这些查看器,默认都是把二进制的数据以十六进制格式来展示的。这里复习一下高中数学:十六进制的 0 到 F 在二进制下,要用几位表达呢?4 位(2^4 = 16 嘛)。于是,两个十六进制数,就能表达一个二进制下 8 位的数了。十进制下,这个范围就是 0-255 了。

而我们又知道,NES 是一台 8 位游戏机。这也就意味着,它的每条 CPU 指令长度与内存寻址范围都是 8 位的。这个长度,刚好和查看器显示的元数据单位格式是一致的。也就是说,打开 ROM 看到的 FF / D8 等数据,直接就是 NES CPU 的机器码了(其实还有 PPU 和文件头信息,这里先略过)。下面会有截图来直观地展示这一点。

那么,这些机器码大致做了些什么操作呢?

6502 带有一个 8 位的算术寄存器 A、两个 8 位的地址寄存器 X 和 Y、一个 6 位的处理器状态寄存器 P、一个 8 位的栈指针 S,和一个 16 位的程序计数器 PC。除了这几个寄存器以外,能够暂存状态信息的就是 2KB 的 CPU 内置 RAM 了。CPU 读取 ROM 的机器码,操作这些寄存器和 RAM,让游戏跑起来。

那么我们的上手思路也就很明确了:用程序语言中的变量来模拟上面的这几个寄存器和 RAM,每次循环,从载入的 ROM 中读取指令,并实现每条指令所对应的操作。这样,我们就模拟出了 NES 的 CPU。当然了,单纯实现了模拟 CPU 的模拟器是既没有声音也没有图像的。在后面继续模拟了负责图像的 PPU 和负责声音的 APU 后,模拟器才算完整。

上手指南

查看 ROM

使用二进制文件查看器打开 nes 文件。这里选用了一个轻量级的查看器 Hex Fiend

用它打开一个游戏 ROM,就能看到存储为二进制格式的 ROM 数据啦。比如下图中的超级马里奥:

mario-1

除了开始的 16 个字节以外,图中的每一个字节都是一条指令。

NES 开发环境搭建

这里介绍两种用于编译 NES 程序的开发方式:C 和 6502 汇编,并且都附有 Hello World 量级的源码。对由源码所编译得到的 nes 格式文件,既可以直接在现有的模拟器中执行,也可以直接在二进制文件查看器中分析,方便加深对程序运行方式的理解。

6502 汇编

有许多优秀的 6502 汇编器,这里选用了 NESASM

关于使用这个汇编语言(也就是使用汇编写 NES 游戏),这里有个相当详尽的 Tutorial

OS X 环境下 NESASM 汇编器的安装(先从 Github 上下载源码,在不同的平台上用不同的 Makefile 编译安装):

cd source
make -f Makefile.osx
cp nesasm /usr/local/bin

C 语言

先下载 cc65 源码并解压,然后编译安装:

make -f make/gcc.mak
sudo make -f make/gcc.mak install

编译大约需要一两分钟,完成后查看是否安装成功:

which cc65
/usr/local/bin/cc65

如果提示信息没有问题,那么就可以尝试编译一下基本的 hello world 了。

cl65 -L .\lib -t nes -I .\include hello.c -o hello.nes

基本概念

NES

小霸王乐趣无穷啊!在美国它称为 NES,而在日本它又叫做 Famicom,也就是所谓的 FC 红白机。

ROM

Read Only Memory,只读存储器,和可读写的 RAM 相区别。游戏卡带都是用它制造的。

CPU

NES 使用了理光 6502 CPU,因此默认和它配套的编程语言就是 6502 汇编了。这是一只 8 位的 CPU,频率 1.79Mhz,板载了六个 8 位寄存器和 2KB 内存。

寄存器

CPU 的重要组成成分,相当于速度极快而容量极小的内存(现在的内存一条 8G,当年的寄存器一个才 8 位呢)。可以存储数据、指令和地址。

PPU

PPU 指 Picture Processing Unit,它能够生成 240 线的像素复合信号,在当时相当先进。CPU 计算出的结果会通过 PPU 显示出来。

APU

APU 是 Audio Processing Unit,用于声音信号的生成。

PRG

Program Memory,游戏程序数据。

CHR

Character Memory,用于显示(贴图等)数据的存储。

Mapper

焊接在卡带上的硬件,实现了地址解码,bank 切换等任务。

Mapper 是 NES 不同卡带上附加的各种额外硬件的统称。它通常将 CPU 与 PPU 较小的地址空间,映射到卡带上的 ROM 地址空间。这也是它名字的由来。

上面的描述肯定还有许多不详之处。如果有任何疑问,在 NES reference guide 上基本都能找到答案。强烈推荐。