Rappresentazione dell’informazione nei calcolatori
Per conservare l’informazione, l’uomo ha inventato la scrittura, che, in fondo, non è niente di più di un insieme di simboli.
Alla nascita dei calcolatori, gli ingegneri si sono posti il problema di rappresentare e conservare l’informazione in dispositivi che, per loro natura, non hanno capacità di utilizzare un alfabeto complesso.
Ricordiamo infatti che i calcolatori (sotto cui raggruppiamo ogni dispositivo tecnologico che svolge operazioni), sono delle schede hardware, e possono al massimo sfruttare proprietà di tensioni e correnti (limitatamente alla conoscenza umana di oggi).
Ci si è quindi appoggiati a “celle di memoria”, ovvero dei componenti fisici che possono assumere valore 1 o 0 (logici, poiché in realtà a livello fisico avranno differenze di potenziale differenti a seconda dello stato logico in cui si trovano). Assoceremo ad ognuna di queste celle un “bit”, che quindi potrà assumere il valore 1 o 0.
Alcune di queste celle di memoria si resettano a 0 quando viene tolta loro l’alimentazione (ad esempio la nostra SRAM), altre invece conservano l’informazione anche dopo (vedi FLASH, EEPROM, …).
Ovviamente anche la memoria di Arduino è costruita su questi principi!
Rappresentazione Binaria
Nei calcolatori, ogni operazione viene quindi svolta rappresentando e operando sui dati in binario, secondo opportune convenzioni.
Innanzitutto, è evidente che un bit soltanto non basta per rappresentare la gran parte dell’informazione. La minima unità di misura che viene utilizzata è perciò il “byte”, ovvero una sequenza di 8 bit (lo leggeremo da destra a sinistra, secondo la convenzione little-endian).
Chiamiamo la cifra più a sinistra della stringa binaria, diversa da 0, e seguita soltanto da zeri, “cifra più significativa”, mentre la cifra più a destra “meno significativa”.
Un numero scritto in binario non cambia se aggiungiamo o togliamo “0” nella parte più a sinistra del numero, se queste seguono la cifra più significativa.
Ovviamente però, non esiste una distinzione netta tra un byte e un altro… se avessimo modo di leggere il contenuto binario di una unità di memoria, troveremmo qualcosa del genere:
0000001010101010101011010101010101010101010101010101110000001111111111111100000010101010101000000010110101010101010101101010100100000000001111110010101010101010101010101010101010101010101010101010101010101010 ....
Già da questo esempio, appare evidente che spesso il byte non è l’unità adatta per visualizzare i dati. Prendiamo allora una nuova unità di misura che chiameremo “word” (16 bit, 2 byte), e in questo modo avremo:
0000001010101010 1010110101010101 0101010101010101 0101110000001111 1111111111000000 1010101010100000 0010110101010101 0101011010101001 0000000000111111 0010101010101010 1010101010101010 1010101010101010 1010101010101010 ....
Ancora appare evidente che per noi umani rimane un’informazione ancora poco accessibile, anche solo da scrivere. Tramite un cambio di base, però, possiamo trovare più leggibile l’informazione scritta in base 16 (esadecimale). Ovvero, secondo questa tabella di Wikipedia,
Useremo il prefisso 0x per indicare che il numero scritto è in esadecimale e non in binario.
Avremo quindi una lunghezza che sarà un quarto di quella di partenza. Ad esempio:
0000001010101010 => 0000 0010 1010 1010 => 0 2 A A => 0x02AA
Ma in pratica, come rappresento un numero?
Finché il nostro numero è un intero naturale (scritto in base 10), le cose sono particolarmente semplici. Basta infatti eseguire una normale conversione in base 2 e viceversa.
Infatti in binario ogni “posizione” della nostra sequenza di bit possiamo vederla come una potenza di 2 (la potenza di 2 maggiore utilizzata è quella in corrispondenza della cifra più significativa). Per cui:
Mentre se invece devo convertire un numero da base 10 a base 2, basta eseguire divisioni per 2 finché ottengo 0, e poi prendere i resti delle varie divisioni al contrario, per avere il numero in binario dalla cifra più significativa a sinistra, alla cifra meno significativa, a destra. L’ordine di lettura al contrario serve soltanto a noi per scrivere il risultato più facilmente: infatti scriviamo prima le cifre più a destra, muovendoci con la mano verso sinistra.
Esempio:
Converto 1853 (vedi immagine sopra) da decimale a binario:
1853 / 2 = 926 resto 1
926 / 2 = 463 resto 0
463 / 2 = 231 resto 1
231 / 2 = 115 resto 1
115 / 2 = 57 resto 1
57 / 2 = 28 resto 1
28 / 2 = 14 resto 0
14 / 2 = 7 resto 0
7 / 2 = 3 resto 1
3 / 2 = 1 resto 1
1 / 2 = 0 resto 1
( 0 / 2 = 0 resto 0)
Il numero in binario è: (0)11100111101 => 0x73D
N.B.: Gli zero davanti possono essere omessi, come abbiamo già detto
Per esprimere invece anche i numeri negativi, utilizziamo una forma leggermente diversa, chiamata “complemento a 2”, che però non vedremo qui.
In realtà questi ultimi due paragrafi vogliono solo dare un assaggio di quello che tratteremo in una sezione più avanzata, per i più curiosi. Al normale lettore basta solo cogliere un semplice fatto: per rappresentare l’informazione in binario, occorre spazio. Parecchio spazio.
Soltanto per scrivere il numero 1853, abbiamo infatti utilizzato 11 bit, ovvero 2 byte.
Perché proprio 2 byte, non è 1 byte e qualcosa? Il motivo è che la memoria è indirizzata a byte, ovvero non possiamo leggere meno di un byte alla volta. Anche se questo può portare ad uno spreco di spazio, è necessario per le operazioni di trasferimento ed elaborazione.
Ma quanta memoria possiede Arduino?
Potremmo pensare che 2 bytes siano ridicoli, di fronte alle capacità di memoria. Magari qualche lettore è un appassionato di tecnologia, e sicuramente saprà che una RAM di un PC è di almeno 8 gigabyte (Gb), al giorno d’oggi, mentre di un telefono di fascia media “soltanto” 3 o 4 Gb.
Se però consideriamo la memoria di Arduino (d’ora in avanti ci riferiamo al modello Arduino Uno), abbiamo:
- Flash: 32k bytes (di cui 0.5k è riservata)
- SRAM: 2k bytes
- EEPROM: 1k byte
Per capire esattamente a quanti bytes ci riferiamo, facciamo riferimento alla seguente tabella dei prefissi per le dimensioni della memoria:
Nota: spesso utilizziamo le potenze di 10 per esprimere le dimensioni in byte: questo perché siamo abituati ad utilizzare la notazione del S.I. (Sistema Internazionale di Misura). In realtà non è totalmente corretto, poiché in realtà le memorie sono costruite solo con dimensioni che sono potenze del 2. In Arduino, con 2Kb, intendiamo quindi 2048 bytes.
Se riprendiamo la nostra lista di tipologie di variabili:
- Char => 1 byte
- Char[] => 1 byte * carattere + 1 byte per il carattere terminatore
- Int, Unsigned int => 2 bytes
- Long => 4 bytes
- Float => 4 bytes
- Bool => 1 byte (perché come abbiamo già detto, anche se usiamo 1 bit, abbiamo comunque riservato il byte intero)
È chiaro quindi che la memoria del nostro Arduino non è infinita: se il nostro codice è completamente vuoto e contiene solo una variabile char[], in questa potremmo salvare al massimo una stringa di testo di circa 2000 caratteri.
Questa stima però deve essere modificata pensando già soltanto con le operazioni base, la memoria disponibile è il 90% di 2048 bytes.
Poiché ci serviranno molte variabili, è quindi fondamentale utilizzare sempre il tipo di variabile che occupa meno spazio possibile, in rapporto alle nostre necessità:
- Utilizzando un tipo Char[] al posto di Char per salvare un singolo carattere, sprechiamo un byte per il terminatore (ne parleremo in un articolo successivo)
- Utilizzando un tipo Float per un Int per rappresentare un intero
tra -32768 e 32767, sprechiamo 2 Byte.
In particolare, per le tipologie di variabili per rappresentare numeri dobbiamo anche considerare che:
Le operazioni tra interi, in qualsiasi processore, vengono eseguite in modo molto più efficiente (e quindi veloce) di quelle tra numeri con virgola. Inoltre con i numeri a virgola mobile abbiamo sempre problemi di approssimazione.
Però… attenti all’overflow!
Se usate una variabile troppo piccola per rappresentare la vostra informazione, rischiate quello che viene chiamato “overflow“.
Somme tra numeri interi positivi in binario
Per capire il prossimo esempio, introduciamo brevemente come si sommano due numeri interi positivi quando lavoriamo in binario:
Overflow nella somma tra numeri interi
Torniamo ora alla memoria di Arduino, e supponiamo di sommare sempre 1 ad una variabile intera, partendo da 0.
La cifra significativa del corrispondente binario si sposterà sempre più a sinistra man mano che il numero cresce. Ma se avete usato un tipo int, avete solo 2 bytes, che quindi contengono al massimo il numero positivo 32767 (in realtà potrebbe contenere il suo doppio, ma il bit più a sinistra è riservato al segno).
Questo significa che quando eseguite 32767 + 1 avete finito lo spazio riservato ai numeri positivi. Il microcontrollore però nota che la variabile non è ancora piena, e inizia allora a corrompere il bit del segno, e improvvisamente vedrete comparire il numero -32768. Per motivi che non tratterò qui, la codifica binaria di questo numero è infatti:
-32768 (dec) = 10000000 00000000 (bin)
Poi il numero ricomincerà a crescere verso numeri positivi. In base alle regole del complemento a 2, infatti, alla fine avrete:
11111111 11111111 (bin) = -1 (dec)
A questo punto succederà un fatto strano. Se vogliamo sommare 1 a questo numero, inevitabilmente dovremmo avere almeno 17 bit, perché ci serve un posto più a sinistra… ma noi ne abbiamo solo 16! Per non uscire dallo spazio destinato alla variabile, e corrompere tutto il vostro spazio di lavoro in memoria, il microcontrollore semplicemente eseguirà un reset della variabile, che di colpo ripartirà da 0: overflow!
Al di là della complessità dell’esempio, quello che dobbiamo notare è che quando sforiamo i limiti consentiti, ad esempio per le variabili numeriche, non abbiamo più il controllo sui dati (cambiamenti di segno, reset, errori di esecuzione, etc).
Tutte le variabili hanno un limite. L’accortezza sta nello scegliere quella che contiene meglio i nostri dati.
Dopo aver visto su quali principi è basata la memoria di Arduino, andiamo avanti, e vediamo finalmente come utilizzare queste conoscenze nel nostro codice.