Gestione della memoria

La gestione della memoria #

Molti linguaggi di programmazione moderni permettono ai programmatori di usare i tipi di dato senza sapere come questi vengono rappresentati. Spesso i programmatori ignorano anche dove i dati sono salvati (allocati).

Il compilatore prende delle decisioni, ma a runtime le allocazioni sono sotto il controllo del sistema operativo e della CPU.

In alcuni linguaggi di programmazione come il C, la gestione della memoria può essere controllata dai programmatori. La memoria può essere allocata e deallocata dinamicamente e si possono ottenere gli indirizzi di memoria delle variabili (puntatori).

Se x è una variabile, in C &x denota il puntatore ad x, cioè l’indirizzo di memoria dove x è salvato.

L’allocazione di memoria #

Consideriamo il programma seguente.

#include <stdio.h>

int main() {
  // Dichiarazione delle variabili
  int i;
  char c;
  short s;
  long l;

  // &var indica l'indirizzo della variabile
  printf("i viene allocata in %p\n", &i);
  printf("c viene allocata in %p\n", &c);
  printf("s viene allocata in %p\n", &s);
  printf("l viene allocata in %p\n", &l);
}

In un’architettura a 64bit possiamo presumere che:

  • int sia lungo 4 byte
  • char sia lungo 1 byte
  • short sia lungo 2 byte
  • long sia lungo 8 byte

La memoria è spesso rappresentata come una sequenza di byte

i1 i2 i3 i4 c1 s1 s2 l1 l2 l3 l4 l5 l6 l7 l8

In un’architettura n*8, i byte sono disposti in gruppi di n. Ad esempio:

i1 i2 i3 i4 c1 s1 s2
l1 l2 l3 l4 l5 l6 l7 l8

Se eseguiamo il programma di prima possiamo osservare come i dati sono allocati in memoria.

❯ gcc -o alloc alloc.c                                              
❯ ./alloc               
i viene allocata in 0x7ffd6600554c
c viene allocata in 0x7ffd66005549
s viene allocata in 0x7ffd6600554a
l viene allocata in 0x7ffd66005550

Lo stack cresce verso il basso, quindi indirizzi più bassi sono più vicini alla cima dello stack. Ci accorgiamo che il compilatore ha spostato le variabili rispetto a come le abbiamo dichiarate noi!

+0x0 +0x1 +0x2 +0x3 +0x4 +0x5 +0x6 +0x7
0x7ffd66005548 c1 s1 s2 i1 i2 i3 i4
0x7ffd66005550 l1 l2 l3 l4 l5 l6 l7 l8

Con alcune versioni di gcc, se scegliamo flag di ottimizzazione differenti, si può notare che il compilatore può fare scelte differenti per l’allocazione di memoria. Il mio gcc 11.2.0 non cambia l’ordine di allocazione per flag di ottimizzazione differenti, ad esempio.

Quindi ricordiamoci che l’ordine di dichiarazione delle variabili locali non rispetta necessariamente l’ordine in memoria.

I segmenti di memoria #

La memoria è allocata per ogni processo (un programma in esecuzione), per contenere i dati e il codice.

La memoria allocata consiste di diversi segmenti:

  • lo stack, per le variabili locali.
  • l’heap, per la memoria dinamica.
  • il data segment, che contiene:
    • le variabili globali non inizializzate (.bss)
    • le variabili globali inizializzate (.data)
  • il code segment, dove di fatto risiede il codice.

Endianness (ordine dei byte) #

Sono modalità differenti per salvare in memoria più byte. Cerchiamo di spiegarlo con un esempio. Il numero 100000000, in esadecimale è 0x05F5E100.

In modalità big-endian il byte più significativo verrà salvato prima.

+0x0 +0x1 +0x2 +0x3
0x05 0xF5 0xE1 0x00

In modalità little-endian lo stesso numero verrà salvato partendo prima dal byte meno significativo.

+0x0 +0x1 +0x2 +0x3
0x00 0xE1 0xF5 0x05
Con il seguente programma, prova ad indentificare usando GDB, come viene salvata la memoria sotto x86_64. Se non sai come fare, vai a vedere la sezione su GDB (in aggiornamento).
#include <stdio.h>

int main() {
    int endian = 0x05F5E100;
    printf("0x%x\n", endian);
    return 0;
}