Assemblare, verificare ed eseguire un programma assembly

Creazione 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

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.

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. Dal momento che il linking crea il modulo binario da caricare, l’operazione deve essere effettuata anche se il programma è composto da un solo modulo oggetto.

In particolare, durante questo corso verrà utilizzato il linker ld.

Per creare l’eseguibile a partire da un file oggetto è necessario eseguire il seguente comando:

ld –o miofile miofile.o

Per saperne di più sulle opzioni disponibili di ld, è possibile consultare il manuale.

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 (ovvero di punti in cui il flusso d’esecuzione è fermato e il controllo passa al debugger);
  • possibilità di visualizzare ed eventualmente modificare il contenuto dei registri e della memoria.

Il debugger più diffuso in ambiente Linux è gdb. Gdb funziona in modalità testuale, pertanto i comandi vengono impartiti mediante la shell. 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.

Di seguito una tabella che riassume i comandi più frequenti di gdb:

ComandoDescrizione del comando
file nome_eseguibileCarica il programma per il debugging.
break numero_rigaImposta un breakpoint alla riga specificata.
runEsegue il programma. L’esecuzioni del programma si sospende quando viene raggiunta il primo breakpoint. Nel caso non vi siano breakpoint l’esecuzione avviene normalmente, senza interruzioni.
stepEsegue 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.
nextSimilmente 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.
continueProsegue l’esecuzione del programma fino al prossimo breakpoint.
finishProsegue l’esecuzione del programma fino alla fine.
info registersVisualizza il contenuto dei registri.
p/formato $registroStampa 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.
x/nw indirizzoVisualizza 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.
helpVisualizza le istruzioni per l’utilizzo della guida in linea

I comandi sopra elencati possono essere eseguiti anche utilizzando ddd, un’interfaccia grafica per gdb. In tal caso la loro esecuzioni avviene cliccando sui corrispondenti pulsanti nelle barre degli strumenti o sulle voci dei menu.

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 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 %edx, %eax.
  • 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, 16 bit) e l per double word (parola doppia, 32 bit). 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 $var_tmp,%eax mette l’indirizzo della variabile var_tmp nel registro EAX, mentre movl var_tmp,%eax mette il contenuto della variabile var_tmp 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.

Dichiarazione di regioni dati statiche

È possibile dichiarare regioni dati statiche (analoghe alle variabili globali) utilizzando speciali direttive assembler per questo scopo. Le dichiarazioni di dati dovrebbero essere precedute dalla direttiva .data . Seguendo questa direttiva, le direttive .byte , .short e .long possono essere utilizzate per dichiarare rispettivamente posizioni di dati a uno, due e quattro byte.

Per fare riferimento all’indirizzo dei dati creati, possiamo etichettarli. Le etichette sono molto utili e versatili nell’assemblaggio, danno nomi a posizioni di memoria che verranno individuate in seguito dall’assemblatore o dal linker. Questo è simile alla dichiarazione di variabili per nome, ma rispetta alcune regole di livello inferiore. Ad esempio, le posizioni dichiarate in sequenza si troveranno in memoria una accanto all’altra.

A differenza dei linguaggi di alto livello in cui gli array possono avere molte dimensioni e sono accessibili da indici, gli array nel linguaggio assembly sono semplicemente un numero di celle posizionate in modo contiguo nella memoria. Un array può essere dichiarato semplicemente elencando i valori, come nel primo esempio seguente. Per il caso speciale di un array di byte, è possibile utilizzare stringhe letterali. Nel caso in cui una vasta area di memoria sia piena di zeri , è possibile utilizzare la direttiva .zero .