Última atualização:

Devlog #02: Emulador NES, emulação da CPU 6502 – parte 1

Nawarian
Nawarian emulador-nes

Este artigo é uma continuação da série “Emulador NES” que vai te mostrar e implementar contigo um emulador NES totalmente funcional utilizando a linguagem C e a biblioteca Raylib. Clique aqui para ler o artigo anterior, ou clique aqui para ler o primeiro artigo e acompanhar desde o começo!

No artigo anterior

Nós já fizemos uma visão geral dos componentes que o emulador precisa pra operar como um sistema NES: cpu, barramento, ppu, apu, etc.. E no artigo anterior nós também preparamos o ambiente de CI para que rode os testes a cada nova adição que fizermos ao nosso projeto usando o Github Actions.

Hoje nós vamos começar uma das partes mais empolgantes do projeto: a emulação da CPU 6502 – o processador do nintendinho. Para isso vamos escrever duas interfaces essenciais do emulador: a CPU e o barramento de memória.

Para cada uma destas interfaces eu fiz um vídeo separado mostrando a implementação do que tá escrito no arquivo pra todo mundo poder acompanhar. Vem comigo!

Emulação do barramento de memória

Antes de mexer em qualquer coisa relacionada ao processador, nós precisamos preparar uma forma de permitir que o processador – e mais tarde outros componentes também – tenham acesso à memória RAM.

Esta parte do trabalho é relativamente simples, porque só envolve criar uma interface para ler e escrever num espaço de memória, que nós vamos implementar com um array.

Propriedades de um barramento de memória

O barramento de memória é uma interface bem simples para ler e escrever da memória. Estas são as duas operações que a gente precisa implementar hoje!

O que é importante notar é que a CPU 6502 tem uma capacidade de endereçamento de apenas 16 bits (2 bytes). Portanto a CPU só consegue ler do barramento os endereços de memória entre 0x0000 e 0xFFFF, totalizando 65535 bytes (~64 kilobytes) de memória RAM.

Em termos práticos significa dizer que não importa se o hardware tiver mais que 64kb de RAM, a CPU não vai conseguir utilizar este espaço extra de memória.

Para emular o barramento e a memória RAM portanto, não precisamos de nada mais do que um array de 64 kb como no exemplo abaixo:

// Declara um array chamado 'bus' de 64 kb (65535 índices)
uint8_t bus[0xFFFF];

E tudo o que precisamos escrever para emular o barramento de memória são duas funções para que possamos ler e escrever da memória. Algo assim:

uint8_t bus_read(uint16_t address);
void bus_write(uint16_t address, uint8_t value);

A interface acima está relativamente incompleta, mas já deve te dar uma ideia de como o programa deverá ficar.

Dê uma olhada no vídeo de apoio caso queira acompanhar a implementação final, ou implemente os testes abaixo caso queira escrever comigo!

Vídeo de apoio

Testes para o barramento de memória

void test_bus_read(void)
{
// Given the memory address '0x0600' has the value '0xA9'
// When I call 'bus_read()' with the address '0x0600'
// Then I should get the value '0xA9'
}

void test_bus_write(void)
{
    // Given the memory bus contains only zeroes
// When I call 'bus_write()' with the address '0x0600' and value '0xA9'
// Then the byte '0x0600' should contain the value '0xA9'

}

Ao fazer o teste acima passar, estamos prontes para a próxima etapa: emular a CPU!

A implementação completa você encontra no vídeo de apoio e também neste Pull Request aqui.

Emulação da CPU 6502

O coração do nosso emulador é a unidade central de processamento, a CPU. Este é o componente que vai nos permitir ler programas presentes no barramento de memória e executar lógica. Na maioria dos casos, os bugs que vamos encontrar no emulador vão morar neste componente também!

Apesar de ser tão fundamental, o processador tem um funcionamento muito simples: ele fica rodando infinitamente (ou até ser encerrado) e a cada iteração (ciclo) o processador tenta identificar qual a próxima instrução que ele deveria executar a partir do barramento de memória.

Características da CPU 6502

O processador 6502 tem 3 registradores chamados de A (acumulador), X e Y – todos eles de apenas 1 byte. Além disso, ele possui um ponteiro de pilha (stack pointer) que também tem 1 byte, um registrador de status da CPU de 1 byte e um ponteiro de instrução (instruction pointer ou program counter) que possui 2 bytes.

A representação disso em uma struct C é mais ou menos a seguinte:

struct Cpu {
uint8_t a; // acumulador
uint8_t x;
uint8_t y;
uint8_t sp; // stack pointer
uint8_t st; // status register
uint16_t pc; // program counter
};

Note que o ponteiro de instrução possui 16 bits, ou 2 bytes, que é justamente o tamanho do nosso barramento. Portanto este é o único ponteiro que consegue armazenar o endereço completo de qualquer parte da memória do barramento.

Cada instrução do processador é descrita com apenas um byte – elas são armazenadas na memória assim. Por exemplo, a instrução LDA #$00 que significa “carregar o valor 0x00 no registrador A” fica descrita assim no barramento de memória: 0xA9 0x00.

Exatamente esta instrução LDA #$00 pode ser representada no nosso barramento de memória assim:

struct Cpu cpu = { 0 };
// aponta para o endereço 0x0600 da memória
cpu.pc = 0x0600;

uint8_t bus[0xFFFF];
bus_write(0x0600, 0xA9); // LDA
bus_write(0x0601, 0x00); // 0x00

De forma que quando a CPU rodar irá encontrar a instrução 0xA9 já que seu ponteiro de instrução (pc) aponta para o endereço 0x0600, onde existe o byte 0xA9. Esta instrução exige que o processador busque um segundo parâmetro gravado em sequência, no endereço 0x0601.

As instruções do processador – também chamadas de OpCode – podem obrigar diferentes números de parâmetros e diferentes formas de acessar a memória. Para você ter uma ideia, veja o exemplo da instrução LDA:

Instrução: LDA – carregar para o acumulador (cpu.a)

Instrução em assemblyRepresentação na memóriaSignificado
LDA #$000xA9 0x00

Imediato – carregar o próximo byte para o registrador

LDA $00

0xA5 0x00

Zero Page – carregar 1 byte da memória RAM para o registrador

LDA $ff000xAD 0x00 0xFF

Absoluto – carregar 1 byte da memória RAM para o registrador

A CPU possui outras formas de endereçamento que eu não citei na tabela acima, mas que nós vamos trabalhar nos próximos artigos.

Para facilitar o entendimento de dar alguma praticidade pro texto, eu montei mais um vídeo de apoio implementando o comecinho da nossa CPU.

Vídeo de apoio

 

Testes para a instrução LDA da CPU 6502

void test_cpu_reset(void)
{
// Given a CPU in any state
// When I call 'cpu_reset()'
// Then the registers A, X and Y should be '0'
// And the stack pointer should be '0x30'
// And the program counter should point to '0x0600'
}

void test_cpu_tick_lda(void)
{
// Given the program '0xA9 0xAA' in the address '0x0600'
// And the program counter (pc) points to the address '0x0600'
// When the cpu executes 1 OpCode
// Then the program counter (pc) should be '0x0602'
// And the A register should be '0xAA'
// And the number of elapsed cycles should be '2'
}

Ao fazer os testes acima passar nós alcançamos uma etapa importantíssima do nosso desenvolvimento: estabilidade para trabalhar os próximos opcodes.

A implementação completa você encontra no vídeo de apoio e também neste Pull Request aqui.

Próximos passos

Nós alcançamos um momento importantíssimo do nosso desenvolvimento! Temos um pull request aberto que nos permite enviar alterações continuamente e que irá ativar a nossa CI caso algo dê errado. Além disso temos uma forma organizada de escrever testes e manter um ritmo de trabalho em que apenas avançamos em vez de criar bugs difíceis de encontrar.

No próximo artigo eu pretendo cobrir a instrução STA (gravar do acumulador para a memória) de forma que com essas duas instruções (LDA e STA) teremos contemplado todos os tipos de endereçamento da memória e poderemos começar a implementar a CPU de forma acelerada utilizando um caso de teste pronto: o nestest.

Portanto na próxima nós vamos ver melhor os endereçamentos de memória e vamos carregar para a memória a ROM nestest que vai nos permitir caminhar muito mais rapidamente na implementação da nossa CPU!

 

Comentários