Benvenuti alle dispense di Informatica - Gestione della memoria

In queste dispense vedremo come i linguaggi di programmazione gestiscono la memoria RAM nell'esecuzione dei processi. In particolare vedremo esempi di codice nel linguaggio C.

Il libro di testo adottato è CORSO DI INFORMATICA 3ED. - VOLUME 1 PER INFORMATICA di Fiorenzo Formichi, Giorgio Meini e altri, editore Zanichelli.

Ricordatevi che il lavoro dell'informatico non è aggiustare computer, passare dei cavi o scrivere un'applicazione. Il nostro mestiere è aiutare le persone a risolvere problemi. Dobbiamo quindi sempre avere bene in mente quale problema stiamo risolvendo e per chi, per fare in modo che il nostro lavoro sia veramente utile agli altri.

Gestione della memoria

La memoria è uno dei due componenti principali di un elaboratore elettronico, insieme alla CPU.

Quando parliamo di memoria in questo capitolo, intendiamo sempre la memoria primaria, quella a cui il processore ha accesso diretto; generalmente è costituita dalla RAM, ovvero Random Access Memory, che si può traddure in memoria ad accesso casuale o meglio in memoria ad accesso non sequenziale.

Diversi tipi di RAM. Fonte: wikipedia

La caratteristica principale della memoria RAM è che, come dice il nome, si può accedere a qualsiasi parte di questo tipo di memoria semplicemente conoscendone l'indirizzo, rappresentato da un numero intero.

Al contrario, altri tipi di memoria come l'hard-disk, devono essere acceduti a cluster: per poter accedere ad una certa porzione di memoria, bisogna sapere in che cluster appartiene. In base alla posizione del cluster, potrebbe volerci più o meno tempo per accedere ad un certo dato.

Possiamo immaginare la RAM come un lunghissimo nastro, diviso in caselle, proprio come il nastro della macchina di Turing che abbiamo studiato ad inizio anno.

Un esempio di macchina di Turing. Fonte: wikipedia

Per convenzione e comodità, si usano i numeri esadecimali per indicare l'indirizzo di memoria a cui vogliamo accedere. Useremo inoltre la convenzione di scrivere gli indirizzi di memoria in verticale, con i numeri più bassi in alto.

Facciamo un esempio: immaginiamo una memoria RAM di 4GB, ovvero 2^32 bit. L'indirizzo più alto è quindi 2^32 che rappresentato in esadecimale è 0xFFFFFFFF (ricordiamoci che ogni lettera corrisponde a 4 bit). La rappresentazione sarà quindi come segue:

Indirizzo
0x00
0x01
0x02
...
0xFFFFFFFF

Memoria fisica e memoria virtuale

Quando noi eseguiamo un programma cliccando sull'icona del suo eseguibile, accadono le seguenti cose:

  1. il sistema operativo cerca un'area di memoria libera nella RAM
  2. il sistema operativo copia il programma dall'hard-disk nella memoria RAM, creando un processo
  3. il processo viene eseguito a partire dalle istruzioni contenute nel main (nel caso di C/C++)

La zona di memoria in cui viene esattamente copiato il programma dipende da quale zona è libera nel preciso momento in cui viene eseguito, cambiando quindi ad ogni esecuzione. Questa cosa è molto scomoda in generale per il programmatore, che si troverà ogni volta ad avere dei puntatori a posizione diverse della memoria. Inoltre espone il sistema operativo a diversi tipi di attacchi informatici, perché svela delle informazioni sullo stato attuale della macchina.

Per questi motivi, il sistema operativo crea uno spazio di indirizzamento virtuale, sempre uguale per tutte le applicazioni, e poi si occupa di mappare gli indirizzi virtuali in indirizzi fisici durante l'esecuzione. Più esattamente questa operazione è svolta dal kernel, la componente del sistema operativo che gestisce le componenti hardware.

La virtualizzazione offre anche un altro vantaggio che avete studiato a sistemi e reti: la possibilità di mappare parte della memoria virtuale sull'hard-disk, in caso la memoria fisica fosse esaurita. Questa operazione si chiama swap.

Memoria fisica e virtuale

Memoria fisica e virtuale. Fonte: wikipedia

Per convenzione, la memoria virtuale di ogni processo inizia sempre da 0x00; il valore finale dipende dal kernel e dall'architettura della macchina, per il kernel linux e processori x86 a 64 bit, l'ultimo valore è 0x7FFFFFFFFFFF.

Indirizzo virtuale
0x00
0x01
0x02
...
0x7FFFFFFFFFFF

Da notare che questo intervallo è lo stesso indipendentemente dalla quantità di memoria fisica installata sulla macchina.

Segmenti di memoria

La memoria assegnata ad un certo processo è divisa in segmenti, ovvero porzioni di memoria ognuna con un compito specifico. Di seguito i più importanti.

IndirizzoSettore
0x00Codice
...Variabili Statiche
...Heap
...
0x7FFFFFFFFFFFStack

Codice

La prima porzione di memoria contiene le istruzioni che dovranno essere eseguite durante il processo; questo settore viene chiamato code o anche text. Le istruzioni vengono memorizzate in memoria in accordo con l'architettura specifica del processore, e possono essere visualizzate in maniera comprensibile all'essere umano attraverso la rappresentazione Assembly.

Prendiamo ad esempio una funzione in C che esegue la somma di due numeri. Di seguito vediamo come questo viene tradotto in Assembly, e come ad ogni riga di Assembly corrispondano dei valori esadecimali. Questi valori sono quelli che si trovano nel segmento di memoria del codice.

Variabili statiche

Subito dopo vengono memorizzate tutte quelle variabili che nascono con l'avvio del programma e vengono distrutte solo alla conclusione dello stesso. Rientrano in questa categoria ad esempio tutte le variabili globali e le variabili locali che vengono dichiarate con il modificatore static.

Stack & Heap

I settori precedenti hanno una dimensione fissa, nota all'avvio del programma. Tuttavia, durante l'esecuzione del programma stesso, le variabili che creiamo all'interno delle funzioni non sono note a priori, perché una funzione potrebbe essere chiamata più volte, oppure mai.

Esistono quindi due ulteriori settori che evolvono con l'esecuzione del programma stesso, chiamate stack (catasta, a sinistra nella foto) e heap (mucchio, a destra nella foto).

Stack and heap

Stack

La stack è una memoria a cui si può aggiungere una nuova variabile solo aggiungendola in cima, ed analogamente si possono togliere variabili solo dalla cima. Questa memoria viene gestita automaticamente dal compilatore e dal processore: ogni volta che creiamo una variabile, viene aggiunta alla stack; ogni volta che usciamo dall'ambito della variabile (in inglese, scope), questa variabile viene distrutta. Si esce dallo scope della variabile appena si raggiunge la parentesi graffa in cui è contenuta.

int somma(int a, int b) {
    if (a > 0) {
        int c = a+b;
        return c;
    } // <-- qui viene distrutta la variabile c
} // qui vengono distrutte le variabili a e b

Per convenzione, la stack parte nello spazio di indirizzamento più alto disponibile della memoria virtuale e cresce verso gli indirizzi più bassi.

Heap

La gestione automatica della stack a volte può essere limitante, perché non sempre lo scope reale della variabile corrisponde con quello del blocco in cui si trova. Per questo motivo esiste la heap, dove si ha maggiore controllo su quando distruggere la variabile, che può quindi passare da un blocco ad un altro (ad esempio può essere passato facilmente ad una o più funzioni).

Se non si cancellano correttamente le variabili nella heap, può succedere che aree di memoria vengano sprecate. Questo fenomeno si chiama memory leak ed è un problema a cui bisogna prestare la massima attenzione. Alcuni linguaggi come Java, Python o Rust hanno dei meccanismi per evitare che si verifichino i memory leaks.

Puntatori in C

Vediamo ora come questi concetti studiati in teoria si applicano alla programmazione.

Immaginiamo il seguente codice.

#include <stdio.h>

int main() {
    int            a = 4;
    printf ("Il valore di a è %d\n",a);
    return 0;
}
Il valore di a è 4

È possibile sapere in quale indirizzo di memoria è situata la variabile a?

La risposta è sì, ed è possibile farlo attraverso l'operatore &, che serve esattamente per sapere l'indirizzo di una variabile.

Operatore & (indirizzo di)

L'operatore & si legge "e commerciale" (oppure in inglese "ampersand" o ancora "operatore di referenziazione") e serve esattamente per sapere l'indirizzo di una variabile.

#include <stdio.h>

int main() {
    int            a = 4;
    printf ("Il valore di a è %d\n",a);
    printf ("L'indirizzo di a è %p\n",&a);
    return 0;
}
Il valore di a è 4
L'indirizzo di a è 0x7ffc18dc19cc

Possiamo immaginare il simbolo & come un nodo a cui è legato il filo alla cui estremità opposta c'è la variabile.

Se volessimo invece sapere il valore di un indirizzo di memoria? Esiste anche un operatore per questo scopo, che è l'operatore *.

Operatore * (indirizzamento indiretto)

L'operatore * si legge "stella" (oppure in inglese "star" o ancora operatore di "dereferenziazione" o "indirezione") serve per conoscere il valore di un certo indirizzo di memoria.

Le variabili che memorizzano al loro interno un indirizzo di memoria si chiamano puntatori.

Vediamo un esempio di codice in cui dichiariamo un puntatore, gli assegniamo l'indirizzo di 'a' e ne stampiamo sia il valore, sia il valore puntato.

#include <stdio.h>

int main() {
    int            a = 4;
    int* p = &a;

    printf ("Il valore di a è %d\n",a);
    printf ("L'indirizzo di a è %p\n",&a);

    printf ("Il valore di p è %p\n",p);
    printf ("Il valore puntato da p è %d\n",*p);

    return 0;
}
Il valore di a è 4
L'indirizzo di a è 0x7ffe344c8874
Il valore di p è 0x7ffe344c8874
Il valore puntato da p è 4

Potete sperimentare anche voi con questo esempio a questo link.

Potete inoltre visualizzare graficamente il comportamento dei puntatori con il seguente strumento. Cliccate next per vedere come si comporta la memoria in fase di esecuzione.