Le T.P. de Compilation |
|||||||||||||||||||||||||||||||||||
Sujet du T.P. (résumé)L’objet de ce travail est la réalisation d’un compilateur pour traduire des programmes écrits en L, qui est un langage de programmation à définir. Ce compilateur produit du code destiné à être interprété par la machine M, qui est une machine virtuelle à pile. Le travail à faire se compose de quatre tâches :
Les éléments imposés de ce travail sont :
Planning
1. Sur l’analyseur lexical• Liste des unités lexicales. Pour
écrire l’analyseur lexical il faut avoir la liste des unités
lexicales ce qui implique qu’on a déjà écrit
les spécifications du langage à implémenter. Si ce
n’est pas votre cas, donnez-vous pour objectif provisoire la reconnaissance
des principales unités lexicales du langage C, éventuellement
traduites en français pour bien montrer que ce sont les vôtres :
Une fois choisies, les unités lexicales doivent faire l’objet
d’une série de déclarations de constantes dans un fichier
d’en-tête (nommé, par exemple, #define IDENTIF 301 #define NOMBRE 302 #define SI 303 #define ALORS 304 ... • Programme de test. Le but du TP est l’écriture
de l’analyseur lexical, que vous validerez avec un programme de test
dans le genre de celui-ci (si l’analyseur lexical provient de lex
il faut lire
Ce programme affiche les unités lexicales obtenues à partir d’un texte donné à l’entrée standard (indépendamment du fait que ce texte soit un programme correct ou non). • Emploi d’un fichier source. Dans une deuxième étape (c.-à-d. lorsque vous aurez vérifié que le programme précédent est correct) vous ferez en sorte que le texte analysé soit lu dans un fichier. Si vous vous y prenez comme il faut, il suffira de changer le programme de test comme ceci :
N.B. Si vous avez utilisé lex, le fichier • Lex. Utiliser lex est certainement
la manière la plus rapide d’obtenir un analyseur lexical correct
et efficace. La fonction • Analyse lexicale et Java. Si vous avez décidé de réaliser votre compilateur en Java vous devrez vous passer de lex, sauf si vous êtes extrêmement aguerris dans l’utilisation des méthodes natives en Java (c’est-à-dire la bibliothèque JNI, Java Native Interface). Si vous ne connaissez pas JNI, il vous est conseillé d’écrire
une classe (nommée ci-dessous Vous rencontrerez un problème avec les opérateurs formés
de deux caractères spéciaux ("==", "<=",
etc.). C’est un problème mineur, mais difficile à traiter.
Compte tenu du temps disponible, il vous est conseillé de vous
en débarrasser en prescrivant que votre langage n’a pas de
tels opérateurs doubles (il suffit, par exemple, de décider
qu’on doit écrire « • Programme de test en Java. Si vous programmez en Java, votre programme de test ressemblera à ceci :
• Reconnaissance des opérateurs simples. Lex ou pas, il y a un choix à faire à propos de la reconnaissance des unités formées d’un unique caractère spécial. Soit on considère que l’analyseur lexical veille à ne reconnaître que les opérateurs légitimes, ce qui en lex donne une longue collection de règles de la forme
soit on décide que l’analyseur doit « laisser passer » tout caractère non reconnu à un autre titre. En lex, cela donne une unique règle (évidemment placée en dernière position) de la forme . return yytext[0]; Ce choix n’a pas de conséquence sur la conception du compilateur, à part ceci : dans le premier cas, les caractères illégaux donnent lieu à une erreur lexicale ; dans le second, ils produisent une erreur de syntaxe. On vous conseille de choisir le plus simple, c’est-à-dire la deuxième manière. • Caractères blancs. N’oubliez pas que, dans tous les cas, les caractères blancs (espaces, tabulations, fins de ligne) doivent disparaître durant l’analyse lexicale. 2. Sur l’analyseur syntaxique• Grammaire. A la suite du premier TP vous avez donc un analyseur lexical en état de marche, basé sur une liste d’unités lexicales peut-être provisoire mais à laquelle il vous sera facile d’ajouter ou retrancher des mots clés. D’autre part, vous avez réfléchi à la définition
du langage que vous voulez compiler et vous en avez écrit la grammaire.
Pour dépanner ceux qui n’auraient pas eu le temps de faire
cela, voici une telle grammaire (les terminaux sont en majuscules ou entre
apostrophes, le symbole de départ est
• Exemple. Voici un programme correct pour la grammaire précédente (il s’agit d’un exercice classique dit « des tours de Hanoi ») :
• A propos des entrées-sorties. La
grammaire ci-dessus montre une manière de réaliser les entrées-sorties
différente de celle qui est conseillée dans le sujet distribué
(sur papier). Dans ce document on vous dit d’implémenter les
entrées-sorties par deux fonctions prédédefinies
Ci-dessus on vous suggère plutôt d’incorporer ces opérations
au langage : • Le pied à l’étrier. Pour obtenir l’analyseur syntaxique il suffit de prendre les règles de la grammaire (les productions) et les traduire en C, en écrivant une fonction pour chaque non terminal, selon la méthodologie expliquée en cours. C’est tellement simple, quand on a compris le principe, qu’on n’ose pas se jeter à l’eau... Pour vous aider à démarrer voici deux exemples : 1° La fonction correspondant au symbole de départ (le membre gauche de la première règle) de la grammaire montrée ci-dessus. La productions est :
Puisqu’une déclaration de variable commence nécessairement
par le terminal
2° La production concernant instructionAffect -> IDENTIF [ '[' expression ']' ] '=' expression ';' donne lieu à la fonction suivante (notez que lorsque cette fonction
est appelée il est certain que
• Programme principal. On déclenche l’analyse du texte source en commandant la reconnaissance du symbole de départ (cela est la définition même du symbole de départ). Ce qui donne un programme principal dans le genre de :
Ce programme est facile à comprendre : après avoir
amorcé l’unité lexicale courante ( En réalité, le programme précédent ne détecterait pas la présence d’éléments illégaux à la suite d’un texte correct, ce qui est un peu regrettable. On préfère donc améliorer le programme précédent en imposant qu’après la reconnaissance d’un programme correct l’unité courante doit se trouver placée sur la fin du fichier :
N.B. Lex et flex représentent la fin du fichier source par la valeur 0. On prendra garde au fait que cette valeur est différente de celle de la constante EOF (généralement l’entier -1). 3. Sur l’analyse syntaxique, suitePas de conseil vraiment nouveau pour cette troisième tranche de travaux. Le but est de vous assurer que vous possédez un analyseur correct et complet. Pour cela il vous faudra faire tourner l’analyseur d’une part sur des textes sources mettant en œuvre toutes les possibilités du langage L, d’autre part sur différents textes sources provoquant toutes les erreurs possibles. • Afficher le texte source. Par défaut, l’analyseur lexical produit par lex n’affiche pas les unités reconnues. Un tel comportement ne vous convient pas car, en cas d’erreur, vous n’aurez pas d’indication sur le point du texte source que l’analyseur a réussi à atteindre avant de s’arrêter (s’il s’agit d’une erreur dans le texte source) ou de se planter (s’il s’agit d’une erreur dans l’analyseur). Pour obtenir l’affichage du texte source au fur et à mesure
de l’analyse lexicale il vous faudra ajouter la commande « • Compter les lignes. Une autre manière
de renseigner sur l’endroit du texte source où une erreur
a été trouvée consiste à compter les lignes
(c’est-à-dire détecter les apparitions du caractère
• Utiliser un débogueur. Dans le développement
de l’analyseur vous gagnerez beaucoup de temps si vous vous aidez
d’un débogueur. Cela vous permettra, par exemple, d’exécuter
l’analyseur pas à pas tout en surveillant la valeur de l’unité
courante, notamment pour découvrir les appels de 4. Sur le dictionnaireL’étape suivante du travail consiste à ajouter des vérifications sémantiques à votre analyseur syntaxique. Pour fixer les idées, disons que votre analyseur pourra désormais détecter des erreurs telles que :
Pour être en mesure de faire de telles vérifications vous devez ajouter à votre analyseur une structure de données appelée ici dictionnaire des identificateurs. Pour cela, vous devez choisir : • La structure de chaque article du dictionnaire. On vous conseille de définir une structure à cinq champs : l’identificateur en question et quatre informations associées (des entiers) : la classe, le type, l’adresse et un éventuel complément qui n’est utile que pour les fonctions. Vous pouvez donner au champ identificateur soit le type tableau de caractères (de taille connue) soit le type pointeur de caractère. L’inconvénient dans le premier cas est que cela introduit une limitation dans la taille des identificateurs et qu’il va falloir modifier (légèrement) l’analyseur lexical. L’inconvénient dans le second cas est que les ajouts d’identificateurs au dictionnaire impliqueront des « malloc » et qu’il faudra donc ne pas oublier les « free » correspondants lors de l’éventuelle destruction d’un dictionnaire. La distinction entre la classe ( Les valeurs de ces champs sont des constantes définies à cet effet (n’utilisez pas les constantes définissant les unités lexicales, elles n’ont rien à voir avec ce qui nous occupe ici). La question des adresses sera vue plus tard. • La structure du dictionnaire lui-même. Si notre compilateur devait faire une carrière commerciale cette question serait très importante, car les compilateurs passent beaucoup de temps à rechercher des identificateurs dans le dictionnaire et il est primordial que ce dernier permette des recherches rapides. Mais puisque nous ne travaillons pas dans cet esprit nous allons nous contenter d’un dictionnaire dont les principales qualités sont la clarté et la simplicité, les recherches n’y étant pas aussi rapides qu’elles le pourraient. On vous conseille donc d’implémenter le dictionnaire par
un tableau dont les éléments sont les structures expliquées
au point précédent. A ce tableau sont associés deux
niveaux, • L’interface du dictionnaire,
c’est-à-dire les fonctions et variables à travers lesquelles
les autres parties de l’analyseur utilisent le dictionnaire. Si vous
n’avez pas d’idée précise sur la question, sachez
qu’on peut avoir tous les services nécessaires à travers
le tableau int recherche(char *identificateur, int haut, int bas); qui recherche dans le dictionnaire un article associé à
l’ int ajout(char *identificateur); qui crée un nouvel article dans le dictionnaire (au sommet) et
en renvoie l’indice. Le champ identificateur est initialisé
avec l’ • Exemple : utilisation du dictionnaire à l’occasion d’une déclaration. Examinons ce que devient l’analyse d’une déclaration de variable, dans le cas de la grammaire donnée en exemple plus haut. La production est declarationVariable -> ENTIER IDENTIF ';' Dans l’analyseur syntaxique cela s’est traduit par une fonction
Après l’introduction du dictionnaire, cela devient
N.B. Initialement nulle, la variable globale • Exemple : utilisation du dictionnaire dans la partie exécutable. Regardons l’instruction « appel d’une fonction ». La grammaire dit
Dans l’analyseur syntaxique cela se traduit par
Voici ce qu’ajoute le dictionnaire :
N.B. Ci-dessus on passe id, l’indice dans le dictionnaire, à finAppelFonction afin que cette dernière, chargée d’analyser les arguments effectifs de l’appel, puisse vérifier qu’il y en a le nombre voulu (dans un compilateur plus complexe cette fonction devrait vérifier également que les arguments effectifs ont des types compatibles avec ceux des arguments formels). • Les adresses des variables globales. Comme nous
verrons à la section suivante, les adresses des variables globales
sont relatives à une certaine base qui n’est pas connue
durant la compilation, mais uniquement lors de l’exécution.
Cette base est notée Il découle de tout cela que la première variable globale
a l’adresse Pour gérer cela il suffit donc de se donner un compteur, appelons-le
Les tableaux sont traités exactement de la même manière,
sauf que lors de la déclaration d’un tableau • Travail à faire. Vous devez écrire les définitions des types, variables et les deux fonctions, recherche et ajout, mentionnés plus haut, puis passer en revue votre analyseur pour déceler tous les endroits où il faut ajouter (il ne s’agit que d’ajouts!) du code nouveau pour effectuer l’analyse sémantique. Pour tester le dictionnaire réalisé
5 et 6. Sur la production de codeLa définition du langage machine se trouve dans le sujet en papier, ou bien ici. Ce langage est défini par un ensemble de codes numériques, appelés opcodes, qui doivent faire l’objet d’une collection de définitions de constantes (par exemple dans un fichier opcodes.h) : #define EMPC 1 #define EMPL 2 #define DEPL 3 ... Veillez à ce que les identificateurs des opcodes soient tous différents des autres constantes déjà utilisées (unités lexicales, valeurs du dictionnaire, etc.). N’essayez pas de réutiliser ici ces constantes préexistantes, elles n’ont rien à voir avec le langage machine. Dans cette partie du travail, l’exercice mental à faire constamment est le suivant : étant donnée une construction du langage L (une instruction, une expression, etc.), déterminer :
• Exemple (du cours). Soient 123 * x + y sa traduction en langage machine est la suite des 8 codes EMPC 123 EMPG 0 MUL EMPG 1 ADD qu’on affichera de préférence, pour faciliter la lecture : EMPC 123 Pour comprendre pourquoi la suite de codes ci-dessus est la traduction de l’expression donnée plus haut il faut en imaginer l’exécution. Si vous comprenez cette exécution vous constaterez qu’elle ajoute une valeur au sommet de la pile (la valeur de l’expression). • Exemple. Considérons maintenant l’instruction z = 123 * x + y; sa traduction en langage machine est la suite des 10 codes EMPC 123 EMPG 0 MUL EMPG 1 ADD DEPG 2 ou EMPC 123 Si vous imaginez l’exécution de cette suite de codes vous constaterez que son exécution laisse la pile dans l’état où elle se trouvait lorsque cette suite a commencé à être exécutée. On retiendra (et on vérifiera) que, en tout circonstance :
• Production de code. Supposons que la mémoire
de la machine cible est représentée par un grand tableau
int mem[MAXMEM]; Puisque le compilateur dépose le code produit directement dans
la mémoire de la machine, une opération comme « générer
l’instruction mem[iMem++] = ADD; ( mem[iMem++] = EMPG; Le travail qui vous reste à faire maintenant est donc de trouver
les endroits, dans l’analyseur que vous avez déjà,
dans lesquels ajouter de telles productions de code. Par exemple, l’instruction
«
L’endroit où générer les deux codes indiqués est facile à trouver :
• Les adresses des fonctions. Il a été dit que la rencontre de la déclaration d’une fonction provoque l’ajout de son nom dans le dictionnaire (global), avec diverses informations, dont son nombre d’arguments et son adresse. Cela est évidemment nécessaire au compilateur pour traiter correctement les appels de la fonction ultérieurement rencontrés. L’adresse d’une fonction est l’endroit de la mémoire
où son code commence, c’est-à-dire tout simplement
la valeur qu’a • Autres exemples. D’autres exemples de production de code très instructifs sont une instruction conditionnelle si...alors...sinon... ou bien une boucle tantque...faire... Examinons quel doit être le code produit dans le cas d’une instruction conditionnelle : le texte source peut-être de la forme
le code produit ressemblera à ceci
Il vous reste à • Les variables locales et les arguments des fonctions.
Pendant la compilation de la partie exécutable (les instructions
qui forment le corps d’une fonction) deux ensembles de variables
sont accessibles : Par fonction active nous entendons la plus récemment appelée des fonctions non encore terminées ; ses variables locales et arguments occupent le dessus de la pile. Notez que les variables locales et les arguments des autres fonctions appelées et non encore terminées (par exemple, la fonction qui a appelé la fonction active, celle qui a appelé celle-là, etc.) ne sont pas perdues ; elles sont simplement inaccessibles tant que la fonction active n’a pas terminé (ces variables sont dans la pile, enfuies de plus en plus profondément qu’on regarde des fonctions de plus en plus anciennement appelées). Pour ce qui concerne l’accès aux variables locales et aux arguments, la figure importante :
Un indice, noté Les arguments sont eux aussi représentés par des déplacements
relatifs à
« Sous » les arguments, une cellule a été réservée dans la pile pour recevoir le résultat de la fonction. Par conséquent, pendant le déroulement de la fonction :
Le travail qui consiste à remplacer dans le dictionnaire k, le rang d'un argument, par – (nbrArgs + 2) + k doit être fait par le compilateur
Dans le deuxième cas, ce calcul suppose qu’on a connaît
constamment la valeur de nbrArgs, le nombre d’arguments de
la fonction qu’on est en train de compiler. Réfléchissez
un peu, vous verrez qu’on peut avoir cette valeur à partir
du • L’appel d’une fonction. L’appel
des fonctions est assez bien décrit dans le sujet
sur papier, pages 6 et 7. Ce texte explique en particulier le rôle
précis des instructions 7. Sur la machine virtuelle• Organisation. Il s’agit de simuler un processeur capable d’exécuter le langage machine donné. Vous pouvez organiser votre programme de deux manières :
En outre, la deuxième manière de procéder ouvre la porte, pour ceux qui auront du temps et de la curiosité, à la mise en place d’un éditeur de liens permettant l’assemblage de fichiers objets compilés séparément. • Programmation. Je ne peux guère vous donner des tuyaux sur la machine virtuelle sans faire l’exercice à votre place ! Il s’agit de simuler l’exécution des codes définis par la table donnée dans le sujet. Cette table est très précise à propos de l'effet de chaque code. Les « conseils » suivants sont des évidences :
• Démarrage et arrêt. Deux points restent à régler : à quel endroit du code l’exécution du programme commence-t-elle (on appelle cela le « point d’entrée » du programme) et comment s’arrête-t-elle ? Vous pouvez traiter ces questions à la manière de Pascal (le langage), en fixant dans la grammaire qu’il doit exister une fonction principale qui n’est pas compilée comme les autres. Cela vous permet de déterminer, pendant la compilation, le point d’entrée et l’endroit où il faut générer une instruction STOP. Ou bien, de manière bien plus simple et élégante, vous pouvez décider (comme en C, Java, etc.) que pour le compilateur toutes les fonctions sont équivalentes. Ensuite, au moment de l’exécution, une convention indique le nom de la fonction par laquelle l’exécution doit commencer. Par exemple, on peut, avant de commencer la compilation, initialiser la mémoire avec les codes 0 PILE 1 2 APPEL 0 4 STOP 5 ... A la fin de la compilation il faut chercher dans le dictionnaire l’adresse
de la fonction nommée |
|||||||||||||||||||||||||||||||||||