Programmare come se fosse il 1982. Parte IV – Istruzioni
Nel post precedente abbiamo costruito la mappa. Sappiamo dove vivono i dati, come la memoria è organizzata in indirizzi e perché certe regioni valevano più di altre. Ora manca il pezzo centrale. Cosa fa davvero il processore mentre esegue un programma.
Il programma è anche byte
Nel post sul ciclo di esecuzione abbiamo visto che il processore legge un byte, lo interpreta come istruzione, lo esegue e avanza al successivo. Ma non abbiamo mai parlato di come un byte diventa un’istruzione.
La risposta è semplice. Ogni istruzione del processore ha un numero fisso che la rappresenta. Quel numero si chiama opcode. Il processore legge il byte $A9 e sa, per costruzione nel silicio, che deve caricare il byte successivo nel registro A. Legge $8D e sa che deve prendere il valore di A e scriverlo in un indirizzo di memoria specificato dai due byte successivi. Legge $60 e sa che deve tornare da una subroutine.
Il processore non legge testo. Non legge nomi. Legge numeri, e ogni numero ha un significato masterizzato nel chip per sempre.
L’assembly è il livello che esiste affinché gli umani non debbano memorizzare quei numeri. Invece di scrivere $A9 $05, scrivi LDA #$05. Invece di $8D $00 $02, scrivi STA $0200. L’assembler, il programma che compila codice assembly, converte quel testo di nuovo nei byte che il processore capisce. È una traduzione diretta, senza interpretazione, senza ottimizzazione. Ogni riga diventa esattamente i byte corrispondenti.
Cosa significa in pratica. Quando scrivi LDA #$05, non stai descrivendo un’intenzione a un compilatore intelligente. Stai specificando letteralmente quali byte si siederanno in memoria, in quale ordine, e cosa farà il processore quando ci arriverà.
Le istruzioni che userai sempre
Con quel modello in mente, le istruzioni diventano naturali. Il 6510 ha circa 56 mnemonic in totale, ma un piccolo insieme compare in praticamente ogni programma.
LDA e STA sono la coppia di movimento dei dati. LDA carica un valore nell’accumulatore A, STA scrive il valore di A in un indirizzo di memoria. Tutto ciò che entra ed esce dal processore passa attraverso di loro. Le versioni per X e Y seguono lo stesso schema. LDX/STX e LDY/STY.
ADC e SBC sono addizione e sottrazione. ADC somma un valore a ciò che è già in A, SBC sottrae. Il 6510 non ha moltiplicazione o divisione nativa. Quelle operazioni venivano implementate a mano, con cicli di addizioni o sottrazioni ripetute. Niente che un compilatore C non faccia sotto al cofano ancora oggi su certe architetture.
INX, INY, DEX, DEY incrementano e decrementano i registri X e Y. Sono i++ e i-- dell’assembly, e compaiono in praticamente ogni ciclo.
JMP, JSR e RTS controllano il flusso. JMP è un goto incondizionato. Il Program Counter salta all’indirizzo specificato e il processore continua da lì. JSR chiama una subroutine, salvando l’indirizzo di ritorno nello stack. RTS ritorna da quella subroutine, rileggendo l’indirizzo dallo stack e tornando da dove era venuto. È il meccanismo dietro ogni chiamata di funzione in assembly.
Quando JSR salta a una subroutine, il processore ha un problema. Deve ricordare dove tornare quando viene chiamata RTS. Il posto in cui memorizza quell’informazione è lo stack, una regione fissa di memoria da $0100 a $01FF che funziona come una pila fisica di piatti.
JSR spinge l’indirizzo di ritorno in cima allo stack e l’SP si sposta di uno slot verso il basso per registrare l’aggiunta. RTS rilegge quell’indirizzo dalla cima, l’SP sale di uno slot e l’esecuzione riprende da dove era stata fatta la chiamata. Ogni JSR spinge, ogni RTS toglie, sempre bilanciati.
Per questo le chiamate annidate a subroutine funzionano senza problemi. Ogni chiamata spinge un nuovo indirizzo di ritorno, ogni ritorno ne toglie uno. Lo stack ha 256 byte, spazio sufficiente per circa 128 chiamate annidate prima di andare in overflow.
Il dettaglio che inganna tutti, # o senza #
Esiste una piccola distinzione nella sintassi che causa più confusione di qualsiasi altra cosa quando si comincia. Il simbolo # prima di un valore cambia completamente quello che fa l’istruzione.
LDA #$05 ; A = 5 (il numero cinque in sé)
LDA $05 ; A = RAM[$05] (qualunque cosa sia all'indirizzo 5)
Con #, stai lavorando con il valore letterale. Senza #, stai lavorando con il contenuto dell’indirizzo di memoria. In Python, la differenza sarebbe tra x = 5 e x = RAM[5].
In pratica. LDA #$00 azzera l’accumulatore. LDA $00 carica nell’accumulatore qualunque cosa sia memorizzata alla posizione zero della Zero Page, che può essere qualsiasi cosa a seconda del programma. Sono istruzioni completamente diverse con una differenza di un carattere.
Un programma completo, riga per riga
Questo programma carica il numero 5 nell’accumulatore e lo salva all’indirizzo $0200:
LDA #$05 ; A = 5
STA $0200 ; RAM[$0200] = A
RTS ; ritorna
Semplice così. Ma la cosa interessante è vedere in cosa si trasforma in memoria. Ogni istruzione occupa un numero diverso di byte a seconda di quante informazioni deve portare:
LDA #$05diventa 2 byte, l’opcode$A9seguito dal valore$05STA $0200diventa 3 byte, l’opcode$8D, il byte basso dell’indirizzo$00e il byte alto$02RTSdiventa 1 byte, l’opcode$60, da solo
Sei byte in totale. Questo è l’intero programma. Quando il processore legge il primo byte a $0801, esegue LDA. Quando avanza a $0803, esegue STA. Quando arriva a $0806, ritorna. Non c’è altro oltre a questo.
Nota l’ordine dei byte dell’indirizzo in STA. Il byte basso viene prima del byte alto. $0200 è memorizzato come $00 $02, non come $02 $00. Questo si chiama little-endian, ed è una caratteristica di tutta la famiglia 6502. Apparirà ogni volta che un indirizzo viene memorizzato in due byte consecutivi in memoria.
Nel prossimo post mettiamo tutto insieme e scriviamo qualcosa che fa davvero qualcosa di visibile. Un ciclo che riempie lo schermo, usando quello che ora sappiamo su registri, memoria, istruzioni e ciclo di esecuzione.