GERLA.CC

tutorial · game boy advance

Compila, gira, e non funziona

Quando scrivi per una macchina nuda, senza sistema operativo e senza libreria standard, «compila e gira» non vuol dire «funziona». Vuol dire soltanto che nessuno si è lamentato. Questi sono cinque guasti veri incontrati scrivendo VECTOR per Game Boy Advance: nessuno dei cinque ha prodotto un errore, e solo per alcuni c'era qualcuno che avrebbe potuto avvisare, se glielo avessi chiesto.

01 · il collegatore

Una variabile su tre non cambiava mai

Per un pezzo il gioco è stato muto. Il codice del suono era giusto, i registri erano quelli. Il difetto stava nello script del collegatore: mancava la sezione .data, quella delle variabili che partono da un valore.

Perché è grave proprio su questa macchina: la cartuccia sta a 0x08000000 ed è sola lettura; la memoria scrivibile sta altrove. Una variabile inizializzata deve stare in due posti: il valore di partenza dentro la cartuccia, la variabile vera in memoria. All'accensione qualcuno copia il primo nella seconda. Se la sezione non c'è, quel qualcuno non copia niente e la variabile vive dentro la ROM: si legge benissimo, e le scritture cadono nel vuoto senza un lamento.

static int brano = -1; /* finita in ROM: restera' -1 per sempre */ if(brano != indice){ /* vero, sempre */ brano = indice; /* scrittura in ROM: ignorata */ avvia_brano(indice); /* quindi riparte da capo ogni quadro */ }
il sintomo: nessun brano partiva mai davvero

Il rimedio sta nello script del collegatore: si dichiara la sezione con due indirizzi diversi, quello dove la variabile vive e quello da cui il suo valore parte, e all'avvio si copia.

/* nello script del collegatore: la variabile vive in RAM, ma il suo valore di partenza viaggia dentro la cartuccia */ .data : { __data_inizio = . ; *(.data .data.*) __data_fine = . ; } >iwram AT>rom /* vive in iwram, parte da rom */ __data_sorgente = LOADADDR(.data); /* e all'accensione, prima del main, si copia: */ for(unsigned i = 0; i < (__data_fine - __data_inizio); i++) ((char*)__data_inizio)[i] = ((char*)__data_sorgente)[i];

morale: il collegatore non si era lamentato. Aveva messo tutto in ROM e il programma girava. Un guasto che non impedisce l'avvio è più costoso di uno che lo impedisce, perché ti fa cercare nel posto sbagliato per giorni.

02 · il compilatore

Il ramo scritto due volte

La schermata di congedo non appariva mai. Il codice era questo, ed è il guasto più insidioso che abbia incontrato in questo progetto:

} else if(schermo == 3){ } else if(schermo == 3){ disegna_congedo(); }
il primo ramo ha il corpo vuoto, il secondo non viene mai raggiunto

È C perfettamente valido. Il primo ramo cattura la condizione e non fa niente; il secondo, che contiene il lavoro, non viene eseguito mai. Nessun errore, e con le opzioni normali nemmeno un avviso.

Qui però qualcuno che avvisa c'è, e va solo acceso. GCC ha un'opzione fatta esattamente per questo: guarda le catene di if / else if e segnala quando la stessa condizione compare due volte, perché il secondo ramo è codice morto per costruzione.

arm-none-eabi-gcc -Wall -Wextra \ -Wduplicated-cond \ # la stessa condizione due volte nella catena -Wduplicated-branches \ # due rami con lo stesso identico corpo -Wshadow ...

C'era poi un secondo difetto sovrapposto, e vale la pena dirlo perché è tipico del passaggio fra schermate. I registri della luminosità li scriveva solo il ramo del gioco. Il passaggio da una camera all'altra avviene dentro un lampo bianco, che è il vecchio trucco per nascondere il caricamento: uscendo dal gioco proprio lì, i registri restavano fermi sull'ultimo valore. Schermo bianco per sempre. Adesso, fuori dal gioco, si rimettono a posto una volta per quadro.

morale: quando lo stato della macchina lo scrive un solo ramo del programma, quel ramo diventa obbligatorio. E prima o poi ne uscirai da un'altra parte.

03 · le somme a mano

Due cose nello stesso posto

Gli sprite pescano i loro disegni da un magazzino di tessere, e ogni sprite dichiara da quale posizione parte. Quelle posizioni erano somme di lunghezze scritte a mano: il personaggio prende tanto, poi il cubo comincia lì, poi il raggio comincia lì.

Una somma sbagliata non dà errore: dà due cose nello stesso posto. A schermo si vede una che mostra i pezzi dell'altra. È successo col raggio laser, che passando da due quadri a quattro è finito dentro le tessere della piattaforma.

/* fragile: ogni numero dipende da tutti quelli sopra */ #define TESSERE_OMINO 0 #define TESSERE_CUBO 64 #define TESSERE_RAGGIO 72 /* +2 quadri = 8 tessere */ #define TESSERE_PIATTA 80 /* ...e il raggio ne vuole 16 */ /* solido: le lunghezze si dichiarano, le posizioni si calcolano, e chi cuoce i dati si ferma se due blocchi si sovrappongono */

morale: qualunque numero che dipenda da tutti i numeri scritti sopra di lui è una bomba a tempo. La difesa non è stare attenti: è far calcolare le posizioni a chi prepara i dati, e farlo fermare con il nome dei due blocchi che si pestano i piedi.

04 · il valore convenuto

L'edera che cresceva nelle camere pulite

Ogni camera ha una tabella di piante: quattordici caselle, e le camere che non ne hanno le lasciano vuote. «Vuoto» però non è un tipo di dato, è un accordo, e i due lati dell'accordo non erano d'accordo. Chi scriveva la tabella marcava la casella vuota con -1. Chi la leggeva saltava le caselle sotto -100.

Preso da solo, ciascuno dei due è ragionevole. Messi insieme vogliono dire che -1 non è vuoto: è un valore buono. Così ogni camera in ordine, quelle della prima metà del gioco dove il cemento è pulito, si disegnava in silenzio quattordici rampicanti, tutti nello stesso posto, accatastati contro la parete di sinistra.

/* chi scrive: la casella vuota e' -1 */ static const short piante[CAMERE][14] = { { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 }, /* camera pulita */ ... }; /* chi legge: e' vuota se sta sotto -100 */ for(int i = 0; i < 14; i++){ if(piante[c][i] < -100) continue; /* -1 NON sta sotto -100 */ disegna_pianta(piante[c][i]); /* quindi le disegna tutte e quattordici */ }
due righe ragionevoli che insieme non lo sono

Il compilatore qui non può fare niente, ed è la ragione per cui questo guasto è nella lista. Non c'è nessun errore di tipo: -1 è uno short valido come tutti gli altri, e < -100 è un confronto legittimo. Il vincolo che è stato violato non esiste nel linguaggio, esiste solo nella testa di chi ha scritto le due righe, e fra le due righe passavano parecchi mesi.

Il rimedio non è ricordarsi meglio: è togliere l'accordo di mezzo. O il valore convenuto lo decide un posto solo, e tutti e due i lati usano quello, oppure non c'è nessun valore convenuto e la tabella porta con sé quante caselle sono piene, che è un numero e non una convenzione.

#define VUOTA (-1) /* un posto solo, e lo usano tutti e due */ if(piante[c][i] == VUOTA) continue; /* oppure, meglio: niente valore convenuto, un conteggio */ for(int i = 0; i < quante_piante[c]; i++) disegna_pianta(piante[c][i]);

Ma la parte che mi ha fatto perdere più tempo non è il difetto: è che non si vedeva. In un gioco dove la seconda metà è un impianto abbandonato invaso dall'edera, dell'edera in più non grida che c'è un errore. A colpo d'occhio sembrava perfino al posto giusto. Un guasto che produce qualcosa di plausibile è più caro di uno che produce qualcosa di assurdo, e va cercato con lo stesso metodo di tutti gli altri: leggendo la tabella in memoria invece che guardando il muro.

morale: un valore convenuto è un contratto che nessuno fa rispettare. Se il linguaggio non può controllarlo, prima o poi le due parti si allontanano, e te ne accorgi solo se il risultato è abbastanza assurdo da farsi notare.

05 · la memoria

Finisce, e finisce in silenzio

Le camere, arrivate a sei, non ci stavano più. Il tetto su questa macchina non è la dimensione della memoria video: è che la casella della mappa dedica dieci bit al numero della tessera. Dieci bit sono 1024 numeri, e quindi uno sfondo può nominare al massimo 1024 tessere diverse, per quanta memoria tu abbia. Le camere ne volevano milleseicento.

La causa non era la quantità di disegni: era la grana. I blocchi del muro avevano una grana di rumore unica per ogni posizione, quindi ogni blocco produceva quattro tessere sue e nessuna si poteva riusare. La cura è stata farla ripetere ogni quattro blocchi: a sessantaquattro punti di periodo la ripetizione va cercata per accorgersene, e nel frattempo le tessere identiche si fondono in una sola. Da 1600 a 900.

E il guasto, di nuovo, non si annuncia: se sfori, la casella della mappa non ha i bit per il numero che le stai dando, e a schermo compare la tessera sbagliata. Nessun avviso, nessun blocco, solo un muro che ha addosso il pezzo di un'altra cosa.

/* la casella della mappa di sfondo, 16 bit: bit 0..9 numero della tessera -> 0..1023, e non uno di piu' bit 10 specchia in orizzontale bit 11 specchia in verticale bit 12..15 quale banco di 16 colori usare */
il contrario

Il guasto che invece si lamenta

Per equilibrio vale la pena chiudere con l'unico che non sta zitto. Il processore di questa macchina non sa dividere: non esiste un'istruzione che lo faccia. Quando scrivi a / b con b qualunque, il compilatore non sbaglia e non inventa: mette una chiamata a una routine di libreria. E siccome qui la libreria standard non c'è, il programma non si collega più, con un bel riferimento non risolto e il nome della routine mancante.

Per questo in VECTOR ogni ciclo, ogni cadenza e ogni rampa è una potenza di due o un contatore che si riavvolge: uno spostamento di bit lo sa fare il processore, una divisione no. È un vincolo che all'inizio sembra una scomodità e dopo un po' diventa un modo di pensare.

int passo = distanza / 10; /* non si collega: chiama la libreria */ int passo = distanza >> 3; /* diviso otto, e lo fa il processore */

morale: quando qualcosa si lamenta, ringrazia. I guasti che ti fermano subito sono i più economici che ti capiteranno.

← Tutti i tutorial La scheda di VECTOR →