Lo stack #
Come già visto, lo stack consiste di una sequenza di stack frame (record di attivazione), ognuno dei quali viene allocato sull’istruzione call e deallocato sull’istruzione return.
La struttura precisa dello stack dipende dall’architettura del sistema, il sistema operativo e dal compilatore che stiamo utilizzando.
Per rendere le cose più semplici in questa Wiki ci concentriamo sulle architetture x86/x86-64 e sul compilatore gcc su Linux.
Tipicamente lo stack cresce verso il basso, cioè la cima dello stack cresce verso indirizzi di memoria inferiori. Lo stack pointer (x86-64: RSP, x86: ESP) si riferisce all’ultimo elemento dello stack (la cima dello stack).
Lo stack frame (in x86, cioè a 32bit) #
Nell’architettura x86, ogni stack frame contiene:
- gli argomenti della funzione
- le variabili locali
- le copie dei registri che devono essere preservati (il return address e il frame pointer precedente)
Il frame pointer (base pointer) fornisce un punto di inizio delle variabili locali.
Calling convention cdecl
#
Una calling convention descrive come le funzioni ricevono i propri parametri dal chiamante e come ritornano i propri risultati.
La cdecl è una calling convention usata in molti compilatori x86.
In cdecl i parametri vengono passati sullo stack, mentre i valori
sono ritornati usando i registri.
I parametri (argomenti) della funzione vengono pushati sullo stack
in ordine da destra verso sinistra. Il chiamante pulisce lo stack dopo che
la funzione chiamata ha eseguito return.
Stack e chiamate a funzione: cosa succede in x86? #
Prendiamo ad esempio un piccolo programma in C per dimostrare tutto quello che abbiamo visto finora.
int foo(int a, int b, int c) {
return a + b + c;
}
int main() {
foo(1, 2, 3);
}
Compiliamo questo snippet di codice con gcc -o i386cc main.c -m32 -no-pie.
L’argomento -no-pie serve per semplificare un po’ la trattazione,
poi vedremo cos’è e come romperlo.
Analizziamo quello che succede prima di chiamare foo nel main.
Usando objdump --disassemble=main ./i386cc -M intel otteniamo questo
disassembly (ho pulito un po’ l’output).
; ... other code
0x0804918f <+13>: push 0x3
0x08049191 <+15>: push 0x2
0x08049193 <+17>: push 0x1
0x08049195 <+19>: call 0x8049166 <foo>
0x0804919a <+24>: add esp,0xc
0x0804919d <+27>: mov eax,0x0
0x080491a2 <+32>: leave
0x080491a3 <+33>: ret
Cosa sta succedendo? Le tre operazioni di push seguono la calling
convention cdecl, quindi lo stato dello stack prima della chiamata a
funzione di foo è la seguente.
ESP ---> |
0x1 |
<– indirizzo più basso |
|---|---|---|
0x2 |
||
0x3 |
<– indirizzo più alto |
Vediamo cosa succede quando chiamiamo foo, con l’istruzione call foo.
Sfruttiamo il nostro disassembler e analizziamolo con
objdump --disassemble=foo ./i386cc -M intel.
0x08049166 <+0>: push ebp
0x08049167 <+1>: mov ebp,esp
0x08049169 <+3>: call 0x80491a4 <__x86.get_pc_thunk.ax> ; non ci interessa
0x0804916e <+8>: add eax,0x2e92 ; non ci interessa
0x08049173 <+13>: mov edx,DWORD PTR [ebp+0x8]
0x08049176 <+16>: mov eax,DWORD PTR [ebp+0xc]
0x08049179 <+19>: add edx,eax
0x0804917b <+21>: mov eax,DWORD PTR [ebp+0x10]
0x0804917e <+24>: add eax,edx
0x08049180 <+26>: pop ebp
0x08049181 <+27>: ret
Implicitamente, quando viene eseguita l’istruzione call,
il return address della funzione viene salvato sullo stack.
Il return address è l’indirizzo alla quale l’esecuzione deve procedere
dopo aver eseguito tutta la funzione chiamata. A intuito,
una volta che foo è stata eseguita, l’esecuzione dovrà continuare
all’indirizzo 0x0804919a, l’istruzione successiva a call foo.
Quindi ci aspettiamo che, appena entrati dentro a foo, lo stack
assuma questa forma.
ESP ---> |
0x0804919a |
<– indirizzo più basso |
|---|---|---|
0x1 |
||
0x2 |
||
0x3 |
<– indirizzo più alto |
Proseguendo nella nostra analisi ci focalizziamo sulle prime due istruzioni
eseguite: cioè pop ebp e mov ebp, esp.
La prima ci permette di salvare l’attuale base pointer (frame pointer)
sullo stack. La seconda aggiorna la posizione del registro EBP a quella
dello stack pointer (ESP). La situazione adesso è la seguente:
ESP, EBP ---> |
0xffffd8a8 |
<– indirizzo più basso |
|---|---|---|
0x0804919a |
||
0x1 |
||
0x2 |
||
0x3 |
<– indirizzo più alto |
Con GDB la verifica è banale, segue sempre la stessa forma, quindi viene lasciata come esercizio per il lettore.
Per implementare la nostra somma dei tre argomenti della funzione,
vengono usate tre operazioni mov reg, [ebp + offset], il cui significato
è copiare il valore contenuto all’indirizzo ebp + offset e metterlo dentro
ad un registro della CPU, e operazioni add op1, op2.
Notiamo come il valore di ritorno venga passato usando il registro EAX!
Quando usciamo dalla funzione abbiamo due istruzioni importantissime:
pop rbp e ret.
La prima poppa (estrae il valore in cima allo stack) in RBP,
ripristinando il frame pointer precedente. Lo stack a questo punto
tornerà ad essere:
ESP ---> |
0x0804919a |
<– indirizzo più basso |
|---|---|---|
0x1 |
||
0x2 |
||
0x3 |
<– indirizzo più alto | |
RBP torna a puntare all'indirizzo 0x0804919a |
L’istruzione ret implicitamente poppa il primo valore dello stack
(la punta dello stack), cioè il return address, dentro RIP.
La CPU riprenderà la propria esecuzione dall’indirizzo appena poppato.
Le istruzioni seguenti del main fanno pulizia dello stack.
add esp, 0xc ci permette di deallocare i tre parametri 0x0,0x1 e
0x3 che avevamo passato prima a foo.
0x0804919a <+24>: add esp,0xc
0x0804919d <+27>: mov eax,0x0
0x080491a2 <+32>: leave
0x080491a3 <+33>: ret
Stack frame in x86-64 #
Nella versione a 64bit di x86, denominata x86_64 oppure amd64, i registri della CPU sono lunghi 64bit e ce ne sono 8 in più rispetto a x86.
Calling convention in x86-64 #
Gli argomenti non vengono più passati alle funzioni tutti attraverso lo stack ma i primi 6 vengono passati attraverso dei registri, i rimanenti vengono passati sullo stack (come x86). I registri ESP ed EBP sono chiamati RSP e RBP.
I registri su Linux che vengono usati per passare gli argomenti sono,
in ordine: RDI, RSI, RDX, RCX, R8, R9.