I compilatori
Un compilatore è un tool che traduce un sorgente scritto in un linguaggio A, in un equivalente sorgente scritto in un linguaggio B. Un ipotetico compilatore che trasforma un sorgente BASIC in un sorgente C, è allora in grado di eseguire conversioni simili alla seguente:
sorgente BASIC
if numero = 1 then
numero = numero \* 10
endif
sorgente C
if( numero == 1 )
numero \*= 10;
Fissata l'idea su che cosa è un compilatore, passo ora ad analizzarne la sua struttura interna utilizzando il modello semplificato che ho schematizzato in Figura 1.
L'analizzatore lessicale
Si preoccupa di convertire il sorgente in unità fondamentali chiamate token. Le parole come IF o WHILE sono token, così come lo è la costante numerica 123.456, la costante letterale "pippo" o il nome di una variabile. La tipica funzione esportata da un analizzatore lessicale, si chiama GetToken() che ritorna al chiamante il nome del token riconosciuto. Il tipico impiego di questa funzione, è il seguente
while( !eof() ){
switch( GetToken() ){
case WHILE:
printf("ho letto la parola while\n")
break;
case IF:
printf("ho letto la parola if\n")
break;
case ...
...
}
}
Questo codice stampa a video il "prossimo" token che l'analizzatore lessicale ha individuato nel file sorgente.
Il parser
Il parser dialoga direttamente con l'analizzatore lessicale che gli passa, tramite il ripetuto richiamo della GetToken(), l'intero sorgente in formato token. Quindi per il parser un sorgente è un flusso di token, così come per l'analizzatore lessicale un sorgente è un flusso di caratteri. Il compito più importante di un parser, è verificare che una sequenza di token abbia senso.
Quindi una scritta del tipo
a + = 100;
piuttosto di
a += 100;
deve essere "non" riconosciuta dal parser, che deve emettere un opportuno messaggio di errore per spiegare al programmatore che il token +, seguito da uno spazio bianco e quindi dal token uguale, non è corretto. L'analizzatore lessicale ed il parser lavorano a così stretto contatto, da rendere possibile (qualche volta) l'implementazione dell'analizzatore lessicale all'interno del parser stesso. Quando l'analizzatore lessicale incontra la fine del file, comunica al parser che non vi sono più token - e che quindi è arrivato il momento di salvare su disco (in un'opportuna struttura dati) il codice sorgente riconosciuto.
Con il passare degli anni sono state studiate molte strutture dati per la "descrizione" di un sorgente, e fra le più note ricordo la "Notazione Polacca".
In sostanza l'espressione
primo + secondo / 2
è letta dal parser che produce in uscita una scritta del tipo
+ primo / secondo 2
che risolve in partenza eventuali problemi legati alla priorità di esecuzione degli operatori aritmetici (ad esempio: prima le divisioni/moltiplicazioni, poi le addizioni/sottrazioni).
Il nocciolo della Notazione Polacca, è quindi di specificare l'operatore prima dei relativi operandi:
Ad esempio a + b diventa + a b.
L'ottimizzazione del codice
L'ottimizzatore modifica la struttura dati creata dal parser per migliorarla allo scopo, ad esempio, di aumentare le prestazioni temporali di esecuzione. Un codice del tipo
- for( j = 0; j \< 100; j++ ) {
a = pippo();
printf("%d", j + a);
}
è trasformato in
a = pippo();
for( j = 0; j \< 100; j++ )
printf("%d", j + a);
perché l'assegnamento a=pippo() si dice che è "invariante" rispetto al ciclo, ed è quindi inutile eseguire sia il richiamo a funzione che l'assegnamento per tutte le volte che il ciclo è eseguito (sarebbe in sostanza uno spreco di tempo...).
Il generatore di codice
Tale elemento legge la struttura dati manipolata dall'ottimizzatore, per generare il codice ASM vero e proprio. A volte è nel generatore di codice che avviene la peephole optimization (ottimizzazione orientata alla macchina), che si propone di ottimizzare la traduzione in codice ASM di costrutti tipici quali l'assegnamento o il test di una condizione logica.
Ad esempio una scritta del tipo mov ax, 0 è sicuramente rimpiazzata dall'istruzione xor ax, ax che ha prestazioni temporali migliori rispetto alla precedente.
Se invece il compilatore genera codice C, un esempio di ottimizzazione possibile è a riguardo dell'uso dei vettori: una scritta del tipo \(array+j) è più veloce della classica array\[j\] se array* è un puntatore dinamico. Si osservi come sia del tutto opzionale il blocco relativo all'ottimizzazione del codice di Figura 1, poiché si preoccupa esclusivamente di migliorare un codice preesistente.
Il linguaggio di programmazione ASM
L'assembler (ASM d'ora in poi) è - come è stato visto in questo speciale - il linguaggio di programmazione comprensibile alla CPU.
In questo articolo, però, non considereremo ASM come linguaggio macchina, ma lo tratteremo piuttosto come un qualsiasi linguaggio di programmazione.
Con l'ASM è possibile eseguire le quattro operazioni algebriche, effettuare salti incondizionati e manipolare variabili temporanee. Programmare in ASM è molto semplice: non bisogna imparare a memoria le regole di priorità che coinvolgono formule del tipo a+b/2, né si deve investire del tempo per studiare le differenze fra un ciclo while e do-while.
In sostanza la struttura del linguaggio ASM definisce un numero fisso di variabili temporanee chiamate in gergo registri, un set di operatori algebrici e logici e un insieme di istruzioni per eseguire dei salti condizionati da una parte all'altra del sorgente.
L'implementazione di un ipotetico linguaggio ASM, potrebbe essere la seguente:
- Due variabili (o se si preferisce registri) generali chiamate AX e BX;
- Un'istruzione, chiamata MOV, per copiare in una delle due variabili un valore che può essere una costante o ancora il contenuto di una variabile. Ad esempio la scritta MOV AX, 2 copia in AX la costante 2, e MOV BX, AX copia in BX il valore contenuto nella costante AX;
- Qattro istruzioni chiamate ADD, SUB, MUL e DIV per quante sono le operazioni algebriche fondamentali. La scritta ADD AX, 2 somma il contenuto di AX con il numero 2 "copiando" il risultato in AX. In generale un'istruzione aritmetica lascia il risultato nel primo dei suoi due operandi.
- Quattro istruzioni di salto JUGU, JNOT, JMIN e JMAG che permettono di eseguire un salto verso una parte qualunque del sorgente "se" il contenuto della variabile AX o BX è uguale, diverso, minore o maggiore di zero. Ad esempio la scritta JMAG AX, PIPPO, sposta l'esecuzione del programma alla prima istruzione che segue l'etichetta ":PIPPO". La lettera J di prefisso, ricorda l'istruzione JMP di molti ASM commerciali (dall'inglese jump=salto).
- Un'istruzione di salto assoluto, chiamata JMP, che permette di effettuare dei salti ad una parte qualunque del sorgente indipendentemente da un test logico.
Con gli strumenti appena descritti, è possibile scrivere programmi per l'implementazione di una qualche formula matematica come quella che risolve l'area di un triangolo. Infatti se in AX memorizziamo la base e in BX l'altezza, la nota formula "base\altezza/2*" diventa:
- MUL AX, BX
DIV AX, 2
L'area verrà posta in AX, poiché il risultato di tutte le istruzioni algebriche è sempre memorizzato nella variabile che compare come primo operando.
Con il linguaggio ASM è anche possibile effettuare dei cicli pur non essendo definita nessuna istruzione while o for.
Per calcolare, ad esempio, la somma dei primi dieci numeri che vanno da 1 a 10, si può scrivere:
- MOV AX, 10
MOV BX, 0
:ANCORA
ADD BX, AX
SUB AX, 1
JNOT AX, ANCORA
ottenendo così nella variabile BX il risultato cercato.
Si osservi come il precedente programma sia equivalente al seguente sorgente C:
- void somma() {
ax = 10;
bx = 0;
ancora:
bx = bx + ax;
ax = ax - 1;
if( ax != 0
goto ancora;
}
essendo ax e bx due variabili generali di tipo (ad esempio) intero.
La stessa cosa vale per un'implementazione BASIC:
- 10 ax = 10
20 bx = 0
30 bx = bx + ax
40 ax = ax - 1;
50 if ax \<\> 0 then goto 30
Un esecutore per il linguaggio ASM
La parte essenziale di un esecutore (o interprete) di un programma ASM, è in pratica un ciclo while che legge il sorgente riga dopo riga sino al raggiungimento della fine del file.
- while( !eof() ) {
read(str);
eseguiRiga(str);
}
La eseguiRiga() interpreta ed esegue il contenuto della stringa str, che contiene l'ennesima riga letta dalla funzione read(). Se l'ennesima istruzione letta è una MOV AX, BX, la eseguiRiga() deve eseguire il codice AX = BX. Lo stesso discorso si applica ad esempio ad ADD AX, BX, che si deve tradurre con l'esecuzione di un codice del tipo AX=AX+BX. Quindi, ed in generale, la eseguiRiga() deve essere in grado di riconoscere due tipi di righe testo:
- un'istruzione, se il primo token di questa è il nome di un'operazione predefinita (come MOV o ADD) seguita da uno o due operandi.
- un'etichetta, utilizzabile da un'istruzione di salto (come JMAG) se il primo carattere di questa è il ':' seguito immediatamente da una sequenza di caratteri alfanumerici di lunghezza arbitraria.
Ma non è tutto qui. Infatti nel codice dell'interprete devono essere previste le variabili AX e BX (da utilizzare nelle istruzioni del tipo MOV AX, BX) e una variabile PC chiamata in gergo "Program Counter" (o contatore di programma), che in sostanza è un registro (o variabile) che contiene il puntatore alla prossima istruzione da eseguire: in un sorgente BASIC non è altro che il noto numero di riga. Il Program Counter è utilizzato soprattutto nelle istruzioni di salto (o goto) che spostano l'attenzione dell'esecutore ad una specifica riga di istruzione. Ad esempio la riga 50 (PC=50) dell'ultimo listato del precedente paragrafo, sposta il PC sulla riga 40 se il valore del registro AX è uguale a zero.
Compilazione, byte-code e virtual machine
Spesso il sorgente ASM da eseguire è di uso frequente perché richiamato all'interno di un file batch, o perché magari è racchiuso all'interno di un ciclo. Quindi rielaborare tutte le volte il riconoscimento delle stesse istruzioni, la verifica della loro sintassi, ed altre cose tipiche di un parser - è una cosa inutile. L'idea è allora di effettuare una pre-interpretazione che associ ad ogni "istruzione base" un numero. Le istruzioni ADD, SUB, MUL e DIV possono essere "rinominate" con i numeri 1, 2, 3 e 4. Anche i registri possono essere rappresentati da un numero (ad esempio) maggiore di 128: AX diventerebbe ad esempio un 129, e BX un 130.
Ancora più in generale occorre associare ad ogni possibile "token", un numero unico che lo identifichi una volta per tutte. Questa pre-interpretazione può essere chiamata compilazione, ed il codice prodotto è una visione "binaria" del codice sorgente che per sua natura è descritto in termini testuali. L'interprete che lavora su di un sorgente questa volta "binario", avrà ancora un aspetto simile a quello presentato nel precedente paragrafo,
- while( !eof() ) {
leggiByte(buf);
eseguiByte(buf);
}
In tal caso però la eseguiByte() (che sostituisce la eseguiRiga) è più rapida nel riconoscere il codice (o byte-code) che descrive l'istruzione da eseguire, poiché non dovrà più cimentarsi in costosi test a livello di stringa per verificare se i primi tre caratteri della stringa siano "ADD" piuttosto di "MOV": sarà invece sufficiente un solo test... su di un solo byte perché, lo ricordo, ogni istruzione è identificata univocamente da un numero. Quanto ho appena descritto è un esempio di virtual machine: quindi, azzardando una traduzione in italiano, di una CPU virtuale. Infatti tutte le CPU classiche, da quelle a quattro bit per usi industriali alle grosse macchine a 32 bit, hanno una struttura di base che può essere descritta dal modello semplificato descritto in questo e nel precedente paragrafo.
La Java virtual machine
Il programma compilato di un sorgente Java (tipicamente memorizzato in un file testo con estensione .java), è un file binario con estensione .class generato da un opportuno compilatore.
La prima sezione del tracciato record di un file .class (che ho schematizzato in Figura 2), raccoglie la descrizione delle costanti, dei nomi, delle classi e dei metodi utilizzati nel programma. La seconda ed ultima sezione, è invece il byte-code vero e proprio che deve essere interpretato ed eseguito. In Java un esempio di byte-code è il 96 che rappresenta l'istruzione iadd per la somma di due interi, o ancora il numero 110 per la divisione (fdiv) di due quantità di tipo float. Tutte le istruzioni lavorano su stack, e non è dunque richiesto l'utilizzo di variabili di uso generale come le AX e BX menzionate nei precedenti paragrafi. Il Riquadro 1 descrive, a sommi capi, che cosa è e come si usa uno stack. L'utilizzo delle operazioni di push e pop è fondamentale, almeno nei confronti delle istruzioni della Java Virtual Machine.
Infatti, ad esempio, una possibile implementazione pratica della iadd, può essere la seguente
- int numero1;
int numero2;
int risultato
numero1 = pop();
numero2 = pop();
risultato = numero1+numero2;
push(risultato);
Tale codice preleva (pop) dallo stack due grandezze intere per sommarle fra loro.
Il risultato è quindi memorizzato (push) nello stack. Un'altra particolarità essenziale a riguardo della JVM (Java Virtual Machine), è nel formato scelto per la memorizzazione di costanti numeriche - che sono tutte trattate in modalità "big-endian". Su una macchina Intel (che utilizza il formato "little-endian") per leggere un intero di due byte, è necessario eseguire la conversione
numero = primobyte\*256 + secondobyte
Inoltre tutte le grandezze sono documentate a priori, e quindi la portabilità del .class generato, è garantita. Un codice del tipo
fwrite((char\*)&numero, sizeof(int), 1, pFile);
non ha senso, perché scrive su disco una quantità "non documentata" di byte pari a un sizeof di un int.
Ricordo infatti che vi sono importanti differenze fra un intero a 16 e a 32 bit, senza considerare la differente memorizzazione di quantità numeriche (rappresentate da un numero di byte maggiore di due), in formato little-endian piuttosto di big-endian. Provare a leggere la definizione della struttura interna di un eseguibile Java, è utile anche ai programmatori che non hanno intenzione di sviluppare un interprete: infatti la specifica della Java virtual machine è tale da essere un ottimo esempio di come possa essere strutturato un tracciato record multipiattaforma.
L'esempio di corredo all'articolo
L'esempio introduttivo che correda questo articolo, si chiama DevCode e definisce poco più di una ventina di istruzioni base tipizzate: in sostanza è possibile eseguire le quattro operazioni algebriche su grandezze di tipo long e double.
Il nome di ogni istruzione è mnemonico, e la addlong somma fra di loro due long così come la adddouble somma fra di loro due double. Tutte le istruzioni utilizzano uno stack per la memorizzazione di valori numerici e la pushkdouble, ad esempio, memorizza in cima allo stack una costante di tipo double, in contrapposizione all'istruzione popdouble che estrae dalla cima dello stack una grandezza double.
La struttura fisica di un tipico sorgente è molto semplice perché individuata da tre sezioni fondamentali e obbligatorie:
- Dichiarazione nomi file DLL: i nomi .dll ed .enddll delimitano l'area utilizzata per dichiarare i file delle librerie a collegamento dinamico utilizzate dall'istruzione calldll per l'esecuzione di una funzione esterna.
- Dichiarazione dati costanti: i nomi .data ed .enddata delimitano la dichiarazione dei dati costanti. Ricordo che con la DevCode è possibile manipolare solo grandezze di tipo long o double, per cui la definizione di una costante ha una forma del tipo \<tipo\> \<costante\>, potendo \<tipo\> valere 'D' o 'L' (double o long) e \<costante\> essere un appropriato valore numerico come 123 per long, e 3.14 per double. Attenzione all'ordine di dichiarazione, perché questi valori costanti sono referenziati con il loro relativo ordine. L'istruzione push_kdouble 1 esegue il push della prima costante dichiarata nella sezione .data.
- Descrizione del codice: i nomi .codice ed .endcodice delimitano l'area utilizzata per scrivere il codice sorgente vero e proprio. Le istruzioni base che descriverò fra poco, sono "base" perché per ogni istruzione designata per il tipo dato double, ne esiste un'altra (identica nell'uso) per il tipo dato long.
Il Listato 1 è un esempio di programma scritto in DevCode, che esegue la somma dei primi dieci numeri visualizzando a video i totali progressivi. La visualizzazione è effettuata richiamando una funzione utente, di nome stampa_d, esportata da una DLL utente chiamata provadll.dll.
OPERAZIONI ARITMETICHE
Le istruzioni adddouble, subdouble, muldouble, divdouble eseguono l'addizione, la sottrazione, la moltiplicazione e la divisione di due grandezze di tipo double poste in cima allo stack. Il risultato è posto in cima allo stack.
OPERAZIONI SU FRAME
L'istruzione newframe riserva spazio sullo stack per un preciso numero di grandezze di tipo double o long. Ad esempio una scritta del tipo newframe LLDL, riserva sullo stack lo spazio necessario per memorizzare due long, un double e un long. Il comportamento dell'istruzione new_frame, ricorda quello della sezione .data/.enddata con la differenza che l'allocazione di spazio è dinamica (all'atto dell'esecuzione), piuttosto che statica (all'atto del caricamento del programma).
OPERAZIONI SU STACK
L'istruzione dup_double duplica il valore di tipo double posto in cima allo stack.
L'istruzione push_kdouble esegue il push su stack di una costante.
L'istruzione pushdouble esegue il push su stack di un valore memorizzato nel frame di stack creato con l'istruzione newframe.
L'istruzione pop_double copia in una locazione del frame il valore double memorizzato in cima allo stack.
SALTO DI ESECUZIONE
Le istruzioni jmpdoublemaggiore, jmpdoubleminore e jmpdoubleuguale leggono il primo double presente in cima allo stack, lo confrontano con la costante 0, ed in caso di esito positivo cambiano il Program Counter all'etichetta specificata come primo ed unico loro parametro.
L'istruzione jmp_jmp esegue un salto incondizionato all'etichetta specificata come primo ed unico parametro.
RICHIAMO DI FUNZIONI ESTERNE
L'istruzione calldll permette di richiamare una funzione contenuta in una delle librerie a collegamento dinamico dichiarate nella sezione .dll/.enddll del sorgente. L'utilizzo della calldll è molto semplice, perché ricorda il prototipo di una funzione C. La scritta:
calldll V stampa_d D
richiama la funzione stampa_d che attende come unico parametro un valore double posto su stack. La funzione è di tipo void (V) ma potrebbe ritornare un long (L) o un double (D). Se una funzione attende più di un parametro, basta enumerarli sequenzialmente con la stessa tecnica utilizzata per la definizione di un nuovo frame. Una scritta del tipo calldll L pippo LL richiama la funzione di nome pippo passandogli due parametri di tipo long da stack; il risultato è un long, immesso in cima allo stack. L'interfaccia del compilatore e dell'interprete byte-code, è rappresentata da un file eseguibile di nome DEVCODE.EXE.
Il suo utilizzo è immediato perché il comando
devcode -compile=prog
compila il file testo prog producendo il file binario prog.dev che può essere eseguito con il comando
devcode -run=prog.dev
Inoltre è possibile specificare a DEVCODE.EXE di compilare ed eseguire un programma in un'unica soluzione: il comando
devcode -compile=prog -run=prog.dev
compila il sorgente prog, ed esegue il file prog.dev.
Bisogna fare attenzione all'ordine di comparsa degli switch -compile e -run, per non rischiare di eseguire un programma non ancora compilato.
L'ambiente di esecuzione
L'ambiente di esecuzione è implementato nel file RUN.C che esporta la funzione run_esegui() che come unico parametro attende (in formato stringa) il nome del file da eseguire (la cui estensione tipica è .DEV).
La funzione run_caricaFile() apre il file in modalità binaria e verifica se i primi tre caratteri sono pari alla stringa "DEV" (il .class di Java è invece battezzato dai quattro byte 0xCafeBabe). Quindi si passa al caricamento della sezione contenente i nomi delle DLL e al caricamento delle costanti: la struttura dati di memorizzazione utilizzata per queste sezioni, è un semplice array. L'ambiente di esecuzione è virtualizzato dalla struttura TProgramma riportata nel Listato 2.
La variabile pStack è uno stack di 5000 byte, ed il registro SP ne è il suo puntatore (ovviamente gestito dalle istruzioni di push e di pop). La variabile FP (Frame Stack) è il registro che memorizza il primo byte libero sullo stack. Il registro FP è alterato dalla istruzione new_frame che alloca un'area dati sullo stack. Dopo l'assegnamento dei valori iniziali dei registri macchina:
FP = pStack;
SP = FP;
si passa all'esecuzione del byte-code vero e proprio che è caricato nel buffer pProgramma. In un ciclo while è letto ogni singolo byte-code che viene "verificato" da uno switch, i cui case puntano alle relative funzioni op_xxx(). Il seguente codice è un estratto dello switch che seleziona il byte-code da eseguire per le istruzioni di somma fra due long o fra due double.
- while( !iEsci ) {
memRead(buf, sizeof(char), gPrg.pMemFile);
switch(iLastOp) {
...
case OPADDL:
case OPADDD:
op_add(iLastOp);
break;
...
}
}
Il corpo della funzione op_add() è molto semplice:
- void op_add(int iOp) {
switch( iOp ) {
case OPADDL:
lWork1 = simplePopLong();
lWork2 = simplePopLong();
simplePushLong(lWork1+lWork2);
break;
case OPADDD:
dWork1 = simplePopDouble();
dWork2 = simplePopDouble();
simplePushDouble(dWork1+dWork2);
break;
}
}
Le implementazioni delle funzioni simplePopXxx e simplePushXxx sono simili a quelle descritte nel Riquadro 1. Se un'istruzione necessita di un argomento, questo è sempre memorizzato immediatamente dopo il byte-code da eseguire. Un esempio è dato dall'istruzione di salto incondizionato jmp_jmp, il cui byte-code=25 è immediatamente seguito dall'indirizzo di salto, quindi dalla locazione del prossimo byte-code da leggere dal buffer pProgramma. Per aiutare lo sviluppo del codice di lettura dei byte-code di un file .DEV, ho implementato un certo numero di funzioni che si ispirano ad un classico package di I/O per l'accesso ai file.
I nomi delle funzioni sono mnemonici, ed un esempio è dato dalla memOpen, memClose, memRead e memTellPtr. Ve ne sono delle altre, per il cui dettaglio rimando al sorgente VARIE.C.
Non è stato definito il registro Program Counter, perché emulato "dall'utilizzo" dalla funzione memSeek usata per spostare la lettura (effettuata con la memRead) del "prossimo" byte-code.
La funzione opjmp, è praticamente l'unica che necessiti dei meccanismi offerti da un Program Counter. Il byte-code dell'istruzione jmpjmp, è immediatamente seguito dall'indirizzo di "salto", ed il seguente frammento di codice:
- void op_jmp(int iOp) {
long lLetti, lPos;
lLetti = memRead((char \*)&lPos, sizeof(long), gPrg.pMemFile);
switch( iOp ) {
...
case OP_JMP:
memSeek(gPrg.pMemFile, lPos, SEEK_SET);
break;
...
}
}
legge l'indirizzo di salto con una memRead, e sposta l'indirizzo della prossima lettura del prossimo byte-code con la funzione memSeek. L'implementazione del richiamo di una funzione all'interno di una DLL esterna, è fornita come esempio nel file DLL.C. In pratica l'interprete cerca in ogni file dichiarato nella sezione .dll/.enddll, la libreria che esporta la funzione passata come argomento alla calldll. Se la funzione esiste, allora viene richiamata - altrimenti un opportuno messaggio di errore avverte il programmatore dell'insuccesso. Per eseguire il push fisico dei parametri da passare alle funzioni "esterne", ho impiegato lo stesso meccanismo utilizzato dai compilatori C. Il codice assembler che un compilatore C genera per passare un argomento di tipo long ad una funzione, è il seguente:
- mov eax, lNumber
push eax
call funzione
che esegue il push della variabile lNumber dichiarata come long. Quindi se in un ciclo for eseguiamo detto codice assembler per quanti sono i parametri da passare, risolveremo il problema di come passare a run-time dei parametri ad una funzione esportata da una DLL. Nel codice ho fatto un uso intensivo di alcune operazioni di cast per la scrittura e lettura di dati dallo stack.
Una scritta del tipo
\(long\)SP = lNumero
copia (push) in cima allo stack un numero long, piuttosto del codice
lRito = \(long\)SP
che lo legge (pop). Per aiutare la lettura del codice, nel file VARIE.H vi è un certo numero di macro di conversione (di cast) pronte all'uso. Ad esempio la macro CAST_PTR2DOUBLE, esegue il cast del contenuto di un char \buffer in un double*.
Un codice del tipo
dRito = CAST_PTR2DOUBLE(SP);
esegue il pop di un double dalla cima dello stack.
Implementazione del compilatore
Il compilatore legge un sorgente DevCode per produrre in uscita un file binario di tipo byte-code. Questo vuol dire che nel sorgente ASM.C del compilatore, è necessario prevedere tre momenti: (1) compilazione della sezione .dll, (2) della sezione .data e (3) della sezione .codice. Ma per manipolare queste tre sezioni, è opportuno attrezzare il compilatore di un piccolo e semplice analizzatore lessicale.
Lettura di una riga di testo
La funzione leggiRiga legge da un file l'ennesima riga testo, e la ripulisce da eventuali caratteri o sequenze di caratteri non utili, come le tabulazioni o i caratteri \n e \r. Se dopo questa operazione di filtro la riga letta "diventa" vuota, la leggiRiga legge la prossima riga sino a trovarne una con qualche cosa di significativo o sino al raggiungimento della fine del file sorgente.
Individuazione dei Token
Per fissare le idee, si immagini che la leggiRiga abbia letto una riga di codice contenente l'istruzione
calldll V stampa_d D
che richiama una funzione di tipo void chiamata stampa_d che ha un solo parametro di tipo double.
Questa riga ha quattro token che, nell'ordine, sono 'calldll', 'V', 'stampa_d' e 'D'. Ecco perché è stata implementata la funzione getToken: per isolare uno ad uno questi token. Per essere usata, la getToken attende come unico parametro la stringa da "spezzettare" in token, e ritorna al chiamante un puntatore NULL se non ha trovato nessun token utile, o il puntatore ad una stringa contenente l'ennesimo token riconosciuto: per l'esempio di prima, la getToken dovrà essere richiamata quattro volte prima di ritornare NULL. Per innescare il meccanismo di riconoscimento è necessario "inizializzare" la getToken.
La prima volta deve essere richiamata con la stringa da "riconoscere", le altre volte con un valore NULL. Il frammento di codice seguente, legge una riga testo e stampa "tutti" i token in essa presenti.
- leggiRiga(str);
p = getToken(str)
while( p != NULL ){
printf("ho trovato il token %s", p);
p = getToken(NULL)
}
Il corpo della funzione getToken è reso semplice dall'utilizzo della funzione di libreria strtok:
char \getToken(char \sBuf)
{
char \*p;
p = strtok(sBuf, " \t");
if( p != NULL && \*p == ';' )
p = NULL;
return p;
}
La documentazione di tale funzione è reperibile in un tipico manuale C o anche nell'help in linea di un altrettanto tipico compilatore come il Visual C. La cosa importante da notare è che la getToken ritorna al chiamante il valore NULL per due soli motivi: perché effettivamente non vi sono più token da leggere, o perché si è incontrato un commento e quindi il carattere ';' punto e virgola. Risolto il problema di come riconoscere un'istruzione, è ora possibile concentrarci sullo sviluppo del compilatore vero e proprio.
Dopo la verifica e l'elaborazione delle sezioni obbligatorie .dll e .data (per il cui dettaglio rimando al sorgente), si passa alla compilazione del sorgente DevCode. La leggiRiga legge la prima riga utile, e con la getToken si legge il primo token di questa che può e deve essere un'istruzione DevCode come pushdouble o adddouble (per un elenco rimando alla tabella in OPCODE.C).
Quindi se il token letto non è "conosciuto", un opportuno messaggio di errore avviserà il programmatore. A riconoscimento eseguito, viene scritto su file il byte-code relativo, ad esempio 25 per l'istruzione jmpjmp, seguito immediatamente dagli eventuali parametri che l'istruzione prevede a livello di sintassi. Nel caso di istruzioni matematiche, come la adddouble o la mullong, non vi è alcun parametro, a differenza della funzione jmpjmp che attende un solo parametro: l'indirizzo di salto ad una etichetta.
Ma le etichette, almeno a livello sorgente, sono dei segnaposto della "prossima" istruzione da eseguire - mentre a livello di byte-code è necessario un indirizzo assoluto espresso in termini di coordinate disco: infatti la prossima istruzione da eseguire (quindi l'etichetta) sarà locata ad esempio al byte "nnn" relativo all'inizio del file binario. Ma è anche vero che un'etichetta potrebbe essere utilizzata prima della sua definizione
o
o
jmp_jmp :etichetta
o
o
:etichetta
È quindi praticamente impossibile conoscere la sua locazione su disco. A tal proposito ho previsto una tabella di etichette costituita da due campi: il nome dell'etichetta e la sua locazione. Ma non basta, perché un'etichetta è utilizzata anche dalla jmp_jmp che durante la "prima" fase della compilazione non sa dove questa sia locata: infatti la compilazione avviene in due "passate".
Quindi esiste un'altra tabella costituita da due campi: la locazione su disco dell'istruzione byte-code di jmp_jmp, e la relativa referenza del nome dell'etichetta.
La prima tabella (la referente per le etichette) è aggiornata man mano che il sorgente viene letto e riconosciuto, mentre la seconda ha bisogno di un trattamento particolare solo dopo... la fine dell'intera compilazione. Ecco perché molti compilatori sono spesso chiamati a "due passate": la seconda serve anche per "ripassare" il codice generato per fissare alcuni punti insoluti, e le locazioni di salto ne sono un esempio. Quindi al termine della compilazione, vi è lo scorrimento della tabella delle etichette, e per ogni etichetta la scrittura sul file binario della sua locazione laddove sono presenti le istruzioni di salto.
C
A parte i normali errori di gioventù, l'esempio presentato (che ho compilato con la versione 4.0 del Visual C) può essere migliorato. L'interprete non è assolutamente veloce: ad esempio sono state scritte tante funzioni per quante sono le famiglie di istruzioni base utilizzabili. Tutte le volte che compare un'istruzione addlong o muldouble, è richiamata la opadd o la opmul che tutte le volte... rieseguono a loro volta il controllo sul tipo di operandi da prelevare su stack.
Non esiste nessuna istruzione per la conversione su stack di grandezze long-double e double-long. Non è stato previsto il controllo numerico sul denominatore di una divisione: una divisione per zero, manda in crisi il programma. Non è stato implementato nessun registro di programma e nessun meccanismo per la copia ed il ripristino di questo da/verso stack: non è possibile eseguire subroutine. Il controllo di sintassi ed i messaggi relativi del compilatore, sono ambigui: possono essere migliorati. Gli unici tipi dato previsti sono il long e il double: può essere interessante prevedere un tipo dato generico di tipo HANDLE, per la gestione di stringhe eccetera.
In ultimo si osservi come il "concetto" di byte-code, sia applicato (o applicabile) alla struttura di base di molti linguaggi descrittivi e/o formati di file di uso quotidiano: postscript, rtf e metafile ne sono un esempio.
