Programmare come se fosse il 1982. Parte V – Controllo di Flusso
Nel post precedente abbiamo visto come il processore sposta dati e fa aritmetica. Ma un programma che esegue solo istruzioni in linea retta non fa molto di utile. A un certo punto deve prendere una decisione. Andare in un modo o nell’altro, a seconda di un risultato. Ha bisogno di cicli, condizionali, della capacità di comportarsi diversamente a seconda dello stato delle cose.
Nell’assembly del 6510, tutto questo si costruisce su un unico meccanismo. I flag.
Il Status Register e i suoi flag
Ogni operazione che il processore esegue lascia tracce. Quando sommi due numeri e il risultato è zero, il processore lo annota. Quando una sottrazione produce un numero negativo, il processore lo annota. Quando una somma supera il limite di un byte, il processore lo annota. Queste tracce vivono in un registro speciale chiamato Status Register, o SR.
Lo SR non conserva un numero. Conserva otto bit indipendenti, ognuno con il proprio significato. Questi bit si chiamano flag, piccole marcature che il processore alza o abbassa automaticamente man mano che le operazioni avvengono.
Di tutti i flag, tre compaiono in praticamente ogni programma:
Il flag Z (Zero) si attiva quando il risultato di un’operazione è esattamente zero. Una sottrazione che dà zero, una somma che dà zero, un registro che è stato azzerato. Tutto questo alza Z.
Il flag N (Negative) si attiva quando il bit più alto del risultato è 1, che in aritmetica con segno indica un numero negativo. Se sottrai un numero più grande da uno più piccolo, N va a 1.
Il flag C (Carry) si attiva quando una somma supera 255, il massimo che sta in un byte. È il “riporto” dell’aritmetica binaria. Se sommi $FF + $01, il risultato sarebbe 256, che non sta in 8 bit. A diventa $00 e il bit che non ci stava va nel Carry.
Questi tre flag sono il fondamento di ogni decisione che un programma assembly prende.
Confrontare valori
Prima di saltare, devi confrontare. Per questo esistono CMP (confronta con A) e CPX (confronta con X).
CPX #$05 ; confronta X con il valore 5
Quello che questa istruzione fa internamente è una sottrazione. Calcola X - 5 e scarta il risultato, mantenendo solo l’effetto sui flag. Se X vale 5, il risultato è zero e Z si attiva. Se X è minore di 5, il risultato è negativo e N si attiva. Se X è maggiore di 5, nessuno dei due si attiva.
Il risultato in sé va nel cestino. Ciò che conta sono i flag rimasti.
Salti condizionali, l’if senza if
Con i flag al loro posto, entrano in gioco le istruzioni di salto. Leggono un flag specifico e decidono se continuare o saltare a un altro indirizzo.
| Istruzione | Condizione per saltare |
|---|---|
BEQ | Z = 1 (risultato era zero, i valori sono uguali) |
BNE | Z = 0 (risultato non era zero, i valori sono diversi) |
BMI | N = 1 (risultato era negativo) |
BPL | N = 0 (risultato era positivo o zero) |
BCS | C = 1 (carry attivo, risultato senza segno ha superato 255) |
BCC | C = 0 (carry inattivo) |
C’è un dettaglio che confonde all’inizio. La logica del salto è invertita rispetto a quello che faresti in Python o Go. Nei linguaggi moderni pensi “se la condizione è vera, esegui questo blocco”. In assembly pensi “se la condizione è falsa, salta questo blocco”.
Prima di guardare il codice, qui compare per la prima volta un elemento di sintassi nuovo. Le label. Una label è un nome che dai a una posizione specifica nel tuo programma. Quando scrivi done: nel codice, l’assembler sostituisce ogni riferimento a done con l’indirizzo reale di memoria di quell’istruzione. Il processore non vede mai il nome. Quando il codice gira, done è già diventato un numero. Le label esistono per non dover ricalcolare e codificare a mano gli indirizzi di memoria ogni volta che aggiungi o rimuovi una riga.
Per implementare if x == 5, scriveresti:
CPX #$05 ; confronta X con 5
BNE fine ; se X != 5, salta
JSR fai_qualcosa ; X è 5, chiama la funzione
fine:
RTS
BNE respinge chi non ha il biglietto, non chi ce l’ha. Il risultato finale è lo stesso, ma il ragionamento si inverte. Abituarsi richiede un po’ di tempo, ma dopo qualche ciclo diventa istinto.
Costruire un ciclo
Un while in Python:
x = 0
while x != 10:
fai_qualcosa()
x += 1
L’equivalente in assembly:
LDX #$00 ; x = 0
loop:
JSR fai_qualcosa ; fai_qualcosa()
INX ; x++
CPX #$0A ; confronta X con 10
BNE loop ; se X != 10, torna al loop
RTS
Il BNE alla fine è il guardiano. Finché il confronto non risulta in zero, finché X non ha raggiunto 10, il salto avviene e il ciclo riparte. Quando X finalmente vale 10, Z si attiva, BNE non salta e l’esecuzione va avanti.
Puntatori e indirizzamento indiretto
Tutti gli esempi finora accedevano a indirizzi fissi. LDA $0200, STA $D418. Ma a volte non sai al momento della scrittura del codice a quale indirizzo vuoi accedere. Lo sai a runtime, perché l’indirizzo dipende da qualche calcolo. Ecco cosa risolvono i puntatori.
Sul 6510, puoi memorizzare un indirizzo a 16 bit in due byte consecutivi della Zero Page e poi dire al processore di andare all’indirizzo memorizzato lì, aggiungergli Y e leggere ciò che c’è in quella posizione.
; Memorizza l'indirizzo $0400 nella Zero Page a $42 e $43
LDA #$00
STA $42 ; byte basso: $00
LDA #$04
STA $43 ; byte alto: $04
; Ora legge RAM[$0400 + Y] usando il puntatore
LDY #$03
LDA ($42),Y ; A = RAM[$0400 + 3] = RAM[$0403]
La notazione ($42),Y significa leggere l’indirizzo a 16 bit memorizzato nella Zero Page a $42 e $43, aggiungervi Y e usare il risultato come indirizzo finale. In C sarebbe esattamente ptr[3] dove ptr punta a $0400.
Un puntatore non è un concetto astratto. È un indirizzo memorizzato in memoria che il processore sa come seguire. Una volta che lo vedi così, tutto, dagli array dinamici alle tabelle di funzioni, inizia ad avere senso a livello hardware.
Nel prossimo post arriviamo al capitolo più sorprendente della serie. Come scrivere un byte in un indirizzo di memoria poteva suonare una nota musicale, muovere uno sprite sullo schermo o leggere lo stato di un pulsante del joystick. Senza driver, senza syscall, senza nessuna astrazione.