用 Rust 实现一个 CHIP-8 模拟器

SUbRO.png

CHIP-8是一个解释型语言,由Joseph Weisbecker开发。最初CHIP-8在上个世纪70年代被使用在COSMAC-VIP和Telmac 1800上。许多游戏如Pong,Space Invaders,Tetris都曾使用该语言编写。

我们今天用 Rust 来模拟实现这个 CHIP-8

CHIP-8 虚拟机构成

  • 内存 Memory : CHIP-8最常在4K系统上实现,如Cosmac VIP和Telmac 1800。这些机器有4096 (0x1000)内存位置,所有这些位置都是8位(字节),这就是 CHIP-8 的起源。但是,CHIP-8 解释器本身占用这些机器上的前 512 字节内存空间。由于这个原因,为原始系统编写的大多数程序开始于位置 512 (0x200) 的内存,不访问位置 512 (0x200)以下的任何内存。最上面的 256 个字节( 0xF00-0xFFF)被保留用于显示刷新,下面的96个字节(0xEA0-0xEFF)被保留用于调用堆栈、内部使用和其他变量。

  • 寄存器 Registers : 16个名为V0VF8 位数据寄存器。VF 寄存器可以作为一些指令的标志,在加法操作中,VF是进位标志,而在减法操作中,它是“no borrow”标志。在绘制指令中,VF设置为像素冲突。
    地址寄存器,它被命名为I,是16位宽的,并且与几个涉及内存操作的操作码一起使用。

  • Stack : 堆栈仅用于在调用子例程时存储返回地址。

  • 计时器 Timers:

    • 延迟计时器: 这个计时器是用来计时的比赛事件。可以设置和读取它的值。
    • 声音定时器: 这个定时器用于声音效果。当它的值非零时,会发出哔哔声。
  • 输入 Input: 输入使用十六进制键盘,有16个从0到f的键。“8”、“4”、“6”和“2”键通常用于定向输入。三个操作码用于检测输入。如果按了特定的键,一个跳过指令,而如果没有按特定的键,另一个也会这样做。第三个等待按键,然后将其存储在一个数据寄存器中。

  • 图像 Graphics and sound : 分辨率为 64×32 像素,单色。图形被绘制到屏幕单独的 sprites

  • 操作符: CHIP-835 个操作符,详细的见 Opcode table

初始化结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pub struct Cpu {
// register index
pub i: u16,

// program counter
pub pc: u16,

// memory
pub memory: [u8; 4096],

//registers, v0 - vf
pub v: [u8; 16],

//display
pub display: Display,

//stack
pub stack: [u16; 16],

//stack point
pub sp: u8,

// delay timer
pub delay_timer: u8,
}

所有的硬件设备我们都用软件来模拟,因为 Display 相对独立,因此我们将这部分抽象出来。

CPU 的执行就是一个大循环,不断的读取指令再运行。因为 CHIP-8 的操作码均是 u16 的,所以我们直接读取操作码,因此增加一个辅助函数来获得一个字节的数据。

Sog4S.png

整个处理过程是非常的清楚易懂的。

读取一个字节
1
2
3
4
fn read_word(memory: [u8; 4096], index: u16) -> u16 {
(memory[index as usize] as u16) << 8
| (memory[(index + 1) as usize] as u16)
}

执行循环就很容易写出来了

执行循环
1
2
3
4
5
6
7
8
impl Cpu {
pub fn run(&mut self) {
let opcode = read_word(self.memory, self.pc);
self.process_opcode(opcode);
}

fn process_opcode(&mut self, opcode: u16) {}
}

对于 CPU 是常驻对象,将其静态化起来非常的合理,这里用了 lazy_static 非常方便的一个 lib

初始化
1
2
3
4
5
6
7
8
9
10
11
12
lazy_static! {
pub static ref CPU: Mutex<Cpu> = Mutex::new(Cpu {
i: 0,
pc: 0,
memory: [0; 4096],
v: [0; 16],
display: Display {},
stack: [0; 16],
sp: 0,
delay_timer: 0,
});
}

对于所有的硬件系统我们都有时钟周期,也就是多久运行一次操作,我么也将这部分抽象出来。

执行循环
1
2
3
4
5
6
7
8
9
pub fn emulate_cycle(&mut self) -> bool {
if self.pc >= 4094 { // 内存最大值,保护性代码
return true;
}

let op = read_word(self.memory, self.pc);
self.process_opcode(op);
false
}

这里的话我们等待每一次的时钟周期触发,会触发这个回调,那我们的主循环就很好写了。

过下文我们会把自己嵌入别人的系统中。

main loop
1
2
3
4
5
6
fn main() {
loop {
cpu::execute_cycle();
sleep(Duration::from_secs(1));
}
}

这里我们用一个 sleep 来假装我们的时钟周期,我们还是尽快进入我们的核心的模拟器的部分,这部分内容我们在后续再进行优化。

模拟器

模拟器的核心就是来实现我们的 Opcode table,我们先来一个简单的试试水

0NNN 操作符不怎么使用,这里就不实现了

00E0 disp_clear

对于这种常量的处理,我们直接进行操作即可

disp_clear
1
2
3
4
5
6
 match opcode {
0x00E0 => {
self.display.clear()
}
_ => {}
}

1NNN goto NNN

这种直接跳转的处理逻辑也非常的轻松

1
2
3
4
5
6
match opcode {
0x1000...0x1FFF => {
self.pc = opcode & 0x0FFF
}
_ => {}
}

使用 & 将最高位的操作符 1 掩盖,然后将后 3 位赋值给 PC 即可。下面我们搞点难的

函数调用

2NNN Calls subroutine at NNN.

函数调用,显然需要压栈,call nnn 也就是移动到 nnn,但是在此之前记得保留上一次的 pc 计数器,对于 CHIP8 仅仅存存 Ret 地址,不会保留其他的寄存信息。

call
1
2
3
4
5
6
0x2000..0x2FFF => {
let nnn = opcode & 0x0FFF;
self.stack[self.sp as usize] = self.pc;
self.sp = self.sp + 1;
self.pc = nnn;
}

00EE return

那么 ret 函数就更简单了,还原下 SP 即可

ret
1
2
3
4
0x00EE => {
self.sp = self.sp - 1;
self.pc = self.stack[self.sp as usize]
}

条件语句

这里就不多写了,直接就写一个示例 3XNN if(Vx==NN)

if eq than jump
1
2
3
4
5
6
7
0x3000..0x3FFF => {
let register_index = (opcode & 0x0F00) >> 8; // 拿到寄存器
let nn = (opcode & 0x00FF); // 拿到立即数
if self.v[register_index] == nn {
self.sp += 2
}
}

到这里我们发现很多操作是可以重复的,在 Java 里面我们就会抽一个函数出来,在 Rust 中,我们有强大的 ,我们直接使用 来构建这些通用的函数。

比如我们经常要取 X 作为寄存器的下标

arg_x
1
2
3
4
5
macro_rules! arg_x {
($opcode:expr) => {
(($opcode & 0x0F00) >> 8) as usize
};
}

其他可以依次类推,因此上文获得寄存器的代码可以改为

1
2
3
4
5
6
7
0x3000..0x3FFF => {
let register_index = arg_x!(optcode); // 拿到寄存器
let nn = (opcode & 0x00FF); // 拿到立即数
if self.v[register_index] == nn {
self.sp += 2
}
}

最终效果

下面我们进入我们的绘图系统

绘图

因为要对接 GUI 觉得有点烦,所以我就索性将 rust-chip8 将作为我们系统的模板,此项目已经帮我们完善了绝大多数的部分,并且使用的 SDL2 作为我们底层的依赖,因此我们把作文题变成填空题了。

rust-chip8 目录结构
1
2
3
4
5
6
7
8
9
10
11
├── src
│   ├── bin
│   │   ├── chip8app.rs
│   │   ├── chip8app_sdl2.rs
│   │   ├── input.rs
│   │   └── main.rs
│   ├── display.rs
│   ├── keypad.rs
│   ├── lib.rs
│   ├── vm.rs
│   └── vm_test.rs

我们要替换掉 display | keypad | vm 这三个部分

对于 Display 的定义很简单

display
1
2
3
4
5
#[derive(Clone)]
pub struct Display {
pub gfx: [[u8; DISPLAY_WIDTH]; DISPLAY_HEIGHT], // 用 gfx 来映射 SDL2 的显存
pub dirty: bool,
}

核心的绘图其实只有一个函数

Draw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
impl Display {
pub fn draw(&mut self, xpos: usize, ypos: usize, sprite: &[u8]) -> bool {
let mut collision = false;
let h = sprite.len();

for j in 0..h {
for i in 0..8 {
// 确定 X Y 位置
let y = (ypos + j) % DISPLAY_HEIGHT;
let x = (xpos + i) % DISPLAY_WIDTH;

// sprite 是显存对象,当对象存在的时候 (!=0x00) 进行点绘图
if (sprite[j] & (0x80 >> i)) != 0x00 {
if self.gfx[y][x] == 0x01 {
collision = true;
}
self.gfx[y][x] ^= 0x01;
}
}
}
self.dirty = true;

collision
}
}

其他

对于键盘的输入输出,在我们的执行 VMLoop 循环中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
pub fn exec_vm(
vm: &mut Vm,
cpu_clock: u32,
tx: Sender<Chip8UICommand>,
rx: Receiver<Chip8VMCommand>,
) {
'vm: loop {
match rx.try_recv() {
// non-blocking receiving function
Ok(vm_command) => match vm_command {
UpdateRunStatus(run) => running = run,
UpdateKeyStatus(index, state) => match state {
Keystate::Pressed => {
if waiting_for_key && (index != wait_for_key_last_pressed) {
vm.end_wait_for_key(index);
wait_for_key_last_pressed = index;
} else {
vm.keypad.set_key_state(index, state);
}
}
Keystate::Released => {
wait_for_key_last_pressed = 0xFF;
if !waiting_for_key {
vm.keypad.set_key_state(index, state);
}
}
}
},
_ => {}
}
}
}

我们通过 Channel 来消费我们的键盘键入和键出。而这个 Channel 的入口是在

Chip8EmulatorBackend exec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
fn exec(
&mut self,
config: &Chip8Config,
tx: Sender<Chip8VMCommand>,
rx: Receiver<Chip8UICommand>,
) {

'main: loop {
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. } => {
paused = true;
tx.send(Quit).unwrap();
}
Event::KeyDown { keycode, .. } => {
if keys_pressed.contains(&keycode) {
continue;
}
match keycode.unwrap() {
// quit on Escape
Keycode::Escape => {
paused = true;
tx.send(Quit).unwrap();
}
// toggle pause on Return
Keycode::Return => {
tx.send(UpdateRunStatus(paused)).unwrap();
paused = !paused;
}
// reset on backspace
Keycode::Backspace => {
info!("Reinitializing the virtual machine.");
tx.send(Reset).unwrap();
}
_ => {
if !paused {
if let Some(index) = key_binds.get(&keycode.unwrap()) {
tx.send(UpdateKeyStatus(*index, Pressed)).unwrap();
}
}
}
}
keys_pressed.push(keycode);
}
Event::KeyUp { keycode, .. } => {
for i in 0..keys_pressed.len() {
if keys_pressed[i] == keycode {
keys_pressed.remove(i);
break;
}
}
if let Some(index) = key_binds.get(&keycode.unwrap()) {
tx.send(UpdateKeyStatus(*index, Released)).unwrap();
}
}
_ => continue,
}
}
}

让我们看看最终效果吧

最终源码: https://github.com/yanickxia/impl-chip8

Kapture 2020-11-08 at 13.41.06.gif

参考