Questa è la seconda 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 del primo articolo in cui abbiamo presentato i vari cross compilatori C per architetture 8 bit e abbiamo dato alcune indicazioni su come scrivere codice C portabile su tutte le architetture 8 bit.
Ci sono alcune regole generali per scrivere codice migliore indipendentemente dal fatto che l’architettura sia 8-bit o meno.
Non tutte le buone pratiche di programmazione saranno ottimali per gli 8 bit. In questa sezione diamo esempi di pratiche generali che rimangono valide anche su architetture 8 bit vintage.
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 ri-fattorizzare il codice introducendo 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.
Se due funzioni fanno la stessa cosa su oggetti diversi, sarebbe meglio avere una unica funzione a cui si passi l’oggetto su cui agire.
In altri casi avremo porzioni di codice simili la cui unica differenza è l’applicazione di una funzione diversa. In questo caso possiamo scrivere un’unica funzione a cui si passa un puntatore a funzione.
Non tutti sono familiari con la sintassi dei puntatori a funzione e quindi ne diamo un esempio in cui definiamo la funzione sumOfSomething(range, something)
che somma il valore di something(i)
per i che va da zero a i-1
:
unsigned short sumOfSomething(unsigned char range, unsigned short (* something) (unsigned char)) { unsigned char i; unsigned short res =0; for(i=0;i<range;++i) { res+=something(i); } return res; }
Quindi date due funzioni:
unsigned short square(unsigned char val)
{
return val*val;
}
unsigned short next(unsigned char val)
{
return ++val;
}
potremo usare sumOfSomething
con l’una o l’altra funzione evitando di scrivere il codice che fa la somma due volte:
printf("%d\n",sumOfSomething(4,square));
mostrerà 14, cioè la somma dei quadrati di 0,1,2,3.
printf("%d\n",sumOfSomething(4,next));
mostrerà 10, cioè la somma di 1,2,3,4.
In altri casi possiamo avere due funzioni quasi identiche la cui unica differenza è il campo di uno struct
che si modifica. In questo caso possiamo scrivere un’unica funzione a cui passiamo l’offset dello struct
.
Un esempio avanzato 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));
}
...
}
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
. I vari cast a unsigned char *
servono per accedere a byte in posizioni diverse all’interno dello struct
.
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 descriveremo una implementazione light per gli 8-bit nel prossimo articolo.
Bisogna evitare operatori di post-incremento/decremento (i++
, i--
) quando non servono (cioè quando non serve il valore prima dell’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 codice 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 dipendere da una opzione di compilazione (per esempio il tipo di target), allora la sostituiremo con una macro da settare attraverso una opzione di compilazione, in maniera tale che sia trattata come una costante dal compilatore.
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.
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)
:
#define OFFS 4
int i;
i = OFFS+3+i;
Quasi sicuramente su una architettura 8-bit avremo bisogno di scrivere e leggere dei singoli byte su alcune specifiche locazioni di memoria.
Il modo per fare questo in BASIC sarebbe stato attraverso i comando peek
e poke
.
In C dobbiamo farlo attraverso dei puntatori la cui sintassi non è leggibilissima. 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
Le conversioni tra tipi e soprattutto le conversioni tra tipi signed
e unsigned
sono costose.
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). Per includere questi tipi a taglia fissata dallo standard usiamo:
#include <stdint.h>
Non tutti i compilatori 8-bit dispongono di questo header.
Fortunatamente per la stragrande maggioranza dei compilatori 8-bit abbiamo la seguente situazione:
tipo | numero bit | nome in stdint.h |
nome alternativo |
---|---|---|---|
unsigned char |
8 | uint8_t |
byte |
unsigned short |
16 | uint16_t |
word |
unsigned int |
16 | uint16_t |
word |
unsigned long |
32 | uint32_t |
dword |
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 dell’header stdint.h
, sarebbe bene 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 perché non sempre il compilatore ottimizza nel modo migliore. Quindi 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 necessariamente 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 se fossero 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 ricorsive. 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 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 o, ancora meglio, ispezionare il codice Assembly generato.
I compilatori che stiamo considerando consentono di allocare e deallocare la memoria dinamicamente (con comandi come malloc
e free
) ma questo ha un ovvio costo computazionale. Se possibile è preferibile allocare tutta la memoria staticamente.
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 potrebbe 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.