CHIP-8 é uma linguagem interpretada e uma máquina virtual criada nos anos 1970 para executar jogos simples. Ela tem 4 KB de memória, 16 registradores, display monocromático de 64x32 pixels e cerca de 35 instruções. Não é exatamente um console: é uma plataforma virtual que originalmente rodava em computadores como o COSMAC VIP e que hoje virou um dos melhores projetos para aprender como um emulador funciona.
Neste guia você vai construir um emulador CHIP-8 completo em Rust, do carregamento da ROM ao loop de CPU. O resultado executa programas reais no terminal, com decodificação de opcodes, teclado hexadecimal, stack, timers a 60 Hz e renderização do framebuffer. CHIP-8 é simples o suficiente para caber em poucas centenas de linhas, mas completo o bastante para ensinar memória, operações bit a bit, temporização, entrada e saída.
Ao final, você poderá testar jogos clássicos como Pong, Space Invaders e Tetris, além de ROMs de diagnóstico criadas para verificar se cada instrução foi implementada corretamente. Se este é seu primeiro projeto de baixo nível, revise também arrays em Rust, operações bit a bit e entrada e saída com std::io.
CHIP-8 em 30 segundos
| Componente | Especificação |
|---|---|
| Memória | 4.096 bytes (4 KB) |
| Início padrão da ROM | 0x200 |
| Registradores | 16 registradores de 8 bits (V0 a VF) |
| Display | 64x32 pixels monocromáticos |
| Instruções | Aproximadamente 35 opcodes de 16 bits |
| Stack | 16 níveis para chamadas de sub-rotina |
| Timers | Delay e som, decrementados a 60 Hz |
| Entrada | Teclado hexadecimal com 16 teclas |
A arquitetura pequena permite enxergar o ciclo inteiro: o emulador busca dois bytes na memória, combina-os em um opcode, decodifica os nibbles, altera o estado virtual e atualiza tela, timers ou teclado. Esse mesmo modelo mental reaparece em emuladores de consoles e CPUs mais complexos.
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.
ROMs CHIP-8: como carregar e testar com segurança
Uma ROM CHIP-8 é apenas um arquivo binário com os opcodes do programa. O emulador lê esses bytes e os copia para a memória a partir de 0x200, endereço reservado historicamente para o início dos programas. O program counter também começa nesse endereço, portanto o primeiro ciclo busca os bytes nas posições 0x200 e 0x201.
Para depurar, prefira começar por test ROMs que desenham na tela o resultado de cada verificação. Elas ajudam a separar erros de CPU, flags, sprites, teclado e temporização antes de testar jogos. Depois, experimente ROMs clássicas cuja distribuição seja autorizada. Evite republicar arquivos de origem ou licença incerta junto do seu código: mantenha o emulador no repositório e documente onde o usuário pode obter ROMs legais.
Algumas ROMs dependem de diferenças históricas entre interpretadores, conhecidas como quirks. As instruções de shift (8xy6 e 8xyE), o salto Bnnn, o comportamento de Fx55/Fx65 e o recorte ou wrap de sprites são fontes comuns de incompatibilidade. Se um jogo falhar mesmo depois de uma test ROM passar, transforme esses comportamentos em opções de configuração em vez de alterar a CPU sem diagnóstico.
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
- GUI em Rust – Iced, egui, Slint e outras opções para trocar o terminal por uma janela
- Testes em Rust – Como validar opcodes e regressões da CPU
Perguntas frequentes sobre CHIP-8
O que é CHIP-8?
CHIP-8 é uma linguagem interpretada executada por uma máquina virtual. Ela foi criada nos anos 1970 para simplificar jogos em microcomputadores e define memória, registradores, stack, timers, teclado, display e um conjunto pequeno de instruções. Por isso, costuma ser o primeiro emulador de quem estuda arquitetura de computadores.
CHIP-8 é um console?
Não exatamente. CHIP-8 não define um aparelho físico único como um console tradicional. O interpretador rodava em máquinas como o COSMAC VIP, e programas escritos para essa plataforma podiam ser executados por implementações compatíveis. Emuladores atuais recriam essa máquina virtual em computadores modernos.
Como criar um emulador CHIP-8 em Rust?
Crie uma estrutura para todo o estado virtual; carregue a ROM em 0x200; implemente o ciclo fetch-decode-execute; trate os opcodes com match; atualize delay e sound timer a 60 Hz; mapeie as 16 teclas; e desenhe o framebuffer 64x32. Depois valide cada grupo de instruções com test ROMs.
Onde uma ROM CHIP-8 é carregada?
A convenção é carregar a ROM a partir do endereço hexadecimal 0x200. Os primeiros 512 bytes eram reservados ao interpretador nos sistemas originais e hoje costumam conter dados internos, inclusive os sprites da fonte hexadecimal.
Quais ROMs usar para testar?
Use primeiro ROMs de diagnóstico com licença e procedência claras. Elas verificam opcodes, flags, teclado, timers e desenho. Quando a CPU estiver estável, teste jogos como Pong, Tetris e Space Invaders, sempre respeitando os direitos de distribuição dos arquivos.