Prima di cominciare a programmare direttamente in asm vi devo spiegare
cos'e' lo stack e prima dello stack cos'e' la memoria :).
LA MEMORIA
Quando lanciamo un programma, esso viene allocato nella memoria del nostro pc.
Un indirizzo di memoria, nei sistemi operativi che tratteremo (Windows e
Linux), e' composto di due parti:
SEGMENT:OFFSET
Il SEGMENT e' un numero (esadecimale) di 4 cifre (che va da 0 a FFFF) mentre
l'OFFSET e' un numero di 8 cifre (che va da 0 a 4 Giga circa (2^32)).
Un esempio di indirizzo di memoria e':
0028:080237d9
Il segmento e' ad uso e consumo del sistema operativo, mentre quando
debuggiamo un programma la parte che ci interessa e' l'OFFSET. Ad esempio
guardiamo questa parte di programma:
0x80483e7 : jmp 0x80483f0
0x80483e9 : jmp 0x80483f7
0x80483eb : nop
0x80483ec : lea 0x0(%esi,1),%esi
0x80483f0 : call 0x80482ec
0x80483f5 : jmp 0x80483e7
Le cifre a sinistra indicano gli OFFSET in cui sono stati allocati gli OPCODE
che compongono il nostro programma.
Ci tornano utili quando dobbiamo vedere dove arriva un j* o per fare altri
giochetti moolto divertenti...
LO STACK
Per quanto riguarda lo stack, esso e' una parte particolare della memoria,
che contiene dei valori in generale temporanei. Lo stack viene gestito come
una struttura LIFO (Last In First Out), detta in gergo pila. Le istruzioni
per la gestione dello stack sono:
PUSH (val/reg) : inserisce un valore nello stack
POP (reg) : prende un valore dallo stack
Un esempio di utilizzo dello stack puo' essere questo:
push 8
pop eax
Con questo codice eax contiene il valore 8. Un altro esempio puo' essere
questo:
push eax
push ebx
mov eax,5
mov ebx,9
add eax,ebx
mov ecx,eax
pop ebx
pop eax
Questo breve snippet non fa altro che sommare i valori 5 e 9 e mettere il
risultato in ecx. I valori di eax ed ebx sono stati salvati nello stack
(le due istruzioni PUSH) prima di sostituirli con 5 e 9
rispettivamente. Alla fine della somma eax ed ebx tornano al loro stato
primordiale (i due POP). E' facile qui notare la struttura LIFO dello
stack.
STACK INTERNALS
Quando prima scrivevo che lo stack e' un'area di memoria `particolare' mi
riferivo al fatto che il sistema operativo riserva allo stack un segmento
puntato dal registro SS (Stack Segment). Al contrario di quanto intuitivamente
si possa pensare, ogni volta che inseriamo un valore nello stack ( e
quindi quando PUSHiamo un valore) esso viene decrementato, e quindi, quando
ne POPpiamo uno, esso viene incrementato.
Gli altri due registri coivolti nelle operazioni dello stack sono ESP e EBP.
ESP : e' lo Stack Pointer, che punta al TOS (Top Of Stack).
EBP : e' il Base Pointer, che punta alla base dello stack-frame
per la porzione di codice corrente.
Ma al di la' delle definizioni vediamo come funzionano un po' piu' da vicino.
Il registro ESP varia per ogni PUSH e POP che eseguiamo in questo modo:
(a) (b)
. . | . .
| | | | |
+---------------+ | +---------------+
| 15 | | | 15 |
+---------------+ | +---------------+
| 1 | | PUSH 10 | 1 |
+---------------+ | +---------------+
| 800 | <-- ESP | | 800 |
+---------------+ | +---------------+
| | | | 10 | <- ESP
+---------------+ | +---------------+
| | | | |
+---------------+ | +---------------+
| | | | |
. . | . .
Possiamo leggere la figura sopra in questo modo: nella parte (a) abbiamo
una raffigurazione dello stack con dei valori contenuti nelle diverse celle.
ESP punta all'ultima cella allocata. Viene quindi eseguita l'istruzione
PUSH 10, ESP `avanza' nello stack, decrementando il suo valore.
Di quanto viene incrementato o decrementato ESP? Trattandosi di un registro
a 32 bit, il registro varia di 4 byte la volta. Anche se nel codice asm
scriviamo:
push byte 1
il registro esp sara' decrementato di 4 byte.
Come utilizzare questi due registri in maniera efficace?
Se provate a disassemblare un qualsiasi programma noterete il ripetersi
continuo di questo pattern (chiamato anche prologo):
push ebp
mov ebp,esp
Se volessi dare una definizione 'scientifica' a questo breve snippet direi che
esso serve a selezionare un nuovo frame nello stack per il codice che lo segue.
Questa definizione e' tanto rigorosa quanto incomprensibile :). Analizziamo
velocemente il codice:
push ebp
con questa push salviamo il contenuto dello stack base pointer nello stack
stesso (poi vedremo meglio perche'), l'istruzione successiva:
mov ebp,esp
sovrascrive l'ebp con l'esp.
Cerchiamo di capire la necessita' di tutto cio'. Una call ad una procedura
ha in generale questa sintassi:
push param2
push param1
call procedura
add esp,8
L'opcode CALL e' equivalente al codice:
push EIP
jmp procedura
quindi quando eseguiamo una call salviamo sullo stack anche l'EIP corrente.
Subito dopo l'esecuzione della CALL lo stato dello stack e' il seguente:
+--------+
| param2 |
+--------+
| param1 |
+--------+
| eip | <-- ESP
+--------+
dopo l'esecuzione del prologo lo stack diventa cosi':
+--------+
| param2 |
+--------+
| param1 |
+--------+
| eip |
+--------+
| ebp | <-- ESP = EBP
+--------+
In questo modo i parametri passati si trovano ad ebp-12 (param2) ed ebp-8
(param1), e posso cosi' facilmente referenziarli facilmente, facendo con lo
stack quanto mi pare visto che l'unico registro a modificarsi e' l'ESP.
Quando chiudiamo una procedura dobbiamo riportare l'ESP a puntare la cella
contenente l'EIP poiche' il ret e' interpretabile come:
pop eax
jmp eax
(in realta' l'aver usato eax non e' corretto, ma e' giusto codice per dare
un'idea). Quindi se eseguiamo un ret quando il TOS (Top Of the Stack) non e'
l'EIP con buona probabilita' il nostro programma andra' in segmentation
fault... . La controparte del prologo e' questa:
mov esp,ebp
pop ebp
che dovremo scrivere prima del ret.
In realta' non dovete troppo preoccuparvi di capire a fondo questo concetto,
visto che quando analizzerete il codice di programmi compilati, potete esser
ben sicuri che il compilatore ha fatto i calcoli giusti ;).
Programmare in ASM
L'assemblatore che useremo in questo corso e' il NASM. Sicuramente il NASM non
e' l'unico strumento presente in linux in questo ambito, ma l'ho preferito per
una serie di
motivi, quali la sintassi INTEL per l'assembler, il suo carattere OPEN-SOURCE,
il suo essere slegato da ogni tipo di compilatore di alto livello (come gas),
e per la sua completezza nella documentazione.
La dichiarazione delle variabili
In un programma in asm le risorse (ad esempio le stringhe) sono relegate nella
sezione .data in questa maniera:
section .data
Bisogna far attenzione al fatto che in questa sezione ci sono i dati
inizializzati, visto che i dati non inizializzati (le variabili) vanno nella
sezione .bss .
E il codice? Il codice sara' racchiuso nella sezione .text .
Vediamo subito un esempio di programma per vedere come utilizzare quanto
appreso e altro :).
global main
extern printf
section .data
msg db "Helloooooo, nurse!",0Dh,0Ah,0
section .text
main:
push dword msg
call printf
pop eax
ret
Analizziamo subito il programma. La keyword global serve a specificare
i simboli globali esportabili (ad esempio le funzioni nelle librerie). Per
quanto riguarda il main, serve al programmatore per capire dove effettivamente
comincia il proprio programma. Con la keyword extern invece
diciamo al sistema operativo di quali funzioni abbiamo bisogno per
l'esecuzione del codice (senza quindi preoccuparci di importare le librerie
come succede in C). In section .data possiamo notare la presenza di
una variabile, approfondiamo la sintassi di dichiarazione:
msg db "Helloooooo, nurse!",0Dh,0Ah,0
dove msg indica il nome della variabile, db indica la dimensione della
variabile (secondo la tabella che segue), ed infine abbiamo il contenuto
della variabile che in questo caso e' una stringa.
Per la dichiarazione delle 'risorse' con il NASM ci si puo' attenere a questa
tabella:
db 0x55 ; just the byte 0x55
db 0x55,0x56,0x57 ; three bytes in succession
db 'a',0x55 ; character constants are OK
db 'hello',13,10,'$' ; so are string constants
dw 0x1234 ; 0x34 0x12
dw 'a' ; 0x41 0x00 (it's just a number)
dw 'ab' ; 0x41 0x42 (character constant)
dw 'abc' ; 0x41 0x42 0x43 0x00 (string)
dd 0x12345678 ; 0x78 0x56 0x34 0x12
dd 1.234567e20 ; floating-point constant
dq 1.234567e20 ; double-precision float
dt 1.234567e20 ; extended-precision float
Per i dati non inizializzati invece le keyword da utilizzare sono:
buffer: resb 64 ; reserve 64 bytes
wordvar: resw 1 ; reserve a word
realarray: resq 10 ; array of ten reals
In questo modo possiamo allocare spazio da utilizzare piu' avanti nel
programma.
In ultimo sulle variabili si puo' utilizzare questa struttura:
message db 'hello, world'
msglen equ $-message
dove si puo' notare l'utilizzo dell'operatore $ e di equ (msglen contiene
la lunghezza di message), equ serve a specificare le costanti.
Approfondiamo il $
Il $ restituisce la posizione corrente (offset) nel programma. Ad esempio:
JMP $
causera' un loop infinito.
Con $$ invece viene indicato l'inizio della sezione, in modo che ad esempio
per sapere a che distanza siamo dall'inizio di .text possiamo usare $-$$
(cioe' EIP corrente - Inizio della sezione).
Piu' che un corso di programmazione in asm definirei quanto scritto finora
un crash-course nella programmazione. Chiunque voglia approfondire la
programmazione puo' farlo leggendo la documentazione del NASM (a cui mi
sono ispirato) o l'introduzione all'assembly language (soprattutto in
ambito linux) su linuxassembly.org.
Uno script che ho trovato sull'Assembly Programming Journal puo' essere
molto utile per la compilazione di file scritti in asm:
#!/bin/sh
# assemble.sh =========================================================
outfile=${1%%.*}
tempfile=asmtemp.o
nasm -o $tempfile -f elf $1
gcc $tempfile -o $outfile
rm $tempfile -f
#EOF ==================================================================
Calling Convetions
Il passaggio dei parametri alle funzioni nel corpo di un programma compilato
puo' essere di vario tipo (visto che in assembler non ci sono regole ben
predefinite), pero' nell'ambito della programmazione si e' pensato che fosse
piu' facile per i programmatori avere una regola generale per il passaggio
dei parametri (per rendere il codice portabile e soprattutto per condividere
le librerie). Queste convenzioni vanno sotto il nome di Calling Conventions e
se ne distinguono di quattro tipi:
- C calling convention
- Pascal calling convention
- StdCall style calling convention
- Register calling convention
La 'C calling convention' e' utilizzata nella quasi maggior parte dei casi,
e funziona in questo modo:
;procedure(param1, param2)
push dword param2
push dword param1
call procedure
add esp, 8
Quindi i parametri sono passati in ordine inverso rispetto alla dichiarazione
della procedura. Da notare l'add finale che ha scopo di riallineare lo stack.
Nella 'Pascal calling convention' la differenza rispetto alla precedente
si nota nel passaggio dei parametri:
;procedure(param1, param2)
push dword param1
push dword param2
call procedure
Questo tipo di convenzione e' utilizzato nei programmi scritti in BASIC,
Fortran e nelle API di windows a 16 bit. In questo caso lo stack viene
riallineato da parte della procedura tipicamente con un'istruzione di tipo
ret n dove n e' solitamente un multiplo di 4.
La 'StdCall' invece risulta una mixture delle due calling conventions
precedenti: i parametri sono passati in ordine inverso a quello della
dichiarazione (come quella in C), ma la procedura si occupa di riallineare
lo stack (come quella in Pascal). Questo tipo di sintassi e' utilizzata
nelle WinAPI a 32 bit. Un esempio puo' essere il seguente:
;procedure(param1, param2)
push dword param2
push dword param1
call procedure
Ultima delle quattro convenzioni e' la 'Register calling convention'. Il
codice seguente e' autoesemplificativo per il suo utilizzo:
mov eax, param1
mov edx, param2
mov ebx, param3
call procedure
in cui l'ordine dei registri da usare e' obbligatorio e bisogna tenere in
mente che i parametri dopo il terzo sono passati via stack. Lo stack e'
nella maggior parte dei casi gestito come nella calling convention in Pascal,
ma questa non e' una regola fissa.
Le system call in linux
Se provaste a visitare l'albero dei sorgenti di linux e per caso vi imbatteste
nella directory /include/asm/, vi consiglio di dare uno sguardo al file
unistd.h . In questo file e' contenuta una lista di funzioni affiancate da un
valore numerico: si tratta delle funzioni di sistema piu' `intime' di linux
e vediamo ora come invocarle. La regola per poter invocare queste funzioni e':
mov eax,num < - num e' il numero cha affianca la syscall in unistd.h
int 80h < - questa istruzione invoca l'interrupt 0x80
Cos'e' un interrupt? Un interrupt e' un `dispositivo' attraverso cui e'
possibile segnalare al processore lo svolgimento di una particolare attivita'.
Nella storia del DOS gli interrupt erano utilizzatissimi ad esempio dai virus
per potersi replicare nei file eseguiti o per fare scherzetti di altro genere.
Nei sistemi operativi a 32bit non sarebbe in teoria possibile invocare
direttamente gli interrupt, ma l'INT 80h fa eccezione. Le funzioni che
potreste invocare attraverso l'INT 80h sono molteplici, e spaziano dal kill
al settaggio della schedulazione dei processi, dalla mappatura della memoria
all'esecuzione di una fork. Ad esempio per predere il pid del processo in
esecuzione si potrebbe con poche istruzioni:
mov eax, dword 20 ; getpid() syscall
int 80h ; syscall INT
ed eax conterra' il valore del pid (restituito dalla call).
Una syscall moooooo[ox1000]lto particolare e' quella rispondente al numero 54:
ioctl. Ioctl e' uno strumento che ci permette di fare programmi che ad
esempio possono eludere la possibilita' ad un utente di switchare tra le
console e molto altro. Ma cio' non e' argomento di questo corso e rimando
alla lettura dei sorgenti del kernel.