Laboratorio di Architettura degli Elaboratori Graziano Pravadelli Dipartimento di Informatica – Università di Verona Introduzione all’assembly In questa lezione vengono introdotti i concetti fondamentali della programmazione assembler (sintassi AT&T) sotto Linux. Perché l’assembly? Confrontando le potenzialità del linguaggio assembly con quelle di un linguaggio di programmazione ad alto livello come il C, si possono individuare i seguenti vantaggi: è possibile accedere ai registri della CPU; è possibile scrivere codice ottimizzato per la architettura target; è possibile ottimizzare accuratamente le sezioni “critiche” dei programmi. Viceversa, i principali svantaggi derivanti dall’utilizzo dell’assembly sono i seguenti: possono essere richieste molte righe di codice assembly per riprodurre il comportamento di poche righe di C; è facile introdurre dei bug nei programmi; i bug sono difficili da trovare; non è garantita la compatibilità del codice per versioni successive dello hardware; Ma allora… a cosa serve conoscere l’assembly ? Da un punto di vista didattico, l’assembly rappresenta il linguaggio di programmazione ideale durante lo studio dell’architettura dei calcolatori perché permette di “toccare con mano” il funzionamento della CPU. Prescindendo dall’aspetto didattico, l’utilizzo del linguaggio assembly, rispetto all’uso dei tradizionali linguaggi ad alto livello, è talvolta giustificato dalla maggiore efficienza del codice prodotto. I programmi assembly, benché più complessi, sono tipicamente più veloci e più piccoli (rispetto al consumo di memoria, non al numero di righe di codice!) dei programmi scritti in linguaggi ad alto livello. Inoltre, la programmazione assembly è indispensabile per la scrittura di driver per hardware specifici. Non va infine dimenticato che conoscere l’assembly consente di scrivere codice ad alto livello di qualità migliore. Assemblare, verificare ed eseguire un programma assembly Il processo di creazione di un programma Assembly passa attraverso le seguenti fasi: Scrittura di uno o più file ASCII (estensione .s) contenenti il programma sorgente, tramite un normale editor di testo. Assemblaggio dei file sorgenti, e generazione dei file oggetto (estensione .o), tramite un assemblatore. Creazione, del file eseguibile, tramite un linker. Verifica del funzionamento e correzione degli eventuali errori, tramite un debugger. L’assemblatore L’Assemblatore trasforma i file contenenti il programma sorgente in altrettanti file oggetto contenenti il codice in linguaggio macchina. Durante il corso verrà utilizzato l’assemblatore gas della GNU. Per assemblare un file è necessario eseguire il seguente comando: as –o miofile.o miofile.s Si consulti la documentazione (man as) per l’elenco delle opzioni disponibili. Il linker Il linker combina i moduli oggetto e produce un unico file eseguibile. In particolare: unisce i moduli oggetto, risolvendo i riferimenti a simboli esterni; ricerca i file di libreria contenenti le procedure esterne utilizzate dai vari moduli e produce un modulo rilocabile ed eseguibile. Notare che l’operazione di linking deve essere effettuata anche se il programma è composto da un solo modulo oggetto. Durante il corso verrà utilizzato il linker ld della GNU. Per creare l’eseguibile a partire da un file oggetto è necessario eseguire il seguente comando: ld –o miofile miofile.o Si consulti la documentazione (man ld) per l’elenco delle opzioni disponibili. Il debugger Il debugger è uno strumento software che permette di verificare l’esecuzione di altri programmi. Il suo utilizzo risulta indispensabile per trovare errori (bug, da qui il nome debugger) in programmi di complessità elevata. Le principali caratteristiche di un debugger sono: possibilità di eseguire il programma “passo a passo”; possibilità di arrestare in modo condizionato l’esecuzione del programma tramite l’inserimento di breakpoint; possibilità di visualizzare ed eventualmente modificare il contenuto dei registri e della memoria. Il debugger più diffuso in ambiente Linux e il gdb della GNU. Il gdb funziona in modalità testo, pertanto i comandi vengono impartiti mediante il prompt. Tuttavia, per semplificare il suo utilizzo sono stati sviluppati numerosi front-end grafici, il più diffuso dei quali risulta essere il ddd. Per poter utilizzare un debugger, i programmi devono essere assemblati e linkati opportunamente tramite le seguenti righe di comando: as –gstabs -o miofile.o miofile.s ld -o miofile miofile.o L’opzione –gstab permette di inserire nel file oggetto, e quindi nell’eseguibile, le informazioni necessarie al debugger. Per avviare il gdb lanciare il comando gdb. Per avviare il ddd lanciare il comando ddd. Comandi più frequenti del gdb: file nome_eseguibile break numero_riga run step next continue finish info registers p/formato $registro x/nw indirizzo help Carica il programma per il debugging. Imposta un breakpoint alla riga specificata. Esegue il programma. L’esecuzioni del programma si sospende quando viene raggiunta il primo breakpoint. Nel caso non vi siano breakpoint l’esecuzione avviene normalmente. Esegue l’istruzione corrente quando l’esecuzione del programma è sospesa a seguito del raggiungimento di un breakpoint. Reiterare il comando step per continuare ad eseguire un’istruzione alla volta. Similmente al comando step esegue l’istruzione corrente, ma nel caso si tratti di una chiamata a funzione, essa viene eseguita atomicamente senza visualizzare le istruzioni che la compongono. Prosegue l’esecuzione del programma fino al prossimo breakpoint. Prosegue l’esecuzione del programma fino alla fine. Visualizza il contenuto dei registri. Stampa il contenuto del registro “registro” nel formato indicato dalla opzione “formato”. Le possibili opzioni sono: x per esadecimale, o per ottale, d per decimale, t per binario. Ad esempio per stampare il contenuto del registro eax in binario bisogna lanciare il comando p/t $eax. Visualizza il contenuto di n parole della memoria a partire della locazione di cui viene fornito l’indirizzo. Se ad esempio una zona di memoria è etichettata con l’etichetta “locazione”, il comando: x/4w &locazione visualizza il contenuto di 4 parole della memoria a partire dall’indirizzo associato all’etichetta. Visualizza le istruzioni per l’utilizzo della guida in linea. I comandi sopra elencati possono essere eseguiti anche utilizzando ddd. In tal caso la loro esecuzioni avviene cliccando sui corrispondenti pulsanti nelle barre degli strumenti o sulle voci dei menu. I registri I processori della famiglia intel x86 possiedono almeno i seguenti registri: AX, BX, CX, DX, CS, DS, ES, SS, SP, BP, SI, DI, IP, FLAGS. Originariamente, fino alla nascita del processore 80386, i registri AX, BX, CX, DX, SP, BP, SI, DI, FLAGS ed IP avevano una dimensione pari a 16 bit. A partire dal 80386, la loro dimensione è stata portata a 32 bit e al loro nome è stata aggiunta la lettera E (per indicare extended) in prima posizione. Ad esempio, il registro AX è diventato EAX. EAX, EBX, ECX, ed EDX sono registri generici (general purpose registers), pertanto è possibile assegnargli qualunque valore. Tuttavia, durante l’esecuzione di alcune istruzioni i registri generici vengono utilizzati per memorizzare valori ben determinati: EAX (accumulator register) è usato come accumulatore per operazioni aritmetiche e contiene il risultato dell’operazione. EBX (base register) è usato per operazioni di indirizzamento della memoria. ECX (counter register) è usato per “contare”, ad esempio nelle operazioni di loop. EDX (data register) è usato nelle operazioni di input/output, nelle divisioni e nelle moltiplicazioni. CS, DS, ES e SS sono i registri di segmento (segment registers) e devono essere utilizzati con cautela CS (code segment) punta alla zona di memoria che contiene il codice. Durante l'esecuzione del programma Usato assieme a IP serve per accedere alla prossima istruzione da eseguire (attenzione: non può essere modificato). DS (data segment) punta alla zona di memoria che contiene i dati. ES (extra segment) può essere usato come registro di segmento ausiliario. SS (stuck segment) punta alla zona di memoria in cui risiede lo stack. ESP, EBP, EIP sono i registri puntatore (pointer registers): ESP (stack pointer) punta alla cima dello stack. Viene modificato dalle operazioni di PUSH (inserimento di un dato nello stack) e POP (estrazioni di un dato dallo stack). Si ricordi che lo stack è una struttura di tipo LIFO (Last In First Out – l’ultimo che entra è il primo che esce). E’ possibile modificarlo anche manualmente a proprio rischio e pericolo! EBP (base pointer) punta alla base dello stack. EIP (instruction pointer) punta alla prossima istruzione da eseguire. Non può essere modificato. ESI e EDI sono i registri indice (index registers) e vengono utilizzati per operazioni con stringhe e vettori: ESI (source index) punta alla stringa/vettore sorgente. EDI (destination index) punta alla stringa/vettore destinazione. EFLAGS è utilizzato per memorizzare lo stato corrente del processore. Ciascuna flag (bit) del registro fornisce una particolare informazione. Ad esempio, la flag in posizione 0 (carry flag) viene posta a 1 quando c’è stato un riporto o un prestito durante un operazione aritmetica; la flag in posizione 1 (parity flag) viene usata come bit di parità e viene posta a 1 quando il risultato dell’ultima operazione ha un numero pari di 1; … Modalità di indirizzamento Il termine modalità di indirizzamento si riferisce al modo in cui l’operando di un istruzione viene specificato. Esistono 7 modalità di indirizzamento principali: Indirizzamento a registro: l’operando è contenuto in un registro. Il nome del registro è specificato nell’istruzione. Indirizzamento assoluto: l’operando è contenuto in una locazione di memoria. L’indirizzo della locazione viene specificato nell’istruzione. Indirizzamento immediato: l’operando è un valore costante ed è definito esplicitamente nella istruzione. Indirizzamento indiretto: l’indirizzo di un operando è contenuto in un registro o in una locazione di memoria. L’indirizzo della locazione o il registro viene specificato nell’istruzione. Indirizzamento indicizzato: l’indirizzo effettivo dell’operando è calcolato sommando un valore costante al contenuto di un registro. Indirizzamento con autoincremento: l’indirizzo effettivo dell’operando è il contenuto di un registro specificato nell’istruzione. Dopo l’accesso all’operando, il contenuto del registro viene incrementato per puntare all’elemento successivo. Indirizzamento con autodecremento: il contenuto di un registro specificato nell’istruzione viene decrementato. Il nuovo contenuto viene usato come indirizzo effettivo dell’operando. Struttura di un programma assembly I programmi assembly sono composti da almeno tre sezioni: text, data e bss. Ognuna di queste sezioni può essere eventualmente vuota. Altre sezioni possono essere create mediante la direttiva .section. La sezione data viene utilizzata per dichiarare dati inizializzati, ovvero costanti. La sezione test contiene il codice assembly vero e proprio. Questa sezione deve iniziare con la dichiarazione global _start che fornisce al kernel la locazione di memoria in cui si trova la prima istruzione del programma (è simile alla funzione main di Java o del C). La sezione bss consente di riservare spazio in memoria per il programma, e contiene la dichiarazione delle variabili. Formato istruzione Etichetta istruzione: operazione operando1, operando2 L’etichetta può essere opzionale. Il numero di operandi dipende dal tipo di operazione. La sintassi AT&T Esistono due principali tipi di sintassi per il linguaggio assembly: la sintassi Intel e la sintassi AT&T. Il compilatore gas utilizza quest’ultima. Confrontando la sintassi AT&T con quella Intel, si possono evidenziare le seguenti differenze: In AT&T i nomi dei registri hanno % come prefisso, cosicché i registri sono %eax, %ebx e così via invece di solo eax, ebx, ecc. Ciò fa sì che sia possibile includere simboli C esterni direttamente nel sorgente assembly, senza alcun rischio di confusione e senza alcun bisogno di orribili underscore anteposti. In AT&T l'ordine degli operandi è l’opposto rispetto a quello della sintassi Intel, ovvero: sorgente, destinazione. Quindi, ciò che nella sintassi intel è mov eax,edx (carica il contenuto del registro EDX nel registro EAX), in AT&T diventa mov %dx, %ax. In AT&T la lunghezza dell'operando è specificata tramite un suffisso al nome dell'istruzione. Il suffisso è b per byte (8 bit), w per word (parola) e l per double word (parola doppia). Ad esempio, la sintassi corretta per l'istruzione menzionata poco fa è movl %dx,%ax. Tuttavia, poiché gas non richiede una sintassi AT&T rigorosa, il suffisso è opzionale quando la lunghezza dell’operando può essere ricavata dai registri usati nell’operazione. In caso contrario, viene posta a 32 bit (con un avviso). Gli operandi immediati sono indicati con il prefisso $. Ad esempio addl $5,%eax (somma il valore long 5 al registro EAX). L'assenza di prefisso in un operando indica che si tratta di un indirizzo di memoria. Pertanto, l’istruzione movl $pippo,%eax mette l'indirizzo della variabile pippo nel registro EAX, mentre movl pippo,%eax mette il contenuto della variabile pippo nel registro %eax. L'indicizzazione o l'indirezione è ottenuta racchiudendo il registro indice, o l'indirizzo della cella di memoria di indirezione, tra parentesi. Ad esempio l’istruzione testb $0x80,17(%ebp) esegue un test sul bit più alto del valore byte all'offset 17 dalla cella puntata dal valore contenuto in EBP. Esempio # # # # # # # # # # # # # # # # # Nome file ---------hello.s Istruzioni per la compilazione -----------------------------as -o hello.o hello.s ld -o hello hello.o Funzionalita' ------------Stampa a video la scritta "Ciao Mondo!\n" Commenti -------Le righe che iniziano con il carattere # sono commenti. E’ possibile usare anche la sintassi C /* */ .section .data #sezione data hello: .ascii "Hello World!\n" #etichetta #stringa costante hello_len: .long . – hello #lunghezza della stringa .section .text .global _start #sezione text #punto di inizio del programma _start: movl $4, %eax #Carica in EAX il codice della #system call write per scrivere #la stringa “Ciao Mondo!” a video xorl %ebx, %ebx #Azzera il contenuto di EBX incl %ebx #Incrementa di 1 il contenuto di #EBX. Quindi ora EBX=1. 1 è il #primo #parametro per la write e #serve per indicare che vogliamo #scrivere nello standard output leal hello, %ecx #carica in ECX l’indirizzo di #memoria associato alla etichetta #hello, ovvero il puntatore alla #stringa “Ciao Mondo!\n” da #stampare. Secondo parametro della #write movl hello_len, %edx #carica in EDX la lunghezza della #stringa “Ciao Mondo!\n”. Terzo #parametro della write int $0x80 #esegue la system call write #tramite l’interrupt 80 xorl %eax, %eax #Azzera il registro EAX incl %eax #incrementa di 1 il registro EAX. 1 #è il codice della system call exit xorl %ebx, %ebx #azzera EBX. Contiene il codice di #ritorno della exit int $0x80 #esegue la system call exit Chiamate a funzioni del sistema operativo Nell’esempio precedente è stata usata la funzione di sistema write per stampare a video la stringa “Ciao Mondo!”. La funzione write è stata invocata mediante l’interrupt 0x80. Il codice corrispondente alla funzione write (4) è stato passato attraverso il registro EAX, mentre i parametri della funzione sono stati forniti nei registri EBX ECX EDX. Allo stesso modo è stata invocata al funzione di sistema exit per terminare il programma. In generale, in Linux, le chiamate alle funzioni di sistema vengono fatte invocando l’interrupt 0x80. Il codice della funzione deve essere inserito nel registro EAX, mentre i parametri devono essere forniti negli altri registri (EBX, ECX, EDX, ESI, EDI). Nel caso in cui i parametri siano più di 5, essi devono essere collocati nella memoria e il registro EBX deve contenere l’indirizzo della locazione di memoria che contiene il primo parametro. La lista delle funzioni di sistema è reperibile nel file /usr/include/asm/unistd.h La descrizione di ciascuna funzione è reperibile nel secondo volume del man di Linux. Ad esempio, digitare nella shell il comando man 2 write per visualizzare le informazioni sulla funzione write. Esercizio 1 Creare un programma assembly che sommi i numeri 100, 33 e 68 e stampi il risultato a video. Istruzioni da utilizzare: MOV sorgente, destinazione Necessaria per caricare i dati nei registri. ADD operando1, operando2 Somma il valore dell’operando1 a quello dell’operando2 e mette il risultato in quest’ultimo Esercizio 2 Utilizzare il debugger gdb per visionare il contenuto dei registri durante l’esecuzione del programma realizzato per l’esercizio precedente. Associare un breakpoint ad ogni linea che coinvolge operazioni MOV o ADD, e visionare il corretto contenuto dei registri.