Programando como se fosse 1982. Parte V – Controle de Fluxo
No post anterior vimos como o processador movimenta dados e faz aritmética. Mas um programa que só executa instruções em linha reta não faz muita coisa útil. Em algum momento ele precisa tomar uma decisão. Ir por um caminho ou outro, dependendo de um resultado. Ele precisa de loops, condicionais, da capacidade de se comportar diferente dependendo do estado das coisas.
No assembly do 6510, tudo isso é construído sobre um único mecanismo. Flags.
O Status Register e suas flags
Cada operação que o processador executa deixa rastros. Quando você soma dois números e o resultado é zero, o processador anota. Quando uma subtração produz um número negativo, o processador anota. Quando uma soma transborda o limite de um byte, o processador anota. Esses rastros ficam num registrador especial chamado Status Register, ou SR.
O SR não guarda um número. Ele guarda oito bits independentes, cada um com seu próprio significado. Esses bits são chamados de flags, pequenas marcações que o processador levanta ou abaixa automaticamente conforme as operações acontecem.
De todas as flags, três aparecem em praticamente todo programa:
A flag Z (Zero) acende quando o resultado de uma operação é exatamente zero. Uma subtração que dá zero, uma soma que dá zero, um registrador que foi zerado. Tudo isso levanta Z.
A flag N (Negative) acende quando o bit mais alto do resultado é 1, que em aritmética com sinal indica um número negativo. Se você subtrair um número maior de um menor, N vai para 1.
A flag C (Carry) acende quando uma soma ultrapassa 255, o máximo que cabe em um byte. É o “vai um” da aritmética binária. Se você somar $FF + $01, o resultado seria 256, que não cabe em 8 bits. A fica com $00 e o bit que não coube vai para o Carry.
Essas três flags são a base de toda decisão que um programa em assembly toma.
Comparando valores
Antes de desviar, você precisa comparar. Para isso existem CMP (compara com A) e CPX (compara com X).
CPX #$05 ; compara X com o valor 5
O que essa instrução faz por dentro é uma subtração. Ela calcula X - 5 e descarta o resultado, mantendo apenas o efeito nas flags. Se X vale 5, o resultado é zero e Z acende. Se X é menor que 5, o resultado é negativo e N acende. Se X é maior que 5, nenhuma das duas acende.
O resultado em si vai para o lixo. O que importa são as flags que ficaram.
Desvios condicionais, o if sem if
Com as flags no lugar, entram as instruções de desvio. Elas leem uma flag específica e decidem se continuam em frente ou saltam para outro endereço.
| Instrução | Condição para saltar |
|---|---|
BEQ | Z = 1 (resultado foi zero, valores são iguais) |
BNE | Z = 0 (resultado não foi zero, valores são diferentes) |
BMI | N = 1 (resultado foi negativo) |
BPL | N = 0 (resultado foi positivo ou zero) |
BCS | C = 1 (carry ativo, resultado sem sinal ultrapassou 255) |
BCC | C = 0 (carry inativo) |
Tem um detalhe que confunde no começo. A lógica do desvio é inversa ao que você faria em Python ou Go. Em linguagens modernas você pensa “se a condição for verdadeira, execute esse bloco”. Em assembly você pensa “se a condição for falsa, pule esse bloco”.
Antes de olhar para o código, uma sintaxe aparece aqui pela primeira vez. Labels. Um label é um nome que você dá a uma localização específica no seu programa. Quando você escreve done: no código, o assembler substitui toda referência a done pelo endereço real de memória daquela instrução. O processador nunca vê o nome. Quando o código roda, done já virou um número. Labels existem para que você não precise recalcular e fixar endereços de memória toda vez que adicionar ou remover uma linha.
Para implementar if x == 5, você escreveria:
CPX #$05 ; compara X com 5
BNE fim ; se X != 5, pula
JSR faz_algo ; X é 5, chama a função
fim:
RTS
O BNE barra quem não tem ingresso, não quem tem. O efeito final é o mesmo, mas o raciocínio inverte. Acostumar com isso leva um tempo, mas depois de alguns loops vira instinto.
Construindo um loop
Um while em Python:
x = 0
while x != 10:
faz_algo()
x += 1
O equivalente em assembly:
LDX #$00 ; x = 0
loop:
JSR faz_algo ; faz_algo()
INX ; x++
CPX #$0A ; compara X com 10
BNE loop ; se X != 10, volta para loop
RTS
O BNE no final é o guardião. Enquanto a comparação não resultar em zero, enquanto X não tiver chegado a 10, o desvio acontece e o loop recomeça. Quando X finalmente vale 10, Z acende, BNE não desvia e a execução segue em frente.
Ponteiros e endereçamento indireto
Todos os exemplos até agora acessavam endereços fixos. LDA $0200, STA $D418. Mas às vezes você não sabe em tempo de escrita de código qual endereço quer acessar. Você sabe em tempo de execução, porque o endereço depende de algum cálculo. É isso que ponteiros resolvem.
No 6510, você pode guardar um endereço de 16 bits em dois bytes consecutivos da Zero Page e depois dizer ao processador para ir ao endereço guardado ali, somar Y a ele e ler o que estiver nessa posição.
; Guarda o endereço $0400 na Zero Page em $42 e $43
LDA #$00
STA $42 ; byte baixo: $00
LDA #$04
STA $43 ; byte alto: $04
; Agora lê RAM[$0400 + Y] usando o ponteiro
LDY #$03
LDA ($42),Y ; A = RAM[$0400 + 3] = RAM[$0403]
A notação ($42),Y significa ler o endereço de 16 bits guardado na Zero Page em $42 e $43, somar Y a ele e usar o resultado como endereço final. Em C isso seria exatamente ptr[3] onde ptr aponta para $0400.
Um ponteiro não é um conceito abstrato. É um endereço guardado na memória que o processador sabe como seguir. Uma vez que você vê dessa forma, tudo desde arrays dinâmicos até tabelas de funções começa a fazer sentido no nível do hardware.
No próximo post chegamos ao capítulo mais surpreendente da série. Como escrever um byte num endereço de memória podia tocar uma nota musical, mover um sprite na tela ou ler o estado de um botão do joystick. Sem driver, sem syscall, sem abstração nenhuma.