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;
}