Introduzione alla Memoria e ai Puntatori in linguaggio C

I puntatori in linguaggio C sono, a torto o ragione, uno dei concetti più fraintesi da chi si approccia a questo linguaggio. Essi sono, tuttavia, fondamentali ma per poterli comprendere bisogna imparare alcuni concetti fondamentali.

In questa lezione, verranno introdotti i concetti alla base dei puntatori, come il funzionamento del processore, la memoria RAM e il bus di sistema in modo semplificato. Verrà spiegato come il processore ragiona in termini di indirizzi e locazioni di memoria.

Cercheremo di capire come il compilatore astrae questo meccanismo attraverso l'associazione di un indirizzo di memoria ad ogni variabile e come le variabili vengono memorizzate in memoria.

Inoltre, verrà presentato l'utilizzo dell'operatore indirizzo &, che permette di ottenere l'indirizzo di una variabile.

Compresi questi concetti, a partire dalla prossima lezione, entreremo nel vivo dello studio dei puntatori.

Concetti Chiave
  • Prima di studiare i puntatori è necessario comprendere le interazioni tra processore e memoria;
  • Noi ragioniamo in termini di variabili; il processore ragiona in termini di indirizzi e locazioni;
  • Una memoria RAM è organizzata in locazioni, ossia byte, identificate da un indirizzo univoco;
  • Ad ogni variabile corrisponde un indirizzo di una locazione di memoria;
  • Quando il processore vuole ottenere il valore di una variabile richiede il contenuto della locazione corrispondente al suo indirizzo alla memoria;
  • Una variabile può occupare una o più locazioni di memoria;
  • Attraverso l'operatore indirizzo, &, è possibile ottenere l'indirizzo di una variabile.

Processore, Memoria e Bus

Prima di poter comprendere cosa sia un puntatore è necessario soffermarsi un attimo su come funziona la memoria e come un programma scritto in C vi acceda.

Bisogna aver chiaro come il processore interagisca con la memoria stessa. Per questo motivo qui faremo un breve excursus sul funzionamento interno di processore, memoria e bus.

Processore

Il componente principale di un computer, sia esso un computer desktop, laptop, server o uno smartphone, è rappresentato dal processore. Lo scopo di un processore è quello di eseguire le istruzioni contenute nei programmi e applicarle ai dati forniti in ingresso.

Per poter svolgere le istruzioni di un programma e lavorare su dati è necessario che il processore porti queste informazioni al proprio interno. In gergo tecnico il processore carica (dall'inglese load) istruzioni e dati al proprio interno per poter lavorare in tutta comodità su di essi.

A questo scopo, la stragrande maggioranza di processori è dotata di una piccola memoria interna dove immagazzinare istruzioni e dati: i registri del processore. Un processore normalmente è dotato di registri per le istruzioni, registri per i dati e registri speciali.

In generale, un registro è una piccola area di memoria in grado di immagazzinare una serie di bit. Ad esempio, in processori a 64 bit i registri possono contenere fino a 64 bit. Un registro ha un'etichetta con cui può essere identificato.

Uno schema, molto semplificato, di un possibile processore è quello rappresentato nella figura che segue:

Schema semplificato di un processore e dei suoi registri
Figura 1: Schema semplificato di un processore e dei suoi registri

Nella figura possiamo osservare che il processore ha vari registri. Alcuni, indicati con R_n, sono registri pensati per poter immagazzinare dati, mentre altri, indicati con Rs_n, sono registri per scopi speciali.

Un registro può contenere un'istruzione che indichi al processore cosa fare e su quali dati operare, mentre altri registri possono contenere i dati o operandi su cui l'istruzione deve essere applicata.

Tuttavia, la quantità di registri, ossia di memoria interna di un processore, è limitata. All'interno di un processore è impossibile immagazzinare, nella stragrande maggioranza dei casi, programmi interi o dati di grosse dimensioni.

Per questo motivo, il processore deve far uso della Memoria.

Memoria

La memoria, spesso chiamata memoria RAM (da Random Access Memory, memoria ad accesso casuale), è un componente di tutti i computer il cui scopo è quello di immagazzinare istruzioni e dati.

Oggigiorno le memorie immagazzinano i dati sotto forma di singoli byte ossia gruppi di 8 bit. Tali byte vengono memorizzati in registri da 8 bit che prendono il nome di locazioni di memoria. Ad ogni locazione è associato un indirizzo, una sorta di identificativo numerico progressivo che parte da zero. Questo per far in modo da poter distinguere in maniera univoca una locazione dall'altra.

Un singolo byte di memoria
Figura 2: Un singolo byte di memoria

Ad esempio, se avessimo una memoria da 1 MB (un Mega byte, poco più di un milione di byte), gli indirizzi andrebbero da 0 a 1048575 come mostrato in figura:

Schema di una memoria con n locazioni
Figura 3: Schema di una memoria con n locazioni

Per cui, se una memoria contiene n byte, gli indirizzi di tali byte andrebbero da 0 a n-1.

In gergo tecnico si dice che un'indirizzo punta ad una precisa locazione di memoria.

Definizione

Memoria, locazioni ed indirizzi

La Memoria di un computer è un dispositivo in grado di immagazzinare dati.

I dati sono memorizzati sotto forma di byte, ossia 8 bit, in locazioni di memoria.

Ad ogni locazione di memoria è associato un'indirizzo univoco. Un indirizzo punta ad una locazione specifica.

Sui byte contenuti in memoria si possono applicare due operazioni:

  • Lettura: si richiede alla memoria uno specifico byte. In particolare si dà in ingresso alla memoria un indirizzo e in uscita si ottiene il byte corrispondente;
Operazione di lettura dalla memoria
Figura 4: Operazione di lettura dalla memoria
  • Scrittura: si modifica uno specifico byte. Si dà alla memoria in ingresso sia l'indirizzo che il nuovo valore del byte da modificare.
Operazione di scrittura in memoria
Figura 5: Operazione di scrittura in memoria

Sfruttando queste due operazioni, il processore è in grado di lavorare su programmi e dati di grosse dimensioni. Per far questo è necessario introdurre un terzo componente fondamentale di interconnessione tra processore e memoria: il Bus.

Bus

Schema semplificato di interconnessione tra il processore e la memoria
Figura 6: Schema semplificato di interconnessione tra il processore e la memoria

Nello schema di sopra è rappresentata, in maniera molto semplificata, l'interconnessione tra il processore e la memoria RAM. Le frecce di grandi dimensioni presenti nello schema prendono il nome di Bus (dal latino omnibus) del sistema e permettono di scambiare dati e altre informazioni tra i componenti. Si tratta, nella pratica, di linee elettriche che collegano i componenti e attraverso la quale fluiscono i dati in formato binario, ossia come zeri e uni.

Nello schema semplificato di sopra esistono tre bus:

  • Bus dati: che permette lo scambio di dati tra il processore e la memoria RAM. I dati possono fluire in tutte e due le direzioni;
  • Bus indirizzi: su cui il processore pone l'indirizzo di interesse verso la memoria. Questo bus permette agli indirizzi di fluire esclusivamente dal processore alla memoria;
  • Bus di controllo: su cui il processore specifica quale operazione la memoria deve eseguire, se si tratta di una lettura (R) o di una scrittura (W).

Quando il processore ha bisogno di un dato o una nuova istruzione effettua tre operazioni:

  1. Pone l'indirizzo del dato o dell'istruzione di cui necessita sul bus indirizzi;
  2. Imposta il bus di controllo per indicare alla memoria che intende leggere;
  3. Attende dal bus dati l'informazione richiesta (uno o più byte).

Viceversa, quando il processore ha bisogno di immagazzinare un dato in memoria effettua le seguenti operazioni:

  1. Pone il dato sul bus dati;
  2. Pone l'indirizzo di destinazione sul bus indirizzi;
  3. Imposta il bus di controllo per indicare alla memoria che intende scrivere.

Questo schema è molto semplificato ma serve per fornire un'idea generale sulla collaborazione tra processore e memoria. Risulta fondamentale avere questo schema in mente quando si vuole affrontare lo studio dei puntatori in linguaggio C come vedremo a breve.

Programmi, variabili e memoria

Quando programmiamo, non solo in linguaggio C, ma in qualunque linguaggio di programmazione di alto livello, non ci dobbiamo preoccupare di tutti i dettagli di interazione tra processore e memoria. Il compilatore ha lo scopo di tradurre un programma scritto in un linguaggio per esseri umani in una serie di istruzioni che un processore è in grado di comprendere.

Prendiamo un esempio di codice in linguaggio C:

int a = 10;
int b = 20;
int c;

c = a + b;

In questo caso abbiamo tre variabili, a, b e c ed un'istruzione che somma a e b e salva il risultato in c.

Un processore non ragiona in termini di variabili, ma solo e soltanto in termini di locazioni e indirizzi. Il processore non è in grado di capire chi sia a o b ne tantomeno è in grado di capire dove salvare il risultato della loro somma.

Per questo motivo, il compilatore ha lo scopo di trasformare il codice in qualcosa che il processore sia in grado di eseguire.

Per prima cosa bisogna tradurre le variabili in indirizzi. Un compilatore, infatti, analizza il codice, individua le variabili presenti ed assegna a ciascuna un indirizzo. Come questo avvenga non importa in questa lezione. Ciò che importa è che il risultato di questa prima operazione è una sorta di tabella che il compilatore mantiene al suo interno e che utilizzerà in un passo successivo. Uno degli scopi per cui nascono i compilatori è proprio quello di compilare una tabella di questo tipo.

Questa tabella potrebbe avere un aspetto del genere:

variabile a -> indirizzo: 2300
variabile b -> indirizzo: 3120
variabile c -> indirizzo: 4510

La tabella, quindi, contiene una serie di indirizzi che puntano alle locazioni in cui sono memorizzati i valori delle variabili.

In un secondo passaggio, il compilatore trasforma le istruzioni del programma in una o più istruzioni del processore. Infatti, nella maggioranza dei casi una singola istruzione C può risultare in molte istruzioni del processore.

Proviamo a vedere come potrebbe essere tradotta l'istruzione "c = a + b;" in istruzioni per un ipotetico processore.

Per prima cosa dobbiamo caricare la variabile a in un registro del processore. Ma la variabile a si trova all'indirizzo 2300 per cui:

CARICA IL CONTENUTO DI 2300 IN R1
Operazione 1: CARICA IL CONTENUTO DI 2300 IN R1
Figura 7: Operazione 1: CARICA IL CONTENUTO DI 2300 IN R1

Quando il processore incontra questa istruzione carica il byte situato all'indirizzo 2300 della memoria nel registro R1.

Successivamente, bisogna caricare la variabile b in un registro. Ma la variabile b si trova all'indirizzo 3120 per cui:

CARICA IL CONTENUTO DI 3120 IN R2
Operazione 2: CARICA IL CONTENUTO DI 3120 IN R2
Figura 8: Operazione 2: CARICA IL CONTENUTO DI 3120 IN R2

A questo punto, il processore ha tutte le informazioni di cui ha bisogno per eseguire la somma. Quindi il compilatore produrrà un'istruzione del tipo:

SOMMA R1 E R2 E PONI IL RISULTATO IN R3
Operazione 3: SOMMA R1 E R2 E PONI IL RISULTATO IN R3
Figura 9: Operazione 3: SOMMA R1 E R2 E PONI IL RISULTATO IN R3

Questa istruzione indica all'ipotetico processore di sommare il dato contenuto in R1 al dato contenuto in R2 e memorizzarlo nel registro R3.

Il risultato, però, si trova ancora all'interno del processore e deve essere portato fuori e salvato in memoria nella variabile c, ossia all'indirizzo 4510. Per cui, l'istruzione finale che il compilatore produrrà sarà qualcosa del tipo:

SALVA IL CONTENUTO DI R3 IN 4510
Operazione 4: SALVA IL CONTENUTO DI R3 IN 4510
Figura 10: Operazione 4: SALVA IL CONTENUTO DI R3 IN 4510

Ossia salva il contenuto del registro R3 alla locazione di memoria 4510.

Quindi la lista di istruzioni finali per il processore sarà:

CARICA IL CONTENUTO DI 2300 IN R1
CARICA IL CONTENUTO DI 3120 IN R2
SOMMA R1 E R2 E PONI IL RISULTATO IN R3
SALVA IL CONTENUTO DI R3 IN 4510

Le istruzioni che abbiamo visto sono ipotetiche, servono per mostrare il concetto. Ma in fin dei conti non sono tanto dissimili dalle istruzioni di un processore reale.

Quello che bisogna capire è che senza compilatori e senza linguaggi di programmazione, bisogna lavorare con il processore in questo modo, ossia con indirizzi anziché variabili.

Il succo di questo discorso è che:

Definizione

Ad ogni variabile corrisponde un indirizzo in memoria.

Il compilatore associa ad ogni variabile definita in linguaggio C un indirizzo in memoria. A tale indirizzo corrisponde una locazione in memoria che contiene il dato effettivo.

In sostanza, una variabile è un'etichetta corrispondente ad un indirizzo che punta ad una locazione di memoria.

Variabili e Indirizzi

Nella sezione precedente abbiamo visto che il compilatore associa ad ogni variabile un indirizzo fisico della memoria. Nella locazione corrispondente a quell'indirizzo viene immagazzinato effettivamente il dato. Noi che sviluppiamo ragioniamo in termini di nomi simbolici piuttosto che indirizzi. Del resto questo è il motivo principale per cui sono nati i compilatori.

Una variabile, tuttavia, può essere di un tipo che occupa più di un singolo byte. Prendiamo, ad esempio, una variabile di tipo double. Sappiamo che una variabile double occupa ben 64 bit, ossia 8 byte.

Per questo motivo, ad una singola variabile possono corrispondere più locazioni di memoria in successione. In tal caso l'indirizzo associato alla variabile corrisponde alla prima locazione in cui è memorizzato il valore. Per chiarire il tutto basta osservare la figura:

Esempio di occupazione in memoria di una variabile di tipo double
Figura 11: Esempio di occupazione in memoria di una variabile di tipo double

In questo esempio, la variabile x di tipo double è memorizzata all'indirizzo 2000. Ma ciò significa che il suo valore occuperà le locazioni di memoria da 2000 a 2007.

Definizione

Indirizzo di una variabile

L'indirizzo in memoria di una variabile che occupa più di un byte corrisponde al primo byte in memoria della variabile stessa.

Operatore indirizzo &

Il compilatore, come abbiamo visto, nasconde la complessità di dover lavorare direttamente con gli indirizzi della memoria e ci permette di concentrarci su problemi di più alto livello. Ci dobbiamo solo occupare di definire le nostre variabili con nomi significativi e utilizzarle nei nostri programmi.

Tuttavia, nel linguaggio C è possibile risalire all'indirizzo assegnato dal compilatore ad una variabile. Questo è uno dei punti di forza del linguaggio C. In molti linguaggi, come ad esempio Java, questa operazione non è possibile.

Per ottenere l'indirizzo di memoria di una variabile basta adoperare l'operatore indirizzo: &.

La sintassi è molto semplice, in quanto basta usare l'operatore davanti la variabile di cui voler ottenere l'indirizzo.

Ad esempio, supponiamo di avere una variabile a e di voler stampare a schermo il suo indirizzo. Potremmo realizzare il tutto in questo modo:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
    int a = 5;
    printf("Valore di a:    %d\n", a);
    printf("Indirizzo di a: %u\n", &a);
    return 0;
}

Se proviamo a compilare ed eseguire il programma di sopra possiamo ottenere un output come quello che segue:

Valore di a:    5
Indirizzo di a: 1862343060

Quindi, in questo esempio, il compilatore ha assegnato l'indirizzo 1862343060 alla variabile a. Ovviamente il risultato può cambiare.

La cosa interessante da notare è che il risultato dell'operatore & è in tutto e per tutto un intero. Più precisamente un intero senza segno, un unsigned. Infatti nella printf abbiamo usato lo specificatore %u che sta per un intero unsigned.

Da qui il passo successivo: essendo un intero, l'indirizzo di una variabile può essere usato in espressioni o, addirittura l'indirizzo di una variabile può essere memorizzato in un'altra.

Questo meccanismo fondamentale è alla base dei puntatori che studieremo nelle prossime lezioni. La capacità di ottenere gli indirizzi delle variabili e lavorare direttamente con essi rappresenta uno dei maggiori punti di forza del linguaggio C.

Ovviamente come dice il detto "Da grandi poteri derivano grandi responsabilità" per cui un meccanismo di tale potenza può anche causare errori difficili da scovare. Molti linguaggi, infatti, fanno a meno dei puntatori per evitare problemi di programmazione. In Java e Python, ad esempio, i puntatori non sono supportati.

Ricapitolando:

Definizione

Operatore Indirizzo &

In linguaggio C, l'operatore indirizzo, &, permette di ottenere l'indirizzo assegnato ad una variabile.

La sintassi è la seguente:

&nome_variabile

Il risultato fornito è un intero senza segno che può essere assegnato ad un'altra variabile o usato in altre espressioni.

Un'ultima nota prima di entrare nel vivo dello studio dei puntatori: in precedenza abbiamo usato già senza saperlo l'operatore indirizzo. Quando, infatti, usiamo la funzione scanf per leggere dati dalla linea di comando, dobbiamo passare l'indirizzo della variabile che accoglierà il risultato.

Motivo per cui, per leggere ad esempio un valore intero e memorizzarlo in una variabile x dobbiamo usare l'operatore indirizzo in questo modo:

int x;

printf("Inserisci un numero: ");
scanf("%d", &x);
Definizione

L'operatore indirizzo e la funzione scanf

La funzione scanf permette di memorizzare valori letti da riga di comando e memorizzarli in variabili. Per far questo ha bisogno di conoscere l'indirizzo delle variabili di destinazione. Per tal motivo si usa l'operatore indirizzo &, ad esempio:

scanf("%d", &x);

In Sintesi

In questo articolo abbiamo introdotto alcuni concetti propedeutici allo studio dei puntatori in linguaggio C. Abbiamo visto come il processore, la memoria RAM e il bus di sistema funzionano in modo semplificato e come il compilatore astrae il meccanismo dell'indirizzamento della memoria attraverso l'associazione di un indirizzo di memoria ad ogni variabile. Abbiamo anche visto l'utilizzo dell'operatore indirizzo & che permette di ottenere l'indirizzo di una variabile.

Questi concetti sono fondamentali per capire il funzionamento interno del computer e per diventare un programmatore C esperto. Adesso che abbiamo compreso i concetti di base, nella prossima lezione entreremo nel vivo dello studio dei puntatori in linguaggio C, per approfondire il loro funzionamento e le loro applicazioni.