Lo stack

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.