Questo articolo descrive alcune tecniche per ottimizzare codice in ANSI C per tutti i sistemi 8-bit vintage, cioè computer, console, handheld, calcolatrici scientifiche dalla fine degli anni 70 fino a metà degli anni 90 ed in particolare sistemi basati sulle seguenti architetture (e architetture derivate e retrocompatibili):
- Intel 8080 (*)
- MOS 6502
- Motorola 6809
- Zilog Z80 (*)
(*) Lo Zilog Z80 è una estensione dell’Intel 8080, quindi un binario Intel 8080 sarà utilizzabile anche su un sistema con Z80 ma il viceversa non è vero.
Buona parte di queste tecniche sono valide su altre architetture 8-bit come quella del COSMAC 1802 e quella del microcontrollore Intel 8051.
Lo scopo di questo articolo è duplice:
- descrivere tecniche generali per ottimizzare il codice C su tutti i sistemi 8-bit
- descrivere tecniche generiche per scrivere codice portabile, cioè valido e compilabile su tutti i sistemi 8-bit indipendentemente dal fatto che un sistema sia supportato esplicitamente da un compilatore o meno
Questo articolo non è un manuale introduttivo al linguaggio C e richiede
- conoscenza del linguaggio C;
- conoscenza della programmazione strutturata e a oggetti;
- conoscenza dell’uso di compilatori e linker.
Inoltre questo articolo non si occuperà in profondità di alcuni argomenti avanzati quali:
- gli ambiti specifici della programmazione come grafica, suono, input/output, etc.
- l’interazione tra C e Assembly.
Questi argomenti avanzati sono molto importanti e meriterebbero degli articoli separati a loro dedicati.
Introduciamo alcuni termini che saranno ricorrenti in questo articolo:
- Un sistema è un qualunque tipo di macchina dotata di processore come computer, console, handheld, calcolatrici, sistemi embedded, etc.
- Un target di un compilatore è un sistema supportato dal compilatore, cioè un sistema per il quale il compilatore mette a disposizione supporto specifico come librerie e la generazione di un binario in formato specifico.
- Un architettura è un tipo di processore (Intel 8080, 6502, Zilog Z80, Motorola 6809, etc.) . Un target appartiene quindi ad una sola architettura data dal suo processore (con rare eccezioni come il Commodore 128 che ha sia un processore derivato dal 6502 e uno Zilog Z80).
Per produrre i nostri binari 8-bit consigliamo l’uso di cross compilatori multi-target (cioè compilatori eseguiti su PC che producono binari per diversi target).
Non consigliamo l’uso di compilatori nativi perché sarebbero molto scomodi (anche se usati all’interno di un emulatore accelerato al massimo) e non potrebbero mai produrre codice ottimizzato perché l’ottimizzatore sarebbe limitato dalla risorse della macchina 8-bit.
Faremo particolare riferimento ai seguenti cross compilatori multi-target:
Architettura | Compilatore/Dev-Kit | Pagina |
---|---|---|
Intel 8080 | ACK | https://github.com/davidgiven/ack |
MOS 6502 | CC65 | https://github.com/cc65/cc65 |
Motorola 6809 | CMOC | https://perso.b2b2c.ca/~sarrazip/dev/cmoc.html |
Zilog 80 | SCCZ80/ZSDCC (Z88DK) | https://github.com/z88dk/z88dk |
Inoltre esistono altri cross compilatori C multi-target che non tratteremo qui ma per i quali buona parte delle stesse tecniche generiche rimangono valide:
- LCC1802 (https://sites.google.com/site/lcc1802/) per il COSMAC 1802;
- SDCC (http://sdcc.sourceforge.net/) per svariate architetture di microprocessori come lo Z80 e di microcontrollori come l’Intel 8051;
- GCC-6809 (https://github.com/bcd/gcc) per 6809 (adattamento di GCC);
- GCC-6502 (https://github.com/itszor/gcc-6502-bits) per 6502 (adattamento di GCC);
- SmallC-85 (https://github.com/ncb85/SmallC-85) per Intel 8080/8085 ;
- devkitSMS (https://github.com/sverx/devkitSMS) per le console Sega basate su Z80 come Sega Master System, Sega Game Gear e Sega SG-1000.
Si noti come il dev-kit Z88DK disponga di due compilatori:
- l’affidabile SCCZ80 che è anche molto veloce nelle compilazione,
- lo sperimentale ZSDCC (versione ottimizzata per Z80 di SDCC sopracitato) che però può produrre codice più efficiente e compatto di SCCZ80 a costo di compilazione più lenta e rischio di introdurre bug.
Quasi tutti i compilatori che stiamo prendendo in considerazione generano codice per una sola architettura (sono mono-architettura) pur essendo multi-target. ACK è una eccezione essendo anche multi-architettura (con supporto per Intel 8080, Intel 8088/8086, I386, 68K, MIPS, PDP11, etc.).
Questo articolo non è né una introduzione né un manuale d’uso di questi compilatori e non tratterà:
- l’istallazione dei compilatori;
- elementi specifici per l’uso di base di un compilatore
Per i dettagli sull’istallazione e l’uso di base dei compilatori in questione, facciamo riferimento ai manuali e alle pagine web dei relativi compilatori.
Sottoinsieme di ANSI C
In questo articolo per ANSI C intendiamo sostanzialmente un grosso sotto-insieme dello standard C89 in cui i float
e i long long
sono opzionali ma i puntatori a funzioni e puntatori a struct
sono presenti.
Non stiamo considerando versioni precedenti del C come per esempio C in sintassi K&R.
Per quale motivo dovremmo usare il C per programmare dei sistemi 8-bit?
Tradizionalmente queste macchine vengono programmate in Assembly o in BASIC interpretato o in un mix dei due.
Data la limitatezza delle risorse è spesso necessario ricorrere all’Assembly. Il BASIC è invece comodo per la sua semplicità e perché spesso un interprete è già presente sulla macchina.
Volendo limitare il confronto a questi soli tre linguaggi il seguente schema ci dà una idea delle ragione per l’uso del C.
facilità | portabilità | efficienza | |
---|---|---|---|
BASIC | SI | parziale | poca |
Assembly | NO | NO | ottima |
C | SI | SI | buona |
Quindi la ragione principale per l’uso del C è la sua portabilità assoluta. In particolare se si usa un sottoinsieme dell’ANSI C che è uno standard.
In particolare l’ANSI C ci pemette di:
- fare porting semplificato tra una architettura all’altra
- scrivere codice “universale”, cioè valido per diversi target senza alcuna modifica
Qualcuno si spinge a dichiarare che il C sia una sorta di Assembly universale. Questa è una affermazione un po’ troppo ottimistica perché del C scritto molto bene non batterà mai dell’Assembly scritto bene.
Ciò nonostante il C è probabilmente il linguaggio più vicino all’Assembly tra i linguaggi che permettono anche la programmazione ad alto livello.
Una ragione non-razionale ma “sentimentale” per non usare il C sarebbe data dal fatto che il C è sicuramente meno vintage del BASIC e Assembly perché non era un linguaggio comune sugli home computer degli anni 80 (ma lo era sui computer professionali 8-bit come sulle macchine che usavano il sistema operativo CP/M).
Credo che la programmazione in C abbia però il grosso vantaggio di poterci fare programmare l’hardware di quasi tutti i sistemi 8-bit.
Scrivere codice facilmente portabile o addirittura direttammente compilabile per diverse piattaforme è possibile in C attraverso varie strategie:
- Scrivere codice agnostico dell’hardware e che quindi usi interfacce astratte (cioè delle API indipendenti dall’hardware).
- Usare implementazioni diverse per le interfacce comuni da selezionare al momento della compilazione (per esempio attraverso direttive al precompilatore o fornendo file diversi al momento del linking).
Questo diventa banale se il nostro dev-kit multi-target mette a disposizione una libreria multi-target o se ci si limita a usare le librerie standard del C (stdio, stdlib, etc.). Se si è in queste condizioni, allora basterà ricompilare il codice per ogni target e la libreria multi-target del del dev-kit farà la “magia” per noi.
Solo CC65 e Z88DK propongono interfacce multi-target per input e output oltre le librerie C standard:
Dev-Kit | Architettura | librerie multi-target |
---|---|---|
Z88DK | Zilog Z80 | standard C lib, conio, vt52, vt100, sprite software, UDG, bitmap |
CC65 | MOS 6502 | standard C lib, conio, tgi (bitmap) |
CMOC | Motorola 6809 | standard C lib |
ACK | Intel 8080 | standard C lib |
In particolare Z88DK possiede strumenti potentissimi per la grafica multi-target (solo su Z80) e fornisce diverse API sia per gli sprite software (https://github.com/z88dk/z88dk/wiki/monographics) che per i caratteri ridefiniti per buona parte dei suoi 80 target.
Esempio: Il gioco multi-piattaforma H-Tron è un esempio (https://sourceforge.net/projects/h-tron/) in cui si usano le API previste dal dev-kit Z88DK per creare un gioco su molti sistemi basati sull’architettura Z80.
Quindi se usassimo esclusivamente le librerie standard C potremmo avere codice compilabile con ACK, CMOC, CC65 e Z88DK. Mentre se usassimo anche conio avremmo codice compilabile per CC65 e Z88DK.
In tutti gli altri casi se vogliamo scrivere codice portabile su architetture e sistemi diversi bisognerà costruirsi delle API. Sostanzialmente si deve creare un hardware abstraction layer che permette di separare
- il codice che non dipende dall’hardware (per esempio la logica di un gioco)
- dal codice che dipende dall’hardware (per esempio l’input, output in un gioco).
Questo pattern è assai comune nella programmazione moderna e non è una esclusiva del C ma il C fornisce una serie di strumenti utili per implementare questo pattern in maniera che che si possano supportare hardware diversi da selezione al momento della compilazione. In particolare il C prevede un potente precompilatore con comandi come:
#define
-> per definire una macro#if
…defined(...)
…#elif
…#else
-> per selezione porzioni di codice che dipendono dal valore o esistenza di una macro.
Inoltre tutti i compilatori prevedono una opzione (in genere -D
) per passare una variabile al precompilatore con eventuale valore. Alcuni compilatori come CC65 implicitamente definiscono una variabile col nome del target (per esempio VIC20) per il quale si intende compilare.
Nel codice avremo qualcosa come:
...
#elif defined(__PV1000__)
#define XSize 28
#elif defined(__OSIC1P__) || defined(__G800__) || defined(__RX78__)
#define XSize 24
#elif defined(__VIC20__)
#define XSize 22
...
per cui al momento di compilare per il Vic 20 il precompilatore selezionerà per noi la definizione di XSize
specifica del Vic 20.
Questo permette al precompilatore non solo di selezionare le parti di codice specifiche per una macchina, ma anche di selezionare opzioni specifiche per configurazione delle macchina (memoria aggiuntiva, scheda grafica aggiuntivo, modo grafica, compilazione di debug, etc.).
Come esempio principale faremo riferimento al progetto Cross-Chase:
https://github.com/Fabrizio-Caruso/CROSS-CHASE
Il codice di Cross-Chase fornisce un esempio su come scrivere codice universale valido per qualsiasi sistema ed architettura:
- il codice del gioco (directory src/chase) è indipendente dall’hardware
- il codice della libreria crossLib (directory src/cross_lib) implementa i dettagli di ogni hardware possibile
I nostri dev-kit supportano una lista di target per ogni architettura attraverso la presenza di librerie specifiche per l’hardware. E’ comunque possibile sfruttare questi dev-kit per altri target con la stessa architettura ma dovremo fare più lavoro e saremo costretti ad implementare tutta la parte di codice specifica del target:
- codice necessario per gestire l’input/output (grafica, tastiera, joystick, suoni, etc.)
- codice necessario per inizializzare correttamente il binario
Per fare ciò potremo in molti casi usare le routine già presenti nella ROM (per un esempio si veda la sezione di questo articolo che tratta l’uso delle routine della ROM).
Inoltre dovremmo anche usare dei convertitori del binario in un formato accettabile per il nuovo sistema (e potremmo essere costretti a doverli scrivere qualora non siano già a disposizione).
Potremo quindi scrivere codice portabile anche a questi sistemi.
Per esempio CC65 non supporta BBC Micro e Atari 7800 e CMOC non supporta Olivetti Prodest PC128 ma è comunque possibile usare i dev-kit per produrre binari per questi target:
- Cross Chase (https://github.com/Fabrizio-Caruso/CROSS-CHASE) supporta (in principio) qualunque architettura anche non supportata direttamente dai compilatori come per esempio l’Olivetti Prodest PC128.
- Il gioco Robotsfindskitten è stato portato per l’Atari 7800 usando CC65 (https://sourceforge.net/projects/rfk7800/files/rfk7800/).
- BBC è stato aggiunto come target sperimentale su CC65 (https://github.com/dominicbeesley/cc65).
Qui diamo una lista delle opzioni di compilazione per target generico per ogni dev-kit in maniera da compilare per una data architettura senza alcuna dipendenza da un target specifico. Per maggiori dettagli facciamo riferimento ai rispettivi manuali.
Architettura | Dev-Kit | Opzione |
---|---|---|
Intel 8080 | ACK | (*) |
MOS 6502 | CC65 | +none |
Motorola 6809 | CMOC | --nodefaultlibs |
Zilog 80 | SCCZ80/ZSDCC (Z88DK) | +test , +embedded (nuova libreria), +cpm (per vari sistemi CP/M) |
(*) ACK prevede solo il target CP/M-80 per l’architettura Intel 8080 ma è possibile almeno in principio usare ACK per produrre binari Intel 8080 generico ma non è semplice in quanto ACK usa una sequenze da di comandi per produrre il Intel 8080 partendo dal C e passando da vari stai intermedi compreso un byte-code “EM”.
Qui di seguito listo i comandi utili:
ccp.ansi
: precompilatore del Cem_cemcom.ansi
: compila C preprocessato producendo bytecodeem_opt
: ottimizza il bytecodecpm/ncg
: genera Assembly da bytecodecpm/as
: genera codice Intel 80 da Assemblyem_led
: linker
Ci sono alcune regole generali per scrivere codice migliore indipendentemente dal fatto che l’architettura sia 8-bit o meno.
In generale, in qualunque linguaggio di programmazione si voglia programmare, è importante evitare la duplicazione del codice o la scrittura di codice superfluo.
Spesso guardando bene le funzioni che abbiamo scritto scopriremo che condividono delle parti comuni e che quindi potremo fattorizzare costruendo delle sotto-funzioni che le nostre funzioni chiameranno.
Dobbiamo però tenere conto che, oltre un certo limite, una eccessiva granularità del codice ha effetti deleteri perché una chiamata ad una funzione ha un costo computazionale e di memoria.
In alcuni casi è possibile generalizzare il codice passando un parametro per evitare di scrivere due funzioni diverse molto simili.
Un esempio si trova in https://github.com/Fabrizio-Caruso/CROSS-CHASE/blob/master/src/chase/character.h dove, dato uno struct
con due campi _x
e _y
, vogliamo potere agire sul valore di uno o dell’altro in situazioni diverse:
struct CharacterStruct
{
unsigned char _x;
unsigned char _y;
...
};
typedef struct CharacterStruct Character;
Possiamo evitare di scrivere due diverse funzioni per agire su _x
e su _y
creando una unica funzione a cui si passa un offset che faccia da selettore:
unsigned char moveCharacter(Character* hunterPtr, unsigned char offset)
{
if((unsigned char) * ((unsigned char*)hunterPtr+offset) < ... )
{
++(*((unsigned char *) hunterPtr+offset));
}
else if((unsigned char) *((unsigned char *) hunterPtr+offset) > ... )
{
--(*((unsigned char *) hunterPtr+offset));
}
...
}
Nel caso sopra stiamo sfruttando il fatto che il secondo campo _y
si trova esattamente un byte dopo il primo campo _x
. Quindi con offset=0
accediamo al campo _x
e con offset=1
accediamo al campo _y
.
Avvertenze: Dobbiamo però considerare sempre che aggiungere un parametro ha un costo e quindi dovremo verificare (anche guardando la taglia del binario ottenuto) se nel nostro caso ha un costo inferiore al costo di una funzione aggiuntiva.
Si può anche fare di più e usare lo stesso codice su oggetti che non sono esattamente dello stesso tipo ma che condividono solo alcuni aspetti comuni per esempio sfruttando gli offset
dei campi negli struct
, puntatori a funzioni, etc.
Questo è possibile in generale tramite la programmazione ad oggetti di cui descriviamo una implementazione leggera per gli 8-bit in una sezione successiva.
Bisogna evitare operatori di post-incremento/decremento (i++
, i--
) quando non servono (cioè quando non serve il valore pre-incremento) e sostituirli con (++i
, --i
).
Il motivo è che l’operatore di post-incremento richiede almeno una operazione in più dovendo conservare il valore originario.
Nota: E’ totalmente inutile usare un operatore di post-incremento in un ciclo for
.
Una qualunque architettura potrà ottimizzare meglio del codici in cui delle variabili sono sostituite con delle costanti.
Quindi se una data variabile ha un valore noto al momento della compilazione, è importante che sia rimpiazzata con una costante.
Se il suo valore, pur essendo noto al momento della compilazione, dovesse dipende da una opzione di compilazione, allora la sostituiremo con una macro da settare attraverso una opzione di compilazione, in maniera tale che sia trattata come una costante dal compilatore.
Inoltre, per compilatori single pass (come la maggioranza dei cross-compilatori 8-bit come per esempio CC65), può essere importante aiutare il compilatore a capire che una data espressione sia una costante.
Esempio (preso da https://www.cc65.org/doc/coding.html):
Un compilatore single pass valuterà la seguente espressione da sinistra a destra non capendo che OFFS+3
è una costante.
#define OFFS 4
int i;
i = i + OFFS + 3;
Quindi sarebbe meglio riscrivere i = i + OFFS+3
come i = OFFS+3+i
oppure i = i + (OFFS+3)
.
Il C è un linguaggio che presenta sia costrutti ad alto livello (come struct
, le funzioni come parametri, etc.) sia costruiti a basso livello (come i puntatori e la loro manipolazione). Questo non basta per farne un linguaggio direttamente adatto alla programmazione su macchine 8-bit.
Quasi sicuramente avremo bisogno di fare scrivere e leggere dei singoli byte su alcune specifiche locazioni di memoria.
Il modo per fare questo è in BASIC sarebbe attraverso i comando peek
e poke
.
In C dobbiamo farlo attraverso dei puntatori la cui sintassi non è legibilissima. Potremo però costruirci delle utili macro che useremo nel nostro codice:
#define POKE(addr,val) (*(unsigned char*) (addr) = (val))
#define PEEK(addr) (*(unsigned char*) (addr))
Nota: I compilatori scriveranno codice ottimale nel caso in cui si passino delle costanti come parametri.
Per maggiori dettagli facciamo riferimento a: https://github.com/cc65/wiki/wiki/PEEK-and-POKE
Una premessa importante per la scelta dei tipi da preferire per architettura è data dal fatto che in generale abbiamo questa situazione:
- tutte le operazioni aritmetiche sono solo a 8 bit
- la maggior parte delle operazioni sono ad 8 bit, alcune sono a 16-bit e nessuna operazione è a 32 bit
- le operazioni
signed
(cioè con segno) sono più lente di quelleunsigned
- l’hardware non supporta operazioni in virgola mobile
Il C prevede tipi numerici interi con segno (char
, short
, int
, long
, long long
e loro equivalenti in versione unsigned
).
Molti compilatori (ma non CC65) prevedono il tipo float
(numeri a virgola mobile) che qui non tratteremo. Bisogna considerare che i float
delle architetture 8-bit sono tutti software float ed hanno quindi un costo computazionale notevole. Sarebbero quindi da usare solo se strettamente necessari.
Siccome le architetture 8-bit che stiamo considerandno NON gestiscono ottimalmente tipi signed
, dobbiamo evitare il più possibile l’uso di tipi numerici signed
.
La dimensione dei tipi numeri standard dipende dal compilatore e dall’architettura e non dal linguaggio.
Più recentemente sono stati introdotti dei tipi che fissano la dimensione in modo univoco (come per esempio uint8_t
per l’intero unsigend
a 8 bit).
Il modo standard per includere questi tipi a taglia fissa
#include <stdint.h>
Non tutti i compilatori 8-bit dispongono di questi tipi.
Fortunatamente per la stragrande maggioranza dei compilatori 8-bit abbiamo la seguente situazione:
tipo | numero bit | equivalente |
---|---|---|
unsigned char |
8 | uint8_t |
unsigned short |
16 | uint16_t |
unsigned int |
16 | uint16_t |
unsigned long |
32 | uint32_t |
Quindi dovremo:
- usare il più possibile
unsigned char
(ouint8_t
) per le operazioni aritmetiche; - usare
unsigned char
(ouint8_t
) eunsigned short
(ouint16_t
) per tutte le altre operazioni, evitando se possibile qualunque operazione a 32 bit.
Nota: In assenza di tipi con dimensione fissata, sarebbe una buona pratica creare dei typedef
opportuni:
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned long uint32_t;
Quando scriviamo codice per una architettura 8-bit dobbiamo evitare se possibile codice con operazioni inefficienti o che ci obblighino a usare tipi non adatti (come i tipi signed
o tipi a 16 o peggio 32 bit).
In particolare, se possibile, spesso si può riscrivere il codice in maniera da evitare sottrazioni e quando questo non è possibile, si può almeno fare in modo che il risultato della sottrazione sia sempre non-negativo.
Tutte le architetture che abbiamo preso in considerazione, con la sola esclusione di Motorola 6809, non dispongono di una operazione per effettuare il prodotto di due valori a 8 bit.
Quindi, se possibile dobbiamo evitare i prodotti adattando il nostro codice, oppure limitarci a prodotti e divisioni che siano potenze di 2 e implementandoli con operazioni di shift con gli operatori << e >>:
unsigned char foo, bar;
...
foo << 2; // moltiplicazione per 2^2=4
bar >> 1; // divisione per 2^1=2
Molte operazioni come il modulo possono essere riscritte in maniera più efficiente per gli 8 bit usando operatori bit a bit. Non sempre il compilatore ottimizza nel modo migliore. Quando il compilatore non ce la fa, dobbiamo dargli una mano noi:
unsigned char foo;
...
if(foo&1) // equivalente a foo%2
{
...
}
Uno dei più grossi limiti dell’architettura MOS 6502 non è la penuria di registri come si potrebbe pensare ma è la dimensione limitata del suo stack hardware (in pagina uno: $0100-01FF
) che lo rende inutilizzabile in C per la gestioni dello scope delle variabili e i parametri delle funzioni.
Quindi un compilatore ANSI C per 6502 sarà quasi sicuramente costretto a usare uno stack software per
- gestire lo scope delle variabili,
- gestire il passaggio dei parametri.
Le altre architetture 8-bit che stiamo considerando soffrono meno di questo problema ma la gestione delle scope delle variabili e dei parametri ha un costo anche quando si usa uno stack hardware.
Un modo per ridurre il problema è limitare l’uso delle variabili locali e dei parametri passati alle funzioni. Questo è chiaramente un antipattern e se lo applicassimo a tutto il nostro codice otterremo il classico spaghetti code. Dobbiamo quindi scegliere sapientemente quali variabili sono assolutamente locali e quali possono essere usate come globali. Avremo codice meno generico di quello che avremmo voluto ma sarà più efficiente. NON sto suggerendo di rendere tutte le variabili globali e di non passare mai parametri alle funzioni.
Il compilatore CC65 per l’architettura MOS 6502 mette a disposizione l’opzione -Cl
che rende tutte le variabili locali come static
, quindi globali. Questo ha l’effetto di evitare l’uso dello stack software per il loro scope. Ha però l’effetto di rendere tutte le nostre funzioni non re-entrant. In pratica questo ci impedisce di usare funzioni recursive. Questa non è un grave perdita perché la ricorsione sarebbe comunque una operazione troppo costosa per una architettura 8-bit.
Il C standard prevede la keyword register
per suggerire al compilatore di mettere una variabile in un registro.
In genere i compilatori moderni ignorano questa keyword perché lasciano questa scelta ai loro ottimizzatori. Questo è vero per i compilatori in questione ad eccezione di quello presenti in CC65 che la usa come suggerimento al compilatore per mettere una variabile in pagina zero. Il MOS 6502 accede in maniera più efficiente a tale pagina di memoria. Si può guadagnare memoria e velocità.
Per quanto riguarda l’architettura MOS 6502, il sistema operativo di queste macchine usa una parte della pagina zero. Resta comunque una manciata di byte a disposizione del programmatore.
CC65 per default lascia 6 byte della pagina zero a disposizione delle variabili dichiarate con keyword register
.
Potrebbe sembrare quindi ovvio dichiarare molte variabili come register
ma NON è così semplice perché tutto ha un costo. Per mettere una variabile sulla pagina zero sono necessarie diverse operazioni. Quindi se ne avrà un vantaggio quando le variabili sono molto usate.
In pratica i due scenari in cui è conveniente sono:
- parametri di tipo puntatore a
struct
usati almeno 3 volte all’interno di una funzione - variabile in un loop che si ripete almeno un centinaio di volte
Un riferimento più preciso è dato da: https://www.cc65.org/doc/cc65-8.html
Il mio consiglio è quello di compilare e vedere se il binario è divenuto più breve.
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.
Spesso (ma non sempre) le architetture basate su MOS 6502 prevedono video memory mapped in cui i dati della grafica si trovano nella stessa RAM a cui accede la CPU.
Molte architetture basate su Z80 (MSX, Spectravideo, Memotech, Tatung Einstein, etc.) usano il chip Texas VDP che invece ha una memoria video dedicata. Quindi non potremo mettere la grafica direttamente in questa memoria.
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 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 costuisce 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
In generale è bene separare in più file il proprio codice se il progetto è di grosse dimensioni.
Questa buona pratica può però avere degli effetti deleteri per gli ottimizzatori dei compilatori 8-bit perché in generale non eseguono link-time optimization, cioè non ottimizzeranno codice tra più file ma si limitano ad ottimizzare ogni file singolarmente.
Quindi se per esempio abbiamo una funzione che chiamiamo una sola volta e la funzione è definita nello stesso file in cui viene usata, l’ottimizzatore potre metterla in line ma non lo farà se la funzione è definita in un altro file.
Il mio consiglio non quello di creare file enormi con tutto ma è quello di tenere comunque conto di questo aspetto quando si decide di separare il codice su più file e di non abusare di questa buona pratica.
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 i due strumenti fondamentali:
- usare 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.
- usare 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.
Il compilatore C in genere produrrà un unico binario che conterrà codice e dati che verranno caricati in una specifica zona di memoria (è comunque possibile avere porzioni di codice non contigue).
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 hardware 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 sistemi che hanno 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 |
(*): Multiple buffer and auxiliary ram for ROM routiens. For more details please refer to:
http://m5.arigato.cz/m5sysvar.html and http://www.trs-80.com/trs80-zaps-internals.htm
In C standard potremmo solo definire le variabili puntatore e gli array come locazioni in queste aree di memoria.
Di seguito diamo un esempio di mappatura delle variabili 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.
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) | -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) | --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 - CMOC:
-O2
ha dei bug - ZSDCC: ha dei bug a prescindere dalle opzioni e ne ha altri presenti con
-SO3
in assenza di--max-alloc-node20000
.
Per ovviare a i problemi sopramenzionati e ridurre i tempi di compilazione (soprattutto per l’architettura Z80) si consiglia:
Architettura | Compilatore | Opzioni |
---|---|---|
Zilog Z80 | SCCZ80 (Z88DK) | -O3 |
MOS 6502 | CC65 | -O |
Motorola 6809 | CMOC | -O1 |
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)
e implementare tutto il resto.
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 svariate routine nelle ROM. E’ quindi importante conoscerle per usarle. 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.
Come visto nelle sezioni precedenti, anche se programmiamo in C non dobbiamo dimenticare l’hardware specifico per il quale stiamo scrivendo del codice.
In alcuni casi conoscere l’hardware può aiutarci a scrivere codice molto più compatto e/o più veloce.
Per esempio, è inutile ridefinire dei caratteri per fare della grafica se il sistema dispone già di caratteri utili al nostro scopo sfruttando l’estensione specifica dei caratteri ASCII (ATASCII, PETSCII, SHARPSCII, etc.).
Conoscere il chip grafico può aiutarci a risparmiare tanta ram.
Esempio (Chip della serie VDP tra cui 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.
Esempio (Commodore Vic 20)
Il Commodore Vic 20 è un caso veramente speciale perché prevede dei limiti hardware (RAM totale: 5k, RAM disponibile per il codice: 3,5K) ma anche dei trucchi per superarli almeno in parte:
- In realtà dispone anche di 1024 nibble di RAM aggiuntiva speciale per gli attributi colore
- Pur avendo soltanto 3,5k di memoria RAM contigua per il codice, molta altra RAM è facilmente sfruttabile per dati (buffer cassetta, buffer comando INPUT del BASIC)
- La caratteristica più sorprendente è che il chip grafico VIC può mappare una parte dei caratteri in RAM lasciandone metà definiti dalla ROM
Quindi, sfruttiamo implicitamente la prima caratteristica accedendo ai colori senza usare la RAM comune.
Possiamo mappare le nostre variabili nei vari buffer non utilizzati.
Se ci bastano n (n<=64) caratteri ridefiniti possiamo mapparne 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 senza alcun dispendio di memoria per i caratteri standard.