Lo stack in Assembly x86_64

Lo stack è una delle strutture fondamentali della memoria di un processo. In questa parte vedremo come funziona, come viene organizzato all'avvio del programma e come utilizzare rsp, push, pop e gli offset per accedere ai dati, compresi gli argomenti passati da riga di comando.

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

Lo stack (`rsp`) memorizza inizialmente il conteggio degli argomenti da riga di comando (`argc`) e un vettore di puntatori (`argv`) puntati da `rsp`. L'accesso a elementi successivi richiede l'uso di offset, solitamente multipli di 8 byte per valori a 64 bit.

A differenza degli indirizzi caricati manualmente o passati in altri registri, ogni processo possiede già una regione di memoria pronta all’uso, lo stack (pila).

  • Area temporanea: lo stack è una regione di memoria utilizzata per memorizzare temporaneamente dati, variabili e informazioni sull’esecuzione del programma.

  • rsp (stack pointer): è il registro della CPU che indica la posizione della cima (top) dello stack.

Lo stato dello stack all’avvio del programma

All’avvio di un programma in ambiente Linux, la cima dello stack ([rsp]) viene inizializzata dal sistema operativo con il conteggio degli argomenti da riga di comando (argc).

Il conteggio degli argomenti include anche il nome o il percorso del programma stesso, che viene considerato il primo elemento del vettore argv.

Accesso agli argomenti da riga di comando

Oltre al conteggio degli argomenti (argc) in cima allo stack ([rsp]), lo stack contiene un vettore di puntatori a stringhe, il classico argv[] del linguaggio C, che indicano dove si trovano in memoria i testi degli argomenti.

Ad esempio, eseguendo:

./programma hello world

avremo:

  • [rsp]argc, cioè 3

  • [rsp + 8]argv[0], il puntatore al nome del programma

  • [rsp + 16]argv[1], il puntatore al primo argomento effettivo

  • [rsp + 24]argv[2], il puntatore al secondo argomento effettivo

  • [rsp + 32]NULL (0), che identifica la fine del vettore degli argomenti

In generale, quindi:

[rsp]                  → argc
[rsp + 8]              → argv[0]
[rsp + 16]             → argv[1]
...
[rsp + 8 × argc]       → argv[argc-1]
[rsp + 8 × (argc+1)]   → NULL

Lo stack contiene molteplici informazioni disposte in sequenza. Per accedere a valori posizionati più in profondità rispetto alla cima ([rsp]), si utilizza un offset, cioè una distanza espressa in byte.

  • [rsp] legge il valore a offset 0.

  • [rsp + 8] legge il valore posizionato 8 byte dopo la cima.

  • [rsp + N] legge il valore posizionato N byte dopo la cima.

In molti casi, lavorando con valori a 64 bit, gli offset che incontreremo nello stack saranno multipli di 8 (+8, +16, +24 e così via).

Questo non significa che la memoria sia organizzata in blocchi di 8 byte: la memoria è sempre indirizzata byte per byte. Il motivo per cui negli esempi vediamo incrementi di 8 byte è che indirizzi e altri valori a 64 bit occupano normalmente 8 byte.

Allineamento dello stack e ABI

Quando si passa dalla semplice lettura dello stack alla scrittura di funzioni che effettuano chiamate con call, la System V AMD64 ABI richiede che rsp sia allineato a 16 byte immediatamente prima dell'istruzione ****call.

È una delle cause più frequenti di comportamenti anomali per chi inizia a scrivere Assembly x86-64, quindi vale la pena tenerlo a mente fin da questi primi esercizi.

Rappresentazione visiva dello stack

L’istruzione pop e la gestione dinamica dello stack

Lo stack è strutturato per consentire l’inserimento e l’estrazione dinamica dei dati dalla cima. L’istruzione pop <registro> combina in una singola istruzione due operazioni:

  1. Lettura del dato: copia nel registro specificato il valore situato in cima allo stack, equivalente a un'istruzione mov <registro>, [rsp].

  2. Avanzamento del puntatore: incrementa lo stack pointer rsp di 8 byte, equivalente a add rsp, 8, spostando la cima dello stack all'elemento successivo.

Il dato estratto rimane fisicamente presente all'indirizzo di memoria precedente, ma per la logica dello stack viene considerato rimosso, in quanto rsp si è spostato oltre.

L'operazione complementare a pop è push, che inserisce un nuovo valore in cima allo stack decrementando rsp di 8 byte e scrivendo il valore nella nuova cima.

Istruzione Effetto su rdi Effetto su rsp
mov rdi, [rsp] Legge il valore in cima allo stack Nessuna modifica
pop rdi Legge il valore in cima allo stack Avanza di 8 byte
push rdi Inserisce il valore in cima allo stack Indietreggia di 8 byte

Ispezionare lo stack con GDB (GNU Debugger)

Per un lettore junior che si avvicina a questi concetti, verificarli empiricamente con un debugger è spesso più efficace della sola lettura teorica. GDB permette di osservare in tempo reale il contenuto dello stack e il valore di rsp subito dopo l’avvio del programma.

gdb -q ./nome_programma
break _start
run hello world
info registers rsp
x/5gx $rsp

Il comando x/5gx $rsp mostra cinque valori a 64 bit (g) in formato esadecimale (x) a partire dall'indirizzo puntato da rsp.

Eseguendo il programma con hello world, sarà quindi possibile osservare argc, i tre puntatori di argv e il NULL che ne indica la fine.

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

Commenti