|
Université de la Méditerranée Ecole Supérieure d’Ingénieurs de Luminy Etudes Supérieures en Ingénierie Informatique
Projet de compilation _______________ Compilateur P2Mips _______________
Enseignants responsables : Marc GENGLER Henri KANOUI Laurent HENOCQUE
Stéphane BENOLIEL Virginie COLLOMB Philippe ELDIN Promotion 1999Guillaume LEVASSEUR Mars 1998Thomas PEYRIC |
Copyright©
1.Présentation
*1.1.Le sujet
*1.2.Le planning
*2.analyse lexicale
*2.1.Transformation avec LEX
*2.2.La classe Utilitaire
*2.2.1.Les fonctions de gestion de flux
*2.2.2.Les globales
*2.2.3.La fonction de DEBUG
*2.2.4.La gestion des erreurs
*3.Syntaxique & sémantique
*3.1.La structure des classes
*3.2.Schéma des classes
*3.3.La table des symboles
*3.4.La classe Nœud
*3.4.1.Représentation des nœuds de l’arbre
*3.4.2.Construction et insertion d’un nœud dans l’arbre
*3.5.La génération de l’arbre
*3.5.1.Programme
*3.5.2.SuperBloc
*3.5.3.Bloc
*3.5.4.Fonction & procédure
*3.5.5.Instructions
*3.6.Conversion de type
*3.6.1.Recherche du type prioritaire
*3.6.2.Insertion des nœuds de conversion et modification du type des opérateurs
*3.7.Les listes chainées
*4.Génération du code intermédiare
*4.1.Choix du code généré
*4.2.Principes de génération du code
*4.3.Les variables globales et locales
*4.4.Procédures, Fonctions et récursivité
*4.5.Génération des labels
*4.6.Codage d’un flottant sur Mips
*5.Un exemple de A à P...
*5.1.Le fichier Pascal
*5.2.Passe 1 : Lex
*5.3.Passe 2 : Syntaxique & Sémantique
*5.4.Passe 3 : Le code
*6.Documentation de l’utilisateur de P2Mips
*6.1.La structure de base
*6.2.Les déclarations
*6.3.Les boucles
*6.4.Les opérations
*6.5.Les procédures fonctions
*6.6.Les entrées/sorties
*7.Bilan
*Sources
*
Faire un compilateur est une étape importante de notre cursus. Ce projet nous a été proposé pour nous permettre de mettre en application les connaissances acquises dans le domaine de la compilation, de la théorie des langages et de la programmation orientée objets.
Le sujet était de réaliser un compilateur de langage Pascal générant un code exécutable sur une machine à pile de notre choix. Notre première orientation a été la Java Virtual Machine qui utilise ce concept, mais des problèmes liés à la protection du P-code Java nous a fait changer d’orientation. Notre choix définitif a donc été d’utiliser une machine réelle, l’assembleur Mips. Lors d’un précédent projet nous avions utilisé un simulateur pour ce type d’architecture. Cette optique nous a permis d’entrevoir une phase de compilation plus proche de la réalité.
Après une analyse précise du sujet, nous avons défini, les différentes phases de l’élaboration de notre compilateur. Nous pouvons différencier quatre grandes étapes :
?
L’analyse lexicale traduit une suite de caractères en lexèmes.?
L’analyse syntaxique effectue une concordance entre les règles grammaticales liées au langage, et le programme.?
L’analyse sémantique réalise une vérification du sens du programme.?
La génération du code est la dernière étape. Elle convertie le programme correct en code exécutable.
La seconde partie de notre analyse nous a permis de définir une répartition des taches dans le temps, adéquate et efficace. Le planning de la page suivante modélise cette idée.
|
DIAGRAMME DE GANTT Université de la Méditerranée Ecole Supérieure d’Ingénieurs de Luminy Etudes Supérieures en Ingénierie Informatique |
PROJET : COMPILATION Début : 01.02.1998 Fin : 24.03.1998 |
||||||||||||||||||||||||||||||||||||||||||||
Tâches : |
|
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
Analyse |
P |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
préliminaire |
R |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
Analyse |
P |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
lexicale |
R |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
Génération de |
P |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
l’arbre |
R |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
Analyse |
P |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
sémantique |
R |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
Génération du |
P |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
code SPIM |
R |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
Rédaction du |
P |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
rapport |
R |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||||
Etabli par : Stéphane BENOLIEL, Virginie COLLOMB, Philippe ELDIN, Guillaume LEVASSEUR et Thomas PEYRIC |
|||||||||||||||||||||||||||||||||||||||||||||
La première étape d’un compilateur est l’analyse lexicale. En effet il faut traduire et analyser le programme pour vérifier l’emploie exclusif du vocabulaire de la grammaire. Nous avons dans notre cas utilisé un outils intéressant pour cette étape, lex.
Lex est un analyseur syntaxique fourni en standard sous UNIX, sous la forme d’une bibliothèque C. Il permet de transformer un flux d’entrée, de l’analyser et d’effectuer les opérations désirées sur le vocabulaire. Nous avons utilisé cette bibliothèque pour simplifier la tâche des analyses suivantes.
Nous avons transformé tous les éléments du vocabulaire, en code, ce qui permet à des fonctions d’analyser simplement les termes employés. Le code utilise deux concepts, les chaînes prédéfinies et les autres. La première catégorie peut être simplement codée par un numéro. En effet si nous codons le mot clé if par le numéro 021, on aura bien un code efficace. Par contre coder une chaîne de caractère ne peut être effectué de la sorte. Nous avons donc introduit un second code. Nous utilisons pour les chaînes, les identificateurs, les entiers et les réels, un autre code ; un code numérique suivi de la chaîne correspondant à l’objet. D’autre part pour des questions de commodité nous avons utilisé des séparateurs, le symbole $.
En résumé nous avons transformé un fichier Pascal en fichier code :
|
program monprog ; var mavar : integer ; begin end. |
$037 $090 monprog$ $084 $052 $090 mavar$ $059 $22 $084 $005 $017 $073 |
Cette transformation assure de plus, que tous les éléments du fichier Pascal, correspondent bien à un mot du vocabulaire. Si ce n’est pas le cas, l’analyse lexicale interrompt la compilation.
Pour faciliter l’utilisation d’un tel code pendant la phase de développement, nous avons créé un ENUM qui convertit les codes numériques en codes littéraux.
Ce format de codage a été défini pour faciliter la lecture du code lors des analyses suivantes. Ceci a été réalisé à l’aide de fonctions que nous avons implantées dans la classe Utilitaire.
La classe utilitaire regroupe toutes les fonctions de gestions de flux, la macro Assert, la fonction de débuggage, la numérotation de la ligne courante et le numéro du dernier objet créé.
2.2.1.Les fonctions de gestion de flux
Elles sont au nombre de six divisées en deux groupes de trois, celles qui prennent des informations dans le flux standard et celles qui se contentent de lire le flux. Il existe trois sortes de données sur le flux : les " code " les " var " les " chaîne ".
Nous avons sur le flux des codes $xxx représentant la conversion de symbole par LEX, les données var et chaînes sont précédées d’un code particulier.
Soit i le flux d’entrée standard : $090 pipo$ $084
code=global.PrendreCode(i) ; Le flux devient : pipo$ $084 et code=90
global.PrendreVar(i,variable); Le flux devient : $084 et variable=pipo
Soit i le flux d’entrée standard : 'l''essai est probant'$ $084
global.PrendreChaine(i,chaine); Le flux devient $084 et chaine=l’essai est probant
Les fonctions de lecture sont identiques le pointeur de début de flux est sauvegardé puis restitué à la fin de l’opération de lecture. Nous utilisons pour cela les fonctions seekg et tellg.
Deux autres fonctions de flux sont implantées : l’une convertie une chaine ou un flux en réel flottant et l’autre permet la lecture d’une variable préfixée de son code.
Soit i le flux d’entrée standard : $090 pipo$ $084
global.LireCodeVar(i,variable) ; Le flux reste inchangé et variable=pipo
Soit i le flux d’entrée standard : 53.65e-3$ $084
res=global.Char2Double(i); Le flux devient : $084 et res=0.5365E-1
Il y a deux variables entières globales, l’une permet de connaître à tout instant la ligne du programme source en cours de traitement et l’autre permet de connaître le nombre d’objets référencés dans les différentes tables des symboles. C’est une CLEF PRIMAIRE permettant de retrouver n’importe quels objets.
Nous avons implanté une gestion de débugage qui lorsque la variable globale _debug est activée produit un affichage, plusieurs niveaux de debug peuvent être implantés.
Il s’agit d’une gestion très sommaire pour relever les exceptions, en effet, il ne s’agit que de la redéfinition de la macro assert ; c’est une gestion bloquante. Un numéro d’exception est adjoint a la fonction assert permettant un affichage de la ligne ou s’est déclenchée l’erreur, le numéro du dernier objet créé ainsi que la nature de l’erreur.
L’étape suivante est composée de la vérification syntaxique et sémantique qui doivent aboutir à la génération de l’arbre.
La première étape de notre réflexion a été d’établir une structure de programmation nous permettant de faciliter la création de l’arbre pendant la vérification de la syntaxe. Nous avons donc défini une structure basée sur une classe principale, la classe Nœud. Cette classe, à l’origine de la création de l’arbre, sera expliquée ultérieurement.
Notre raisonnement a commencé par une réflexion sur la structuration du langage Pascal. Ce dernier est construit en deux grandes parties. Une partie déclarative et une partie de définitions.
La classe de départ est un Bloc. Elle hérite naturellement de Nœud, pour permettre la création de l’arbre. Cette classe génère un objet qui est la structure de base de la définition du code. Elle possède une table des symboles pour permettre la visibilité des variables, fonctions et autres déclarations.
La classe SuperBloc est une sous classe de la classe Bloc. Elle implémente le concept de la déclaration. Nous avons donc le principe implanté dans le schéma. Il en est de même pour la classe Program, Procedure, et Function qui sont des Superbloc particuliers.
La deuxième partie des classes héritant de Bloc sont de deux types :
?
Les instructions : Série de classes correspondant à un mot clé précis. Nous avons les tests et les autres. Chacune de ces classes comportent un constructeur capable de définir un traitement particulier.?
Les Nœuds particuliers : Série de nœud qui comportent des informations supplémentaires.Le principe de base est une analyse prenant un objet istream en argument, et lors de l’appel des constructeurs des classes, la génération de l’arbre et l’analyse du code se font simultanément. Lors de la création d’un SuperBloc, nous utilisons l’initialisation d’un Bloc. Le fait que nous ayons besoin d’utiliser la construction du SuperBloc avant, nous a obligé à ne pas appeler le constructeur définie du Bloc, mais un constructeur par défaut, suivie par une fonction d’initialisation de Bloc. En effet nous voyons sur le schéma, que la partie déclarative est avant la partie de définition.
Pour permettre le bon déroulement du programme il nous a fallu une série de classes ne faisant pas partie de cette architecture (voir schéma des classes). Nous allons maintenant détailler plus précisément l’implantation de toutes ces classes.
Lors de l’élaboration du compilateur, il nous a fallu faire une table des symboles, permettant d’identifier toutes les variables, quelles soient globales, où locales à un bloc. Notre choix a été alors d’associer une table par bloc. Cette méthode, possible grâce au langage objet comme C++, nous a permis d’éviter les problèmes de visibilité. Il est naturel, lors de l’élaboration d’un programme de considérer que la liste des variables accessibles, sont celles du bloc courant, et des blocs qui l’englobe. Nous avons repris exactement la même philosophie.
La classe TableSymbole est un objet contenant des membres, des constructeurs et des accesseurs, qui définissent une déclaration. Le bloc contient un membre _table_symbole qui est une liste chainée de ces éléments de base. Un objet TableSymbole se compose comme suit :
|
Référence |
Nom |
Type |
Type de retour |
Dimension |
Paramètre[10] |
Valeur |
?
La référence : C’est un numéro unique permettant d’identifier un objet. C’est une clé primaire.?
Le nom : C’est le nom utilisé par le programmeur pour appeler l’objet.?
Le Type : C’est le type de l’objet, autrement dit :Variable, Constante, Type utilisateur, Fonction, Procédure
?
Le Type de retour : C’est soit le type de retour dans le cas des fonctions, soit le type de l’objet référencé pour les variables, les constantes...Les types connus sont : Boolean, Char, Integer, Real
?
La Dimension : Lors de la déclaration d’un tableau c’est la dimension de ce dernier, lors de la déclaration d’une fonction où procédure, c’est le nombre de ses paramètres.?
Les Paramètres : C’est un tableau de 10 éléments. Pour les tableaux, ce sont les tailles respectives des dimensions. Pour les fonctions et procédures, ce sont les types de retour des différents paramètres.?
La Valeur : C’est la valeur de l’objet Const. Lors de la création d’une constante on met la valeur dans la table des symboles.La gestion de la table des symboles se fait par l’intermédiaire de deux types de fonctions ; les AddTableSymbole et les FindTableSymbole. La première catégorie de fonctions, sont des fonctions d’ajout d’éléments dans la table des symboles. Elles vérifient que l’objet n’existe pas dans la table locale, et ajoute la référence. Il existe plusieurs types de fonctions d’ajout, permettant l’insertion de plusieurs types d’éléments. La seconde est une fonction de recherche dans la table des symboles, à partir d’un nom ou d’une référence, elle recherche dans les tables des symboles visibles l’objet. Ces fonctions sont essentielles au compilateur pour fonctionner normalement. Nous avons pour les utiliser depuis n’importe quel nœud, fait également une fonction de recherche de bloc, qui renvoie le bloc local dans lequel le nœud se trouve.
Nous avons fait un choix, à même de faciliter la compilation, en effectuant les ajouts et les vérifications dans la table des symboles lors de la création de l’arbre. Cette façon de procéder, est rendue possible grâce à la structuration de nos classes, et nous évite de créer les nœuds inéluctables aux déclarations. C’est une optimisation intéressante qui accélère la compilation.
3.4.1.Représentation des nœuds de l’arbre
Au sein de l’arbre de dérivation nous avons défini cinq types de nœuds.
Ä
Le nœud de base comporte un champ type représentant une instruction ou un mot réservé du langage Pascal. Cinq pointeurs lui permettent de connaître le nœud père, le nœud situé à sa gauche et à sa droite, le nœud fils et le nœud du dernier fils.
Représentation du nœud de base
Le chaînage entre un nœud père et ses fils est donc représenté comme ceci :
Chaînage entre un nœud père et ses fils
Ä
Les nœuds " NœudInt ", " NœudBool " et " NœudReal " ont exactement la même structure que le nœud de base. Ils possèdent un champ supplémentaire qui représente la valeur numérique du nœud.
Représentation des " NœudInt ", " NœudBool " et " NœudReal "
Ä
Le nœud " NœudVar " a la même structure que le nœud de base. Il possède un champ supplémentaire qui représente son identifiant au sein de la table des symboles associé à son bloc de données.
Représentation du " NœudVar "
3.4.2.Construction et insertion d’un nœud dans l’arbre
La construction d’un nœud nécessite de spécifier les pointeurs Père, Petit_frère, Fils et le champ (type si c’est un nœud de base, valeur numérique si c’est un " NœudInt " ou " NœudReal " ou " NœudBool ", référence si c’est un nœud " NœudVar ").
Les pointeurs Grand_frère et Petit_dernier ne sont pas accessibles à l’utilisateur et ont été implémentés pour faciliter la gestion interne de l’arbre et pour améliorer les temps de parcours.
L’insertion d’un nœud dans l’arbre peut être réalisée de deux façons. La première consiste simplement à spécifier le type du nœud à insérer. La deuxième nécessite au préalable la construction d’un nœud et se contente de l’attacher au sein de l’arbre.
Voici la description de la mise à jour des pointeurs lors d’une insertion.
Ä
Insertion d’un nouveau nœud à partir d’un nœud qui n’a pas de fils :
Avant insertion du nœud
Après insertion du nœud
Ä
Insertion d’un nouveau nœud à partir d’un nœud qui a un ou plusieurs fils :Lorsque nous voulons insérer un nœud sous un maillon qui possède déjà des fils, nous le plaçons à la suite de tous les fils.
Avant insertion du nœud
Après insertion du nœud
Lorsqu’un nœud a été créé et inséré, l’utilisateur ne peut en aucun cas modifier les pointeurs Père, Petit_frère, et Fils.
La détection du " ; " déclenche l’appel de la construction de SuperBloc et attend un " . " final.

Il y a trois phases dans SuperBloc, une phase de détection des déclarations ( VAR, CONST, TYPE ) avec inscription des déclarations dans la table des symboles. Une 2ème phase consiste à détecter d’éventuelle définition et déclaration des fonctions et procédures. Cette phase est primordiale puisque c’est elle qui génère la table des symboles. Et finalement une phase d’implantation du code.

C’est le nœud initiateur de l’implantation du code, c’est lui qui effectue la vérification de la syntaxe, des instructions et déclenche le constructeur approprié. Il réalise également un contrôle sémantique entre les identificateurs utilisés dans son bloc et ceux déclarés dans les différentes tables des symboles.

Il y a 2 phases ; une phase de définition et une phase de déclaration. La définition permet la récursion dans des procédures ou des fonctions imbriquées ; la définition permet une première vérification sémantique. La gestion du passage des paramètres est en interaction direct avec la table des symboles.
Function pipo(a :integer ; var b :real ; tab :tableau) :integer ;
Va générer, à la déclaration, une entrée dans la table des symboles de la forme :
|
Référence |
Nom |
Type |
Type de retour |
Dimension |
Paramètre[10] |
|
? ? ? |
pipo |
FUNCTION |
INTEGER |
3 |
INTEGER VARREAL USERTYPE |
Lors de la définition :
|
Référence |
Nom |
Type |
Type de retour |
Dimension |
Paramètre[10] |
|
? ? ? |
a |
VARIABLE |
INTEGER |
0 |
0 |
|
? ? ? |
b |
VARIABLE |
VARREAL |
0 |
N° ref |
|
? ? ? |
tab |
VARIABLE |
USERTYPE |
0 |
N° ref |

Automate de fonction

Automate de procédure :

Tout langage comporte un certain nombre d’instructions de base nécessaires à toute opération. Ce sont en Pascal, le calcul des expressions, des affectations, des tests, des appels de procédures et de fonctions et nous y avons rajouté des primitives d’entrée/sortie.
Nous allons détailler l’implantation dans l’arbre des diverses instructions et règles de la grammaire.
Principe générale
Le principe de base de l’implantation de la grammaire dans l’arbre est résumé par le schéma suivant. Nous avons ici différents types de nœud d’un arbre, où a et b sont des symboles pouvant représenter différents vocabulaires.
L’aspect intéressant de ce schéma est la formation d’un nœud de type bloc, qui est le principe de base de la construction d’un programme. Un programme, est en effet une imbrication de blocs.
Expression
Une expression est une succession d’éléments qui permettent de calculer une expression arithmétique, c’est à dire un nombre.
Le but de notre travail est tout d’abord de lire cette expression, mais surtout de la représenter sous une forme facile à comprendre, à visualiser et à utiliser pour les opérations futures. C’est pour cela que nous utilisons un arbre.
Dans cet arbre chaque feuille représentera un terminal, et chaque nœud un connecteur différent ( opérateur, nom d’identificateur ).
Tous les noeuds non terminaux sont de même type ( ici Nœud ). Tous les terminaux ont quant à eux un type différent, correspondant au rôle de ces terminaux dans l’expression. Ces sous-types héritent tous de la classe Noeud, et ont chacun des éléments supplémentaires qui leur sont propres.
si la feuille est un entier , le noeud correspondant sera de type NoeudInt
réel NoeudReal
booléen NoeudBool
idem pour les identificateurs qui correspondent aux variables, constantes, fonctions ou bien tableau sauf qu’ils seront de type NoeudVar.
Tout ceci nous permet de gérer un arbre très efficacement qui est lui même très efficace.
Remarques :
Nous allons par contre voir comment sont représentés les tableaux car c’est dans cette section que nous les traitons.
Tout d’abord il faut trouver un identificateur en entrée, et vérifier qu’il existe bien dans la table des symboles et que c’est bien un tableau (cf. class TableSymbole) . Ensuite nous comptons le nombre d’argument et vérifions que ces derniers sont tous de type entier. Nous regardons aussi s’il n’y a pas d’erreur de syntaxe ( une virgule est absente ... ). Lorsque tout est OK, nous pouvons passer à la suite.
Voici un schéma représentant la grammaire que nous utilisons pour reconnaître une expression :

La création de l’arbre
Nous l’avons implanté grâce à des fonctions récursives qui vont générer l’arbre au fur et à mesure. Cela ressemble aux automates : suivant le caractère lu nous passons dans un autre état en appelant une autre fonction récursive et en construisant l’arbre si nécessaire. Cette façon de faire nous permet de vérifier la syntaxe tout en générant l’arbre.
De plus dans la construction il nous arrive de sauvegarder des sous arbres qui ont été construits précédemment pour pouvoir ensuite les concaténer au dernier nœud créé.
Voici un exemple pour l’addition 1+2+3
Lecture de 1 => tmp = NoeudInt(1)
Lecture de + => tmp2 = Nœud(+)
Tmp1 devient le premier fils de tmp2
Lecture de 2 => tmp3 = NoeudInt(2)
Tmp3 devient le deuxième fils de tmp2
Lecture de + => tmp4 = Noeud(+)
Tmp2 devient le premier fils de tmp4
Lecture de 3 => tmp5 = NoeudInt(3)
Tmp5 devient le deuxième fils de tmp4
On retourne tmp4
Remarque : ces calculs sont en fait optimisés dans la programmation mais suivent les mêmes règles.
Voici un exemple pour la représentation grammaticale de l’expression : i+3=TOTO*tab[1,2]
dans l’arbre, où
tab[] est un tableauAffectation
Ci dessous se trouve un schéma grammatical représentant une affectation.

Nous pouvons constater qu’une affectation est en fait la lecture d’un identificateur, puis celle d’un := et enfin celle d’une expression.
Une vérification est faite au niveau de variable pour vérifier qu’elle existe.
IF
Le IF est une structure de test simple. Elle effectue une vérification sur un test, et exécute un des deux blocs joint.
La programmation de cette instruction est faite par la création d’un nœud de type Expression, un ou deux de type Bloc, que nous lions ensemble.
WHILE & REPEAT

Le WHILE et le REPEAT utilisent le même principe pour la programmation. Ces deux schémas nous montrent les grandes lignes.
FOR
Le FOR est basé sur le même principe avec un niveau de difficulté légèrement supérieure. Nous considèrons qu’il existe deux types de FOR, les positifs et les négatifs. Les premiers partent d’une valeur, et vont à une autre valeur en additionnant 1, les second utilisent une décrémentation du compteur. Nous pouvons donc décomposer le FOR en quatres étapes : Une affectation, un test, un bloc et une incrémentation ou décrémentation. On a donc le schéma suivant :
APPELS de fonctions & procédures
Les appels n’utilisent pas de grands concept lors de la création de l’arbre. Il suppose tout de même un certain nombre de vérification, avant la création. En effet il faut vérifier l’existence de la fonction appelée. Puis vérifier que cette fonction comporte bien les bons paramètres. Il faut ensuite créer un arbre de la forme du schéma.
Entrées/sorties avec READ et WRITE
Ces deux fonctions ne créent pas d’arbre complexe, mais juste un nœud. Il est composé d’une référence dans la table des symboles, soit pour pointer l’objet qui recevra le résultat du READ, soit pour indiquer l’objet à afficher. Ces fonctions n’acceptent qu’un seul argument, contrairement au Turbo Pascal.
L’analyse sémantique a pour but de vérifier le sens d’un programme syntaxiquement correct. Elle doit donc inspecter si les variables sont bien déclarées et non dupliquées. Or, nous avons choisi pour des raisons d’optimisation de vérifier ces deux conditions à l’étape de l’analyse syntaxique. L’analyse sémantique de notre compilateur Pascal se résume donc à la conversion des types.
La conversion de type consiste à insérer des nœuds de transformation et à expliciter le type de certains opérateurs. Les nœuds de conversion peuvent contenir au sein du champ type l’information suivante :
Ä
REAL_TO_INTEGER,Ä
INTEGER_TO_REAL,Ä
INTERGER_TO_BOOLEAN,Ä
BOOLEAN_TO_INTEGER,Ä
REAL_TO_BOOLEAN,Ä
BOOLEAN_TO_REAL,Pour des raisons de commodité, nous avons géré le type BOOLEAN de la même façon qu’un type INTEGER.
La première étape consiste à rechercher pour chaque instruction stockée dans l’arbre les types prioritaires.
3.6.1.Recherche du type prioritaire
Deux cas doivent être distingués :
Ä
Pour une affectation, le type des instructions situées à droite de l’affectation doit être identique au type de la variable ou du tableau situé à gauche de l’affectation. Le type prioritaire est donc celui de la variable ou du tableau.Ä
Pour les instructions de comparaisons (>, <, >=, <=, <>, =, …, OR, XOR, AND, NOT), le type prioritaire doit être recherché avant de réaliser les insertions de conversion.
Arbre représentant une instruction
<
Dans l’exemple ci-dessus, nous voyons qu’il est nécessaire de parcourir le sous-arbre de l’instruction pour déterminer que REAL est le type prioritaire.
La deuxième étape consiste à reparcourir l’arbre pour insérer les nœuds de conversion et pour modifier le type des opérateurs.
3.6.2.Insertion des nœuds de conversion et modification du type des opérateurs
Lorsque le type prioritaire d’une instruction a été déterminé, chaque nœud dont le type est différent du type prioritaire doit être converti.
Dans l’exemple précédent, l’insertion d’un nœud " INTERGER_TO_REAL " doit être effectué et le type de l’opérateur doit être modifié.
Avant la conversion
Après la conversion
Lorsque la conversion à réaliser est de type REAL_TO_INTEGER, INTERGER_TO_BOOLEAN, BOOLEAN_TO_INTEGER, REAL_TO_BOOLEAN, il n’y a pas de modification du type de l’opérateur à effectuer pour deux raisons. La première est que nous avons géré les types BOOLEAN de la même manière que les types INTEGER. La deuxième est que si un type opérateur n’est pas réel, nous le considérons au niveau de la génération de code comme un opérateur entre deux entiers.
Cette liste est composée d’une classe List trouvée sur internet, dans laquelle nous avons rajouté des fonctions utiles pour notre projet de compilation.
Nous avons donc implémenté deux classes :
ListElement : Cette classe nous permet en fait de créer des maillons de type TableSymbole, qui serviront de support pour la liste.
Les données membres sont
Le maillon suivant : de type ListElement
L'objet du maillon : de type TableSymbole

List : Cette classe génère une liste, et fournit un grand nombre d'outil pour l'utiliser
Les données membres sont
le premier maillon de la liste : de type ListElement
le dernier maillon de la liste : de type ListElement

Dans le schéma ci-dessous se trouve la représentation d’une liste avec nos classes

Voici une rapide énumération des fonctions disponibles dans la classe List :
IsEmpty
InserDebut
InsertFin
EnleveDebut
EnleveFin
AjouteListDebut
AjouterFin
RechercheCharList
RechercheRefList
Ref2CharList
Char2RefList
Comment fonctionne cette liste ?
A la création, cette liste est vide. Quand nous lui rajoutons un élément (un maillon), nous pouvons soit le mettre en début, soit en fin de liste. Une remarque très important est que les éléments ne sont pas triés. Nous avons aussi la possibilité de concaténer deux listes différentes. Pour la suppression d’un maillon, nous enlevons soit le premier, soit le dernier. Mais nous n’avons pas implanté de fonctions de suppressions d’un maillon n’importe où dans la liste. Pourquoi ?. Parce que nous ne traitons que des blocs de maillon, c’est à dire des suites successives de maillons. Et lorsqu’il faut les enlever de la liste, il suffit de les retirer par là où ils ont été inséré.
Il nous est aussi possible de savoir à tout moment si une liste est vide grâce à une fonction appropriée.
Il y a de plus des fonctions qui font appels à la classe TableSymbole : Ces fonctions permettent de rechercher dans la liste le maillon correspondant à une certaine demande, et renvoie son adresse.
4.Génération du code intermédiare

Etape 4 : génération du code intermédiaire
Dans un premier temps, nous avions pensé générer du code pour la machine virtuelle Java. Rapidement, nous nous sommes rendus compte que cela allait engendrer de nombreuses difficultés liées aux contrôles de sécurité qu’effectue le simulateur Java avant et pendant l’exécution du code.
Pour ces raisons, nous avons préféré diriger notre choix sur la génération d’un code assembleur pour processeurs Mips.
Les avantages de notre choix :
·
Très bonne lisibilité du code produit·
Pas besoin de définir sa propre machine, ce qui a représenté pour nous un gain de temps·
Certitude d’utiliser un langage possédant toutes les instructions nécessaires·
Possibilité d’utiliser Xspim, un simulateur avec un débugger intégré afin de trouver plus rapidement nos erreurs·
Meilleur approche des véritables compilateurs en générant un assembleur existantLes inconvénients de notre choix :
·
Contraintes : se plier à l’existant·
Difficultés liées à l’utilisation d’une machine à registres comme une machine à pileLe code généré fonctionne donc principalement à l’aide de la pile. Cependant l’utilisation de six registres reste indispensable. Quatre afin de pouvoir exécuter les instructions Mips qui nécessitent comme opérandes des registres, $s0 et $s1 afin d’effectuer les calculs sur les entiers, puis $f0 et $f2, registres du copossesseur mathématique pour réaliser les calculs sur les réels. Les deux derniers registres utilisés sont $sp, le pointeur de pile et $s7, le pointeur sur le dernier bloc de variables locales alloués.
4.2.Principes de génération du code
Comme le montre le schéma précédant, la génération du code est réalisée à l’aide de l’arbre et des tables de symboles que lui fournit l’analyse sémantique. Il est donc nécessaire d’interpréter cet arbre en lui associant les instructions assembleurs correspondantes.
Intéressons nous au code généré par les instructions suivantes :
if ( a = 12 + b ) then
begin
...
end
else
begin
...
end;
Voici l’arbre (simplifié) généré par ces instructions :

Représentation logique Représentation physique
Voici le code (simplifié) généré par cet arbre :
â
Afficher le commentaire " Traitement d’un If " Généré par le noeud If en 1â
Empiler la valeur de la variable a Généré par le noeud a en 2â
Empiler l’entier 12 Généré par le noeud 12 en 3â
Empiler la valeur de la variable b Généré par le noeud b en 4â
Additionner les deux derniers éléments sur Généré par le noeud + en 5la pile, les dépiler et empiler le résultat
de l’addition
â
Tester l’égalité des deux derniers éléments Généré par le noeud = en 6sur la pile, les dépiler et empiler le résultat
du test.
â
Tester la dernière valeur sur la pile, Généré par le noeud If en 7la dépiler. Si cette valeur est " vrai ", aller
à l’étiquette If, sinon à l’étiquette Else
Etiquette If :
â
Insersion du bloc If Généré par les noeuds du bloc en 8â
Aller à l’étiquette Fin Généré par le noeud If en 9Etiquette Else :
â
Insertion du bloc Else Généré par les noeuds du bloc en 10Etiquette Fin : Généré par le noeud If en 11
Principe théorique de génération du code à partir de l’arbre logique :
·
Pour chaque feuille rencontrée, générer son code assembleur associé·
Pour chaque nœud rencontré, compter le nombre de fois ou le nœud est touché par la récursivité et produire le code associé au nœud en fonction de ce nombre.Dans l’exemple précédant, le nœud If est touché quatre fois par la récursivité. Une première fois en arrivant de son père et trois fois en remontant de ses fils.
Principe utilisé pour la génération du code à partir de l’arbre physique :
Cette nouvelle représentation de l’arbre nous pose ici un problème évidant. Afin d’implémenter le nœud If, il faut générer quatre portions de code entre lesquels vont s’insérer le code généré par les fils. Malheureusement, le nœud n’est rencontré que deux fois, une fois à la descente et une fois à la remonté de la récursivité.
Solution :
·
Chaque nœud génère son propre code. Cas des nœuds 1, 2, 3, 4, 5, 6, 8 et 10 de l’exemple précédant.·
Les nœuds, lorsqu’ils sont parcourus proposent à leur père de générer du code s’il le souhaite. Cas des nœuds 7 ,9 et 11 de l’exemple précédant.
4.3.Les variables globales et locales
Les variables globales sont déclarées dès la compilation et sont stockées dans le segment de donné. Les variables locales sont allouées et désallouées sur la pile au cours de l’exécution du code assembleur.
4.4.Procédures, Fonctions et récursivité
A chaque appel d’une procédure/fonction et à plus forte raison, lors d’un appel récursif, il faut allouer un nouvel espace pour les variables locales. Les variables passées en paramètre à ces procédures/fonctions sont gérées de la même manière que les variables locales à la seule différence qu’elles sont initialisées par les arguments de la procédure/fonction.
L’allocation d’espace pour les variables locales est effectuée durant l’exécution du programme. Lors de l’appel d’une procédure/fonction, un bloc d’espace est réservé sur la pile. A la compilation, nous ne connaissons pas l’adresse des variables locales mais nous connaissons pour chaque variable son emplacement relatif par rapport au début du bloc courant. Ainsi nous avons lors de l’exécution du programme toujours accès aux variables en additionnant le déplacement relatif de la variable recherchée à la valeur du registre $s7 qui représente un pointeur sur le début du bloc courant. Ce pointeur est soigneusement remis à jour sur les bons blocs au fur et à mesure des appels.

Exemple de Pile d’exécution
Lors de l’appel successif de la même procédure/fonction, il est évidant grâce au principe cité ci-dessus, que l’utilisation des variables dans les procédures/fonctions englobées n’a aucune influence sur les valeurs des variables des procédures/fonctions englobantes. Les variables globales sont quant à elles uniques et leurs modifications est visible dans tout le programme.
Tout au long de la génération du code assembleur, il est nécessaire d’utiliser des labels. Ces labels sont donc générés en même temps que le code. Les labels fonctionnent par groupe, un label unique est donc utilisé un nombre de fois déterminé, à des emplacements déterminés. Un nombre inconnu de label peuvent être généré entre deux labels devant être identiques (cas de If imbriqué, etc ...). Il est donc impossible d’utiliser une fonction GénérerUnLabel() qui retournerait le label approprié au bon moment. Nous avons donc choisi d’utiliser une fonction GénérerLabels() qui empile tous les labels nécessaires à un moment donné, et une fonction PrendreLabel() qui renvoie un label après l’avoir dépilé sur cette pile.
4.6.Codage d’un flottant sur Mips
Le Mips étant un processeur Risc, il possède un jeu d'instructions limité. Cela nous pose un problème lorsque nous souhaitons placer dans un registre un flottant car il n'existe pas d'instruction prévue à cet effet. L'astuce est de coder dans un premier temps ce flottant sur un entier de 32 bits en utilisant la notation suivante.
La représentation d'un réel sous Mips est définie par la norme IEEE. Sa forme générale est la suivante:
(-1)S x (1+signifiant) x 2 E
Ä
S représente le signe,Ä
signifiant est une fraction entre 0 et 1,Ä
E est l'exposant.En appliquant cette formule, un flottant simple précision est stockée sous Mips au sein de 32 bits :
![]()
signe exposant signifiant
Prenons comme exemple le réel -0.75.
Sous forme de fraction ce chiffre est représenté par
-0.75 = -3 x 2 -2 = -11 x 2 -2 = -1.1 x 2 -1.
Sa représentation générale est donc
(-1)1 x (1 + .1000 0000 0000 0000 0000 000) x 2 (-1+127).
En binaire :
![]()
signe exposant signifiant
En hexadécimal
![]()
signe exposant signifiant
La nouvelle représentation hexadécimale de nos réels nous permettra grâce à l'instruction li (load immédiat) de les charger dans un registre.
Nous allons montrer les différentes étapes de la compilation d’un fichier Pascal, calculant le PGCD de deux nombres.
program pgcd ;
var nombre_1 : integer;
var nombre_2 : integer;
begin
write ('Programme calculant le PGCD\n' );
write ('---------------------------\n\n');
write ('Valeur du premier nombre : ' );
read(nombre_1);
write ('\nValeur du deusieme nombre : ' );
read( nombre_2 );
while (nombre_1 <> nombre_2) do
begin
if (nombre_1 > nombre_2) then
begin
nombre_1 := nombre_1 - nombre_2;
end
else
begin
nombre_2 := nombre_2 - nombre_1;
end;
end;
write ('\nLe PGCD des deux nombres est : ' );
write (nombre_1);
end.
$037 $090 pgcd$ $084
$052 $090 nombre_1$ $059 $022 $084
$052 $090 nombre_2$ $059 $022 $084
$005
$055 $071 $089 'Programme calculant le PGCD\n'$ $072 $084
$055 $071 $089 '---------------------------\n\n'$ $072 $084
$055 $071 $089 'Valeur du premier nombre : '$ $072 $084
$039 $071 $090 nombre_1$ $072 $084
$055 $071 $089 '\nValeur du deusieme nombre : '$ $072 $084
$039 $071 $090 nombre_2$ $072 $084
$053 $071 $090 nombre_1$ $062 $090 nombre_2$ $072 $014
$005
$021 $071 $090 nombre_1$ $068 $090 nombre_2$ $072 $045
$005
$090 nombre_1$ $058 $090 nombre_1$ $076 $090 nombre_2$ $084
$017
$016
$005
$090 nombre_2$ $058 $090 nombre_2$ $076 $090 nombre_1$ $084
$017 $084
$017 $084
$055 $071 $089 '\nLe PGCD des deux nombres est : '$ $072 $084
$055 $071 $090 nombre_1$ $072 $084
$017 $073
5.3.Passe 2 : Syntaxique & Sémantique

.data
nombre_1: .space 4
nombre_2: .space 4
Progr: .asciiz "Programme calculant le PGCD\n"
_____: .asciiz "---------------------------\n\n"
Valeu: .asciiz "Valeur du premier nombre : "
_nVal: .asciiz "\nValeur du deusieme nombre : "
_nLe_: .asciiz "\nLe PGCD des deux nombres est : "
.text
.globl main
main: addi $sp,-4 # Ajoute un element sur la pile
sw $ra,($sp) # Empile l'adresse de retour
# Ecriture d'une chaine de caracteres
li $v0,4 # Service systeme : Afficher une chaine
la $a0,Progr # Adresse de la chaine
syscall # Affichage de la chaine
# Ecriture d'une chaine de caracteres
li $v0,4 # Service systeme : Afficher une chaine
la $a0,_____ # Adresse de la chaine
syscall # Affichage de la chaine
# Ecriture d'une chaine de caracteres
li $v0,4 # Service systeme : Afficher une chaine
la $a0,Valeu # Adresse de la chaine
syscall # Affichage de la chaine
# Lecture d'un entier
li $v0,5 # Service systeme : Lecture d'un entier
syscall # Lecture de l'entier
la $s0,nombre_1 # Lecture de l'adresse de la variable
sw $v0,($s0) # Ecriture dans la variable
# Ecriture d'une chaine de caracteres
li $v0,4 # Service systeme : Afficher une chaine
la $a0,_nVal # Adresse de la chaine
syscall # Affichage de la chaine
# Lecture d'un entier
li $v0,5 # Service systeme : Lecture d'un entier
syscall # Lecture de l'entier
la $s0,nombre_2 # Lecture de l'adresse de la variable
sw $v0,($s0) # Ecriture dans la variable
# While(CONDITION) do BLOCK
l0001: # --> Insertion de l'evaluation de la condition
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_1 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_2 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Test si 4($sp) <> ($sp)
lw $s0,4($sp) # Lecture de l'expression gauche
lw $s1,($sp) # Lecture de l'expression droite
sne $s0,$s0,$s1 # Test si <>
sw $s0,4($sp) # Empile le resultat
addi $sp,4 # Retire un element sur la pile
li $s0,0 # Pour tester si CONDITION fausse
lw $s1,($sp) # Resultat de l'expression
addi $sp,4 # Retire un element sur la pile
beq $s0,$s1,l0000 # Branche si CONDITION=FAUX
# --> Insertion du block
# If(CONDITION) then BLOCK else BLOCK
# --> Insertion de l'evaluation de la condition
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_1 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_2 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Test si 4($sp) > ($sp)
lw $s0,4($sp) # Lecture de l'expression gauche
lw $s1,($sp) # Lecture de l'expression droite
sgt $s0,$s0,$s1 # Test si >
sw $s0,4($sp) # Empile le resultat
addi $sp,4 # Retire un element sur la pile
li $s0,0 # Pour tester si CONDITION fausse
lw $s1,($sp) # Resultat de l'expression
addi $sp,4 # Retire un element sur la pile
beq $s0,$s1,l0003 # Branche si CONDITION=FAUX
# --> Insertion du block if
# Empile l'adresse d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_1 # Lecture de l'adresse de la variable
sw $s0,($sp) # Empile la variable
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_1 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_2 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Soustraction de deux entiers
lw $s0,4($sp) # Lecture de la premiere operande
lw $s1,($sp) # Lecture de la seconde operande
neg $s1,$s1 # Ajout du signe moins devant l'entier
add $s0,$s0,$s1 # Addition des deux entiers
sw $s0,4($sp) # Empile le resultat
addi $sp,4 # Retire un element sur la pile
# Affectation 4($sp) := ($sp)
lw $s0,4($sp) # Lecture de l'expression gauche
lw $s1,($sp) # Lecture de l'expression droite
sw $s1,($s0) # Ecrit le resultat
addi $sp,8 # Retire deux elements sur la pile
j l0002
l0003: # --> Insertion du block else
# Empile l'adresse d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_2 # Lecture de l'adresse de la variable
sw $s0,($sp) # Empile la variable
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_2 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Empile la valeur d'une variable
addi $sp,-4 # Ajoute un element sur la pile
la $s0,nombre_1 # Lecture de l'adresse de la variable
lw $s0,($s0) # Lecture de la valeur de la variable
sw $s0,($sp) # Empile la variable
# Soustraction de deux entiers
lw $s0,4($sp) # Lecture de la premiere operande
lw $s1,($sp) # Lecture de la seconde operande
neg $s1,$s1 # Ajout du signe moins devant l'entier
add $s0,$s0,$s1 # Addition des deux entiers
sw $s0,4($sp) # Empile le resultat
addi $sp,4 # Retire un element sur la pile
# Affectation 4($sp) := ($sp)
lw $s0,4($sp) # Lecture de l'expression gauche
lw $s1,($sp) # Lecture de l'expression droite
sw $s1,($s0) # Ecrit le resultat
addi $sp,8 # Retire deux elements sur la pile
l0002:
j l0001
l0000:
# Ecriture d'une chaine de caracteres
li $v0,4 # Service systeme : Afficher une chaine
la $a0,_nLe_ # Adresse de la chaine
syscall # Affichage de la chaine
# Ecriture d'un entier
li $v0,1 # Service systeme : Afficher un entier
la $s0,nombre_1 # Lecture de l'adresse de la variable
lw $a0,($s0) # Lecture de la valeur de la variable
syscall # Affichage de l'entier
lw $ra,($sp) # Depile l'adresse de retour
addi $sp,4 # Retire un element sur la pile
jr $ra # Retour au systeme appellant
6.Documentation de l’utilisateur de P2Mips
program prog;
...
begin
...
end.
var a : integer;
var b : boolean;
var c : real;
const CONSTANTE = 4;
type tab = array[1..4,3..5] of integer;
for a:=1 to 10 do
begin
...
end;
for a:=10 downto 1 do
begin
...
end;
while a=1 do
begin
...
end;
repeat
...
until a=1;
if (a=1) then
begin
...
end;
if (a<1) then
begin
...
end
else
begin
...
end;
if not (a and b or c xor d) then
begin
...
end;
a := a + 1;
c := 4.7 - 1.8;
c := a * 1.8;
a := 2 / 1.8;
a := 1 + tab[1,4];
b:= true and not false;
procedure proc (arg1 : boolean);
procedure proc (arg1 : boolean)
begin
...
end;
function func ( arg1 :real; arg2 : integer) : boolean;
function func ( arg1 :real; arg2 : integer) : boolean
begin
...
end;
read(a);
write(a);
write(‘Ceci est une chaine de caractere\n’);
Nous sommes heureux d’avoir achevé ce projet de compilation dans les temps impartis. Nous en avons retiré un enrichissement personnel. Il nous à permis d’approfondir et de consolider nos connaissances en compilation. Les résultats obtenus sont très satisfaisants puisque d’un programme source Pascal nous sommes arrivés à générer un fichier en assembleur qui est exécuté par le simulateur Xspim sur Es2i. Ces résultats positifs n’ont pas été obtenu sans peine, nous estimons y avoir passé 350 heures homme soit environ 75 heures par personne.
L’étude de la conception à été la phase la plus difficile mais le temps consacré sur cette partie était indispensable, nous n’y avons pas rencontré d’erreur fondamentale par la suite.
L’analyse lexicale grâce à Lex a abouti rapidement.
De part notre travail de programmation orienté objet, nous avons rencontré quelques difficultés dans la réalisation des fonctions permettant l’analyse sémantique car elles vérifient la syntaxe et construisent simultanément l’arbre associé.
Pour la sémantique, nous avons effectué des conversions de type, les autres étapes qui incombent généralement à cette partie ayant été réalisées pendant l’analyse syntaxique et la génération du code intermédiaire.
La complexité de la génération du code intermédiaire a résidé surtout dans la réalisation simultané du code assembleur et du code permettant de le générer ainsi que la gestion des variables locales.
Une bonne organisation du groupe et son dynamisme nous a permis d’effectuer un assemblage rapide des différentes parties réalisées par chacun d’entre nous. Une distribution homogène des taches au sein du groupe nous a permis d’interagir sur les programmes pour assurer leur compatibilité.
Ayant abordé les principaux points stratégiques de la conception, nous avons décidé de l’interrompre, malgré l’absence de certaines fonctionnalités mineures.