DEV Community

Cover image for Construindo o seu próprio emulador, parte 1
Maurício Antunes
Maurício Antunes

Posted on • Updated on

Construindo o seu próprio emulador, parte 1

O que é um emulador?

Um emulador emula hardware em software. É uma técnica que habilita um computador imitar as características de outro hardware, como por exemplo, um video game. Existem emuladores famosos como o Project64 e o ZSNES, usados para emular os video games Nintendo 64 e Super Nintendo respectivamente.

Além de emular, um emulador pode ir adiante, providenciando uma maior performance, maior qualidade de vídeo e melhor gerenciamento de recursos como CPU e memória.

Por que aprender sobre emuladores?

Boa pergunta! Entender o básico de emuladores vai te proporcionar uma boa ideia de como computadores funcionam.

Emuladores são compostos de vários componentes de um computador, como uma memória, uma CPU, um teclado e um display. Cada um desses componentes tem suas características. O desenvolvimento de um emulador vai te ajudar a entender como um programa é carregado na memória (ROM, read-only memory), ou como as instruções do programa são interpretadas e executadas e como as informações são mostradas na tela.

Alguns requisitos são necessários para entender e construir um, como:

  • Conhecer alguma linguagem de programação;
  • Operações lógicas;
  • Deslocamento de bits (bit shifting).

Se você conhece uma linguagem de programação mas não entende sobre operações lógicas e operações de bit, não se preocupe, eu vou colocar referências de estudo e explicar as partes do código que falam sobre isso em detalhes.

O hello world! dos emuladores é o chip-8, e é ele que vamos construir.

Um pouco de base

Antes de começarmos a falar sobre o emulador que vamos construir, vamos relembrar ou aprender um pouco sobre dois assuntos que precisamos compreender para seguir este guia.

Binário e hexadecimal

Nosso sistema número mais comum é o sistema decimal. Isso quer dizer que temos do 0 ao 9 para formarmos outros números, como o 2, o 321, o 9825, etc. Depois do 9, para adicionarmos um número passamos o 9 para 0 e colocamos 1 à esquerda e teremos o 10. Computadores usam outros sistemas de números: binário e hexadecimal.

O sistema binário é constituído de 2 números, o 0 e o 1. Zero é 0, um é 1 e o 2 troca o 1 pra 0 e adiciona um 1 à esquerda. Contando em binário: 0, 1, 10, 11, 100, 101, 110, 111, 1000 (0, 1, 2, 3, 4, 5, 6, 7, 8).

O sistema hexadecimal conta com 16 símbolos. Isso provê uma maior compactação na representação de números maiores. Por exemplo o 15 é representado pela letra F. Quanto maior o número, mais isso fica perceptível.

Todas as instruções do chip-8 são analisadas de forma hexadecimal. A instrução de limpar a tela começa com 0x0 e a operação de desenhar é 0xD.

Lógica binária

Lógica binária opera em bits. Usamos para manipular valores e fazer comparações.

Algumas dessas operações você já pode conhecer, mas uma em especial é importante conhecer, o XOR. O XOR devolve 1 bit sempre que a quantidade de números 1 for ímpar:

 |  V1 |  V2 |  R  |
 +-----+-----+-----+
 |  0  |  0  |  0  |
 +-----+-----+-----+
 |  1  |  0  |  1  |
 +-----+-----+-----+
 |  0  |  1  |  1  |
 +-----+-----+-----+
 |  1  |  1  |  0  |
 +-----+-----+-----+
Enter fullscreen mode Exit fullscreen mode

Você pode testar isso com alguma linguagem com REPL ou usando bash:

echo $((0 ^ 0))
0
echo $((0 ^ 1))
1
echo $((1 ^ 1))
0
Enter fullscreen mode Exit fullscreen mode

Deslocamento de bits

Uma das razões do deslocamento de bits existir é a possibilidade de codificar informações importantes usando menos dados. O receptor da mensagem pode decodificar a mensagem, extraindo valores com significado.

As instruções do chip-8, por exemplo, são codificadas de 2 em 2 bytes, e cada byte pode ser dividido em 2 nibbles usando deslocamento de bits. Veremos isso em mais detalhes no próximo post, quando vamos aprender a decodificar as instruções da ROM.

Geralmente a primeira instrução de uma ROM é limpar a tela, a 00E0. Em binário isso seria 00000000 (00) e 11100000 (E0).

A tela é limpa quando o OpCode é 0 e o quarto nibble é 0. Vamos testar usando o REPL do Python:

>>> (0b00000000 >> 4) & 0xF // OpCode
0
>>> 0b11100000 & 0xF // quarto nibble chamado de N
0
Enter fullscreen mode Exit fullscreen mode

A instrução de jump (0x1NNN) faz o código pular até a instrução de memória NNN.

0b11010 | 0b100101
1A      | 25

JUMP | 0xA25
0x1  | A25
Enter fullscreen mode Exit fullscreen mode

0x1 é a operação de pular e A25 forma o NNN, o quarto, terceiro e segundo nibble dos dois bytes da instrução.

Veremos esse assunto em detalhes na parte 2.

O que é o chip-8?

O chip-8 é uma linguagem interpretada e, também, uma máquina virtual criada por Joe Weisbecker em 1977 para rodar no COSMAC VIP

foto de um computador COSMAC VIP

A ideia do Joe era rodar pequenos programas e jogos com a ajuda de um teclado de hexadecimal. Em vez de usar linguagem de máquina, o teclado hexadecimal era usado para digitar instruções que seriam interpretadas.

Interpretadas? O correto não seria emuladas? Não. Nesse guia não vamos construir um emulador exatamente, mas sim um interpretador de instruções chip-8. Nosso interpretador vai ler instruções de uma ROM e executá-las uma a uma, em um loop de ler, decodificar e executar as instruções carregadas.

Mas qual a diferença entre interpretador e emulador nesse caso? O chip-8 é um programa que rodava em um computador. Um emulador imita hardware. Simular o chip-8 significa que vamos escrever uma máquina virtual que interpreta comandos via uma linguagem hexadecimal, porém, trazendo vários conceitos vistos em emulação e arquitetura de computadores como PC (program counter), stack, timers, RAM, ROM, etc.

O chip-8 é formado por diversos componentes. Abaixo vamos falar de cada um dos componentes de maneira breve.

Memória

O espaço de memória deve ser de 4kB (4096 bytes). Toda essa memória é volátil (RAM) e pode ser modificável.

Uma ROM deve começar começar a ser carregada a partir do endereço 0x200 (512 em decimal). Os endereços 0x000 até o 0x1FF são reservados para o chip-8, porém vamos construir ele usando nosso computador, então não precisamos nos preocupar com isso.

Registradores

Existem dois registradores: dados e de endereço.
Eles são usados para gerenciar dados no chip-8. Há várias operações como adição, subtração, leitura de valores, etc. Essas operações se tornam possíveis quando você tem registradores.

O registrador de dados é um array com 16 posições de valor inteiro de 8 bits sem sinal (u8). O endereço 0xF é normalmente usado para configurar uma flag (0 ou 1), que pode ser usado para indicar se houve colisão ao desenhar no display.

O registrador de endereço não é um array, mas sim uma espécie de ponteiro. Ele é chamado de index e é usado para ler e escrever na memória. O registrador de endereços, chamado de I, também é usado para desenhar as fontes no display, que serão configuradas através de uma das instruções contidas na ROM.

Display

Com as dimensões de 64 pixels de largura e 32 pixels (64x32) de altura, o display é usado para renderizar na tela toda atualização identificada pela instrução de draw (DXYN) que veremos adiante.

No chip-8, os pixels são valores booleanos, 0 ou 1, on ou off, 0x0 ou 0x1. Os displays eram monocromáticos (preto e branco), porém, quando chegar a hora você vai poder usar diferentes cores para os pixels. O display começa com todos os pixels off.

Em desenvolvimento você pode, em vez de escrever na tela usando alguma engine de gráficos, escrever no STDOUT para conferir se o programa está desenhado da maneira correta.

Teclado

O teclado do COSMAC VIP tem 16 teclas, por isso é chamado de hex keypad. Não vamos construir o teclado, mas precisamos saber que ele existe e que algumas instruções do chip-8 esperam por uma tecla a ser pressionada.

teclado com 16 teclas do COSMAC VIP

Ao construir seu chip-8 você não precisa seguir esse layout. O layout que usei é como mostrado abaixo:

╔═══╦═══╦═══╦═══╗
║ 1 ║ 2 ║ 3 ║ 4 ║
╠═══╬═══╬═══╬═══╣
║ Q ║ W ║ E ║ R ║
╠═══╬═══╬═══╬═══╣
║ A ║ S ║ D ║ F ║
╠═══╬═══╬═══╬═══╣
║ Z ║ X ║ C ║ V ║
╚═══╩═══╩═══╩═══╝
Enter fullscreen mode Exit fullscreen mode

Se o seu teclado não é no layout QWERTY, tudo bem, você pode mapear outras teclas ou até deixar isso configurável.

Stack

A stack é uma área de memória relacionada a sub-rotinas que podem ser chamadas durante a execução da ROM.
Como o próprio nome já diz, isso é uma stack. Se a sua linguagem de programação suporta o uso de stacks, use-a para maior legibilidade.

Os valores guardados nessa stack são de 16 bits.

Em Rust, os vectors possuem as funções push e pop:

let mut stack: Vec<u16> = Vec::new();
stack.push(0xF);
stack.pop();
Enter fullscreen mode Exit fullscreen mode

Existe uma limitação no chip-8 de 12 a 16 endereços de memória, porém, não precisamos nos preocupar com isso, deixando o tamanho da stack ilimitada.

Fontes

O chip-8 vem com fontes pré-determinadas. Isso significa que existe na memória do chip-8 sprites que são basicamente números e letras no intervalo hexadecimal: 0 a F. Cada uma dessas representações tem 4 pixels de largura e 5 pixels de altura.

Guarde essas fontes em uma área de memória antes do 0x200 que falamos acima. Por algum motivo ficou popular gravar a partir do 0x50. É possível definir e carregar as fontes usando um código similar ao debaixo:

static FONTS: [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
];

for (n, font) in FONTS.iter().enumerate() {
    machine.memory[0x50 + n] = *font;
}
Enter fullscreen mode Exit fullscreen mode

Existe uma instrução (FX29) que guarda o endereço da fonte a ser desenhada. Então, a instrução de desenhar vai usar esse valor de memória para escrever a fonte na tela.

Timers

O chip-8 conta com 2 timers (temporizadores): delay timer e sound timer.

Ambos timers são decrementados numa taxa de 60khz até chegar a zero. Ao chegar em zero, cada um terá seu evento interrompido e a contagem recomeça.

O delay timer é usado para sincronizar eventos.
O sound timer vai tocar um som (beep) enquanto o valor for maior que zero.

Os dois timers podem ser inicializados como um inteiro de 8 bits sem sinal (u8).

if self.delay_timer > 0 {
    self.delay_timer -= 1;
}

if self.sound_timer > 0 {
    self.sound_timer -= 1;
}
Enter fullscreen mode Exit fullscreen mode

No próximo post vamos abordar, em detalhes, a codificação do chip-8 usando a linguagem Rust, entendendo como decodificar as instruções e executar as operações.

Top comments (0)