Sviluppo di un compilatore per un linguaggio imperativo



Documenti analoghi
4 3 4 = 4 x x x 10 0 aaa

COS È UN LINGUAGGIO? LINGUAGGI DI ALTO LIVELLO LA NOZIONE DI LINGUAGGIO LINGUAGGIO & PROGRAMMA

Gian Luca Marcialis studio degli algoritmi programma linguaggi LINGUAGGIO C

Dispensa YACC: generalità

Analizzatore lessicale o scanner

Corso di Informatica

Introduzione alla programmazione in C

Capitolo Quarto...2 Le direttive di assemblaggio di ASM Premessa Program Location Counter e direttiva ORG

Appunti del corso di Informatica 1 (IN110 Fondamenti) 4 Linguaggi di programmazione

Fondamenti di Informatica PROBLEMI E ALGORITMI. Fondamenti di Informatica - D. Talia - UNICAL 1

Linguaggi e Paradigmi di Programmazione

Ing. Paolo Domenici PREFAZIONE

APPUNTI DI MATEMATICA LE FRAZIONI ALGEBRICHE ALESSANDRO BOCCONI

LINGUAGGI DI PROGRAMMAZIONE

Corso di Informatica Applicata. Lezione 3. Università degli studi di Cassino

CALCOLATORI ELETTRONICI A cura di Luca Orrù. Lezione n.7. Il moltiplicatore binario e il ciclo di base di una CPU

3. Programmazione strutturata (testo di riferimento: Bellini-Guidi)

Architettura di un calcolatore

Excel. A cura di Luigi Labonia. luigi.lab@libero.it

Codifica: dal diagramma a blocchi al linguaggio C++

Funzioni in C. Violetta Lonati

Dall Algoritmo al Programma. Prof. Francesco Accarino IIS Altiero Spinelli Sesto San Giovanni

Corso di Informatica

Fasi di creazione di un programma

Siamo così arrivati all aritmetica modulare, ma anche a individuare alcuni aspetti di come funziona l aritmetica del calcolatore come vedremo.

Informazione analogica e digitale

Lezione 8. La macchina universale

3 - Variabili. Programmazione e analisi di dati Modulo A: Programmazione in Java. Paolo Milazzo

ESEMPIO 1: eseguire il complemento a 10 di 765

Database 1 biblioteca universitaria. Testo del quesito

Dispensa di Informatica I.1

Introduzione al Linguaggio C

INFORMATICA 1 L. Mezzalira

Algoritmi e strutture dati. Codici di Huffman

Convertitori numerici in Excel

Automatizzare i compiti ripetitivi. I file batch. File batch (1) File batch (2) Visualizzazione (2) Visualizzazione

4. Operazioni aritmetiche con i numeri binari

lo PERSONALIZZARE LA FINESTRA DI WORD 2000

Calcolatori Elettronici Parte X: l'assemblatore as88

La selezione binaria

AXO Architettura dei Calcolatori e Sistema Operativo. processo di assemblaggio

Lezione 1: L architettura LC-3 Laboratorio di Elementi di Architettura e Sistemi Operativi 10 Marzo 2014

FONDAMENTI di INFORMATICA L. Mezzalira

Laboratorio di Informatica

INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI

Introduzione. Informatica B. Daniele Loiacono

Esempio di moltiplicazione come somma e spostamento

Il microprocessore 8086

Informatica B a.a 2005/06 (Meccanici 4 squadra) PhD. Ing. Michele Folgheraiter

Capitolo 3. L applicazione Java Diagrammi ER. 3.1 La finestra iniziale, il menu e la barra pulsanti

Gli array. Gli array. Gli array. Classi di memorizzazione per array. Inizializzazione esplicita degli array. Array e puntatori

Università di Torino Facoltà di Scienze MFN Corso di Studi in Informatica. Programmazione I - corso B a.a prof.

I file di dati. Unità didattica D1 1

Sistema operativo: Gestione della memoria

SISTEMI DI NUMERAZIONE DECIMALE E BINARIO

Architettura dei calcolatori e sistemi operativi. Assemblatore e Collegatore (Linker) Capitolo 2 P&H Appendice 2 P&H

Rappresentazione dei numeri in un calcolatore

EXCEL PER WINDOWS95. sfruttare le potenzialità di calcolo dei personal computer. Essi si basano su un area di lavoro, detta foglio di lavoro,

L unità di controllo. Il processore: unità di controllo. Le macchine a stati finiti. Struttura della macchina a stati finiti

Uso di base delle funzioni in Microsoft Excel

LE FUNZIONI A DUE VARIABILI

Sommario. Definizione di informatica. Definizione di un calcolatore come esecutore. Gli algoritmi.

Semantica operazionale dei linguaggi di Programmazione

APPUNTI SUL LINGUAGGIO DI PROGRAMMAZIONE PASCAL

I sistemi di numerazione

Introduzione. Classificazione di Flynn... 2 Macchine a pipeline... 3 Macchine vettoriali e Array Processor... 4 Macchine MIMD... 6

I componenti di un Sistema di elaborazione. Memoria centrale. È costituita da una serie di CHIP disposti su una scheda elettronica

A destra è delimitata dalla barra di scorrimento verticale, mentre in basso troviamo una riga complessa.

CPU. Maurizio Palesi

Lezioni di Matematica 1 - I modulo

Appunti sulla Macchina di Turing. Macchina di Turing

Introduzione agli algoritmi e alla programmazione in VisualBasic.Net

Matematica in laboratorio

Analisi sensitività. Strumenti per il supporto alle decisioni nel processo di Valutazione d azienda

risulta (x) = 1 se x < 0.

PROTOTIPAZIONE DI UN TRADUTTORE DA SORGENTE PLC AD ASSEMBLY DI UNA MACCHINA VIRTUALE

ARCHITETTURE MICROPROGRAMMATE. 1. Necessità di un architettura microprogrammata 1. Cos è un architettura microprogrammata? 4

Università di Roma Tor Vergata Corso di Laurea triennale in Informatica Sistemi operativi e reti A.A Pietro Frasca.

Fondamenti di Informatica Ingegneria Clinica Lezione 19/10/2009. Prof. Raffaele Nicolussi

La struttura dati ad albero binario

E possibile modificare la lingua dei testi dell interfaccia utente, se in inglese o in italiano, dal menu [Tools

Gestione Rapporti (Calcolo Aree)

Dimensione di uno Spazio vettoriale

Architettura degli elaboratori (A)

Nell esempio verrà mostrato come creare un semplice documento in Excel per calcolare in modo automatico la rata di un mutuo a tasso fisso conoscendo

LABORATORIO DI PROGRAMMAZIONE EDIZIONE 1, TURNO B

A intervalli regolari ogni router manda la sua tabella a tutti i vicini, e riceve quelle dei vicini.

Gli algoritmi: definizioni e proprietà

GUIDA ALLA PROGRAMMAZIONE GRAFICA IN C

Introduzione al MATLAB c Parte 2

Arduino: Programmazione

Allocazione dinamica della memoria - riepilogo

CREAZIONE DI UN DATABASE E DI TABELLE IN ACCESS

Dispense di Informatica per l ITG Valadier

EVOLUZIONE DEI LINGUAGGI DI ALTO LIVELLO

STAMPA UNIONE DI WORD

Modulo. Programmiamo in Pascal. Unità didattiche COSA IMPAREREMO...

Invio SMS. DM Board ICS Invio SMS

Guida all uso di Java Diagrammi ER

La Macchina Virtuale

Transcript:

Universita degli studi di Roma Tor Vergata Facoltà di Ingegneria Corso di laurea in ingegneria informatica Tesi di laurea Sviluppo di un compilatore per un linguaggio imperativo Relatore Prof. Alberto Pettorossi Laureando Fernando Iazeolla Anno Accademico 2003-2004

Indice 1. Principi di funzionamento dei compilatori.... 5 1.1 Il lexical analizer.... 7 1.2 Il parser.. 7 1.3 Il back end.. 12 2. Il punto di partenza.. 14 3. Progetto del compilatore... 21 4. Uso e funzionamento del compilatore 32 5. Descrizione del codice sorgente.. 35 5.1 compiler... 35 5.2 lexan. 35 5.3 parser. 39 5.4 symtab. 45 5.5 encode. 46 5.6 tointel. 53 5.7 assemble... 58 5.8 tostream... 64 5.9 tocode. 66 6. Il codice sorgente... 69 7. Conclusioni... 81 2

Introduzione Ad appassionarmi a questa disciplina sono stati gli argomenti trattati in un corso di informatica del mio relatore. Il professore ha trattato argomenti teorici sugli algoritmi di parsing e sulle teorie dei linguaggi e grammatiche. Al giorno d oggi possiamo trovare diversi generatori di grammatiche e di parser (come ad esempio Lex e Yacc [1]) in cui possiamo fornire in input una grammatica ed ottenere in uscita un parser per la stessa grammatica. Ad esempio se volessimo codificate la seguente grammatica con Yacc: E E + T T T T * F F F (E) digit digit 0 1 2... 9 dove E è un espressione T è un termine ed F è un fattore, basterà fornire al generatore di parser, Yacc, il seguente programma: 3

%{ #include<ctype.h> %} %token DIGIT %% line: expr \n { printf( %d\n,$1); } expr: expr + term { $$=$1 + $3; } term ; term: term * factor { $$ = $1 * $3 ; } factor ; factor: ( expr ) { $$ = $2 ; } DIGIT ; %% yylex() { int c; c=getchar(); if(isdigit(c)) { yylval=c- 0 ; return DIGIT; } return c; }. Tuttavia la cosa che mi interessava maggiormente era applicare le conoscenze acquisite sull argomento, e quindi la cosa migliore era partire da zero: progettare quindi un compilatore dall inizio alla fine, in tutte le sue componenti, una cosa che tempo fa consideravo difficilissima che solo una persona con doti particolari potesse essere in grado di fare. Certo rimane il fatto che per progettare e sviluppare un compilatore occorre avere una profonda conoscenza che spazia in quasi tutti i campi dell informatica: sistemi operativi, architetture dei calcolatori, elementi di programmazione e codici macchina. 4

Capitolo 1 Principi di funzionamento dei compilatori Essenzialmente un compilatore è un programma che legge un programma scritto in un linguaggio (il linguaggio sorgente) e lo traduce in un programma equivalente in un altro linguaggio (di solito codice macchina o codice oggetto). Programma sorgente Compilatore Codice oggetto Figura 1. Compilatore visto come traduttore Oggi esistono moltissimi compilatori per diversi linguaggi di programmazione. Ogni linguaggio ha insita in sè una caratteristica principale che lo rende preferibile rispetto ad un altro a seconda del tipo di progetto che si deve sviluppare. Così ad esempio il Fortran ed il Pascal sono preferiti nello sviluppo di applicazioni scientifico-didattiche, il Prolog è utilizzato nell intelligenza artificiale, il Basic dai neofiti che si avvicinano per la prima volta alla programmazione, il C e Java sono liguaggi general pur pose (il primo è usato per programmazione di sistemi anche a basso livello, il secondo per progetti commerciali basati sull architettura inter-computer e platform independent). In principio programmare un computer significava essere in grado di programmare in linguaggio macchina (sequenze binarie {0,1}) su schede perforate da inserire 5

nell elaboratore che li eseguiva a seconda di come era programmato il suo job scheduler (multi-programmato o meno). Il risultato era poi serializzato in output e se si presentava un errore il programmatore doveva analizzare il suo programma (che era una sequenza di 0 e 1) riperforare una nuova scheda e richiedere di nuovo un tempo di CPU da dedicare alla rielaborazione del job. Stiamo parlando degli anni 40 ed i computer erano macchine molto grandi che occupavano intere stanze come ad esempio l ENIAC (Electronic Numerical Integrator And Calculator) al cui progetto partecipò J. von Neumann. Nasce così l esigenza di portare la fase di scrittura del codice verso linguaggi più vicini all uomo che alla macchina in modo da poter ottenere una maggiore efficienza, robustezza, comprensibilità del codice e maggior facilità nel debugging. Nacque così negli anni 50 il Fortran (FORmula TRANslation). L ideatore di questo linguaggio fu John Backus. Lo scopo di questo linguaggio era quella di automatizzare calcoli matematici e scientifici. Questo linguaggio ebbe molto successo e sulla sua scia vennero progettati moltissimi altri linguaggi di alto livello. Si cominciarono così ad affinare le tecniche di progettazione dei compilatori. Diamo ora uno sguardo più approfondito ai compilatori. Un compilatore può essere visto come diviso in due parti: il cosiddetto front end (costituito da un lexical analizer, o scanner, e da un parser) e il back end. Il front end legge il programma sorgente mentre il back end genera il programma equivalente in codice macchina. Front end Compilatore Back end. Figura 2. Schema più approfondito del compilatore 1.1 Il Lexical Analizer 6

Il primo passo è senz altro quello di leggere il file di testo in input cercando i simboli e parole chiave del linguaggio. Questo è il compito del lexical analizer: leggere dall input stream e restituire una sequenza di token che il parser può utilizzare per l analisi sintattica. Gli elementi da riconoscere sono numeri, parole e simboli del linguaggio. Il riconoscimento di tali elementi può essere rappresentato tramite espressioni regolari o automi finiti. - Numeri Digit 0 1 2 3 4 5 6 7 8 9 Digits digit digit* Optional_fraction. digit Optional_ exponent (E (+ - ) digits) Number digits optional_fraction optional exponent - Identificatori Letter A B C Z a b c z Digit 0 1 2 3 4 5 6 7 8 9 Id Letter (letter digit)* - Relazioni e simboli aritmetici Sym rel_op op rest rel_op <= >= ==!= op + - * / rest ( ) ;. &&! dove ε e la stringa vuota. 1.2 Il Parser Una volta eseguita la scanzione del file di codice ed identificati tutti i token il controllo passa al parser, il vero cuore del compilatore. La sintassi del linguaggio di programmazione riconosciuta dal parser può essere descritta dalle grammatiche context-free o dalla notazione BNF (Backus-Naur Form). Le grammatiche context-free hanno quattro componenti: 1. un insieme di simboli terminali (es. t) 2. un insieme di simboli non terminali (es. N) 3. un insieme di produzioni nella forma N N t 4. un simbolo non terminale che prende il nome di simbolo di partenza. 7

Assumiamo il parser come una rappresentazione dell albero di parsing per i token generati dal lexical analizer. Ad esempio sia data la seguente grammatica: list list + digit list list digit list digit digit 0 1 2 3 4 5 6 7 8 9 Figura 3. Linguaggio che riconosce somma e sottrazione i simboli non terminali sono: list e digit, mentre i terminali sono + - 0 1 2 3 4 5 6 7 8 9 0 e list è detto simbolo iniziale perchè è definito per primo. Se ora vogliamo fare il parsing della stringa 9-5+2 otteniamo il seguente albero di parsing: List List Digit List Digit digit 9-5 + 2 Figura 4. Albero di parsing della stringa 9-5+2. 8

gli alberi di parsing delle grammatiche context-free hanno le seguenti proprietà: la radice dell albero è il simbolo iniziale ogni foglia finale è un terminale o ε (stringa vuota) ogni nodo interno (cioè non una foglia finale) è un non terminale. Se A è un non terminale e rappresenta un nodo all interno dell albero e se X1,X2,,Xn sono i Figli di quel nodo (che possono essere sia simboli terminale che non terminali) da sinistra a destra allora A X1 X2.. Xn è una produzione. Bisogna fare attenzione però perchè se definiamo male una grammatica corriamo il rischio di non poter determinare un solo albero di parsing. In questo caso siamo di fronte a grammatiche dette ambigue. Ad esempio, supponiamo di non distinguere tra list e digit come precedentemente fatto e consideriamo la seguente grammatica: string -> string + string string string 0 1 2 3 4 5 6 7 8 9 e la seguente espressione: 9-5+2. ora siamo di fronte a due alberi di parsing: (9-5)+2 e 9-(5+2) che danno risultati totalmente diversi. string string string string string string string string string string string string 9-5 + 2 9-5 + 2 Figura 5. Due alberi di parsing generati dalla grammatica ambigua Nella maggior parte dei linguaggi di programmazione le quattro operazioni aritmetiche (addizione, sottrazione, moltiplicazione e divisione) sono left associative, cioè data l espressione 9+5+2 essa equivale a (9+5)+2 così come 9-5-2 equivale a (9-5)-2. C è tuttavia da stabilire l ordine di precedenza degli operatori in quanto data l espressione 9+5*2 questa può dare luogo a due diverse interpretazioni: (9+5)*2 e 9

9+(5*2). L associatività dell addizione (+) e della moltiplicazione (*) non risolvono questa ambiguità. Bisogna stabilire due livelli di precedenza: la moltiplicazione e la divisione legano di piu dell addizione e della sottrazione. Creiamo quindi due non terminali expr e term per i due livelli di precedenza piu un altro non terminale factor che sono atomi nelle espressioni (digit o espressioni racchiuse tra parentesi). La grammatica risultante sarà quindi: expr expr + term expr term term term term * factor term / factor factor factor digit (expr) digit 0 1 2 3 4 5 6 7 8 9 Questa grammatica tratta un espressione come una lista di term separati da + o e i termini come una lista di factor separati da * o da /. Le espressione racchiuse tra parentesi sono trattate come fattori così possiamo ottenere espressioni annidate. Il parser ha il compito di verificare se una certa sequenza di token, passategli dal lexical analizer, può essere generato da una grammatica. Ci sono tre tipi principali di parser di grammatiche. Gli algoritmi di parsing quali il Cocke-Younger-Kasami [3] e l Earley [3] sono in grado di parsare qualsiasi tipo di grammatica. Ma questi parser sono poco efficienti per essere implementati su calcolatore. Così i compilatori vengono progettati con dei parser top-down, bottom-up o predictive parser. Il top-down parsing costruisce l albero di parsing a partire dalla radice che è il simbolo iniziale della grammatica. data la seguente grammatica: S cad A ab a e il seguente input w=cad, vediamo col metodo top-down se tale stringa è generata dalla grammatica. La radice quindi corrisponde ad S e espandiamo tale radice in modo da ottenere l albero in Figura 6. 10

S c A d Figura 6. Albero di parsing dopo la prima produzione Visto che la foglia più a sinistra c corrisponde con il primo simbolo di w, avanziamo nell input e consideriamo il secondo simbolo di w e la seconda foglia A. espandiamo A usando la prima alternativa nella grammatica ottenendo l albero in Figura sottostante. S c A d a b Figura 7. Albero di parsing dopo la seconda produzione e vediamo che abbiamo la corrispondenza per il secondo simbolo di w. Avanziamo quindi nell input e consideriamo il suo terzo simbolo d. questo non corrisponde con la terza foglia dell albero. Andando in backtracking consideriamo l albero di parsing con la seconda alternativa della produzione di A come in Figura sottostante. S c A d a Figura 8. Albero di parsing dopo il backtracking 11

Abbiamo così la corrispondenza tra la stringa di input e le foglie dell albero il che ci dice che la stringa appartiene alla grammatica. Una grammatica left-recursive può causare un loop infinito perchè una produzione del tipo A Ab a espande un non terminale con lo stesso non terminale senza dare luogo a nessun terminale. I bottom-up parser costruiscono l albero di parsing dato una stringa di input a partire dalle foglie a salire fino alla radice. Possiamo pensare agli algoritmi bottom-up come delle riduzioni successive della stringa di input fino all assioma di partenza della grammatica (al simbolo non terminale iniziale della grammatica). Ad ogni riduzione sostituiamo alla stringa di input la parte sinistra della produzione la cui parte destra coincide con una sottostringa dell input. Ad esempio consideriamo la seguente grammatica: S aabe A Abc b B d E sia la stringa di input la seguente: abbcde. abbcde aabcde aade aabe S Alla stringa di partenza applichiamo la riduzione A b e la stringa diventa: aabcde. Poi dalla produzione A Abc la stringa diventa aade. Dalla produzione B d la stringa diventa aabe. Infine applichiamo la seguente riduzione S aabe. 1.3 Il Back End Una volta generato l albero di parsing e le relative symbol table (le tabelle relative alle variabili globali e locali), il back end può essere visto come la visita dell albero di parsing da cui poi si genera il codice oggetto (per i compilatori) e da cui vengono interpretate le singole istruzioni una alla volta (per esempio negli interpreti). Gli interpreti infatti eseguono una istruzione alla volta del codice sorgente senza tradurlo in nessun altro programma equivalente. Molti anni fa ad esempio nel interprete BASIC si leggeva una riga alla volta del programma che terminava con un 12

ritorno a capo, si analizzava la riga e la si eseguiva. Nei moderni interpreti di linguaggi come ad esempio il Perl si legge tutto il programma generando l albero di parsing in memoria, e l esecuzione del programma non è altro che la visita di tale albero. Ciò consente di rilevare eventuali errori sintattici prima dell esecuzione della prima istruzione del programma sorgente nonchè consente una esecuzione più veloce del programma in quanto già parzialmente codificato in memoria. 13

Capitolo 2 Il punto di partenza Il punto di partenza di questa tesi è l articolo di Sterling-Shapiro [2] nel quale si introduce brevemente la possibilità di progettare un semplice compilatore scritto in Prolog. Il linguaggio sorgente da loro considerato è un sottoinsieme del Pascal, il PL ideato da loro stessi a scopo dimostrativo. Gli statement presenti nel loro linguaggio sono pochissimi: abbiamo lo statement di assegnamento ad una variabile, uno statement condizionale (if-then-else), uno statement di loop (while), e due di I/O (read e write). Il PL ammette solo variabili globali, senza quindi la possibilità di definire istanze di variabili locali, non tipate visto che è presente solo il tipo intero e non è previsto l utilizzo di funzioni e procedure. Il PL si riduce quindi ad uno script-language. program factorial; begin read value; count:=1; result:=1; while count < value do begin count:= count +1; result:=result*count; end; write result end. Figura 9. Programma fattoriale scritto in PL. La grammatica relativa al linguaggio PL è descritta qui sotto dove in grassetto sono indicati i simboli terminali. 14

pl_program program identifier ; statement statement begin statement rest_statement statement identifier := expression statement if test then statement else statement statement while test do statement statement read identifier statement write expression rest_statement ; statement rest_statement rest_statement end. expression pl_constant pl_constant aritmetic_op expression aritmetic_op + - * / pl_constant identifier pl_integer identifier word pl_integer int_number test expression comparison_op expression comparison_op = < > >= <= =\= word letter letter word letter a b z int_number -> digit digit int_number digit 1 2 9 0. Il codice oggetto prodotto in uscita dal processo di compilazione è uno pseudoassembler sempre da loro stesso ideato. È un assembler che ha un solo registro (accumulatore) che è implicito nell istruzione, mentre l altro operando che può essere un intero, una costante, un indirizzo di una cella di memoria contenente dati o istruzioni del programma, è esplicito. Le istruzioni con il relativo significato sono elencate nella seguente tabella.. add mem somma il contenuto della cella di memoria mem istruzione operando significato all accumulatore addc sub cost mem add sottrai constant: il contenuto addiziona della cost cella all accumulatore di memoria mem all accumulatore subc mul cost mem sub moltiplica constant: il contenuto sottrae cost della all accumulatore cella di memoria mem con l accumulatore mulc div cost mem mul divide constant: l accumulatore moltiplica con cost il valore all accumulatore della cella di memoria mem divc load cost mem div immetti constant: nell accumulatore dividi cost all accumulatore il contenuto della cella di memoria mem loadc write cost load scrive constant: su standard metti output il valore il contenuto di cost nell accumulatore dell accumulatore store read mem immetti nell accumulatore il valore dell accumulatore il valore dello nella cella standard di memoria input puntata da mem add jump mem label somma salto incondizionato il contenuto della cella di memoria mem all accumulatore 15

jumpeq label jump if equal jumpne label jump if not equal jumplt label jump if less then jumpgt label jump if greater then jumple label jump if less or equal jumpge label jump if greater or equal Figura 10. Target language instructions Il programma fattoriale presentato in Figura 1 sarà quindi tradotto nel programma mostrato nella seguente Figura. symbol address instruction operand symbol 16

LABEL1 LABEL2 COUNT RESULT VALUE 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 READ LOADC STORE LOADC STORE LOAD SUB JUMPGE LOAD ADDC STORE LOAD MUL STORE JUMP LOAD WRITE HALT BLOCK 21 1 19 20 20 19 21 16 19 1 19 20 19 20 6 20 0 0 3 VALUE COUNT RESULT COUNT VALUE LABEL2 COUNT COUNT RESULT COUNT RESULT LABEL1 RESULT Figura 11. Codice assembly del programma fattoriale Dove halt termina il programma e block alloca un blocco di celle di memoria per le variabili utilizzate dal programma pari al valore del suo operando. Il processo di compilazione è di solito eseguito in cinque passi: 1) analisi lessicale, eseguita dal lexical analizer, in cui si esegue la scansione del codice sorgente e si restituiscono al parser i token (identificatori di numeri, parole e simboli). 2) Analisi sintattica, in cui il parser esamina i token fornitegli dal lexical analizer e genera il relativo albero di parsing. 3) Generazione del codice oggetto, in cui tramite una visita dell albero di parsing si produce un codice oggetto rilocabile, cioè in cui gli indirizzi delle variabili o celle di memoria non sono ancora definite in maniera assoluta. 4) Link, in cui si importano le eventuali funzioni di libreria del linguaggio e si risolvono tutte le assegnazioni di memoria. 5) Output, in cui si scrive finalmente il codice su file generando anche le opportune intestazioni per i relativi sistemi operativi qualora il tipo di file lo richieda. L articolo di Sterling-Shapiro si focalizza completamente sui tre passi centrali del processo di compilazione, tralasciando del tutto gli altri aspetti. 17

Source text Token list Parse tree Object structure Object structure (absolut e) output Lexical analysis Syntax analysis Code generation link output Figura 12. Processo di compilazione Quindi al parser passiamo già una lista di token. Test_compiler(X,Y):- program (X,P), compile(p,y). Program(test1,[program,test1, ;,begin,write,x, +,y, -,z, /,2,end). Program(test2,[program,test2, ;,begin,ig,a, >,b,then,max, :=,a, else,max, :=,b,end]). Program(factorial, [program,factorial, ;,begin,read,value, ;,count, :=,1, ;,result, :=,1, ;,while,count, <,value,do,begin,count, :=,count, +,1, ;,result, :=,result, *,count end, ;,write,resutl,end]). Una volta parsato l input otteniamo i relativi output del processo di parsing: program test1: write(expr(x,name(x),expr(-,name(y),expr(/,name(z),number(2)))));void program test2: 18

if(compare(>,name(a),name(b)),assign(max,name(a)),assign(max,name( b)));void program test3 (factorial): read(value);assign(count,number(1));assign(result,number(1)); while(compare(<,name(count),name(value)), (assign(count,expr(+,name(count),number(1))); assign(result,expr(*,name(result),name(count)));void)); write(name(result));void Questi alberi di parsing vengono poi dati in pasto all encoder che genera il codice oggetto rilocabile. L output generato dall encoder per i relativi programmi di test è il seguente: program test1: ((((instr(load,z);instr(divc,2));instr(store,temp); instr(load,y);instr(sub,temp));instr(add,x));instr(write,0));no_op program test2: (((instr(load,a);instr(sub,b));instr(jumple,l1)); (instr(loada);instr(store,max));instr(jump,l2);label(l1); (instr(load,b);instr(store,max));label(l2));no_op program test3 (factorial): instr(read,value);instr(loadc,1);instr(store,count)); (instr(loadc,1);instr(store,result));label(l1); ((instr(load,count);instr(sub,value));instr(jumpge,l2)); (((instr(load,count);intr(addc,a));instr(store,count)); ((instr(load,result);instrmul,count));instr(store,result)); no_op);instr(jump,l1);label(l2));(instr(load,result);instr(write,0 ));no_op Ed infine l assemblatore prende il codice oggetto non istanziato e genera il codice finale. program test1: instr(load,11);instr(divc,2);instr(store,12);instr(load,10); instr(sub,12);instr(add,9);instr(write,0);instr(halt,0);block(4) program test2: instr(load,10);instr(sub,11);instr(jumple,7);instr(load,10); instr(store,12);instr(jum,9);instr(load,11);instr(store,12); instr(halt,0);block(3) program test3 (factorial): instr(read,21);instr(loadc,1);instr(store,19);instr(loadc,1); instr(store,20);instr(load,19);instr(sub,21);instr(jumpge,16); instr(load,19);instr(addc,1);instr(store,19);instr(load,20); instr(mul,19);instr(store,20);instr(jump,6);instr(load,20); 19

instr(write,0);instr(halt,0);block(3) 20

Capitolo 3 Progetto del compilatore Partendo dall articolo di Sterling-Shapiro [2], il mio compito era di progettare e realizzare un compilatore di un linguaggio imperativo stile C-like funzionante in tutte le sue parti e ovviamente scritto in Prolog. Il linguaggio da me creato ricalca quindi lo stile C ed ha le seguenti caratteristiche: ha l aritmetica dei puntatori ha le funzioni con i parametri passati alle funzioni ha variabili globali ha variabili locali supporta tipi di dato interi, puntatori e stringhe è procedurale adotta una sintassi C like L aritmetica dei puntatori è implementata come in C attraverso i simboli & e *. Ad esempio se x è una variabile intera, allora con &x si intende l indirizzo di memoria associato alla variabile, mentre con *x si intende la locazione di memoria puntata dal valore di x. Le funzioni si definiscono tramite la keyword sub seguita dall identificatore (il nome della funzione) e da eventuali parametri racchiusi tra parentesi tonde. Ad esempio la funzione che somma due interi potrebbe essere così implementata: sub somma(a,b) { local(x); x=a+b; return(x); } Nel mio linguaggio non serve dichiarare una variabile o una funzione prima di utilizzarla, sarà il compilatore che risolve i nomi automaticamente. Per chiamare una funzione si fa precedere l identificatore dal simbolo del dollaro, così per chiamare la funzione somma sopra implementata basterà fare: 21

val=$somma(val1,val2); Le variabili globali si usano in qualsiasi parte del codice senza bisogno di dichiararle. Es. x=12; while(x>0) { x=x-1; } y=x; Per usare le variabili locali invece bisogna dichiararle come tali tramite la keyword local all inizio della funzione di riferimento. Es. sub sum1(a,b) { local(var); var=a+b; write(var); } main() { var=1; $sum1(5,6); } All inizio dell esecuzione del programma riportato qui sopra, la variabile var avrà valore 1. Viene poi chiamata la funzione sum1 in cui si assegna alla variabile var il valore della somma tra 5 e 6. La variabile locale che ha sede nello stack avrà valore 11 e manterrà tale valore fino al completamento della funzione sum1 e verrà poi distrutta e sarà l unica con tale nome che può essere utilizzata nello scope della funzione in cui è dichiarata, mentre la variabile globale var che avrà un indirizzo di memoria nella zona dati avrà valore 1 e manterrà tale valore fino alla fine del programma e si potrà fare riferimento a tale variabile se in nessuna funzione in esecuzione sarà dichiarata una variabile locale con tale nome. Le due variabili sono quindi due entità distinte. Abbiamo quindi visto come utilizzare i tipi di dato interi e puntatori. Per quanto riguarda le stringhe invece, queste non sono altro che un puntatore ad una zona di memoria dove vi è memorizzata la stringa. Se ad esempio vogliamo avere la stringa mia_stringa' nella variabile x, allora dobbiamo scrivere quanto segue: *x= mia_stringa ; ed in memoria corrisponde ad avere una locazione assegnata alla variabile x di lunghezza 2 bytes (intero) che contiene il puntatore alla zona di memoria della stringa. 22

x mia_stringa In uscita il compilatore qui sviluppato produce i seguenti tre diversi tipi di output. 1) Output in pseudo assembler. È una versione ampliata rispetto allo pseudo assembler proposto da Sterling-Shapiro, in quanto si era di fronte alla necessità di dover gestire lo stack per le variabili locali, puntatori, e accumulo e rilascio di risorse necessarie alle funzioni. 2) Output in assembler per processori Intel 80x86. 3) Output direttamente in codice macchina per processori Intel 80x86. Pseudo-assembler sorgente compilatore Assembler sorgente Intel 80x86 Codice macchine Intel 80x86 Figura 13. Output del mio compilatore Prendiamo ad esempio il seguente programma sorgente per il mio compilatore e vediamo come viene tradotto nei vari tipi di output. 23

main() { x=5; y=&x; *y=2; } Figura 14. Programma sorgente 1 2 3 4 5 6 7 8 9 10 11 12 13 14 jump 2 init_funz loadc 5 store 12 loadc 12 store 13 loadc 2 storep 13 halt destroy_local(0) end_funz dw 0 dw 0 block(3) x y Figura 15. Output in pseudo assembler 00000100 0E push cs 00000101 1F pop ds 00000102 E90000 jmp 0x5 00000105 90 nop 00000106 55 push bp 00000107 89E5 mov bp,sp 00000109 B80500 mov ax,0x5 0000010C A32D01 mov [0x12d],ax 0000010F B82D01 mov ax,0x12d 00000112 A32F01 mov [0x12f],ax 00000115 B80200 mov ax,0x2 00000118 89C2 mov dx,ax 0000011A BB2F01 mov bx,0x12f 0000011D 8B07 mov ax,[bx] 0000011F 50 push ax 00000120 5B pop bx 00000121 89D0 mov ax,dx 00000123 8907 mov [bx],ax 00000125 CD20 int 0x20 00000127 81C40000 add sp,0x0 0000012B 5D pop bp 0000012C C3 ret 0000012D 0000 add [bx+si],al 0000012F 0000 add [bx+si],al 00000131 90 nop 24

Figura 16. Output in codice e assembler Intel 80x86 Altro esempio, il programma somma. sub somma(a,b) { local(z); z=a+b; return(z); } main() { x=$somma(1,3); } jump 12 init_funz create_local(2) load stack_bp(6) add stack_bp(4) store stack_bp(-2) load stack_bp(-2) destroy_local(2) end_funz destroy_local(2) end_funz init_funz loadc 1 push 0 loadc 3 push 0 call 2 destroy_local(4) store 23 halt destroy_local(0) end_funz dw 0 block(2) Figura 17. Programma 'somma e il suo output in pseudo-assembler 00000100 0E push cs 00000101 1F pop ds 00000102 E93400 jmp 0x39 00000105 90 nop 00000106 55 push bp 00000107 89E5 mov bp,sp 00000109 81EC0200 sub sp,0x2 0000010D 89EB mov bx,bp 0000010F 81C30600 add bx,0x6 00000113 8B07 mov ax,[bx] 00000115 89EB mov bx,bp 25

00000117 81C30400 add bx,0x4 0000011B 0307 add ax,[bx] 0000011D 89EB mov bx,bp 0000011F 81C3FEFF add bx,0xfffe 00000123 8907 mov [bx],ax 00000125 89EB mov bx,bp 00000127 81C3FEFF add bx,0xfffe 0000012B 8B07 mov ax,[bx] 0000012D 81C40200 add sp,0x2 00000131 5D pop bp 00000132 C3 ret 00000133 81C40200 add sp,0x2 00000137 5D pop bp 00000138 C3 ret 00000139 90 nop 0000013A 55 push bp 0000013B 89E5 mov bp,sp 0000013D B80100 mov ax,0x1 00000140 50 push ax 00000141 B80300 mov ax,0x3 00000144 50 push ax 00000145 E8BDFF call 0x5 00000148 81C40400 add sp,0x4 0000014C A35701 mov [0x157],ax 0000014F CD20 int 0x20 00000151 81C40000 add sp,0x0 00000155 5D pop bp 00000156 C3 ret 00000157 0000 add [bx+si],al 00000159 90 nop Figura 18. Output in assembler intel del programma 'somma La prima istruzione del codice prodotto in pseudo-assembler è un salto incondizionato alla funzione di ingresso (main). Ogni funzione inizia con init_funz che ha lo scopo di settare lo stack per le eventuali allocazione future di variabili locali. La funzione main in questo caso, immette nello stack i valori da passare alla funzione somma. Quindi esegue un push degli argomenti da sinistra a destra come nella convenzione adottata dal linguaggio Pascal. A questo punto esegue una chiamata alla funzione somma, tramite l istruzione call. Una volta terminata la funzione somma, a differenza della convenzione adottata nel linguaggio Pascal dove è la funzione chiamata a liberare lo stack dai parametri di ingresso, qui è la funzione chiamante che fa tale operazione tramite la pseudo istruzione destroy_local(n) dove n rappresenta il numero di bytes da liberare. Nel caso dell applicazione di esempio avevamo due variabili intere da passare alla funzione somma, quindi una volta terminata la funzione chiamata dobbiamo 26

eliminare 4 bytes dallo stack, visto che nei processori intel in modalità reale ogni intero è codificato con due bytes usando la convenzione little-endian ossia scrivendo in memoria prima il byte meno significativo e poi quello più significativo. A questo punto viene salvato il valore di ritorno nella apposita variabile e viene invocato l uscita dal programma tramite la pseudo istruzione halt. Poi il programma termina immediatamente. Diamo uno sguardo più da vicino a quello che succede quando viene passato il controllo alla funzione somma e vediamo come viene usato lo stack e come viene tradotto lo pseudo codice in codice Intel 80x86. Un indirizzo assoluto in modalità reale di un processore Intel 80x86 viene rappresentato tramite una coppia di registri detti segmento (segment register SR) e offset (offset register OR) entrambi di 16 bit. Il calcolo di tale indirizzo può essere trovato tramite la formula SR*16+OR. Lo stack è rappresentato tramite la coppia di registri SS (segmento) e SP (offset). registro ax bx cx dx si di bp sp ds es ss cs ip significato registro accumulatore registro generico indice sorgente indice destinazione base pointer stack pointer data segment extra segment stack segment code segment instruction pointer Figura 19. I registri del processore intel 80x86 in modalità reale È una convenzione scrivere la coppia segmento e offset separati da due punti: SS:SP che quindi punta alla posizione attuale dello stack. Se inseriamo un nuovo valore nello stack (ad esempio push ax) verranno eseguiti dal processore i seguenti passi: sottrai un byte a SP: SP=SP-2 immetti nella nuova posizione il valore di ax. 27