Emulador CHIP-8 em Rust

Construa um emulador CHIP-8 completo em Rust: decodificação de instruções, registradores, memória, display 64x32, teclado e timers.

Neste projeto vamos construir um emulador CHIP-8 completo em Rust. O CHIP-8 é uma linguagem interpretada criada nos anos 1970 para facilitar o desenvolvimento de jogos em microcomputadores. Com apenas 35 instruções, um display de 64x32 pixels, 16 registradores e 4KB de memória, o CHIP-8 é o ponto de entrada perfeito para o mundo da emulação – simples o suficiente para construir em poucas centenas de linhas, mas complexo o suficiente para ensinar os fundamentos.

Construir um emulador é uma experiência única: você vai implementar um computador virtual completo capaz de executar programas reais. Ao final, você poderá rodar jogos clássicos como Pong, Space Invaders, Tetris e muitos outros ROMs disponíveis na internet.

O Que Vamos Construir

Um emulador CHIP-8 com as seguintes funcionalidades:

  • CPU com 16 registradores de 8 bits (V0-VF)
  • 4KB de memória RAM com fontset embutido
  • Display monocromático de 64x32 pixels renderizado no terminal
  • Decodificação e execução das 35 instruções do CHIP-8
  • Stack de 16 níveis para sub-rotinas
  • Delay timer e sound timer (60Hz)
  • Teclado hexadecimal de 16 teclas mapeado para o teclado
  • Carregamento de ROMs a partir de arquivos

Estrutura do Projeto

chip8-emulator/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new chip8-emulator
cd chip8-emulator

Edite o Cargo.toml:

[package]
name = "chip8-emulator"
version = "0.1.0"
edition = "2021"

[dependencies]
crossterm = "0.28"
rand = "0.8"

Usamos crossterm para renderização no terminal e leitura de teclado, e rand para a instrução de número aleatório do CHIP-8.

Passo 1: Definindo a Estrutura da CPU

Vamos começar definindo a estrutura de dados que representa todo o estado do sistema CHIP-8.

use crossterm::{
    cursor, event::{self, Event, KeyCode, KeyEvent},
    execute, queue,
    style::{self, Stylize},
    terminal::{self, ClearType},
};
use rand::Rng;
use std::fs;
use std::io::{self, Write};
use std::time::{Duration, Instant};

/// Largura do display CHIP-8
const LARGURA_TELA: usize = 64;
/// Altura do display CHIP-8
const ALTURA_TELA: usize = 32;
/// Tamanho total da memória (4KB)
const TAMANHO_MEMORIA: usize = 4096;
/// Endereço onde as ROMs são carregadas
const INICIO_ROM: usize = 0x200;

/// Fontset padrão do CHIP-8 (caracteres 0-F, 5 bytes cada)
const FONTSET: [u8; 80] = [
    0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
    0x20, 0x60, 0x20, 0x20, 0x70, // 1
    0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
    0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
    0x90, 0x90, 0xF0, 0x10, 0x10, // 4
    0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
    0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
    0xF0, 0x10, 0x20, 0x40, 0x40, // 7
    0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
    0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
    0xF0, 0x90, 0xF0, 0x90, 0x90, // A
    0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
    0xF0, 0x80, 0x80, 0x80, 0xF0, // C
    0xE0, 0x90, 0x90, 0x90, 0xE0, // D
    0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
    0xF0, 0x80, 0xF0, 0x80, 0x80, // F
];

/// Estado completo do emulador CHIP-8
struct Chip8 {
    memoria: [u8; TAMANHO_MEMORIA],        // 4KB de RAM
    v: [u8; 16],                            // Registradores V0-VF
    i: u16,                                 // Registrador de índice
    pc: u16,                                // Program counter
    tela: [[bool; LARGURA_TELA]; ALTURA_TELA], // Display 64x32
    stack: [u16; 16],                       // Pilha de sub-rotinas
    sp: u8,                                 // Stack pointer
    delay_timer: u8,                        // Timer de delay (60Hz)
    sound_timer: u8,                        // Timer de som (60Hz)
    teclado: [bool; 16],                    // Estado das 16 teclas
    tela_modificada: bool,                  // Flag para redesenhar
}

impl Chip8 {
    fn novo() -> Self {
        let mut chip = Chip8 {
            memoria: [0; TAMANHO_MEMORIA],
            v: [0; 16],
            i: 0,
            pc: INICIO_ROM as u16,
            tela: [[false; LARGURA_TELA]; ALTURA_TELA],
            stack: [0; 16],
            sp: 0,
            delay_timer: 0,
            sound_timer: 0,
            teclado: [false; 16],
            tela_modificada: true,
        };

        // Carrega o fontset na memória (endereço 0x000-0x04F)
        chip.memoria[..80].copy_from_slice(&FONTSET);
        chip
    }

    /// Carrega uma ROM na memória a partir do endereço 0x200
    fn carregar_rom(&mut self, dados: &[u8]) -> Result<(), String> {
        if dados.len() > TAMANHO_MEMORIA - INICIO_ROM {
            return Err("ROM muito grande para a memória".into());
        }
        self.memoria[INICIO_ROM..INICIO_ROM + dados.len()].copy_from_slice(dados);
        Ok(())
    }
}

Cada campo da struct Chip8 corresponde a um componente de hardware do sistema original. O fontset contém os sprites dos caracteres hexadecimais (0-F) e é carregado nos primeiros 80 bytes da memória durante a inicialização.

Passo 2: Decodificação e Execução de Instruções

O coração do emulador: buscar, decodificar e executar as instruções CHIP-8. Cada instrução tem 2 bytes (big-endian).

impl Chip8 {
    /// Busca a próxima instrução da memória (2 bytes, big-endian)
    fn buscar_instrucao(&self) -> u16 {
        let byte_alto = self.memoria[self.pc as usize] as u16;
        let byte_baixo = self.memoria[self.pc as usize + 1] as u16;
        (byte_alto << 8) | byte_baixo
    }

    /// Executa um ciclo da CPU: busca, decodifica e executa uma instrução
    fn executar_ciclo(&mut self) {
        let opcode = self.buscar_instrucao();
        self.pc += 2; // Avança o PC (cada instrução tem 2 bytes)

        // Extrai os nibbles da instrução para decodificação
        let nnn = opcode & 0x0FFF;          // Endereço de 12 bits
        let kk = (opcode & 0x00FF) as u8;   // Byte constante
        let x = ((opcode >> 8) & 0x0F) as usize;  // Registrador X
        let y = ((opcode >> 4) & 0x0F) as usize;  // Registrador Y
        let n = (opcode & 0x000F) as u8;    // Nibble inferior

        match opcode & 0xF000 {
            0x0000 => match opcode {
                0x00E0 => {
                    // CLS: Limpa a tela
                    self.tela = [[false; LARGURA_TELA]; ALTURA_TELA];
                    self.tela_modificada = true;
                }
                0x00EE => {
                    // RET: Retorna de sub-rotina
                    self.sp -= 1;
                    self.pc = self.stack[self.sp as usize];
                }
                _ => {} // Instruções SYS ignoradas
            },
            0x1000 => {
                // JP addr: Pula para endereço
                self.pc = nnn;
            }
            0x2000 => {
                // CALL addr: Chama sub-rotina
                self.stack[self.sp as usize] = self.pc;
                self.sp += 1;
                self.pc = nnn;
            }
            0x3000 => {
                // SE Vx, byte: Pula se Vx == kk
                if self.v[x] == kk { self.pc += 2; }
            }
            0x4000 => {
                // SNE Vx, byte: Pula se Vx != kk
                if self.v[x] != kk { self.pc += 2; }
            }
            0x5000 => {
                // SE Vx, Vy: Pula se Vx == Vy
                if self.v[x] == self.v[y] { self.pc += 2; }
            }
            0x6000 => {
                // LD Vx, byte: Carrega kk em Vx
                self.v[x] = kk;
            }
            0x7000 => {
                // ADD Vx, byte: Adiciona kk a Vx (sem carry)
                self.v[x] = self.v[x].wrapping_add(kk);
            }
            0x8000 => match n {
                0x0 => self.v[x] = self.v[y],                     // LD Vx, Vy
                0x1 => self.v[x] |= self.v[y],                    // OR
                0x2 => self.v[x] &= self.v[y],                    // AND
                0x3 => self.v[x] ^= self.v[y],                    // XOR
                0x4 => {
                    // ADD Vx, Vy com carry
                    let (resultado, overflow) = self.v[x].overflowing_add(self.v[y]);
                    self.v[x] = resultado;
                    self.v[0xF] = if overflow { 1 } else { 0 };
                }
                0x5 => {
                    // SUB Vx, Vy com borrow
                    let carry = if self.v[x] >= self.v[y] { 1 } else { 0 };
                    self.v[x] = self.v[x].wrapping_sub(self.v[y]);
                    self.v[0xF] = carry;
                }
                0x6 => {
                    // SHR Vx: Shift right, VF = bit removido
                    self.v[0xF] = self.v[x] & 0x01;
                    self.v[x] >>= 1;
                }
                0x7 => {
                    // SUBN Vx, Vy: Vx = Vy - Vx
                    let carry = if self.v[y] >= self.v[x] { 1 } else { 0 };
                    self.v[x] = self.v[y].wrapping_sub(self.v[x]);
                    self.v[0xF] = carry;
                }
                0xE => {
                    // SHL Vx: Shift left, VF = bit removido
                    self.v[0xF] = (self.v[x] >> 7) & 0x01;
                    self.v[x] <<= 1;
                }
                _ => {}
            },
            0x9000 => {
                // SNE Vx, Vy: Pula se Vx != Vy
                if self.v[x] != self.v[y] { self.pc += 2; }
            }
            0xA000 => {
                // LD I, addr: Define registrador I
                self.i = nnn;
            }
            0xB000 => {
                // JP V0, addr: Pula para V0 + nnn
                self.pc = nnn + self.v[0] as u16;
            }
            0xC000 => {
                // RND Vx, byte: Vx = random AND kk
                let aleatorio: u8 = rand::thread_rng().gen();
                self.v[x] = aleatorio & kk;
            }
            0xD000 => {
                // DRW Vx, Vy, n: Desenha sprite na posição (Vx, Vy)
                self.v[0xF] = 0;
                let px = self.v[x] as usize % LARGURA_TELA;
                let py = self.v[y] as usize % ALTURA_TELA;

                for linha in 0..n as usize {
                    let byte_sprite = self.memoria[self.i as usize + linha];
                    for bit in 0..8 {
                        let sx = (px + bit) % LARGURA_TELA;
                        let sy = (py + linha) % ALTURA_TELA;

                        if (byte_sprite >> (7 - bit)) & 1 == 1 {
                            if self.tela[sy][sx] {
                                self.v[0xF] = 1; // Colisão
                            }
                            self.tela[sy][sx] ^= true; // XOR
                        }
                    }
                }
                self.tela_modificada = true;
            }
            0xE000 => match kk {
                0x9E => {
                    // SKP Vx: Pula se tecla Vx pressionada
                    if self.teclado[self.v[x] as usize & 0xF] { self.pc += 2; }
                }
                0xA1 => {
                    // SKNP Vx: Pula se tecla Vx NÃO pressionada
                    if !self.teclado[self.v[x] as usize & 0xF] { self.pc += 2; }
                }
                _ => {}
            },
            0xF000 => match kk {
                0x07 => self.v[x] = self.delay_timer,    // LD Vx, DT
                0x0A => {
                    // LD Vx, K: Espera tecla pressionada
                    if let Some(tecla) = self.teclado.iter().position(|&t| t) {
                        self.v[x] = tecla as u8;
                    } else {
                        self.pc -= 2; // Repete a instrução até uma tecla ser pressionada
                    }
                }
                0x15 => self.delay_timer = self.v[x],    // LD DT, Vx
                0x18 => self.sound_timer = self.v[x],    // LD ST, Vx
                0x1E => self.i += self.v[x] as u16,      // ADD I, Vx
                0x29 => self.i = self.v[x] as u16 * 5,   // LD F, Vx (endereço do sprite)
                0x33 => {
                    // LD B, Vx: Armazena BCD de Vx em I, I+1, I+2
                    let valor = self.v[x];
                    self.memoria[self.i as usize] = valor / 100;
                    self.memoria[self.i as usize + 1] = (valor / 10) % 10;
                    self.memoria[self.i as usize + 2] = valor % 10;
                }
                0x55 => {
                    // LD [I], Vx: Armazena V0-Vx na memória
                    for reg in 0..=x {
                        self.memoria[self.i as usize + reg] = self.v[reg];
                    }
                }
                0x65 => {
                    // LD Vx, [I]: Carrega memória em V0-Vx
                    for reg in 0..=x {
                        self.v[reg] = self.memoria[self.i as usize + reg];
                    }
                }
                _ => {}
            },
            _ => {}
        }
    }

    /// Decrementa os timers (deve ser chamado a 60Hz)
    fn atualizar_timers(&mut self) {
        if self.delay_timer > 0 { self.delay_timer -= 1; }
        if self.sound_timer > 0 { self.sound_timer -= 1; }
    }
}

Cada opcode é decodificado usando pattern matching no primeiro nibble, e os operandos são extraídos com operações bit a bit. A instrução DRW (0xDxyn) é a mais complexa: ela desenha sprites na tela usando XOR, permitindo tanto desenhar quanto apagar pixels, e define VF como flag de colisão.

Passo 3: Renderização e Mapeamento de Teclado

Agora conectamos o emulador ao terminal para renderização e entrada.

/// Mapeia teclas do teclado para o teclado hexadecimal do CHIP-8
/// Layout original:     Mapeamento no teclado:
/// 1 2 3 C              1 2 3 4
/// 4 5 6 D              Q W E R
/// 7 8 9 E              A S D F
/// A 0 B F              Z X C V
fn mapear_tecla(code: KeyCode) -> Option<usize> {
    match code {
        KeyCode::Char('1') => Some(0x1),
        KeyCode::Char('2') => Some(0x2),
        KeyCode::Char('3') => Some(0x3),
        KeyCode::Char('4') => Some(0xC),
        KeyCode::Char('q') => Some(0x4),
        KeyCode::Char('w') => Some(0x5),
        KeyCode::Char('e') => Some(0x6),
        KeyCode::Char('r') => Some(0xD),
        KeyCode::Char('a') => Some(0x7),
        KeyCode::Char('s') => Some(0x8),
        KeyCode::Char('d') => Some(0x9),
        KeyCode::Char('f') => Some(0xE),
        KeyCode::Char('z') => Some(0xA),
        KeyCode::Char('x') => Some(0x0),
        KeyCode::Char('c') => Some(0xB),
        KeyCode::Char('v') => Some(0xF),
        _ => None,
    }
}

/// Renderiza o display CHIP-8 no terminal
fn renderizar_tela(stdout: &mut io::Stdout, tela: &[[bool; LARGURA_TELA]; ALTURA_TELA]) {
    queue!(stdout, cursor::MoveTo(0, 0)).unwrap();

    for y in 0..ALTURA_TELA {
        let mut linha = String::with_capacity(LARGURA_TELA * 2);
        for x in 0..LARGURA_TELA {
            if tela[y][x] {
                linha.push_str("██");
            } else {
                linha.push_str("  ");
            }
        }
        queue!(stdout, style::Print(&linha), cursor::MoveToNextLine(1)).unwrap();
    }

    // Barra de status
    let info = " CHIP-8 | Esc=Sair | Teclas: 1234/QWER/ASDF/ZXCV ";
    queue!(
        stdout,
        cursor::MoveToNextLine(1),
        style::PrintStyledContent(info.black().on_white())
    ).unwrap();

    stdout.flush().unwrap();
}

O mapeamento de teclado segue a convenção padrão dos emuladores CHIP-8, onde o teclado hexadecimal 4x4 é distribuído nas teclas 1-4, Q-R, A-F e Z-V do teclado QWERTY.

Passo 4: Loop Principal e main.rs Completo

Agora montamos o loop de emulação principal.

fn main() {
    // Lê o caminho da ROM dos argumentos
    let args: Vec<String> = std::env::args().collect();
    if args.len() < 2 {
        eprintln!("Uso: chip8-emulator <arquivo.ch8>");
        eprintln!();
        eprintln!("Baixe ROMs em: https://github.com/kripod/chip8-roms");
        std::process::exit(1);
    }

    // Carrega a ROM
    let dados_rom = match fs::read(&args[1]) {
        Ok(dados) => dados,
        Err(e) => {
            eprintln!("Erro ao ler '{}': {}", args[1], e);
            std::process::exit(1);
        }
    };

    // Inicializa o emulador
    let mut chip8 = Chip8::novo();
    if let Err(e) = chip8.carregar_rom(&dados_rom) {
        eprintln!("Erro ao carregar ROM: {}", e);
        std::process::exit(1);
    }

    println!("ROM carregada: {} ({} bytes)", args[1], dados_rom.len());
    println!("Iniciando emulação...");

    // Configura o terminal
    terminal::enable_raw_mode().unwrap();
    let mut stdout = io::stdout();
    execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).unwrap();
    execute!(stdout, terminal::Clear(ClearType::All)).unwrap();

    let mut ultimo_timer = Instant::now();
    let ciclos_por_frame = 10; // ~600Hz de CPU com 60Hz de timer

    // Loop de emulação
    'emulacao: loop {
        // Processa entrada do teclado
        chip8.teclado = [false; 16]; // Reseta todas as teclas
        while event::poll(Duration::from_millis(0)).unwrap() {
            match event::read().unwrap() {
                Event::Key(KeyEvent { code: KeyCode::Esc, .. }) => break 'emulacao,
                Event::Key(KeyEvent { code, kind, .. }) => {
                    if kind == event::KeyEventKind::Press || kind == event::KeyEventKind::Repeat {
                        if let Some(tecla) = mapear_tecla(code) {
                            chip8.teclado[tecla] = true;
                        }
                    }
                }
                _ => {}
            }
        }

        // Executa múltiplos ciclos de CPU por frame
        for _ in 0..ciclos_por_frame {
            chip8.executar_ciclo();
        }

        // Atualiza timers a 60Hz
        if ultimo_timer.elapsed() >= Duration::from_micros(16667) {
            chip8.atualizar_timers();
            ultimo_timer = Instant::now();
        }

        // Renderiza apenas quando o display muda
        if chip8.tela_modificada {
            renderizar_tela(&mut stdout, &chip8.tela);
            chip8.tela_modificada = false;
        }

        // Pausa para manter a taxa de atualização
        std::thread::sleep(Duration::from_millis(2));
    }

    // Restaura o terminal
    execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).unwrap();
    terminal::disable_raw_mode().unwrap();
    println!("Emulação encerrada.");
}

O loop de emulação executa múltiplos ciclos de CPU por frame (simulando ~600Hz), atualiza os timers a 60Hz e renderiza apenas quando o display foi modificado. A pausa de 2ms entre iterações mantém a emulação em velocidade razoável sem consumir 100% da CPU.

Como Executar

Compile o projeto:

cargo build --release

Baixe ROMs CHIP-8 (são domínio público):

# Clone um repositório com ROMs clássicos
git clone https://github.com/kripod/chip8-roms.git

Execute uma ROM:

./target/release/chip8-emulator chip8-roms/games/Pong\ \(1\ player\).ch8
./target/release/chip8-emulator chip8-roms/games/Space\ Invaders.ch8
./target/release/chip8-emulator chip8-roms/games/Tetris.ch8

Controles:

  • 1234 / QWER / ASDF / ZXCV: Teclado hexadecimal do CHIP-8
  • Esc: Encerrar emulação

Desafios para Expandir

  1. Som – Implemente a emissão de um beep quando o sound_timer é maior que zero, usando a crate rodio para gerar um tom senoidal de 440Hz.

  2. Interface gráfica – Migre a renderização do terminal para uma janela gráfica usando minifb ou sdl2, com pixels reais e suporte a redimensionamento.

  3. Super CHIP-8 – Estenda o emulador com as instruções do Super CHIP-8 (SCHIP), que adiciona resolução 128x64, scroll, e sprites maiores.

  4. Debugger integrado – Adicione um modo de depuração com breakpoints, execução passo a passo, visualização de registradores e memória, e disassembly em tempo real.

  5. Salvar estado – Implemente save states que serializam todo o estado do emulador (memória, registradores, tela) em arquivo, permitindo salvar e restaurar a qualquer momento.

Veja Também