А почему бы не замутить эмулятор CHIP-8 на Rust? Эта мысль пришла мне в 2 часа ночи, когда я собирался ложиться спать. К этому моменту я прочитал где-то 15 глав учебника по Rust и написал десяток hello world. “Этого должно хватить” - подумал я, заварил чай, включил ПК и начал гуглить про архитектуру CHIP-8.

CHIP-8 - интерпретируемый язык программирования, разработанный Джозефом Вайсбекером для своего микропроцессора 1802. Первоначально он использовался в COSMAC VIP и Telmac 1800 - 8-битных микрокомпьютерах, выпущенных в середине 1970-х. CHIP-8 был разработан для того, чтобы его было легко программировать, а также для того, чтобы он использовал меньше памяти, чем другие языки программирования, такие как BASIC.

Что по железу?

Эмулятор должен иметь на борту:

  • 4096 байт ОЗУ
  • 16 8-битных регистров
  • 12-битный I регистр для операций с памятью
  • 16-битный указатель на текущую инструкцию
  • стэк
  • таймер задержки (уменьшается на 1 если больше 0 с частотой 60Hz)
  • таймер звука (уменьшается на 1 если больше 0 с частотой 60Hz и проигрывает звук BEEP если таймер больше 0)
  • монохромный дисплей 64x32
  • клавиатура с 16 клавишами

Пользовательские программы загружаются в память с адреса 512 (0x200). В оригинальных микрокомпьютерах в первых 512 байтах размещался сам интерпретатор CHIP-8, в современных эмуляторах в эту область помещают другие данные, например шрифты.

В коде структура данных может выглядеть так:

pub struct Chip8 {
    pub memory: [u8; 4096], // память
    pub registers: [u8; 16], // регистры
    pub i_register: u16, // i-регистр
    pub pc: u16, // указатель на текущую операцию (индекс в memory)
    pub delay_timer: u8, // таймер задержки
    pub sound_timer: u8, // таймер звука
    pub stack: Vec<u16>, // стэк
    pub display: [[u8; 64]; 32], // дисплей
    pub keys: [bool; 16], // клавиатура
}

Общий план работы эмулятора такой:

  1. Загружаем ROM в массив memory начиная с индекса 512 (0x200)
  2. Устанавливаем регистр pc в 512 (0x200)
  3. Считываем из memory два элемента (2 байта) и увеличиваем pc на 2
  4. Делаем из двух считанных байт 16 битный опкод
  5. Парсим опкод чтобы получить номер инструкции и аргументы
  6. Выполняем полученную инструкцию с аргументами
  7. Идем на шаг 3 чтобы получить следующий опкод

Загрузка ROM

Сделаем загрузчик ROM в память эмулятора:

impl Chip8 {
    fn load_rom(&mut self, rom: &Vec<u8>){
        if rom.len() > 4096 - 0x200 {
            panic!("ROM too big to fit in memory");
        }

        for (i, &byte) in rom.iter().enumerate() {
            self.memory[0x200 + i as usize] = byte;
        }
        
        self.pc = 0x200;
    }

    fn load_from_file(&mut self, path: &str) {
        let rom = std::fs::read(path).expect("Failed to read ROM");
        self.load_rom(&rom);
    }
}

В этом коде мы получаем ROM в виде массива, проверяем поместится ли он в память, перебираем элементы итератором, добавляем их в нашу память и устанавливаем указатель pc на начало программы.

Вторая функция считывает ROM из файла и загружает в память эмулятора используя первую функцию.

Опкоды

CHIP-8 имеет 35 инструкций, каждая из которых представлена 2-байтовым (16-битным) опкодом. Опкоды имеют такой формат: CXYN, CXNN или CNNN, где каждая из позиций 4-битная.

  • C - код инструкции или группы
  • X и Y обычно используются как индексы регистров
  • N, NN, NNN - 4, 8 или 12-битные числа. Используются чтобы устанавливать значения регистров и прочих операциях

Чтобы выполнять код в памяти нам надо уметь читать опкоды на которые указывает текущий регистр pc

impl Chip8 {
    ...
    fn get_opcode(&mut self) -> u16 {
        if self.pc as usize >= self.memory.len() {
            panic!("PC out of bounds");
        }
        let byte1 = self.memory[self.pc as usize] as u16;
        let byte2 = self.memory[(self.pc + 1) as usize] as u16;
        self.pc += 2;
        (byte1 << 8) | byte2
    }
}

Берем из памяти два 8-битных числа с индексами pc и pc+1, собираем одно 16-битное и увеличиваем регистр pc на 2.

Проверим, что у нас получилось:

fn main() {
    let mut chip8 = Chip8 {
        memory: [0; 4096],
        registers: [0; 16],
        i_register: 0,
        pc: 0x200,
        delay_timer: 0,
        sound_timer: 0,
        stack: Vec::new(),
        display: [[0; 64]; 32],
        keys: [false; 16],
    };

    let rom:Vec<u8> = vec![0x00, 0x01];

    chip8.load_rom(&rom);

    loop {
        let opcode = chip8.get_opcode();
        if opcode == 0 {
            break;
        }
        println!("Opcode: {:04x}", opcode);
    }

}
Opcode: 0000
Opcode: 0001

Впечатляет, правда? 😅

Выполнение инструкций

В принципе этого достаточно чтобы начать реализацию инструкций. Предлагаю начать с места в карьер и реализовать три инструкции:

  • JMP (1NNN) – перейти к выполнению кода по адресу NNN (pc:=NNN)
  • LD VX, NN (6XNN) – поместить значение NN в регистр Vx
  • ADD VX, NN (7XNN) – прибавить NN к значению в регистре Vx

Для тестов нам нужен простой ROM:

let rom:Vec<u8> = vec![0x60, 0x0A, 0x70, 0x01 , 0x12, 0x02];

Держим в уме, что опкоды - 16-битные и формируются из 8-битных пар. В данном случаем у нас три опкода: 600A, 7001, 1202.

Что должна делать эта “программа”?

  • [200] 0x600A – вызывает инструкцию LD, которая помещает 0xA (10) в registers[0]
  • [202] 0x7001 – вызывает инструкцию ADD, которая прибавляет 1 к registers[0]
  • [204] 0x1202 – вызывает инструкцию JMP чтобы перейти к инструкции по адресу 0x202.

В итоге эта программа должна реализовать цикл в котором в registers[0] будет добавляться 1.

Погнали:

impl Chip8 {
    ...
    fn exec_opcode(&mut self, opcode: u16) {
        match opcode & 0xF000 { // отбрасываем младшие 12 бит чтобы получить код инструкции
            0x1000 => { // JMP NNN
                self.pc = opcode & 0x0FFF;
            }
            0x6000 => { // LD XNN
                let x = ((opcode & 0x0F00) >> 8) as usize;
                let value = (opcode & 0x00FF) as u8;
                self.registers[x] = value;
            }
            0x7000 => { // ADD XNN
                let x = ((opcode & 0x0F00) >> 8) as usize;
                let vx = self.registers[x] ;
                let value = (opcode & 0x00FF) as u8;
                self.registers[x] = vx.wrapping_add(value); // отбрасываем старшие биты в сумме при переполнения u8
            }
            _ => {
                panic!("Unknown opcode: {:04x}", opcode);
            }
        }
    }
}


fn main() {
    let mut chip8 = Chip8 {
        memory: [0; 4096],
        registers: [0; 16],
        i_register: 0,
        pc: 0x200,
        delay_timer: 0,
        sound_timer: 0,
        stack: Vec::new(),
        display: [[0; 64]; 32],
        keys: [false; 16],
    };

    let rom:Vec<u8> = vec![0x60, 0x0A, 0x70, 0x01, 0x12, 0x02];

    chip8.load_rom(&rom);

    loop {
        let opcode = chip8.get_opcode();
        if opcode == 0 {
            break;
        }
        println!("Opcode: {:04x}", opcode);
        chip8.exec_opcode(opcode);
        println!("registers: {:?}", chip8.registers);
    }
}
Opcode: 600a
registers: [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 7001
registers: [11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 1202
registers: [11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 7001
registers: [12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 1202
registers: [12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 7001
registers: [13, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 1202
registers: [13, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 7001
registers: [14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 1202
registers: [14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 7001
registers: [15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Opcode: 1202
registers: [15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
...

Видно, что LD выполнилась только один раз установив начальное значение регистра V0. Затем происходит прибавление единицы и прыжок на адрес 0x202, который снова выполняет суммирование и т.д. Таким образом у нас получилось написать цикл со счетчиком используя CHIP-8 🚀

Осталось реализовать еще 32 инструкции чтобы интерпретатор начал работать полноценно.

Весь список инструкций можно посмотреть например тут https://tonisagrista.com/blog/2021/chip8-spec/#instruction-set