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.