ASM: catena di compilazione Lorenzo Dematté October 2, 2011 1 La nostra Macchina Virtuale Una macchina virtuale o modello di programmazione è l ambiente nel quale i programmi girano. Di solito si parla di macchina virtuale solo nel caso di linguaggi interpretati o a bytecode (come a JVM, la macchina virtuale di Java) ma in realtà tutte il software viene programmato facendo delle assunzioni che vanno oltre quelle della macchina fisica sulla quale girano. E il caso del C/C++ per esempio, dal quale ci aspettiamo alcune funzioni standard; o ancora quando programmiamo utility per sistema operativo Linux, dove spesso si segue lo standard POSIX. In questi 2 casi stiamo programmando, per esempio, la VM ANSI C, o ISO C++, o ancora la VM POSIX. Nel nostro caso, programmando in assembly saremo molto vicini alla macchina fisica, ma anche noi faremo delle assunzioni: il set di istruzioni e opcode Intel a 32 bit (chiamto i386, x86, o ancora ia32) 1 ; un modello di memoria a 32 bit flat (come quelli che si trovano nelle varianti a 32 bit di Windows, Linux e OSX). La nostra VM quindi sarà ia32 con modello di memoria FLAT 2. 2 La catena di compilazione La compilazione é il processo di tradurre un programma scritto in un certo linguaggio (il codice sorgente) in un programma equivalente in un altro linguaggio (codice oggetto). Spesso il codice oggetto é codice macchina eseguibile. Quando compiliamo un programma (come esempio, consideriamo un tipico compilatore C), in realtá stiamo facendo una serie di passaggi di cui la compilazione vera e propria é solo uno: preprocessing compilazione assemblaggio linking 1 Stiamo parlando qui di architettura, non di processori: i vari modelli di processori Intel e AMD implementano tutti il set di istruzioni i386, e quelli piu recenti il set esteso x86. Ogni nuovo modello porta con se qualche nuova estensione, ma di poco conto. 2 L architettura ia32 supporta diversi tipi di gestione della memoria virtuale: segmentazione, paginazione, segmentazione paginata. Il modello FLAT prevede un unico segmento, della dimensione massima indirizzabile (32 bit nel nostro caso). 1
Nel preprocessing un singolo file C viene preprocessato, cioé le dichiarazione comincianti con # (#define, #include ecc.) vengono esaminate e sostituite con altro codice. Nella compilazione il codice C viene elaborato e tradotto in codice assembly. Nell assemblaggio, il codice ASM generato viene assemblato, producendo un file oggetto (.o oppure.obj), che contiene codice macchina e tabelle di linking. Durante il linking, un programma detto linker prende l insieme di files oggetto generati, ogni libreria aggiuntiva specificata implicitamente o esplicitamente 3 e li unisce in un unico programma eseguibile, risolvendo le referenze tra i vari files oggetto (tipicamente, chiamate a funzioni). 3 I files oggetto I files oggetto contengono solitamente una tabella di linking, codice macchina e (solo nelle build di debug) simboli di debug. I simboli di debug sono informazioni aggiuntive sui nomi e la locazione nei files sorgenti di funzioni, variabili ecc. Sono quindi una sorta di mappa che permette al debugger di seguire passo passo il codice macchina eseguito e allo stesso tempo mostrare al programmatore variabili e codici in un modo piú leggibile. Ci sono programmi che permettono di visualizzare il contenuto dei file oggetto, come disassamblatori ecc. Per esempio, il tool objdump in ambiente UNIX-like e il tool pedump in Windows. Le tabelle di linking sono usate dal linker per reperire informazioni su dove si trovino e dove ci sia il bisogno di certe funzione. Infatti, ogni file oggetto può usare funzioni definite in un altro file o libreria ed esporre delle funzioni che saranno chiamate da altre librerie o da altri file oggetto. Per esempio: #include <stdio.h> extern int add(int a, int b) { int res; res = a + b; return res; } int main() { int i = 2; int j = 3; } printf("hello %d + %d = %d", i, j, add(i, j)); return i + j; Questo file definisce due funzioni, main and add, e ne usa altre, tra cui printf. printf è dichiarata nel file stdio.h, ma é definita altrove (nella libreria standard 3 La libreria standard del C è un esempio di libraria che viene sempre implicitamente aggiunta; altre librerie sono aggiunte a seconda del sistema operativo (p.e. kernel32.dll sotto Windows) 2
Sections: Idx Name Size VMA LMA File off Algn 0.text 00000090 00000000 00000000 00000104 2**4 CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE 1.data 00000000 00000000 00000000 00000000 2**4 ALLOC, LOAD, DATA 2.bss 00000000 00000000 00000000 00000000 2**4 ALLOC 3.stab 00000a80 00000000 00000000 00000194 2**2 CONTENTS, RELOC, READONLY, DEBUGGING 4.stabstr 00004d45 00000000 00000000 00000c14 2**0 CONTENTS, READONLY, DEBUGGING 5.rdata 00000020 00000000 00000000 00005959 2**4 CONTENTS, ALLOC, LOAD, READONLY, DATA SYMBOL TABLE: [ 0](sec -2)(fl 0x00)(ty 0)(scl 103) (nx 1) 0x00000000 hello.cpp File [ 2](sec 1)(fl 0x00)(ty 20)(scl 2) (nx 1) 0x00000000 Z3addii AUX tagndx 0 ttlsiz 0x0 lnnos 0 next 0 [ 4](sec 1)(fl 0x00)(ty 20)(scl 2) (nx 0) 0x00000014 _main [ 5](sec 1)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x00000000.text AUX scnlen 0x84 nreloc 4 nlnno 0 [ 7](sec 2)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x00000000.data AUX scnlen 0x0 nreloc 0 nlnno 0 [ 9](sec 3)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x00000000.bss AUX scnlen 0x0 nreloc 0 nlnno 0 [ 11](sec 4)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x00000000.stab AUX scnlen 0xa80 nreloc 5 nlnno 0 [ 13](sec 5)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x00000000.stabstr AUX scnlen 0x4d45 nreloc 0 nlnno 0 [ 15](sec 6)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x00000000.rdata AUX scnlen 0x13 nreloc 0 nlnno 0 [ 17](sec 0)(fl 0x00)(ty 20)(scl 2) (nx 0) 0x00000000 main [ 18](sec 0)(fl 0x00)(ty 0)(scl 2) (nx 0) 0x00000000 alloca [ 19](sec 0)(fl 0x00)(ty 20)(scl 2) (nx 0) 0x00000000 _printf Figure 1: Parte dell output del porgramma objdump. Notate le tabelle di linking. del C). Queste funzioni e informazioni a loro relative sono inserite nelle tabelle di linking (Fig. 1). 4 GCC: GNU Compiler Collection I tools visti a lezione comprendono i compilatori, assemblatore e linker della GNU. Il vantaggio di questi tools è che sono di qualità abbastanza buona e che sono disponibili su praticamente tutte le piattaforme. Gli svantaggi è che non sono di qualità ottima (per quanto riguarda aderenza a standard e qualità del codice prodotto) e che usano una sintassi diversa da quelle ufficiali Intel e AMD per il codice assembly. 3
4.1 Windows Per windows, andremo a usare il porting dei tools GNU dato da cygwin. Quello che é necessario sono le librerie di runtime del C, versione sviluppo, il pacchetto GCC, il pacchetto binutils, e il pacchetto GDB. 4.2 Linux Tutte le distro Linux includono GCC o pacchetti per installarlo. A noi servono le librerie di runtime del C, versione sviluppo, il pacchetto GCC, il pacchetto binutils, e il pacchetto GDB. 4.3 Mac Mac OSX fornisce tutti i tools GNU con il suo pacchetto di sviluppo XCode. Installatelo, oppure se avete una vecchia vesione di OSX considerate MacPorts o Fink (cercate su google). 4.4 Utilizzo Utilizzare i tools é semplice: basta aprire un terminale e lanciare il comando corrispondente: gcc per il compilatore, as per l assembler, ld per il linker. Alcuni switch importanti sono: -g (-gstabs per as) genera i simboli di debugging. NB: va usata su tutti i tools della catena! -o mette l output nel file seguente. -v per il GCC: fa vedere la riga di comando completa dei programmi invocati -E per il GCC: solo preprocessing -S per il GCC: solo preprocessing e generazione codice --save-temps per il GCC: salva i file temporanei (.ii per il file C preprocessato,.s per il codice ASM generato,.o per il file oggetto) Tutti hanno opzioni --help o man pages che spiegano in dettaglio le opzioni. 4.5 Esempio Come esempio, consideriamo una semplice funzione della libreria standard del C: strcpy. La string copy (strcpy) copia una stringa data in input in un altra, usando un buffer pre-allocato dato come input alla funzione. In pratica, la funzione copia carattere per carattere finchè non arriva al terminatore NULL 4 Di seguito, il codice assembler per la funzione: 4 Ricordiamo che una string non é altro che un array di bytes, dove ogni singolo byte é un carattere in codifica ASCII, terminata dal carattere speciale \0 (NULL) che ha il valore di zero. 4
.data src:.string "Hello World" dst:.skip 20.text.globl _start _start: movl $src, %eax movl $dst, %ebx whil: movsbl (%eax), %ecx testl %ecx, %ecx jz exit movb movb addl addl jmp (%eax), %dl %dl, (%ebx) $1, %eax $1, %ebx whil exit: movb $0, (%ebx) Per assemblare questo codice, possiamo invocare il comando gcc (che si occuperá di chiamare l assembler ed il linker nel modo corretto), oppure fare a mano i due passi distinti: as -gstabs -o strcyp.s strcpy.o ld -g -o strcpy strcpy.o A seconda del vostro ambiente di esecuzione (shell + sistema operativo), potrebbe essere necessario rinominare l etichetta globale start in main o main: la bash sotto Linux per esempio richiede che il programma lanciato sia sempre linkato alla libreria standard del C (per settare standard input/output/error, l ambiente, i parametri alla riga di comando, ecc.). In questo caso, il vostro programma comincia dentro la libreria del C, in una funzione che poi va a chiamare la main. E quindi necessario che un simbolo main sia definito nella tabella di linking del nostro file oggetto. Seguite l errore dato da vostro linker per il nome decorato (i.e. con il corretto numero di davanti) da usare nel vostro caso. Se provate a eseguire il programma direttamente, fallirà (segmentation fault sotto Linux, GPE sotto Windows), perché non abbiamo fatto nulla per rendere il nostro programma simpatico al sistema operativo. Un moderno sistema operativo chiede a un programma che viene lanciato di fare una serie di operazioni che noi per semplicità non vediamo 5. Possiamo comunque eseguire il 5 In particolare, il programma alla fine deve chiamare una funzione (es: exit sotto Linux) per informare il SO che la sua esecuzione è finita. Altrimenti, il processore continuerà a 5
programma passo passo in un debugger, ed esaminare i vari registri e variabili per vedere come le varie istuzioni sono eseguite. 5 Assembly inline Un alternativa é quella di inserire una porzione di codice assembler dentro un programma C, in modo da far fare tutte le operazioni di contorno allo scheletro C e di concentrarci sull algoritmo. Questo modo di scrivere codice ASM si chiama assembler inline. Il GCC ha una sintassi molto complicata per l assembler inline; andiamo a vedere come fare nel caso della strcpy: #include <stdio.h> int main() { char* src = "Hello world"; char dst[20]; asm ( "start: movsbl (%0), %%ecx\n\t" "testl %%ecx, %%ecx\n\t" "jz exit\n\t" "movb "movb "addl "addl "jmp (%0), %%dl\n\t" %%dl, (%1)\n\t" $1, %0\n\t" $1, %1\n\t" whil\n\t" "exit: movb $0, (%1)\n\t" : : "r" (src), "r" (dst) : "%ecx", "%edx"); printf("src: %s, dst: %s\n", src, dst); return 0; } Notiamo subito 2 cose: che l assemble é scritto riga per riga come stringa, inclusi i caratteri di fine riga ( \n ); sull assembly che scriviamo vengono fatte alcune sostituzione, e poi viene passato pari pari all assembler. Seconda cosa, i registri vengono prefissti da 2 % (%%ecx, per esempio) e ci sono dei numeri prefissati da %. I primi hanno 2 segni per distinguerli dai secondi, che sono pseudo-registri che verranno sostituiti con registri (o riferimenti a memoria) reali prima di passare il codice all assemblatore. eseguire il programma, caricando come prossima istruzione la parola di memoria presente dopo l ultima istruzione. Chiaramente, essendo dati garbage, la probabilità di ottenere un Segmentation fault o un Invalid Instruction è prossima a 1 6
Cosa viene associato a questi pseudo-registri é definito nelle righe sottostanti che iniziano con : elementi di output: pseudo-registri associati a una variabile. Il valore della variabile alla fine del blocco ASM sará impostato uguale a quello del pseudo-registro. elementi di input: pseudo-registri associati a una variabile. Il valore del registro all inizio del blocco ASM sará impostato uguale a quello della variablile. registri sporcati: lista di registri che abbiamo usato nel nostro blocco. I pseudo registri possono essere di tipo: =r alla variabile verrá dato un registro vero (tra quelli disponibili); la variabile verrá letta e scritta r alla variabile verrá dato un registro vero (tra quelli disponibili); la variabile verrá solo letta m, =m la variabile potrá essere usata dentro al codice come pseudoregistro, ma verrano generati dei riferimenti a memoria. Ci sono molti altri modificatori, e l uso puó diventare complicato. Chi volesse saperne di piú, si riferisca al manuale del GCC. 6 GDB: debugger Vedi appunti lezione e reference card sul sito esse3. 7 La convenzione di chiamata del C (cdecl) Per convenzione di chiamata si intende il contratto che si crea tra codice che chiama la funzione (il chiamante) e la funzione (chiamata). Ovviamente, le due parti si devono mettere d accordo: dove sono memorizzati i valori dei parametri? Dove mettere il valore di ritorno? Quali registri posso sporcare, e quali invece devono rimanere come sono (e, da parte del chiamante, quali registri non posso considerare invariati dopo una chiamata a funzione?) Ci sono molte convenzioni di chiamata; le piu diffuse usano lo stack come luogo principale per lo scambio dei dati. In particolare, per le architetture Intel, c e un registro particolare (EBP) che viene usato per facilitare la creazione e l uso di uno stack frame, una zona dello stack, specifica per ogni funzione, usata per lo scambio di informazioni tra chiamante e chiamato. In particolare, noi ci soffermiamo sulla convenzione di chiamata del C (cdecl): i parametri vengono messi sullo stack dal chiamante, da dx a sx; la f chiamata salva il base pointer del frame precedente, e lo ripristina all uscita; la f chiamata riserva spazio sullo stack per le sue variabili locali; 7
la f puo sporcare solo EAX, ECX, EDX; il valore di ritorno va messo/si trova in EAX la funzione chiamante si occupa di togliere i parametri dallo stack (es: perché?) Figure 2: Lo stack frame nella convenzione di chiamata del C La chiamata si divide in 6 fasi, alternate tra chiamante e fun chiamata: chiamante: passaggio dei parametri, trasferimento controllo, salvataggio punto di ritorno (PUSH, CALL); chiamata: prologo (salva EBP, spazio per i locali con SUB); chiamata: esecuzione; chiamata: epilogo (ripristino ESP, EBP, RET); chiamante: pulizia stack dai parametri (ADD); chiamante: salvataggio valore di ritorno. Questa sequenza di operazioni porta alla realizzazione e distruzione dello stack frame, in Fig. 2, come visto a lezione. 8 Altre convenzioni di chiamata su Intel 8
Figure 3: Lo stack frame nella convenzione di chiamata del C++, per le funzioni membro Figure 4: Lo stack frame nella convenzione di chiamata del C++, per le funzioni virtuali 9