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:

  1. busca (fetch) a instrução que o program counter está apontando;
  2. decodifica (decode) esses bits para descobrir o que a instrução quer;
  3. executa (execute) a ação, e avança para a próxima instrução.
1
2
3
4
fetch  ->  decode  ->  execute
  ^                        |
  +------------------------+
        (PC avança, repete)

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
endereço   byte
0x202      0xA2   <- byte mais significativo (vem primeiro)
0x203      0x2A   <- byte menos significativo

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
high  (mem[0x202]) = 0xA2 = 1010 0010
low   (mem[0x203]) = 0x2A = 0010 1010

high << 8          = 1010 0010 0000 0000   (0xA200)
       | low       = 0000 0000 0010 1010   (0x002A)
                     ---------------------
opcode             = 1010 0010 0010 1010   (0xA22A)

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
// Fetch lê a instrução que o PC está apontando na memória.
// Cada instrução tem 2 bytes; juntamos os dois num valor de 16 bits.
func (c *CPU) Fetch() uint16 {
    high := uint16(c.memory[c.pc])
    low := uint16(c.memory[c.pc+1])

    return (high << 8) | low
}

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
      1010 0010 0010 1010   (0xA22A)
  &   0000 1111 0000 0000   (0x0F00, a máscara)
      -------------------
      0000 0010 0000 0000   (0x0200)

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
      0000 0010 0000 0000   >> 8
  =   0000 0000 0000 0010   (0x2)

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
  A     2     2     A
1010  0010  0010  1010

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
kind = opcode & 0xF000
      1010 0010 0010 1010  &  1111 0000 0000 0000  =  1010 0000 0000 0000  (0xA000)

x = (opcode & 0x0F00) >> 8
      1010 0010 0010 1010  &  0000 1111 0000 0000  =  0000 0010 0000 0000
      depois >> 8                                  =  0x2

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
kind := opcode & 0xF000     // primeiro nibble: a família da instrução
x := (opcode & 0x0F00) >> 8 // segundo nibble: registrador x
y := (opcode & 0x00F0) >> 4 // terceiro nibble: registrador y
n := opcode & 0x000F        // quarto nibble: número de 4 bits
nn := opcode & 0x00FF       // último byte: número de 8 bits
nnn := opcode & 0x0FFF      // últimos 12 bits: um endereço

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
func (c *CPU) Execute(opcode uint16) {
    kind := opcode & 0xF000
    // ... aqui vêm x, y, n, nn, nnn ...

    switch kind {
    case 0x1000: // 1NNN JP: salta para NNN
        // TODO
    case 0x6000: // 6XNN LD Vx, NN: Vx = NN
        // TODO
    case 0x8000:
        switch n {
        case 0x4: // 8XY4 ADD Vx, Vy: Vx += Vy, VF = carry
            // TODO
        // ... os outros casos 0x8 ...
        }
    // ... as demais famílias ...
    default:
        // opcode desconhecido
    }
}

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
// Cycle roda um ciclo completo de fetch-decode-execute.
func (c *CPU) Cycle() uint16 {
    opcode := c.Fetch()
    c.pc += 2
    c.Execute(opcode)
    return opcode
}

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
steps := len(data) / 2
for i := 0; i < steps; i++ {
    addr := cpu.PC()
    opcode := cpu.Cycle()
    fmt.Printf("0x%04X: 0x%04X\n", addr, opcode)
}

Rodando no IBM Logo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ go run ./cmd/chip8 "testdata/roms/IBM Logo.ch8"
ROM loaded: 132 bytes
0x0200: 0x00E0
0x0202: 0xA22A
0x0204: 0x600C
0x0206: 0x6108
0x0208: 0xD01F
0x020A: 0x7009
0x020C: 0xA239
0x020E: 0xD01F
0x0210: 0xA248

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á.

Referências