Accesso alla memoria in Assembly x86_64
Capire la differenza tra un valore e un indirizzo di memoria è uno dei passaggi fondamentali per iniziare a comprendere davvero l'Assembly. In questa seconda parte vedremo come funzionano la dereferenziazione, i puntatori, gli offset e l'ordine dei byte in memoria, con esempi pratici in Assembly x86-64.
In assembly x86-64, la notazione `mov reg, value` copia un valore letterale, mentre `mov reg, [value]` dereferenzia `value` interpretandolo come un indirizzo di memoria. I registri fungono da puntatori quando contengono un indirizzo che viene poi utilizzato per leggere o scrivere nella memoria tramite la sintassi delle parentesi quadre.
Valori, indirizzi e dereferenziamento in assembly x86-64
Uno degli scogli concettuali più importanti per chi si avvicina alla programmazione a basso livello è capire la differenza tra un valore e un indirizzo di memoria. Nella sostanza sono la stessa cosa, un numero, ma il modo in cui l'assembly le tratta cambia completamente il comportamento di un programma.
La memoria come indirizzario
La memoria di un computer può essere immaginata come una lunga via di case numerate in sequenza. Ogni casa corrisponde a un byte (8 bit) di memoria e possiede un indirizzo numerico univoco. Nei sistemi moderni, come discusso nel primo articolo della serie, lo spazio di indirizzamento è enorme e presenta ampie zone non mappate, esattamente come una via con numeri civici che saltano da un blocco all'altro.
Questa analogia torna utile per distinguere due concetti che in assembly si scrivono in modo molto simile ma significano cose opposte: conoscere il numero civico di una casa non equivale a sapere cosa contiene quella casa.
Valore diretto e lettura di un indirizzo
Chiarito il concetto, possiamo usare mov sia per assegnare un valore a un registro, sia per leggere il contenuto di un indirizzo di memoria.
Per assegnare un valore a un registro, la sintassi corretta è la seguente:
assembly
mov rdi, 13317
La lettura, invece, avviene usando la sintassi [valore]:
assembly
mov rdi, [13317]
Le parentesi quadre indicano alla CPU che 13317 va interpretato come un indirizzo di memoria. La CPU si reca a quell'indirizzo, preleva il byte (o i byte) memorizzati e li copia nel registro rdi.
A questo punto qualcuno potrebbe provare ad assegnare a un registro generico un valore, per poi dereferenziare quel valore in rdi e chiamare la syscall exit restituendo il contenuto del registro generico. Ed è qui che si sbaglia più spesso.
assembly
mov rbx, 10
mov rdi, [rbx]
Questo non assegna il valore 10 a rdi. Se vogliamo semplicemente copiare lo stesso valore, basta fare:
assembly
mov rdi, rbx
Letteralmente, stiamo dicendo alla CPU: prendi il valore contenuto in rbx e copialo in rdi. In questo caso:
text
rdi = rbx = 10
Con mov rdi, [rbx], invece, stiamo dicendo alla CPU: prendi il valore contenuto in rbx, usalo come indirizzo di memoria, vai a quell'indirizzo e copia in rdi ciò che trovi lì. In altre parole:
text
rbx → valore contenuto nel registro
[rbx] → valore contenuto nella memoria all'indirizzo indicato da rbx
I registri come puntatori
Nella programmazione reale è raro lavorare con indirizzi fissi scritti a mano nel codice. Più spesso un indirizzo viene calcolato o ricevuto a runtime e salvato in un registro, che a quel punto assume il ruolo di puntatore.
assembly
mov rax, 133700
Dopo questa istruzione, rax contiene l'indirizzo 133700. Per leggere il dato a cui punta, si applica la dereferenziazione tramite parentesi quadre:
assembly
mov rdi, [rax]
I registri della CPU, come rax o rdi, non hanno un ruolo semantico fisso. Lo stesso registro può contenere in momenti diversi l'identificativo di una syscall, un puntatore a un'area di memoria, o un semplice valore numerico. Per la CPU un registro è solo un contenitore di bit: il significato dipende esclusivamente dall'istruzione che lo utilizza.
La scelta del registro da usare come puntatore è arbitraria: qualsiasi registro general purpose (rax, rdi, rbx, rcx e così via) può contenere un indirizzo. Una conseguenza interessante è che un registro può dereferenziare se stesso, sovrascrivendo il proprio contenuto (l'indirizzo) con il valore letto dalla memoria a quell'indirizzo. Nel flusso di esecuzione lo stesso registro cambia quindi ruolo: da indirizzo diventa contenitore del dato finale.
Specificare la dimensione dell'operando
Quando si scrive un valore immediato direttamente in un indirizzo di memoria, l'assemblatore non può dedurre da solo quanti byte occupare, perché né la sorgente (un numero puro) né la destinazione (un indirizzo, non un registro) portano un'informazione di dimensione. È necessario specificarla esplicitamente con un modificatore.
mov è un indirizzo di memoria e la sorgente è un valore immediato, va sempre indicata la dimensione dell'operando, altrimenti l'assemblatore restituisce un errore di ambiguità. È possibile farlo attraverso quattro modificatori. | Modificatore | Dimensione |
|---|---|
byte ptr | 1 byte, 8 bit |
word ptr | 2 byte, 16 bit |
dword ptr | 4 byte, 32 bit |
qword ptr | 8 byte, 64 bit |
Applicando il modificatore corretto per lavorare con registri a 64 bit come rax, la sequenza completa di scrittura e autodereferenziamento diventa:
assembly
mov qword ptr [133700], 42
mov rax, 133700
mov rax, [rax]
La prima riga scrive il valore 42 all'indirizzo di memoria 133700, specificando esplicitamente che si tratta di un blocco a 8 byte attraverso il modificatore qword. La seconda riga carica in rax l'indirizzo 133700. La terza riga fa leggere a rax il contenuto presente a quell'indirizzo, sovrascrivendo se stesso: da indirizzo, rax diventa il valore 42.
Offset di memoria e indirizzamento relativo
Un puntatore non sempre indica esattamente il dato di interesse: spesso punta all'inizio di una struttura più ampia, come una stringa, un array o un record. Poiché ogni indirizzo identifica un singolo byte, per raggiungere un dato specifico all'interno della struttura si somma un offset, cioè una distanza in byte, all'indirizzo base.
assembly
.intel_syntax noprefix
.global _start
_start:
mov rax, [rdi + 1]
mov rax, 60
syscall
In questo esempio, rdi contiene l'indirizzo base, mentre [rdi + 1] accede al byte immediatamente successivo, saltando il primo byte della struttura. La sintassi con l'offset all'interno delle parentesi quadre è lo stesso pattern che si userà per scorrere array o stringhe carattere per carattere.
Un cenno all'ordine dei byte (little-endian)
Quando un valore occupa più di un byte, la CPU deve stabilire in quale ordine salvare i singoli byte in memoria. L'architettura x86-64 usa l'ordine little-endian: il byte meno significativo viene memorizzato per primo, cioè all'indirizzo di memoria più basso.
Per esempio, il valore:
text
0x12345678
viene memorizzato in memoria come:
text
78 56 34 12
Quindi, il valore che leggiamo normalmente da sinistra a destra non corrisponde all'ordine con cui i singoli byte sono disposti in memoria. È un dettaglio che diventa importante soprattutto quando si analizza la memoria con strumenti come GDB.
Puntatori a puntatori
Gli indirizzi di memoria sono a tutti gli effetti valori numerici, quindi la memoria stessa può contenere un indirizzo che punta a un altro indirizzo.
| Passaggio | Istruzione assembly | Valore nel registro | Spiegazione |
|---|---|---|---|
| 1. Caricamento indirizzo base | mov rdi, 123400 | rdi = 123400 | Inserisce l'indirizzo iniziale nel registro |
| 2. Prima dereferenziazione | mov rdi, [rdi] | rdi = 133700 | Legge il valore contenuto in 123400 |
| 3. Seconda dereferenziazione | mov rax, [rdi] | rax = 42 | Legge il dato finale all'indirizzo 133700 |
Catena di puntatori a partire da un registro
L'ultimo scenario combina i due concetti visti finora: l'uso di un registro come puntatore e il doppio dereferenziamento, in un caso in cui non si conosce a priori alcun indirizzo numerico fisso, ma tutto parte dal contenuto di un registro fornito in ingresso.
Tabella riepilogativa delle modalità di indirizzamento
| Pattern | Sintassi tipo | Cosa restituisce |
|---|---|---|
| Valore diretto | mov rdi, 133700 | Il numero grezzo, nessun accesso alla memoria |
| Dereferenziamento singolo | mov rdi, [rax] | Il contenuto memorizzato all'indirizzo in rax |
| Autodereferenziamento | mov rax, [rax] | Il registro sovrascrive se stesso col dato letto |
| Offset | mov al, [rdi + 1] | Il byte a un byte di distanza dall'indirizzo base |
| Doppia dereferenziazione | mov rdi, [rdi] seguito da mov rax, [rdi] | Il dato finale, raggiunto attraverso due accessi consecutivi alla memoria |
| Catena da registro | mov rdi, [rdi] ripetuto | Il valore in fondo a una catena di puntatori partita da un registro |