Computer / Programmazione / Z80 · 11 marzo 2019 0

LM80C: come espandere l’interprete BASIC

Ho passato gli ultimi 10 giorni cercando di modificare il NASCOM BASIC per aggiungere dei nuovi comandi all’interprete. Il lavoro non è stato facile perché stiamo parlando di un linguaggio di programmazione vecchio di 42 anni e la documentazione che si può trovare online è poca e incompleta. Sono comunque riuscito a fare alcune modifiche e ad aggiungere un contatore per i centesimi di secondo basato su una doppia word (32 bit) incrementato da un interrupt sollevato dallo Z80 CTC del nostro LM80C ed a leggerlo con un nuovo comando, TMR().

Riavvolgiamo per un momento il nastro e partiamo dagli inizi… Come detto in precedenza, non volevo solo inserire un contatore dei centesimi di secondo ma anche aggiungere un nuovo comando per essere in grado di ottenerne il valore corrente in modo semplice. La difficoltà risiedeva nel comprendere prima di tutto l’interprete… Mi spiego: all’epoca del rilascio del NASCOM BASIC avere l’accesso al codice sorgente di un programma era una cosa molto difficile dato che tutti gli sviluppatori distribuivano solo i binari dei propri lavori. Nel caso degli interprete integrati, memorizzati nella memoria di un computer, l’utente poteva accedere al programma nella sua interezza ma era comunque nella sua forma disassemblata. Per “disassemblata” intendo l’azione inversa della compilazione: quando si scrive un programma in assembly c’è un programma chiamata “assemblato” che traduce le istruzioni mnemoniche del nostro sorgente in qualcosa che può essere compreso dalla CPU, le istruzioni in linguaggio macchina. Il disassemblatore è un programma che esegue l’operazione inversa: prende un programma in linguaggio macchina e lo riporta alla lista di mnemonici assembly. La parte semplice del processo è proprio l’operazione di recupero dato che il software compilato è stato memorizzato nella memoria del computer. La parte difficile del processo è “capire” quella versione disassemblata del software: dato che l’assembly non è un linguaggio di alto livello, non ha né variabili né sub-routine con nomi auto-esplicativi che possono aiutare a capire cosa il programma cerca di fare per cui si spende un sacco di tempo saltando da un punto del listato disassemblato all’altro. Per fortuna il listato disassemblato del NASCOM BASIC è stato pubblicato (con i commenti) in una rivista specializzata degli anni ’80 dedicata ai computer NASCOM, “80-BUS News”. NASCOM è stato un produttore di computer britannico che sviluppò un terminale basato sulla CPU Z80, e prese in licenza il BASIC dalla Microsoft, apportandogli alcune migliorie atte ad usare l’hardware dei loro computer. A partire dal 1983 la rivista pubblicò il listato disassemblato del NASCOM BASIC perciò, grazie ad essa, abbiamo il sorgente di un interprete BASIC. Nonostante sia un buon interprete, è specifico per i computer NASCOM e per questo manca di alcune caratteristiche mentre altre sono inutili. Grant Searle ha fatto un ottimo lavoro adattandolo al suo hardware ma resta comunque ancora incompleto per i nostri scopi dato che non usa per niente l’hardware dell’LM80C. Così, dopo averlo adattato a funzionare con lo Z80 SIO al posto dell’MC6850, ho deciso di usare un’altra periferica che è all’interno dell’LM80C, l0 Z80 CTC. Un compito molto importante di un computer è la misura del tempo. La temporizzazione è importante: possiamo misurare l’intervallo tra due eventi, possiamo decidere di fare compiere una specifica azione dopo un certo lasso di tempo, possiamo tenere un orologio con l’ora e la data. Lo Z80 CTC è una bella periferica : può essere programmata facilmente per i nostri scopi semplicemente passandogli alcuni comandi all’avvio della macchina. Dato che ha 4 temporizzatori e dato che, al momento, ne stiamo usando solo uno per generare il clock seriale RX/TX, possiamo usare un altro timer per i nostri bisogni. Per questo compito useremo il timer 3 dato che non ha un pin di uscita che può cambiare di stato quando il contatore interno raggiunge lo zero. Ho deciso di impostare un contatore di centesimi di secondo: un centesimo di secondo è una buona frazione di tempo, che non è né troppo lunga né troppo corta: un buon compromesso. Per liberarci dal compito di leggere il timer e incrementare il nostro contatore useremo un interrupt agganciato al timer 3. Quando verrà sollevato, esso “interromperà” (da “interrupt”) il codice principale e forzerà la CPU ad eseguire la corrispondente routine di servizio dell’interrupt (ISR). Se vi ricordate abbiamo già usato questa caratteristica in passato, quando abbiamo usato lo Z80 SIO per incrementare il contatore binario che abbiamo realizzato con lo Z80 PIO. Dobbiamo solo impostare il timer 3 affinché sollevi un interrupt dopo un certo intervallo di tempo. Per trovare il giusto tempo, facciamo qualche conto assumendo che:

Fsys = 3.686.400 Hz
Timer prescaler: 256x
Fint = 3.686.400 / 256 = 14.400 Hz

Siamo molto vicini al risultato corretto. Dato che vogliamo un interrupt ogni 1/100 secondo abbiamo bisogno di una frequenza di 100 Hz. Visto che il valore qui sopra è 14.400, è facile trovare il valore che dobbiamo impostare nel registro del timer:

14.400 / 100 = 144

Perciò, impostato il timer per dividere il clock di sistema per 256 e usando un valore iniziale di 144 otteniamo un clock preciso di 100 Hz, ossia 100 incrementi al secondo. Quando l’interrupt sarà riconosciuto dalla CPU, esso andrà ad incrementare un contatore a 32 bit (4 byte di memoria). Con 32 bit possiamo misurare 4.294.967.296 incrementi prima che il contatore si resetti nuovamente. 4.294.967.296 incrementi sono circa 42.949.672 secondi, vale a dire 497 giorni: più di un anno, penso che sia sufficiente…

Se date un’occhiata al codice vedrete che il contatore è memorizzato in RAM da $812E a $8131 (33070-33073). Potremmo semplicemente leggere quelle celle di memoria ma volevamo qualcosa di più, no? Perciò ho deciso di aggiungere una nuova funzione che legga la word (una coppia di byte, 16 bit di dati) specificata dal parametro passato. La nostra funzione si chiamerà TMR() e accetterà un parametro numerico:

TMR(x

Se ‘x’ è uguale a ‘0’ allora la funzione restituisce il valore dei primi 2 byte, i byte meno significativi, che sono incrementati più frequentemente; altrimenti, se ‘x’ vale ‘1’ allora restituisce i byte più significativi.

Per aggiungere questa nuova funzione ho dovuto modificare il codice sorgente del BASIC in diversi punti, per via del modo in cui i programmatori hanno scelto di riconoscere le parole chiave. Per risparmiare memoria e per velocizzare l’esecuzione delle differenti istruzioni, essi hanno scelto di memorizzare le parole delle funzioni e dei comandi usando dei “token”, speciali codici mono-byte per cui, ad esempio, un comando come “PRINT” potesse essere memorizzato usando 1 solo byte. Questo ha portato ad un uso più efficiente della memoria e ad una decodifica più veloce durante l’esecuzione. Durante l’inserimento delle istruzioni l’interprete riconosce le diverse parole chiave e le trasforma nelle loro corrispondenti forme in token (si dice che “tokenizza” i comandi). Quando l’interprete esegue il codice dell’utente, deve solo leggere 1 singolo byte per decodificare l’istruzione: ad ogni token è associata una ed una sola istruzione. A causa di questo modo di memorizzare i listati, non solo ho dovuto modificare la porzione di codice che riconosce le istruzioni ma anche quella che gestisce i token. C’è poi un altro problema: l’interprete differenza fra “comandi” e “istruzioni” o, per meglio dire, l’interprete opera in 2 modalità: diretta e indiretta. In modalità diretta l’interprete esegue le istruzioni così come vengono inserite mentre in modalità indiretta l’interprete esegue le istruzioni estraendole da un programma memorizzato in memoria. I comandi sono direttive normalmente usate solo in modalità diretta mentre le istruzioni sono direttive usate solo in modalità indiretta. Un esempio di comando è CONT(inue), dato che non ha nessun significato all’interno di un programma. Dato che la mia nuova direttiva è una semplice funzione essa può essere usata sia come comando che come istruzione. La prima modifica è stata fatta qui, la tabella che elenca gli indirizzi delle funzioni supportate:


; FUNCTION ADDRESS TABLE (this is a sort of offset table)
FNCTAB:
defw SGN ; this list must be coherent with the tokens' functions list
defw TMR ; added by Leonardo Miliani
defw INT
defw ABS
defw USR

La seconda linea contiene la mia nuova funzione. Dopo di ciò, ho dovuto inserire il nome della mia funzione nell’elenco delle parole riservate. L’elenco contiene tutte le parole che sono riservate all’interprete e che non possono essere usate dall’utente, ad esempio come nome per una funzione definita con DEF oppure come nome di variabile.

; RESERVED WORD LIST
; Here are all the reserved words used by the interpreter
; To add custom functions/commands, the user must insert the keyword
; in this list, following the schematic
WORDS: defb 'E'+80H,"ND" ; from here the list contains the commands
defb 'F'+80H,"OR"
(....)

L’elenco è diviso in diverse sezioni. L’ultima contiene l’elenco delle parole riservate usate dalle funzioni: questa sezione inizia con la funzione SGN. L’elenco delle parole riservate è anche un elenco di offset, per cui la posizione è importante dato che l’interprete conta da SGN all’ultima parola presente per prendere l’offset per saltare al corrispondente codice recuperato dall’elenco che abbiamo incontrato qui sopra. Ecco la modifica:

defb 'S'+80H,"GN"
defb 'T'+80H,"MR" ; <-- added by Leonardo Miliani
defb 'I'+80H,"NT"

Come potete vedere, ogni parola è codifica in una particolare maniera: viene presa la prima lettera e le viene aggiunto $80, e poi vengono salvate le restanti lettere così come sono. Dopo questo punto, scorrendo giù il codice possiamo trovare un’altra sezione chiamata “RESERVED WORD TOKEN VALUES”: questa lista elenca i codici token di ogni parola chiave. L’elenco non contiente ogni singola parola chiave perché l’interprete riconosce la “classe” della parola chiave, ad esempio una funzione, trovandola in uno specifico intervallo di valori. Dato che dobbiamo aggiungere una nuova funzione, dobbiamo incrementare il valore delle ultime funzioni memorizzate nell’elenco dopo SGN, che sono ZPOINT e LEFT$.

Perciò ho cambiato quella porzione da così:

ZSGN equ 0B6H ; SGN
ZPOINT equ 0C7H ; POINT
ZLEFT equ 0CDH +2 ; LEFT$

a così:

ZSGN equ 0B6H ; SGN
ZPOINT equ 0C8H ; POINT ; if the user enters a custom function,
ZLEFT equ 0CEH +2 ; LEFT$ ; must be increment these two pointers

Adesso, quando andremo ad usare la nuova funzione, l’interprete esaminerà l’elenco delle parole riservate per vedere se è una funzione supportata: se la troverà nell’elenco riconoscerà il nome come parola riservata perciò l’interprete andrà ad analizzare il suo utilizzo per capire se è una funzione o un comando, poi salterà al corrispondente codice recuperando l’indirizzo di inizio del codice dalla tabella dei salti.

Ho fatto anche un altro cambiamento. Dato che il BASIC è stato scritto in un periodo in cui le memorie erano molto costose, i programmatori, per salvare spazio, hanno memorizzato i messaggi di errore in una forma molto compressa: solo 2 lettere per ogni messaggio! Dato che personalmente non ho una buona memoria e dato che l’LM80C è dotato di una ROM molto capiente (32 KB!), possiamo inserire i messaggi completi per meglio capire cosa sta andando storto. Perciò otterrete, ad esempio, un bel messaggio di “NEXT WITHOUT FOR ERROR” al posto del più succinto “NF ERROR”! Per ottenere questo ho usato lo stesso trucco che i programmatori hanno usato per la tabella dei token: il codice dell’errore identifica la voce nella tabella degli errori da cui il sistema recupera il messaggio di testo completo e lo stampa.

Questo è tutto. Come al solito, potete trovare il codice nel mio repo GitHub.