Programando como se fosse 1982. Parte IV – Instruções
No post anterior construímos o mapa. Sabemos onde os dados vivem, como a memória é organizada em endereços e por que certas regiões valiam mais do que outras. Agora falta a peça central. O que o processador realmente faz enquanto executa um programa.
O programa também são bytes
No post sobre o ciclo de execução vimos que o processador lê um byte, interpreta como instrução, executa e avança para o próximo. Mas nunca falamos sobre como um byte vira uma instrução.
A resposta é simples. Cada instrução do processador tem um número fixo que a representa. Esse número se chama opcode. O processador lê o byte $A9 e sabe, por construção em silício, que deve carregar o próximo byte no registrador A. Lê $8D e sabe que deve pegar o valor de A e gravá-lo num endereço de memória especificado pelos próximos dois bytes. Lê $60 e sabe que deve retornar de uma sub-rotina.
O processador não lê texto. Não lê nomes. Lê números, e cada número tem um significado gravado no chip para sempre.
O assembly é a camada que existe para que humanos não precisem memorizar esses números. Em vez de escrever $A9 $05, você escreve LDA #$05. Em vez de $8D $00 $02, você escreve STA $0200. O assembler, o programa que compila código assembly, converte esse texto de volta para os bytes que o processador entende. É uma tradução direta, sem interpretação, sem otimização. Cada linha vira exatamente os bytes correspondentes.
O que isso significa na prática. Quando você escreve LDA #$05, não está descrevendo uma intenção para um compilador inteligente. Está especificando literalmente quais bytes vão sentar na memória, em que ordem, e o que o processador vai fazer quando chegar neles.
As instruções que você vai usar o tempo todo
Com esse modelo em mente, as instruções ficam naturais. O 6510 tem cerca de 56 mnemonics no total, mas um conjunto pequeno aparece em praticamente qualquer programa.
LDA e STA são a dupla de movimentação de dados. LDA carrega um valor no acumulador A, STA grava o valor de A em algum endereço da memória. Tudo que entra e sai do processador passa por eles. As versões para X e Y seguem o mesmo padrão. LDX/STX e LDY/STY.
ADC e SBC são soma e subtração. ADC soma um valor ao que já está em A, SBC subtrai. O 6510 não tem multiplicação ou divisão nativa. Essas operações eram implementadas à mão, com loops de somas ou subtrações repetidas. Nada que um compilador de C não faça por baixo até hoje em certas arquiteturas.
INX, INY, DEX, DEY incrementam e decrementam os registradores X e Y. São o i++ e i-- do assembly, e aparecem em praticamente todo loop.
JMP, JSR e RTS controlam o fluxo. JMP é um goto incondicional. O Program Counter vai para o endereço especificado e o processador continua dali. JSR chama uma sub-rotina, salvando o endereço de retorno na pilha. RTS retorna dessa sub-rotina, relendo o endereço da pilha e voltando de onde veio. É o mecanismo por trás de toda chamada de função no assembly.
Quando JSR salta para uma subrotina, o processador tem um problema. Precisa lembrar onde voltar quando RTS for chamado. O lugar onde guarda essa informação é a pilha, uma região fixa de memória de $0100 a $01FF que funciona como uma pilha física de pratos.
JSR empurra o endereço de retorno para o topo da pilha e o SP se move um slot para baixo para refletir que algo foi adicionado. RTS lê esse endereço de volta do topo, o SP sobe um slot e a execução continua de onde a chamada foi feita. Cada JSR empurra, cada RTS desempilha, sempre equilibrado.
É por isso que chamadas de subrotinas aninhadas funcionam sem quebrar nada. Cada chamada empurra um novo endereço de retorno, cada retorno desempilha um. A pilha tem 256 bytes, espaço suficiente para cerca de 128 chamadas aninhadas antes de transbordar.
O detalhe que confunde todo mundo, # ou sem #
Existe uma distinção pequena na sintaxe que causa mais confusão do que qualquer outra coisa quando se está começando. O símbolo # antes de um valor muda completamente o que a instrução faz.
LDA #$05 ; A = 5 (o número cinco em si)
LDA $05 ; A = RAM[$05] (o que estiver guardado no endereço 5)
Com #, você está trabalhando com o valor literal. Sem #, você está trabalhando com o conteúdo do endereço de memória. Em Python, a diferença seria entre x = 5 e x = RAM[5].
Na prática. LDA #$00 zera o acumulador. LDA $00 carrega no acumulador o que estiver guardado na posição zero da Zero Page, que pode ser qualquer coisa dependendo do programa. São instruções completamente diferentes com uma diferença de um caractere.
Um programa completo, linha a linha
Esse programa carrega o número 5 no acumulador e o guarda no endereço $0200:
LDA #$05 ; A = 5
STA $0200 ; RAM[$0200] = A
RTS ; retorna
Simples assim. Mas o interessante é ver o que isso vira na memória. Cada instrução ocupa um número diferente de bytes dependendo de quantas informações precisa carregar:
LDA #$05vira 2 bytes, o opcode$A9seguido pelo valor$05STA $0200vira 3 bytes, o opcode$8D, o byte baixo do endereço$00e o byte alto$02RTSvira 1 byte, o opcode$60, sozinho
Seis bytes no total. É o programa inteiro. Quando o processador ler o primeiro byte em $0801, vai executar o LDA. Quando avançar para $0803, vai executar o STA. Quando chegar em $0806, vai retornar. Não há mais nada além disso.
Perceba a ordem dos bytes do endereço no STA. O byte baixo vem antes do byte alto. $0200 é armazenado como $00 $02, não como $02 $00. Isso se chama little-endian, e é uma característica de toda a família 6502. Vai aparecer sempre que um endereço for armazenado em dois bytes consecutivos na memória.
No próximo post juntamos tudo isso e escrevemos algo que realmente faz algo visível. Um loop que preenche a tela, usando o que agora sabemos sobre registradores, memória, instruções e o ciclo de execução.