CHIP-8: o ciclo fetch-decode-execute
No post anterior a gente montou a máquina e deixou ela paradinha: memória com a fonte carregada, ROM copiada a partir de 0x200, registradores e timers zerados, o program counter apontando para a primeira instrução. Tudo no lugar, mas nada acontecendo. Neste post a máquina começa a pulsar.
Esse pulso tem um nome: o ciclo fetch-decode-execute. É o laço que toda CPU repete o tempo todo, do primeiro ao último instante em que está ligada, e é literalmente o que faz um processador processar. Vamos construir esse laço e, mais importante, entender o que acontece em cada batida.
O que é uma instrução?
Antes do ciclo, vale firmar o termo que vai aparecer o tempo todo. Uma instrução é uma ordem única que a CPU sabe obedecer, uma ação pequena e concreta: "limpe a tela", "coloque o valor 12 no registrador V0", "salte para o endereço 0x22A". Nada muito esperto sozinho. O que dá vida a um programa é a sequência dessas ordens, uma atrás da outra.
Para a máquina, cada instrução não passa de um número. Esse número é o opcode,
e é ele que a CPU lê da memória e interpreta. No CHIP-8 são uns 35 opcodes no
total (que mencionei lá na especificação do primeiro post), e cada
um ocupa 16 bits. O 0x00E0, por exemplo, é "limpe a tela"; o 0x1NNN é "salte
para o endereço NNN". Executar um programa é, no fundo, ler esses números um a um
e agir sobre cada um. Que é exatamente o que o ciclo faz.
O ciclo em uma frase
Um processador não faz mágica, ele faz a mesma coisa repetidamente. A cada volta do laço ele:
- busca (fetch) a instrução que o program counter está apontando;
- decodifica (decode) esses bits para descobrir o que a instrução quer;
- executa (execute) a ação, e avança para a próxima instrução.
1 2 3 4 | |
Parece pouco, mas é isso. Um jogo inteiro de CHIP-8 rodando é essa volta acontecendo milhares de vezes por segundo. Vamos por partes.
Fetch: dois bytes viram uma instrução
Cada instrução do CHIP-8 tem 16 bits de largura. A memória, porém, é endereçada em bytes: cada posição guarda 8 bits. Não fecha a conta, e a saída para isso é simples: cada instrução ocupa duas posições consecutivas da memória. Buscar uma instrução é ler esses dois bytes e juntar num único valor de 16 bits.
Aqui aparece um detalhe que confunde muita gente de primeira: a ordem dos bytes. O CHIP-8 é big-endian, o que quer dizer que o byte guardado no endereço menor é o mais significativo, o que vale mais. Pega a segunda instrução do IBM Logo, que está em 0x202. Na memória os dois bytes são:
1 2 3 | |
Juntar os dois na ordem certa é empurrar o primeiro byte 8 casas para a esquerda, abrindo espaço embaixo, e encaixar o segundo nesse espaço com um OR:
1 2 3 4 5 6 7 | |
Os 8 bits de cima vieram do primeiro byte, os 8 de baixo do segundo, e o valor
final é 0xA22A. Em código:
1 2 3 4 5 6 7 8 | |
Repare no uint16(...) em volta dos dois bytes. Isso não é decoração. Um byte
em Go tem só 8 bits, e deslocar um byte 8 casas para a esquerda jogaria todo o
conteúdo para fora, sobrando zero. Convertendo para uint16 antes, o valor passa
a caber nos 16 bits e o deslocamento preserva tudo. É o tipo de detalhe que não
dá erro de compilação, só um bug silencioso, então melhor entender agora.
Um respiro: máscara e shift
O fetch já usou dois operadores bitwise, o << (shift para a esquerda) e o |
(OR). O decode, que vem a seguir, vai depender de outros dois, então vale um
respiro rápido para firmar a intuição.
O AND (&) funciona como uma peneira. Bit a bit, 1 & 1 é 1 e qualquer
coisa com & 0 vira 0. Se você montar um número onde os bits que interessam são
1 e o resto é 0, esse número vira uma máscara: passar um valor por ela com
& mantém só os pedaços que você marcou e zera todo o resto. Digamos que eu
queira só o segundo nibble (o segundo grupo de 4 bits) de 0xA22A:
1 2 3 4 | |
Sobrou só o pedaço que eu queria, mas ele ficou lá no meio do número, valendo
0x0200 em vez do 0x2 que eu esperava. É aí que entra o shift para a
direita (>>), que empurra os bits para o canto de baixo até o grupo ficar
alinhado e voltar a ser um número comum:
1 2 | |
Máscara para selecionar, shift para alinhar. Com essas duas ferramentas a gente desmonta qualquer instrução.
Decode: lendo os campos da instrução
Aqueles 16 bits não são um número solto, eles têm estrutura. O primeiro nibble diz a família da instrução, o tipo dela. Os nibbles seguintes carregam os operandos: qual registrador mexer, qual valor usar, para qual endereço saltar. Um nibble, aliás, é meio byte, 4 bits, exatamente um dígito hexadecimal. Por isso é tão cômodo ler opcodes em hexa: cada dígito é um nibble.
O 0xA22A se abre assim:
1 2 | |
O problema é que instruções diferentes fatiam esses bits de formas diferentes.
Uma pode usar os últimos 12 bits como um endereço, outra pode usar o segundo
nibble como registrador e o último byte como valor. Como ainda não sabemos qual é
qual só de olhar, o decode recorta todas as fatias úteis de uma vez, usando a
máscara e o shift que acabamos de ver. Aplicando em 0xA22A, o kind e o x
passo a passo:
1 2 3 4 5 6 | |
Os demais seguem a mesma receita, mudando só a máscara e o quanto se desloca:
| Visão | Máscara e shift | Em 0xA22A |
|---|---|---|
kind |
opcode & 0xF000 |
0xA000 |
x |
(opcode & 0x0F00) >> 8 |
0x2 |
y |
(opcode & 0x00F0) >> 4 |
0x2 |
n |
opcode & 0x000F |
0xA |
nn |
opcode & 0x00FF |
0x2A |
nnn |
opcode & 0x0FFF |
0x22A |
Em Go isso são seis linhas no começo do Execute:
1 2 3 4 5 6 | |
Nem toda instrução usa todas essas fatias. O decode só faz o recorte e deixa cada campo à mão; quais deles importam é decisão de cada instrução, e cada operação vai usar os pedaços que precisa na hora de executar. Por ora o objetivo é só separar os campos direito.
Execute: o despacho
Com o kind na mão, falta rotear a instrução para o lugar certo. Isso é um
switch em cima do primeiro nibble, cada família num case:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
Tem uma sutileza aqui. O primeiro nibble quase sempre basta para identificar a
instrução, mas algumas famílias compartilham o mesmo nibble e se diferenciam pelo
resto dos bits. O 0x8, por exemplo, é uma família inteira de operações entre
registradores, e quem decide qual é o último nibble (n). O 0x0, o 0xE e o
0xF têm a mesma história. Por isso o switch tem dois níveis em alguns pontos:
um olhar para a família, outro para o detalhe. É uma pequena árvore de decisão.
Repare que todo case ainda é um TODO. Neste post o Execute só
identifica cada instrução, ele ainda não faz nada. E o default, para opcode
desconhecido, não fica vazio à toa: se um dia aparecer um valor que não bate com
nenhum caso, é bem melhor a máquina reclamar do que engolir em silêncio e deixar
um bug escondido. Erro barulhento é amigo de quem está debugando.
O ciclo montado, e por que o PC anda de 2 em 2
As três peças se juntam num método só, o Cycle:
1 2 3 4 5 6 7 | |
O pc += 2 é o "avança para a próxima" do nosso laço. Anda 2 e não 1 porque cada
instrução ocupa dois bytes, então a próxima está sempre dois endereços à frente.
O que não é óbvio é por que avançar antes de executar, e não depois. A resposta
aparece de verdade no próximo post, mas dá para adiantar: algumas instruções são
saltos, elas mandam o PC pular para outro endereço, sobrescrevendo ele. Se a
gente executasse primeiro e só então fizesse pc += 2, esse += 2 viria por
cima do salto e estragaria o destino. Avançando antes, o valor padrão fica posto,
e quem quiser saltar dá a palavra final depois. Ordem importa.
O Cycle devolve o opcode que rodou. Não é necessário para a máquina funcionar,
mas é comodíssimo para o que vem agora: espiar o processador trabalhando.
O estado do processador em movimento
Ainda não temos nenhuma instrução implementada, então a máquina não "faz" nada.
Mas ela já consegue ler e identificar cada instrução da ROM, e isso a gente pode
ver. No main, um laço que roda o ciclo e imprime o endereço junto com o opcode:
1 2 3 4 5 6 | |
Rodando no IBM Logo:
1 2 3 4 5 6 7 8 9 10 11 | |
Não é bonito, mas é a máquina pensando. O PC sai de 0x200 e marcha de 2 em 2, cada linha é um fetch bem-sucedido, e cada opcode é um valor de 16 bits que o decode saberia quebrar. Juntando com o que vimos, dá para ler o estado do processador nas primeiras batidas:
| PC | bytes na memória | opcode | família |
|---|---|---|---|
| 0x200 | 00 E0 |
0x00E0 |
0x0 (limpa a tela) |
| 0x202 | A2 2A |
0xA22A |
0xA (carrega I) |
| 0x204 | 60 0C |
0x600C |
0x6 (carrega Vx) |
| 0x206 | 61 08 |
0x6108 |
0x6 (carrega Vx) |
| 0x208 | D0 1F |
0xD01F |
0xD (desenha) |
Uma ressalva honesta: esse laço passeia pela memória em linha reta, do começo ao
fim da ROM. Um programa de verdade não roda assim, ele desvia com saltos e
chamadas de sub-rotina, e o PC pula para trás e para frente. Como ainda não temos
nenhuma instrução implementada, nada altera o PC além do nosso += 2, então o
passeio linear é inofensivo e serve perfeitamente para uma coisa: confirmar que o
fetch e o decode estão de pé.
Próximos passos
No próximo post a gente começa a preencher esses case e a implementar as
instruções de verdade. É quando o emulador para de só identificar os opcodes e
começa a trabalhar pra valer.
Até lá.
Comentários