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.

Immagine generata con AI
Immagine generata con AI
Riassunto generato automaticamente tramite intelligenza artificiale:

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.

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

Articolo scritto con il supporto di LLM per la formattazione e la struttura del codice.

Commenti