CHIP-8 是一种解释型语言,设计之初就是为了编写简单的小游戏。我猜是作者嫌老机器的汇编语言太复杂繁琐,从而自己设计了一门汇编语言,并且摆脱硬件的束缚,在模拟器上运行。其实这个思想和 Java 等基于虚拟机的高级语言也是类似的,提供方便程序员编写的指令集,在硬件之上空架一层虚拟机,实现 “Write Once, Run Everywhere”。

虚拟机的结构

和现代的计算机一样,虚拟起也有内存,CPU,寄存器等计算机的“基础零件”:

  • Memory:CHIP-8 最多有 4096 字节的内存

CHIP-8 解释器本身占用这些机器上的前 512 字节内存空间。因此,为原始系统编写的大多数程序都从内存位置 512 (0x200) 开始,并且不会访问位置 512 (0x200) 以下的任何内存。最上面的 256 个字节 (0xF00-0xFFF) 保留用于显示刷新,下面的 96 个字节 (0xEA0-0xEFF) 保留用于调用堆栈、内部使用和其他变量。

  • Program Counter:16 位的 PC,记录当前程序指令运行的内存位置,因为需要访问最多 4K 的内存(0xFFF)
  • Stack:16 位地址的堆栈,用于调用函数和返回。栈调用深度最初设计位 12 层,可以自行调整。
  • Registers:
    • 16 个 8 位数据寄存器(data register),名为 V0 至 VF。 VF 寄存器兼作某些指令的标志;因此,应该避免这种情况。在加法运算中,VF 是进位标志,而在减法运算中,VF 是“无借位”标志。在绘制指令中,VF 在像素冲突时设置。
    • 一个 16 位索引寄存器(index register),用于记录内存地址
  • Timers
    • 8 位延迟定时器,以 60 Hz(每秒 60 次)的速率递减,直至达到 0
    • 8 位声音定时器,当其值非零时,会发出蜂鸣声。
  • Display:64 x 32 像素(或 128 x 64 对于 SUPER-CHIP)单色,即黑或白
  • Inputs:16 个输入键,与前 16 个十六进制值匹配:0 到 F。

上面就是 CHIP-8 虚拟机的全部“硬件”了,接下来就是实现用软件模拟硬件。

内存

如上所述,CHIP-8 有 4096 字节的内存,并且内存的最小分配单位是一字节,因此只需要 12 位(0xFFF)便可以寻址整个内存空间。

由于 C 语言中没有 12 位的类型,所以使用 uint16_t 也就是 unsigned short 来表示内存地址:

1
2
3
4
5
struct chip8 {
  uint8_t mem[4096];
  uint16_t index_reg;
  uint16_t pc;      // keep trace of opcode address
};

栈也是内存的一部份,CHIP-8 是支持函数调用的,因此也需要考虑函数调用栈的实现,栈的特性就是先进后出,先调用的函数后执行。比如下面的程序会输出:cba

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

int main() {
  a();
}

void a() {
  b();
  printf("a");
}

void b() {
  c();
  printf("b");
}

void c() {
  printf("c");
}

要实现栈的特性,可以简单使用数组来实现,我们需要记住最后一个函数入栈的下标 i

  • 入栈:新函数的内存地址从 i+1 位置讲函数内存地址写入数组
  • 出栈:新函数调用结束(return),递减栈的下标 i = i-1

函数调用对应指令为:2NNN,函数返回的指令为:00EE

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
  uint16_t stack[16];
  uint8_t sp;  // keep trace of stack top

/**
 * Return from a subroutine
 */
void opcode_00EE(CHIP8 *chip8) {
  byte pc = chip8->stack[--(chip8->sp)];
  chip8->pc = pc;
}

/**
 * Call subroutine at NNN
 */
void opcode_2NNN(CHIP8 *chip8) {
  chip8->stack[chip8->sp++] = chip8->pc;
  chip8->pc = NNN(_OPCODE);
}

定时器

CHIP-8 有两个独立的定时器寄存器:延迟定时器和声音定时器。大小为 1 个字节,只要它们的值大于 0,它们就应该每秒减少 60 次(即 60 Hz ),并且与执行指令的速度无关。也就是说无论代码怎么执行,即使进入了死循环,定时器也需要以 60 Hz 的频率运行。

  • 延时计时器作用是,使用 CHIP-8 游戏将检查计时器的值并根据需要自行等待

  • 声音计时器则是只要它高于 0,就会让计算机发出“蜂鸣声”

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  uint8_t delay_timer;
  uint8_t sound_timer;

  long last_timer_time = current_micros();
  while (1) {
    long now = current_micros();
    if (now - last_timer_time >= TIMER_DELAY) {
        if (chip8->delay_timer > 0) {
          chip8->delay_timer--;
          }
    if (chip8->sound_timer > 0) {
      chip8->sound_timer--;
      }
    }
  }

显示屏

CHIP-8 的显示屏宽 64 像素,高 32 像素。每个像素都可以打开或关闭(黑色或者白色)。换句话说,每个像素都是一个布尔值,或者说是一个 bit。

最初的 CHIP-8 解释器以 60 Hz 的频率更新显示(即它们的帧率为 60 FPS)。并且仅在模拟器执行修改显示数据的指令时才更新屏幕,以便运行得更快。现代计算器运行速度更快,因此这个优化可做可不做。

显示屏对应的绘制指令为 DXYN,表示在 (VX,VY) 坐标位置绘制一个 sprite,其中 n 字节的 sprite 数据从存储在 I 寄存器中的内存地址位置。如果任何设置的像素被更改为 unset,则将 VF 设置为 1,否则设置为 0。绘制流程如下,需要考虑绘制屏幕边缘的场景:

字体

CHIP-8 模拟器有一组内置的字体,就是 0 到 F 的十六进制数字,需要加载到内存中。例如,字符 F 表示为 0xF0、0x80、0xF0、0x80、0x80。看一下二进制表示:

1
2
3
4
5
11110000
10000000
11110000
10000000
10000000

可以看到 “F” 吗?绘制屏幕的原理就是这样:用一个 bit 表示像素的打开或者关闭,然后以一个字节为一组来绘制。

更新显示屏前,需要将索引寄存器 I 设置为字符的内存位置,然后开始绘制。因此将字体数据放在内存的前 512 字节中的任何位置 ( 000 – 1FF ) 都可以。内置的字体都以 5 个字节来表示,一共 16 个字体,需要 80 个字节,一般规定将字体放在 050 – 09F。

键盘

CHIP-8 最早的计算机使用十六进制键盘。它们有 16 个键,标记为 0 到 F ,并排列在 4x4 网格中。

1
2
3
4
1	2	3	C
4	5	6	D
7	8	9	E
A	0	B	F

在现代计算器中一般使用 QWERTY 键盘(目前只考虑这种排列的键盘),为了方便游戏,我们需要做键位映射:

1
2
3
4
1	2	3	4
Q	W	E	R
A	S	D	F
Z	X	C	V

虚拟机的运行

CHIP-8 使用两个字节的十六进制编码来编写程序,两个字节对应 cpu 指令集中的一个指令,虚拟机需要将其翻译成现代系统的操作。

  1. 设计 CHIP-8 的结构体,需要包含符合规定的内存、调用栈、寄存器、定时器、显示器和键盘输入;
  2. 加载程序文件到内存数组中,程序可访问的内存从 0x200 开始;
  3. 虚拟机的任务就是以对应的频率(不同的游戏以不同的频率运行效果比较好)无限循环运行,执行下面三个步骤:
    • Fetch:从当前 PC 的内存中取出 CPU 指令,并将 PC 指针 +2 指向下一个指令;
    • Decode:解码指令,根据指令规范计算 X,Y,N,NN,NN 的值,便于执行使用;
    • Excute:执行指令运算。
  4. 实现键盘输入(使用 SDL2,一个用 C 语言实现视频、音频、输入设备(如键盘、鼠标)等操作的库,主要用于游戏开发);
  5. 实现屏幕显示(使用 SDL2)。

指令说明

根据不同的指令实现不同的操作,最直观的方式就是使用 if 语句 或者 switch 语句判断,唯一的缺点是会导致代码比较长,可读性不好。后面可以考虑用函数表实现。

CHIP-8 有 35 个操作码(指令),都是两个字节长并以大端存储。完整的列表可以参考 WiKi 上的。下面给出操作码的规范,将指令分为了 4 个半字节(4 bits):

  • NNN: 第二、第三和第四半字节。表示 12 位内存地址。
  • NN: 第二个字节(第三个和第四个半字节)。8 位立即数
  • N: 第四个半字节。4 位立即数
  • X 和 Y:4 位寄存器标识符,指令的第二个和第三个半字节。用于查找从 V0 到 VF 的 16 个寄存器之一

可以优先实现下面几个指令,然后运行 IBM Logo.ch8 这个程序用于测试,只这个程序是会显示 IBM 的标志,并且只使用下面的指令。包括最重要的显示指令 DXYN

1
2
3
4
5
6
00E0 (clear screen)
1NNN (jump)
6XNN (set register VX)
7XNN (add value to register VX)
ANNN (set index register I)
DXYN (display/draw)

IBM Logo.ch8 成功绘制的结果如下:

当写完所有 35 个指令实现后,可以用下面的测试程序来测试正确性,如果一切正常屏幕会展示如下提示:

总之 CHIP-8 是一个很好的练手项目,需要阅读资料,熟悉位运算,自己动手更加深刻的理解计算机运行的原理。

我实现的 CHIP-8 代码地址: https://github.com/LeeReindeer/chip8-c

下面是我查阅的一些 CHIP-8 资料: