Это продолжение предыдущего поста, где я начал писать эмулятор CHIP-8 на Rust и вот, в неравной борьбе со своей невнимательностью и опечатками, я его доделал до рабочего состояния.
Никогда раньше (со времен универа) не занимался ничем низкоуровневым, где понадобились бы битовые сдвиги, маски и вот это всё, но по факту это просто.
В предыдущей заметке показал как разбирать код ROM-ов на опкоды и интерпретировать их. Далее просто необходимо внимательно реализовать все 35 команд CHIP-8.
Итоговый код получился такой:
use std::{path::PathBuf, usize};
pub struct Chip8 {
pub memory: [u8; 4096],
pub registers: [u8; 16],
pub i_register: u16,
pub pc: u16,
pub delay_timer: u8,
pub sound_timer: u8,
pub stack: Vec<u16>,
pub display: [[u8; 64]; 32],
pub keys: [bool; 16],
pub waiting_key_opcode: u16,
}
impl Default for Chip8 {
fn default() -> Self {
Self {
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],
waiting_key_opcode: 0,
}
}
}
impl Chip8 {
// очищаем память и всё остальное
pub fn reset(&mut self) {
self.memory.fill(0);
self.restart();
}
// очищаем всё кроме памяти
pub fn restart(&mut self) {
self.registers.fill(0);
self.i_register = 0;
self.pc = 0x200;
self.stack.clear();
self.display.fill([0; 64]);
self.keys.fill(false);
self.delay_timer = 0;
self.sound_timer = 0;
self.waiting_key_opcode = 0;
}
fn load_rom(&mut self, rom: &[u8], start_address: usize) {
self.reset();
let end_address = start_address + rom.len();
if end_address > self.memory.len() {
panic!("ROM is too large to fit in memory");
}
for (i, &byte) in rom.iter().enumerate() {
self.memory[start_address + i] = byte;
}
}
pub fn load_from_file(&mut self, filename: &PathBuf) {
let buffer = std::fs::read(&filename).unwrap();
self.load_rom(&buffer, 0x200);
}
pub fn get_current_opcode(&self) -> u16 {
let byte1 = self.memory[self.pc as usize] as u16;
let byte2 = self.memory[(self.pc + 1) as usize] as u16;
(byte1 << 8) | byte2
}
// получаем текущий opcode и сдвигаем указатель pc на 2
fn get_opcode(&mut self) -> u16 {
if self.pc as usize + 1 >= self.memory.len() {
panic!("Attempted to read opcode outside of memory bounds");
}
// ждем нажатия клавиши если нужно
if self.waiting_key_opcode > 0 {
return self.waiting_key_opcode;
}
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
}
// уменьшаем таймеры
pub fn update_timers(&mut self) {
if self.delay_timer > 0 {
self.delay_timer -= 1;
}
if self.sound_timer > 0 {
self.sound_timer -= 1;
}
}
pub fn execute_opcode(&mut self) {
let opcode = self.get_opcode();
match opcode & 0xF000 {
0x0000 => self.handle_0xxx(opcode),
0x1000 => self.handle_1xxx(opcode),
0x2000 => self.handle_2xxx(opcode),
0x3000 => self.handle_3xxx(opcode),
0x4000 => self.handle_4xxx(opcode),
0x5000 => self.handle_5xxx(opcode),
0x6000 => self.handle_6xxx(opcode),
0x7000 => self.handle_7xxx(opcode),
0x8000 => self.handle_8xxx(opcode),
0x9000 => self.handle_9xxx(opcode),
0xA000 => self.handle_Axxx(opcode),
0xB000 => self.handle_Bxxx(opcode),
0xC000 => self.handle_Cxxx(opcode),
0xD000 => self.handle_Dxxx(opcode),
0xE000 => self.handle_Exxx(opcode),
0xF000 => self.handle_Fxxx(opcode),
_ => panic!("Unknown opcode: {:#X}", opcode),
}
}
fn handle_0xxx(&mut self, opcode: u16) {
match opcode {
0x00E0 => self.display.fill([0; 64]), // CLS
0x00EE => self.pc = self.stack.pop().expect("Stack underflow"), // RET
_ => panic!("Unknown opcode: {:#X}", opcode),
}
}
fn handle_1xxx(&mut self, opcode: u16) {
self.pc = opcode & 0x0FFF; // JMP
}
fn handle_2xxx(&mut self, opcode: u16) {
self.stack.push(self.pc);
self.pc = opcode & 0x0FFF;
}
fn handle_3xxx(&mut self, opcode: u16) {
let x = ((opcode & 0x0F00) >> 8) as usize;
let nn = (opcode & 0x00FF) as u8;
if self.registers[x] == nn {
self.pc += 2;
}
}
fn handle_4xxx(&mut self, opcode: u16) {
let x = ((opcode & 0x0F00) >> 8) as usize;
let nn = (opcode & 0x00FF) as u8;
if self.registers[x] != nn {
self.pc += 2;
}
}
fn handle_5xxx(&mut self, opcode: u16) {
let x = ((opcode & 0x0F00) >> 8) as usize;
let y = ((opcode & 0x00F0) >> 4) as usize;
if self.registers[x] == self.registers[y] {
self.pc += 2;
}
}
fn handle_6xxx(&mut self, opcode: u16) {
let x = ((opcode & 0x0F00) >> 8) as usize;
let nn = (opcode & 0x00FF) as u8;
self.registers[x] = nn;
}
fn handle_7xxx(&mut self, opcode: u16) {
let x = ((opcode & 0x0F00) >> 8) as usize;
let nn = (opcode & 0x00FF) as u8;
self.registers[x] = self.registers[x].wrapping_add(nn);
}
fn handle_8xxx(&mut self, opcode: u16) {
let x = ((opcode & 0x0F00) >> 8) as usize;
let y = ((opcode & 0x00F0) >> 4) as usize;
let vx = self.registers[x];
let vy = self.registers[y];
match opcode & 0xF00F {
0x8000 => { // LD
self.registers[x] = vy;
}
0x8001 => { // OR
self.registers[x] |= vy;
}
0x8002 => { // AND
self.registers[x] &= vy;
}
0x8003 => { // XOR
self.registers[x] ^= vy;
}
0x8004 => { // ADD
let (res, carry) = vx.overflowing_add(vy);
self.registers[x] = res;
self.registers[0xF] = if carry { 1 } else { 0 };
}
0x8005 => {
self.registers[0xF] = if vx > vy { 1 } else { 0 };
self.registers[x] = vx.wrapping_sub(vy);
}
0x8006 => {
self.registers[0xF] = vx & 0x01;
self.registers[x] = vx >> 1;
}
0x8007 => {
let (res, carry) = vy.overflowing_sub(vx);
self.registers[0xF] = if carry { 0 } else { 1 };
self.registers[x] = res;
}
0x800E => {
self.registers[0xF] = (vx >> 7) & 1;
self.registers[x] = vx << 1;
}
_ => panic!("Unknown opcode: {:#X}", opcode),
}
}
fn handle_9xxx(&mut self, opcode: u16) {
let x = ((opcode & 0xF00) >> 8) as usize;
let y = ((opcode & 0x0F0) >> 4) as usize;
if self.registers[x] != self.registers[y] {
self.pc += 2;
}
}
fn handle_Axxx(&mut self, opcode: u16) {
self.i_register = opcode & 0xFFF;
}
fn handle_Bxxx(&mut self, opcode: u16) {
self.pc = self.registers[0] as u16 + (opcode & 0xFFF);
}
fn handle_Cxxx(&mut self, opcode: u16) {
let x = ((opcode & 0xF00) >> 8) as usize;
let nn = (opcode & 0x0FF) as u8;
self.registers[x] = rand::random::<u8>() & nn;
}
fn handle_Dxxx(&mut self, opcode: u16) {
let vx = self.registers[((opcode & 0xF00) >> 8) as usize];
let vy = self.registers[((opcode & 0x0F0) >> 4) as usize];
let n = opcode & 0x000F;
let x = (vx as usize) % 64;
let y = (vy as usize) % 32;
let sprite_data =
&self.memory[self.i_register as usize..self.i_register as usize + n as usize];
self.registers[0xF] = 0;
for row in 0..n {
for col in 0..8 {
let pixel = (sprite_data[row as usize] >> (7 - col)) & 1;
let disp_x = (x + col as usize) % 64;
let disp_y = (y + row as usize) % 32;
let cur_pixel = self.display[disp_y][disp_x];
if cur_pixel > 0 && pixel > 0 {
self.registers[0xF] = 1;
}
self.display[disp_y][disp_x] = cur_pixel ^ pixel;
}
}
}
fn handle_Exxx(&mut self, opcode: u16) {
let x = ((opcode & 0xF00) >> 8) as usize;
let vx = self.registers[x] as usize; //key index
match opcode & 0xF0FF {
0xE09E => {
if self.keys[vx] {
self.pc += 2;
}
}
0xE0A1 => {
if !self.keys[vx] {
self.pc += 2;
}
}
_ => panic!("Unknown opcode: {:#X}", opcode),
}
}
fn handle_Fxxx(&mut self, opcode: u16) {
let x = ((opcode & 0xF00) >> 8) as usize;
let vx = self.registers[x];
match opcode & 0xF0FF {
0xF007 => {
self.registers[x] = self.delay_timer;
}
0xF00A => {
self.waiting_key_opcode = opcode;
for (i, &key) in self.keys.iter().enumerate() {
if key {
self.waiting_key_opcode = 0;
self.registers[x] = i as u8;
break;
}
}
}
0xF015 => {
self.delay_timer = self.registers[x];
}
0xF018 => {
self.sound_timer = self.registers[x];
}
0xF01E => {
self.i_register += self.registers[x] as u16;
}
0xF029 => {
self.i_register = self.registers[x] as u16 * 0x05;
}
0xF033 => {
self.memory[self.i_register as usize] = vx / 100;
self.memory[self.i_register as usize + 1] = (vx / 10) % 10;
self.memory[self.i_register as usize + 2] = vx % 10;
}
0xF055 => {
for i in 0..=x {
self.memory[self.i_register as usize + i] = self.registers[i];
}
}
0xF065 => {
for i in 0..=x {
self.registers[i] = self.memory[self.i_register as usize + i];
}
}
_ => panic!("Unknown opcode: {:#X}", opcode),
}
}
}
Как теперь это запустить?
После того как реализация эмулятора готова, можно визуализировать состояние дисплея и организовать обновление состояния эмулятора. Я выбрал Bevy для рендеринга и обработки нажатий кнопок. В итоге получилось что-то такое:
mod chip8;
use bevy::prelude::*;
use chip8::Chip8;
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to the ROM file
#[arg(short, long)]
rom: PathBuf,
}
fn main() {
let args = Args::parse();
println!("Loading ROM: {:?}", args.rom);
let mut chip8 = Chip8::default();
chip8.load_from_file(&args.rom);
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(chip8)
.add_systems(Startup, setup)
.add_systems(Startup, setup_display)
.add_systems(Update, (update_keys, draw_display))
.add_systems(FixedUpdate, (run_chip8, update_chip8_timers))
.add_systems(FixedUpdate, draw_registers)
.run();
}
#[derive(Component)]
struct PcText;
fn setup(mut commands: Commands) {
commands.spawn(Camera2d::default());
commands.spawn((
Text::new("PC: 0x0000\nOP: 0x0000"),
TextFont {
font_size: 16.0,
..default()
},
PcText,
));
}
const DISPLAY_WIDTH: usize = 64;
const DISPLAY_HEIGHT: usize = 32;
const PIXEL_SIZE: f32 = 10.0;
const COLOR_ON: Color = Color::srgb(0.0, 1.0, 0.0);
const COLOR_OFF: Color = Color::srgb(0.0, 0.0, 0.0);
#[derive(Component)]
struct Chip8Pixel {
x: usize,
y: usize,
}
// Создаем "пиксели" для отображения состояния дисплея CHIP-8
fn setup_display(mut commands: Commands) {
for y in 0..DISPLAY_HEIGHT {
for x in 0..DISPLAY_WIDTH {
commands.spawn((
Sprite::from_color(COLOR_OFF, Vec2::new(PIXEL_SIZE, PIXEL_SIZE)),
Transform::from_xyz(
x as f32 * PIXEL_SIZE - DISPLAY_WIDTH as f32 * PIXEL_SIZE / 2.0,
DISPLAY_HEIGHT as f32 * PIXEL_SIZE / 2.0 - y as f32 * PIXEL_SIZE,
0.0,
),
Chip8Pixel { x, y },
));
}
}
}
/// Обновляем состояние CHIP-8 с частотой 500Hz
fn run_chip8(mut chip8: ResMut<Chip8>, time: Res<Time>, mut accumulator: Local<f32>) {
*accumulator += time.delta_secs();
let cycle_time = 1.0 / 500.0;
while *accumulator >= cycle_time {
chip8.execute_opcode();
*accumulator -= cycle_time;
}
}
/// Обновляем таймеры CHIP-8 с частотой 60Hz
fn update_chip8_timers(mut chip8: ResMut<Chip8>, time: Res<Time>, mut accumulator: Local<f32>) {
*accumulator += time.delta_secs();
let timer_interval = 1.0 / 60.0;
while *accumulator >= timer_interval {
chip8.update_timers();
*accumulator -= timer_interval;
}
}
// Обрабатываем нажатия клавиш
fn update_keys(keyboard_input: Res<ButtonInput<KeyCode>>, mut chip8: ResMut<Chip8>) {
// Перезапускаем эмулятор при нажатии Escape
if keyboard_input.pressed(KeyCode::Escape) {
chip8.restart();
}
let key_map = [
KeyCode::KeyX,
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::KeyQ,
KeyCode::KeyW,
KeyCode::KeyE,
KeyCode::KeyA,
KeyCode::KeyS,
KeyCode::KeyD,
KeyCode::KeyZ,
KeyCode::KeyC,
KeyCode::Digit4,
KeyCode::KeyR,
KeyCode::KeyF,
KeyCode::KeyV,
];
for (i, &key_code) in key_map.iter().enumerate() {
chip8.keys[i] = keyboard_input.pressed(key_code);
}
}
// Обновляем состояние пикселей
fn draw_display(chip8: Res<Chip8>, mut query: Query<(&Chip8Pixel, &mut Sprite)>) {
for (px, mut sprite) in query.ite r_mut() {
sprite.color = if chip8.display[px.y][px.x] > 0 { COLOR_ON } else { COLOR_OFF };
}
}
// Обновляем регистр PC и текущий opcode
fn draw_registers(chip8: Res<Chip8>, mut text: Single<&mut Text, With<PcText>>) {
let pc = chip8.pc;
let op = chip8.get_current_opcode();
text.0 = format!("PC: {:04X}\nOP: {:04X}", pc, op);
}
Компилируем, запускаем и видим такую красоту. Аж олдскулы свело!

Управлять играми можно через клавиши: 1 2 3 4 Q W E R A S D F Z X C V и Esc для сброса состояния эмулятора.
Единственное что я не делал - звук. В оригинале это просто бипер, который выдает одну ноту пока sound_timer > 0.
Если есть желание, можете доделать, патчи приветствуются. Код доступен на GitHub, там же лежат готовые сборки под Linux и Windows, ROMы можно скачать тут.
Как еще можно улучшить эмулятор:
- Добавить меню с выбором ROMa
- Оформить окно в ретро стиле
- Сделать CRT шейдер
- Сделать overlay с состоянием регистров эмулятора
UPD. Всё таки добавил звук. Сначала просто с помощью проигрывания готового OGG, потом переделал на генератор волны т.к. таскать с эмулятором единственный дополнительный ресурс - такое себе.
UPD2. Добавил паузу эмулятора на P
Что делать дальше?
После успешной разработки эмулятора CHIP-8 следующим шагом может быть создание эмулятора для более сложной системы:
Super CHIP-8: Это расширение оригинального CHIP-8, добавляет новые инструкции и увеличивает разрешение экрана. Логичный и простой шаг.
COSMAC VIP: Оригинальная платформа, для которой был разработан CHIP-8. Эмуляция COSMAC VIP предоставит опыт работы с реальной аппаратной архитектурой, включая эмуляцию процессора RCA 1802 и периферийных устройств.
Game Boy: Портативка от Nintendo с 8-битной архитектурой. Потребуется понимание работы с более сложными процессорами, такими как Z80, а также работы с аудио- и видеоподсистемами.
NES: Классическая 8-битная консоль, в СНГ продавались клоны под названием Dendy. Насколько знаю, может быть сложной т.к. имеет много недокументированных особенностей.
