TRADUZIONE DEL PROGRAMMA Una volta che un programma sia stato scritto in C, esso non può essere eseguito senza un’ulteriore traduzione. Ciò perché qualsiasi computer è in grado di eseguire solo le istruzioni scritte nel linguaggio proprio della sua architettura, detto linguaggio macchina, costituito da una serie di 0 e 1. Dato che la programmazione in linguaggio macchina è difficile e soggetta a errori, sono stati sviluppati dei programmi, detti genericamente traduttori, che accettano in ingresso un programma scritto in un linguaggio di alto livello (per esempio in C), detto programma sorgente, e producono in uscita un programma scritto in linguaggio macchina, detto programma (o codice) oggetto. Un programma può essere tradotto in linguaggio macchina secondo due strategie diverse, ossia usando un programma interprete oppure un programma compilatore: un programma interprete traduce individualmente ogni istruzione del programma sorgente e la esegue immediatamente; quindi non produce un codice oggetto. un programma compilatore traduce tutte le istruzioni del programma sorgente in un programma equivalente, prima che ciascuna di esse sia eseguita; quindi produce un codice oggetto. Il C è un linguaggio compilato, quindi il programma sorgente è tradotto come un tutt’uno nel linguaggio macchina. In realtà, la traduzione di un programma sorgente in un programma eseguibile avviene in due passaggi consecutivi: • il primo eseguito da un programma detto compilatore • il secondo eseguito da un programma detto loader/linker. Il compilatore dunque accetta in ingresso il programma sorgente e produce in uscita un programma oggetto, scritto nelle istruzioni proprie del microprocessore usato (ossia in linguaggio assembly). In genere il programma oggetto non consiste in un codice adatto per l’esecuzione, codice che sarà prodotto successivamente da un gruppo di tre programmi datti assemblatore, legatore, caricatore. Intel 4004 Instruction Set Programma compilatore. Un compilatore è costituito in realtà da diversi programmi, che eseguono sul sorgente un certo numero di operazioni. Esse si possono dividere in due parti: analisi e sintesi. L’analisi suddivide il programma sorgente nelle sue parti costituenti e ne crea una rappresentazione intermedia. La sintesi genera il programma target dalla rappresentazione intermedia. La parte di analisi si può suddividere nelle seguenti fasi: 1. analisi lessicale (o lexing); 2. analisi sintattica (o parsing); 3. analisi semantica; La parte di sintesi si può suddividere nelle seguenti fasi: 1. generazione del codice intermedio; 2. ottimizzazione del codice; 3. generazione del codice finale. Alla fine viene prodotto il programma target. Nell’analisi lessicale (o lexing) il programma è considerato come un’unica sequenza di caratteri; essa viene scansionata per individuare gli elementi base del linguaggio che costituiscono un’istruzione, quali parole chiave, identificatori, costanti, operatori, delimitatori, ... Un analizzatore lessicale legge il programma da sinistra a destra e raggruppa le sequenze di caratteri in token, che sono unità lessicali di significato compiuto. La sequenza di caratteri che dà luogo a un token è detta lessema. L’analisi lessicale può rilevare errori relativi alla grafia delle parole chiave o di identificatori creati dall’utente, al formato delle costanti, ... Ad es., il seguente diagramma lessicale permette di stabilire se un identificatore abbia il formato corretto (cioè inizi con una lettera, seguita da lettere e/o cifre). Una volta eseguita l’analisi lessicale, le fasi successive della compilazione non faranno più riferimento ai singoli caratteri, ma alle parole individuate come elementi di base. Consideriamo, come esempio, la seguente istruzione di assegnazione: totale = base + increm * 60 L’analizzatore lessicale raggruppa i caratteri nei seguenti token: A questo punto il compilatore costruisce una Tabella dei simboli, dove registra gli identificatori usati nel programma insieme ai loro attributi. Essa ha l’aspetto indicato in figura Come si vede, gli attributi di un token ID si possono riferire a: memoria allocata, tipo dati, portata o ambito, numero e tipo di argomenti, ecc. Quando viene rilevato un identificatore e generato un token ID, il lessema corrispondente è inserito nella Tabella dei simboli, e al token ID viene associato un puntatore alla posizione nella Tabella. Si ottengono anche una o più tabelle dei simboli contenenti tutti gli identificatori dichiarati nell’ambito del programma come tipi, variabili, procedure, funzioni,... che saranno utilizzate nelle successive fasi di analisi semantica, generazione del codice e gestione della memoria. Nell’analisi sintattica, o parsing, viene riconosciuta la struttura del programma e viene rappresentato il suo significato in una forma intermedia, dalla quale verrà poi prodotto con semplici trasformazioni il codice oggetto. L’analisi sintattica è guidata dalla definizione formale del linguaggio di programmazione - data per esempio mediante la forma normale di Backus o i diagrammi sintattici - e consente di caratterizzare tutti e soli i programmi “validi”, cioè sintatticamente corretti, e di comprenderne il significato. Ad es., il seguente diagramma sintattico permettere di stabilire se sia stato impiegato un identificatore valido in C Il diagramma seguente permette invece di controllare la validità di una istruzione if. Gli errori che si rilevano in questa fase riguardano l’erronea strutturazione del programma a partire dalle proposizioni, ad es. la mancata corrispondenza di parametri in un’espressione, l’assenza di parole chiave attese in determinate posizioni (come il while dopo il do), la mancanza della parola chiave case in corrispondenza di una switch e così via. Come risultato di questa fase si ottiene una forma intermedia (albero, matrice, ...) corrispondente alla struttura sintattica del programma analizzato. I token vengono raggruppati in frasi grammaticali rappresentate da un albero parse, che fornisce una struttura gerarchica al programma sorgente. La struttura gerarchica è espressa da regole ricorsive dette produzioni. Ad es., produzioni per istruzioni di asegnazione sono: <assegnazione> <espr> <op> → ID “=” <espr> → ID | NUM | <espr><oper> | (<espr>) → + | - | * Ecco un esempio di albero parse | / Nell’analisi semantica vengono compiute diverse verifiche di consistenza semantica dei concetti utilizzati dal programma. Ad esempio, la frase “il libro legge lo studente” è sintatticamente corretta, mentre risulta errata dal punto di vista del significato. L’analisi semantica di un programma verifica: • se gli identificatori usati in una procedura siano stati dichiarati nella procedura stessa, • se rispettino le regole di “visibilità” previste dal linguaggio per variabili e procedure, e • se il loro tipo sia coerente con l’uso che ne viene fatto (ad es., i numeri reali non possono essere usati come indici dei vettori). In caso di errori viene fornita la diagnostica relativa. Fase di sintesi A partire dalla forma intermedia acquisita durante l’analisi sintattica avviene la generazione del codice intermedio, nella forma di un programma per una macchina astratta. Il codice deve essere facilmente traducibile nel programma target. Tale generazione avviene in base a regole molto semplici, che fanno corrispondere un’istruzione macchina a ciascuna struttura elementare rappresentata nella forma intermedia. Il codice prodotto nella fase precedente può non essere efficiente. Al fine di ottenere un programma oggetto corto ed efficiente si possono effettuare vari interventi di ottimizzazione del codice. Per esempio spostare all’esterno di un ciclo le istruzioni che non dipendono dalle variabili del ciclo stesso, eliminare il calcolo di espressioni ripetute più volte o semplificare espressioni aritmetiche o logiche. Questi interventi possono essere: dipendenti dalla macchina, se tengono conto delle caratteristiche hardware e del repertorio di istruzioni del computer su cui dovrà operare il codice oggetto; indipendenti dalla macchina, se non ne tengono conto. I diversi compilatori adottano diverse tecniche di ottimizzazione. Nella generazione del codice viene prodotto il codice target in linguaggio assembly, una versione mnemonica del codice macchina che usa: codici operativi o mnemoniche per le operazioni (Tabella dei codici); nomi simbolici per gli operandi (al posto delle locazioni di memoria) (Tabelle dei simboli); Vengono scelte le locazioni di memoria per ciascuna variabile, le istruzioni sono tradotte in una sequenza di istruzioni assembly e le variabili e i risultati intermedi sono assegnati ai registri di memoria. Programmi assemblatore, legatore, caricatore. Il programma assemblatore (assembler) s’incarica di tradurre ciascuna istruzione del codice target in una in linguaggio macchina, producendo un codice in linguaggio macchina secondo lo schema seguente Tuttavia, mentre la traduzione del codice operativo in linguaggio macchina è in ogni caso univoca, non è sempre possibile sostituire immediatamente a ogni riferimento simbolico di locazione di memoria l’indirizzo effettivo della relativa locazione. Infatti gli operandi che indicano una locazione di memoria possono indicare: indirizzi assoluti, quali quelli dei dispositivi di ingresso/uscita, che non dipendono ovviamente dalla particolare collocazione del programma in memoria. Essi possono essere tradotti direttamente in indirizzi binari; indirizzi relativi ai dati e alle istruzioni del codice macchina. Ovviamente tali indirizzi cominciano da 0, e affinché il codice possa esere eseguito correttamente, essi dovrebbero rimanere gli stessi anche quando il codice viene caricato in memoria centrale. Ciò richiederebbe che il codice venisse allocato in memoria a partire dall’indirizzo 0, cosa non sempre possibile; d’altra parte, la grande maggioranza dei codici possono essere caricati in qualsiasi zona di memoria disponibile (per cui sono detti codici rilocabili). Gli indirizzi relativi, che a differenza di quelli assoluti devono essere tradotti in indirizzi di memoria centrale tenendo conto dell’indirizzo di memoria a partire dal quale il programma viene caricato, sono detti anche rilocabili. Per gli operandi rilocabili l’assemblatore crea, in una prima fase, una tabella dei simboli, nella quale pone i nomi che individua come riferimenti simbolici e la loro posizione relativa nel programma. Solo in una seconda fase gli operandi simbolici saranno sostituiti con gli indirizzi corrispondenti, consultando la tabella dei simboli. Perciò il programma assemblatore viene detto a due passi o fasi. Programma legatore. Prima della trasformazione definitiva di tutti gli indirizzi in assoluti, è però necessaria un’altra operazione, eseguita dal programma legatore (linker). Esso unisce insieme i differenti file e moduli che possono costituire un sigolo programma (file oggetto), ai quali aggiunge eventualmente dei file di biblioteca (library). Spesso i termini linker e loader sono usati in modo interscambiabile. Programma caricatore. A questo punto interviene il programma caricatore (loader), per trasformare tutti gli indirizzi in assoluti. Esso memorizza in uno speciale registro base o di riferimento l’indirizzo di memoria a partire dal quale viene caricato il programma (detto indirizzo base), quindi somma tale indirizzo base a ogni indirizzo relativo, ottenendo un indirizzo assoluto. In tal modo, se sarà necessario spostare il programma in un’altra zona di memoria (processo detto rilocazione), sarà sufficiente modificare il valore dell’indirizzo base. Riassumendo, la trasformazione degli operandi di un programma in linguaggio assembly avviene, nel caso più generale, nelle tre fasi seguenti: l’assemblatore trasforma gli operandi simbolici in indirizzi assoluti, rilocabili o globali il legatore trasforma gli indirizzi globali in rilocabili il caricatore trasforma gli indirizzi rilocabili in assoluti. Compilatore DMC. In Internet si trovano ottimi compilatori assolutamente freeware. Noi utilizzeremo il compilatore DMC della Digital Mars, che si può scaricare gratuitamente all’indirizzo cliccando sul link: Si tratta di un programma che esegue la complazione e il link di file C, C++ e ASM in un solo passaggio. Il download crea nella cartella Documenti una cartella dm con alcune sottocartelle: Dato che il compilatore (dmc.exe) si trova nella sottocartella bin, il modo più veloce (anche se non il più elegante) per provare il funzionamento dei propri programmi in C consiste nel: 1. salvare il proprio programma in formato solo testo e con estensione .c nella sottocartella bin della cartella dm; 2. eseguire il programma Prompt dei comandi da Start → Tutti i programmi → Accessori → 3. dal prompt C:\Documents … \Documenti> digitare: cd dm\bin dmc hello.c 4. se l’operazione ha successo, vengono creati i tre file hello.exe, hello.map, hello.obj, il primo dei quali si può ora eseguire con il comando hello