Questa è la terza parte di una serie di tre articoli che descrivono tecniche per scrivere codice portabile e ottimizzato in ANSI C per tutti i sistemi 8-bit vintage, cioè computer, console, handheld, calcolatrici scientifiche e microcontrollori dalla fine degli anni 70 fino a metà degli anni 90. L’articolo completo è disponibile on-line su
https://github.com/Fabrizio-Caruso/8bitC/blob/master/8bitC.md
Consigliamo la lettura dei primi due articoli in cui abbiamo presentato i vari cross compilatori C, abbiamo dato alcune indicazioni su come scrivere codice C portabile e ottimizzato su tutte le architetture 8 bit.
Contrariamente a quello che si possa credere, la programmazione ad oggetti è possibile in ANSI C e può aiutarci a produrre codice più compatto in alcune situazioni. Esistono interi framework ad oggetti che usano ANSI C (es. Gnome è scritto usando GObject che è uno di questi framework).
Nel caso delle macchine 8-bit con vincoli di memoria molto forti, possiamo comunque implementare classi, polimorfismo ed ereditarietà in maniera molto efficiente.
Una trattazione dettagliata non è possibile in questo articolo e qui ci limitiamo a citare due strumenti fondamentali:
- puntatori a funzioni per ottenere metodi polimorfici, cioè il cui binding (e quindi comportamento) è dinamicamente definito a run-time. Si può evitare l’implementazione di una vtable se ci si limita a classi con un solo metodo polimorfico.
- puntatori a
struct
e composizione per implementare sotto-classi: dato unostruct
A, si implementa una sua sotto-classe con unostruct
B definito come unostruct
il cui primo campo è A. Usando puntatori a talistruct
, il C garantisce che gli offset di B siano gli stessi degli offset di A.
Esempio (preso da https://github.com/Fabrizio-Caruso/CROSS-CHASE/tree/master/src/chase)
Definiamo Item
come un sotto-classe di Character
a cui aggiungiamo delle variabili ed il metodo polimorfico _effect()
:
struct CharacterStruct
{
unsigned char _x;
unsigned char _y;
unsigned char _status;
Image* _imagePtr;
};
typedef struct CharacterStruct Character;
...
struct ItemStruct
{
Character _character;
void (*_effect)(void);
unsigned short _coolDown;
unsigned char _blink;
};
typedef struct ItemStruct Item;
e poi potremo passare un puntatore a Item
come se fosse un puntatore a Character
(facendo un semplice cast):
Item *myItem;
void foo(Character * aCharacter);
...
foo((Character *)myItem);
Perché ci guadagniamo in termine di memoria? Perché sarà possibile trattare più oggetti con lo stesso codice e quindi risparmiamo memoria.
In molte architetture alcune aree della memoria RAM sono usate come buffer oppure sono dedicate a usi specifici come alcune modalità grafiche. Il mio consiglio è quindi di studiare le mappa della memoria di ogni sistema per trovare queste preziose aree. Per esempio per il Vic 20: http://www.zimmers.net/cbmpics/cbm/vic/memorymap.txt
In particolare consiglio di cercare:
- buffer della cassetta, della tastiera, della stampante, del disco, etc.
- memoria usata dal BASIC
- aree di memoria dedicate a modi grafici (che non si intendono usare)
- aree di memoria libere ma non contigue e che quindi non sarebbero parte del nostro binario
Queste aree di memoria potrebbero essere sfruttate dal nostro codice se nel nostro use-case non servono per il loro scopo originario (esempio: se non intendiamo caricare da cassetta dopo l’avvio del programma, possiamo usare il buffer della cassetta per metterci delle variabili da usare dopo l’avvio potendolo comunque usare prima dell’avvio per caricare il nostro stesso programma da cassetta).
Esempi utili
In questa tabella diamo alcuni esempi utili per vari sistemi tra cui molti con poca memoria disponibile:
computer | descrizione | area |
---|---|---|
Commodore 16 | tape buffer | $0333-03F2 |
Commodore 16 | BASIC input buffer | $0200-0258 |
Commodore 64 & Vic 20 | tape buffer | $033C-03FB |
Commodore 64 & Vic 20 | BASIC input buffer | $0200-0258 |
Commodore Pet | system input buffer | $0200-0250 |
Commodore Pet | tape buffer | $033A-03F9 |
Galaksija | variable a-z | $2A00-2A68 |
Sinclair Spectrum 16K/48K | printer buffer | $5B00-5BFF |
Mattel Aquarius | random number space | $381F-3844 |
Mattel Aquarius | input buffer | $3860-38A8 |
Oric | alternate charset | $B800-B7FF |
Oric | grabable memory per modo hires | $9800-B3FF |
Oric | Page 4 | $0400-04FF |
Sord M5 | RAM for ROM routines (*) | $7000-73FF |
TRS-80 Model I/III/IV | RAM for ROM routines (*) | $4000-41FF |
VZ200 | printer buffer & misc | $7930-79AB |
VZ200 | BASIC line input buffer | $79E8-7A28 |
(*): Vari buffer e locazioni ausiliarie usate dalle routine in ROM. Per maggiori dettagli facciamo riferimento rispettivamente a:
http://m5.arigato.cz/m5sysvar.html e http://www.trs-80.com/trs80-zaps-internals.htm.
In C standard potremmo solo assegnare le variabili puntatore e gli array su specifiche locazioni di memoria. Di seguito diamo un esempio di mappatura a partire da 0xC000
in cui abbiamo definito uno struct
di tipo Character
che occupa 5 byte, e abbiamo le seguenti variabili:
player
di tipoCharacter
,ghosts
di tipoarray
di 8Character
(40=$28 byte)bombs
di tipo array di 4Character
(20=$14 byte)
Character *ghosts = 0xC000;
Character *bombs = 0xC000+$28;
Character *player = 0xC000+$28+$14;
Questa soluzione generica con puntatori non sempre produce il codice ottimale perché obbliga a fare diverse deferenziazioni e comunque crea delle variabili puntatore (ognuna delle quali dovrebbe occupare 2 byte) che il compilatore potrebbe comunque allocare in memoria.
Non esiste un modo standard per dire al compilatore di mettere qualunque tipo di variabile in una specifica area di memoria.
I compilatori di CC65 e Z88DK invece prevedono una sintassi per permetterci di fare questo e guadagnare diverse centinaia o migliaia di byte preziosi. Vari esempi sono presenti in:
https://github.com/Fabrizio-Caruso/CROSS-CHASE/tree/master/src/cross_lib/memory
In particolare bisogna creare un file Assembly .s (con CC65) o .asm (con Z88DK) da linkare al nostro eseguibile in cui assegnamo un indirizzo ad ogni nome di variabile a cui aggiungiamo un prefisso underscore.
Sintassi CC65 (esempio Vic 20)
.export _ghosts;
_ghosts = $33c
.export _bombs;
_bombs = _ghosts + $28
.export _player;
_player = _bombs + $14
Sintassi Z88DK (esempio Galaksija)
PUBLIC _ghosts, _bombs, _player
defc _ghosts = 0x2A00
defc _bombs = _ghosts + $28
defc _player = _bombs + $14
CMOC mette a disposizione l’opzione --data=<indirizzo>
che permette di allocare tutte le variabili globali scrivibili a partire da un indirizzo dato.
La documentazione di ACK non dice nulla a riguardo. Potremo comunque definire i tipi puntatore e gli array nelle zone di memoria libera.
Se il nostro programma prevede dei dati in una definita area di memoria, sarebbe meglio metterli direttamente nel binario che verrà copiato in memoria durante il caricamento. Se questi dati sono invece nel codice, saremo costretti a scrivere del codice che li copia nell’area di memoria in cui sono previsti. Il caso più comune è forse quello degli sprites e dei caratteri/tiles ridefiniti. Questo sarà possibile solo in sistemi in cui i dati stanno nella stessa memoria RAM a cui accede direttamente il processore come per esempio molti sistemi che prevedono video memory mapped.
Ogni compilatore mette a disposizioni strumenti diversi per definire la struttura del binario e quindi permetterci di costruirlo in maniera che i dati siano caricati in una determinata zona di memoria durante il load del programma senza uso di codice aggiuntivo. In particolare su CC65 si può usare il file .cfg di configurazione del linker che descrive la struttura del binario che vogliamo produrre. Il linker di CC65 non è semplicissimo da configurare ed una sua descrizione andrebbe oltre lo scopo di questo articolo. Una descrizione dettagliata è presente su: https://cc65.github.io/doc/ld65.html. Il mio consiglio è di leggere il manuale e di modificare i file di default .cfg già presenti in CC65 al fine di adattarli al proprio use-case.
In alcuni casi se la nostra grafica deve trovarsi in un’area molto lontana dal codice, e vogliamo creare un unico binario, avremo un binario enorme e con un “buco”. Questo è il caso per esempio del C64 in cui la grafica per caratteri e sprites può trovarsi lontana dal codice. In questo caso io suggerisco di usare exomizer sul risultato finale: https://bitbucket.org/magli143/exomizer/wiki/Home.
Z88DK fa molto di più e il suo potente tool appmake costruisce dei binari nel formato richiesto. Z88DK consente comunque all’utente di definire sezioni di memoria e di definire il “packaging” del binario ma non è semplice. Questo argomento è trattato in dettaglio in z88dk/z88dk#860.
Non tratteremo in modo esaustivo le opzioni di compilazione dei cross-compilatori e consigliamo di fare riferimento ai loro rispettivi manuali per dettagli avanzati. Qui daremo una lista delle opzioni per compilare codice ottimizzato su ognuno dei compilatori che stiamo trattando.
Le seguenti opzioni applicano il massimo delle ottimizzazioni per produrre codice veloce e soprattutto compatto:
Architettura | Compilatore | Opzioni |
---|---|---|
Intel 8080 | ACK | -O6 |
Zilog Z80 | SCCZ80 (Z88DK) | -O3 |
Zilog Z80 | ZSDCC (Z88DK) | -compiler=sdcc -SO3 --max-alloc-node20000 |
MOS 6502 | CC65 | -O -Cl |
Motorola 6809 | CMOC | -O2 |
In generale su molti target 8-bit il problema maggiore è la presenza di poca memoria per codice e dati. In generale il codice ottimizzato per la velocità sarà sia compatto che veloce ma non sempre le due cose andranno assieme.
In alcuni altri casi l’obiettivo principale può essere la velocità anche a discapito della memoria.
Alcuni compilatori mettono a disposizioni delle opzioni per specificare la propria preferenza tra velocità e memoria:
Architettura | Compilatore | Opzioni | Descrizione |
---|---|---|---|
Zilog Z80 | ZSDCC (Z88DK) | -compiler=sdcc --opt-code-size |
Ottimizza memoria |
Zilog Z80 | SCCZ80 (Z88DK) | --opt-code-speed |
Ottimizza velocità |
MOS 6502 | CC65 | -Oi , -Os |
Ottimizza velocità |
Problemi noti
- CC65:
-Cl
impedisce la ricorsione - ZSDCC: ha dei bug a prescindere dalle opzioni e ne ha altri presenti con
-SO3
in assenza di--max-alloc-node20000
.
Per ridurre i tempi di compilazione di ZSDCC (a volte lunghissimi) e i suoi bug, consigliamo di usare SCCZ80 (eventualmente con opzione -O3
) durante la fase di sviluppo. ZSDCC andrebbe provato solo alla fine.
I compilatori che trattiamo non sempre saranno capaci di eliminare il codice non usato. Dobbiamo quindi evitare di includere codice non utile per essere sicuri che non finisca nel binario prodotto. Possiamo fare ancora meglio con alcuni dei nostri compilatori, istruendoli a non includere alcune librerie standard o persino alcune loro parti se siamo sicuri di non doverle usare.
Evitare nel proprio codice la libraria standard nei casi in cui avrebbe senso, può ridurre la taglia del codice in maniera considerevole. Questa regola è generale ma è particolarmente valida quando si usa ACK per produrre un binario per CP/M-80. In questo caso consiglio di usare esclusivamente getchar()
e putchar(c)
quando è possibile.
Z88DK mette a disposizione una serie di pragma per istruire il compilatore a non generare codice inutile.
Per esempio:
#pragma printf = "%c %u"
includerà solo i convertitori per %c
e %u
escludendo tutto il codice per gli altri;
#pragma-define:CRT_INITIALIZE_BSS=0
non genera codice per l’inizializzazione dell’area di memoria BSS;
#pragma output CRT_ON_EXIT = 0x10001
il programma non fa nulla alla sua uscita (non gestisce il ritorno al BASIC);
#pragma output CLIB_MALLOC_HEAP_SIZE = 0
elimina lo heap della memoria dinamica (nessuna malloc possibile);
#pragma output CLIB_STDIO_HEAP_SIZE = 0
elimina lo heap di stdio (non gestisce l’apertura di file).
Alcuni esempi sono in https://github.com/Fabrizio-Caruso/CROSS-CHASE/blob/master/src/cross_lib/cfg/z88dk.
La stragrande maggioranza dei sistemi 8-bit (quasi tutti i computer) prevede molte utili routine nelle ROM. E’ quindi importante conoscerle. Per usarle esplicitamente dovremo scrivere del codice Assembly da richiamare da C. Il modo d’uso dell’Assembly assieme al C può avvenire in modo in line (codice Assembly integrato all’interno di funzioni C) oppure con file separati da linkare al C ed è diverso in ogni dev-kit. Per i dettagli consigliamo di leggere i manuali dei vari dev-kit.
Questo è molto importante per i sistemi che non sono (ancora) supportati dai compilatori e per i quali bisogna scrivere da zero tutte le routine per l’input/output.
Esempio (preso da https://github.com/Fabrizio-Caruso/CROSS-CHASE/blob/master/src/cross_lib/display/display_macros.c)
Per il display di caratteri sullo schermo per i Thomson Mo5, Mo6 e Olivetti Prodest PC128 (sistemi non supportati da nessun compilatore) piuttosto che scrivere una routine da zero possiamo affidarci ad una routine Assembly presente nella ROM:
void PUTCH(unsigned char ch)
{
asm
{
ldb ch
swi
.byte 2
}
}
Fortunatamente spesso potremo usare le routine della ROM implicitamente senza fare alcuna fatica perché le librerie di supporto ai target dei nostri dev-kit lo fanno già per noi. Usare una routine della ROM ci fa risparmiare codice ma può imporci dei vincoli perché per esempio potrebbero non fare esattamente quello che vogliamo oppure usano alcune aree della RAM (buffer) che noi potremmo volere usare in modo diverso.
Se non riusciamo a trovare informazioni sulle routine della ROM come per esempio le loro entry points perché, per esempio stiamo sviluppando per un sistema poco documentato, possiamo usare il tool BASCK (https://github.com/z88dk/z88dk/blob/master/support/basck/basck.c, sviluppato da Stefano Bodrato) che viene distribuito con Z88DK. BASCK prende in input i file delle ROM di sistemi basati su Z80 e 6502 e cercando vari pattern, trova le routine e i loro indirizzi. Usare queste routine non è sempre facile ma in alcuni casi è banale.
Esempio:
- Dobbiamo lanciare BASCK passandogli la ROM e leggere il suo output. Per esempio se cerchiamo la routine PRINT nella ROM, filtriamo nell’output la stringa “PRS” (in Unix useremo il comando “grep”)
> basck -map romfile.rom |grep PRS
PRS = $AAAA ; Create string entry and print it
Otterremo in questo modo l’indirizzo della routine del BASIC per stampare dei caratteri.
- A questo punto potremo scrivere del codice in Assembly o C per usarla:
extern void rom_prs(char * str) __z88dk_fastcall @0xAAAA;
main() {
rom_prs ("Hello WORLD !");
while (1){};
}
Come visto nelle sezioni precedenti, anche se programmiamo in C non dobbiamo dimenticare l’hardware specifico per il quale stiamo scrivendo del codice. Conoscere l’hardware può aiutarci a scrivere codice molto più compatto e/o più veloce. In particolare la conoscenza del chip grafico può aiutarci a risparmiare tanta ram.
Esempio (Chip TI VDP come il TMS9918A presente su MSX, Spectravideo, Memotech MTX, Sord M5, etc.)
I sistemi basati su questo chip prevedono una modalità video testuale (Mode 1) in cui il colore del carattere è implicitamente dato dal codice del carattere. Se usiamo questo speciale modo video, sarà quindi sufficiente un singolo byte per definire il carattere ed il suo colore con un notevole risparmio in termini di memoria. I computer Atari 8-bit prevedono un modo grafico testuale analogo (graphics mode 1+16, Antic mode 6).
Esempio (Chip VIC del Commodore Vic 20)
Il Commodore Vic 20 è un caso veramente speciale perché prevede dei limiti hardware considerevoli (RAM totale: 5k, RAM disponibile per il codice: 3,5K) ma anche dei trucchi per superarli almeno in parte.
La caratteristica più sorprendente è che il chip grafico VIC può mappare una parte dei caratteri in RAM lasciandone metà definiti dalla ROM. Se bastano n (<=64) caratteri ridefiniti possiamo mapparne in RAM solo 64 con POKE(0x9005,0xFF);
. Ne potremo usare anche meno di 64 lasciando il resto per il codice ma mantenendo in aggiunta 64 caratteri standard.
Inoltre è possibile in alcuni casi fare uso della memoria video dedicata a cui accedono alcuni chip grafici (come il TI VDP, MOS VDC del C128, etc.) per altri scopi. Il costo computazionale sarebbe comunque notevole perché l’accesso su questa memoria sarebbe indiretto.