А почему бы не замутить эмулятор 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], // клавиатура
}
Общий план работы эмулятора такой:
- Загружаем ROM в массив memory начиная с индекса 512 (0x200)
- Устанавливаем регистр
pcв 512 (0x200) - Считываем из memory два элемента (2 байта) и увеличиваем pc на 2
- Делаем из двух считанных байт 16 битный опкод
- Парсим опкод чтобы получить номер инструкции и аргументы
- Выполняем полученную инструкцию с аргументами
- Идем на шаг 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
