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
Som – Implemente a emissão de um beep quando o
sound_timeré maior que zero, usando a craterodiopara gerar um tom senoidal de 440Hz.Interface gráfica – Migre a renderização do terminal para uma janela gráfica usando
minifbousdl2, com pixels reais e suporte a redimensionamento.Super CHIP-8 – Estenda o emulador com as instruções do Super CHIP-8 (SCHIP), que adiciona resolução 128x64, scroll, e sprites maiores.
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.
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
- Arrays na Biblioteca Padrão – Arrays de tamanho fixo usados em memória, registradores e tela
- Vec e Coleções – Coleções dinâmicas para dados auxiliares
- Módulo IO – Leitura de ROMs e renderização
- Medição de Tempo – Controle de timing dos timers a 60Hz
- Rust para Jogos – Ecossistema de jogos e emuladores em Rust