Compilation TP Lexeur/Parseur
C : Flex et Bison
17 décembre 2021
Prérequis : ce TP suppose que vous avez initialisé un dépôt git avec un com- mit initial (avec uniquement le readme). Si ce n’est pas le cas, faites le.
Au début du TP, vous devez vous placer dans le dossier cloné, vous serez au- tomatiquement sur la branche master ,
1qui sera la branche de rendu pour la seconde partie compilateur (si vous préférez qu’elle ai un autre nom, vous pouvez le changer).
Si vous n’êtes pas à l’aise du tout avec Git, vous pouvez regarder ces slides (niveau L2...). Si vous êtes perdu dans les branchements, utilisez l’interface graphique gitk ou un simulateur.
Attention : Une erreur courante sur ce TP consiste à remplacer brutalement un fichier par la correction ou par la version présente sur une autre branche git.
Il ne faut jamais faire ça, car l’algorithme de différenciation de git n’arrivera pas à suivre et les future merge vont tous échoué. Pour l’exercice, il faut modifier, à la main, chaque ligne qui a besoin d’être modifé. Si vous vous êtes trompé sur un branchement, rien de critique, cherchez en ligne ou demandez à l’enseignant comment revenir en arière avec Git.
2Exercice 1 (Un parseur sans AST).
Attention, avec flex/bison, la séparation lexeur/parseur est différente du cours : on fait le maximum dans le parseur, et le lexeur n’est en apparence utilisé que pour les tokens non triviaux ; cela n’empêche pas que l’on passe toujours par le lexeur.
1. Créez trois fichiers :
— main.c qui est le programme exécutable,
— lexeur.l qui génère le lexeur,
— parseur.y qui génère le parseur.
2. Dans main.c , on se contente d’essayer de parser, et d’afficher si l’annalyse syntaxique à réussie ou échouée :
1. Celon le dépot git utilisé, votre branchemasters’appelleramain, dans ce cas remplacez simplement “master” par “main” dans toutes les commandes données.
2. l’avantage d’un gestionaire de version est que rien n’est jamais perdu, par contre l’outil est complexe et il faut apprendre à utiliser.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
if (!yyparse()) { // call to the parsing (and lexing) function printf("\nParsing:: syntax OK\n"); // reached if parsing follows the grammar } exit(EXIT_SUCCESS);
}
La fonction yyparse() est générée par bison dans le code du parseur.
Cette fonction retourne 0 lorsque les analyses sémantiques et syntaxiques terminent avec succes,
31 lorsqu’il y a une erreur lexicale ou syntaxique, et 2 s’il y a une erreur innatendue (de mémoire principalement).
3. Dans parseur.y , on définit le parseur en utilisant des char comme TO- KEN :
/* file parseur.y
* compilation: bison -d parseur.y
* result: parseur.tab.c = C code for syntaxic analyser
* result: parseur.tab.h = def. of lexical units aka lexems
*/
%{ // the code between %{ and %} is copied at the start of the generated .c
#include <stdio.h>
int yylex(void); // -Wall : avoid implicit call
int yyerror(const char*); // on fonctions defined by the generator
%}
%token NUMBER // kinds of non-trivial tokens expected from the lexer
%start expression // main non-terminal
%% // denotes the begining of the grammar with bison-specific syntax expression: // an expression is
expression '+' term // either a sum of an expression and a term
| expression '-' term // or an expression minus a term
| term // or a term
;
term: // a term is
term '*' factor // either a product of a term and a factor
| factor // or a factor
;
3. De nos jours un programme qui renvoit0pour réussir est très étrange, mais n’oubliez pas que Bison est créé sur la base de Yacc, lui même écris début des années 70 pour le langage B...
factor: // a factor is
| '(' expression ')' // either an expression surounded by parentheses
| '-' factor // or the negation of a factor
| NUMBER // or a token NUMBER
;
%% // denotes the end of the grammar
// everything after %% is copied at the end of the generated .c
int yyerror(const char *msg){ // called by the parser if the parsing fails printf("Parsing:: syntax error\n");
return 1; // to distinguish with the 0 retured by the success }
Dans l’ordre :
— on définit le prototype du lexeur et de la fonction d’erreur de bison
4— on y décrit un unique token non trivial (càd qui n’est pas un simple char ) appelé NUMBER ,
— on y décrit une grammaire avec expression , term et factor comme non terminaux et avec '+' , '-' , '*' , '/' , '(' , ')' , '-' ainsi que le token NUMBER comme terminaux.
— on inclue des bibliothèques pour pouvoir écrire le programme renvoyé en cas d’erreur,
— on y décrit la fonction yyerror qui est appelée en cas d’erreur 4. Dans lexeur.l , on définit l’unique token non trivial :
/* file lexeur.l
* compilation: flex lexeur.l
* result: lex.yy.c = lexical analyser in C
*/
%{ #include <stdio.h> // printf
#include "parseur.tab.h" // token constants defined in parseur.y via #define
%}
%%
0|[1-9][0-9]* { printf("lex::NUMBER %s\n",yytext); return NUMBER; } [ \t\n] { ; } // ignore space and tab
[ \t\n] { return 0; } // exit sucessfully
. { printf("lex::char %s\n",yytext); return yytext[0]; } // other one-char = token, ex. '+'
%% int yywrap(void){ return 1; } // function called at the end of the file
4. Il s’agit d’éviter certains warning à la compilation : cf.
https://stackoverflow.com/questions/20106574/simple-yacc-grammars-give-an-error
Dans l’ordre :
— on inclut parseur.tab.h qui est généré à partir de parseur.y et qui définit le token NUMBER ,
— 0|[1-9][0-9]* est l’expression régulière capturant les entiers,
— la ligne “ printf("lex::NUMBER %s\n",yytext); return NUMBER; ” est l’action associée à l’expression régulière : lorsque le lexeur a re- connu l’expression en question il va donc afficher “ lex::NUMBER%s "
sur le terminal où %s est remplacé par le lexème reconnu, puis il va envoyer le token NUMBER au parseur avant de continuer à chercher le prochain lexème,
— la ligne [ \t] {;} permet d’ignorer les séparateurs (ici l’espace et la tabulation),
— la ligne \n {return 1;} permet d’arrêter le lexeur (et donc le par- seur) au premier retour à la ligne,
— la ligne . {return yytext[0];} dit que si on lit autre chose, on renvoit au parseur un token trivial avec ce caractère,
— la fonction yywrap est appellée à la fin du fichier, elle doit toujours renvoyer 1.
5. Compilez tout ça à l’aide des trois commandes suivantes dans le terminal :
$ bison -d parseur.y
$ flex lexeur.l
$ gcc -o main main.c parseur.tab.c lex.yy.c
Cela génère deux fichiers intermédiaires : votre parseur parseur.tab.c , votre lexeur lex.yy.c , puis votre exécutable main
6. Vous pouvez lancer main dans un terminal entrez une expression arith- métique, si elle est correct vous aurez un message l’indiquant sinon vous aurez un message d’erreur.
7. Vous pouvez ajouter ces trois fichier au suivit de git, commit-er et push- er :
$ git add lexeur.l parseur.y main.c
$ git commit -m "mon premier parseur"
$ git push
master head premier parseur
8. On va introduire une feature, pour ne pas polluer la branche master, on crée une branche séparée (ici appelée parser_work ) et se mettre dessus :
$ git checkout -b parser_work
master parser_work head premier parseur
9. On voudrait ne reconnaitre que des expressions finissant par un “ ;”. Pour ça, on va modifier parseur.y :
— ajouter un non-terminal commande qui reconnaisse une expression sui- vie d’un “ ;”
— changez le non-terminal principal (celui après %start ) pour y mettre commande
Ajouter une expression régulière pour cela, et créez un nouveau token
\PT_VIRG sans oublier de modifier la grammaire du parseur.
10. Faites un commit et un push de vos changements :
$ git add lexeur.l parseur.y
$ git commit -m point_virg
$ git push
Remarquez que l’on refait un git add , en fait celui-ci sert aussi à spécifier les fichiers que l’on a mis à jours.
master parser_work head premier parseur point_virg
11. On veut maintenant voir si on peut rajouter une feature du fragment 0.1, or même si on n’a pas encore tout ce qu’il faut pour le fragment 0.0, on peut tout de même s’avancer en créant une nouvelle branche temporaire :
$ git checkout -b TP
master parser_work TP head premier parseur point_virg
12. Rajoutez une écriture décimal (virgule fixe) pour nos nombres en modi- fiant l’expression régulière corespondante (dans le fichier lexeur.l ).
Faites un nouveau commit. La situation de votre dépôt devrait alors être la suivante :
master parser_work TP head premier parseur point_virg decimals
Exercice 2 (Simplification et ouverture de fichiers).
1. Revenez sur la branche master :
$ git checkout master
master parser_work TP head
premier parseur point_virg decimals
2. Nous allons simplifier la grammaire en spécifiant des priorités et associa- tivité.
Allez dans parseur.y et modifiez ainsi le fichier :
%token NUMBER
%left '+' '-'
%left '*'
%nonassoc MOINSU
%%
result: expression expression:
expression '+' expression
| expression '-' expression
| expression '*' expression
| '(' expression ')'
| '-' expression %prec MOINSU
| NUMBER
;
3. Cette version fait exactement la même chose que la précédente, mais en plus concis et intuitif grâce aux lois de priorité et d’associativité :
— Les règles d’associativité sont indiqués par %left ou %right .
— Les règles de priorité sont implicites : '*' est prioritaire sur '+' et '-' car la ligne “ %left '+' '-' ” est placée avant la ligne “ %left '*' ”.
— Lorsque l’associativité de fait aucun sens (par exemple pour un opéra- teur unaire) mais que l’on veut avoir une priorité, on utilise %nonassoc et on place les opérateur au bon niveau.
— Lorsqu’un token est utilisé dans plusieurs règles avec des priorités/associativités différentes (comme '-' ), on utilise une balise pour indiquer la priorité
d’une des règles, ici la seconde règle du moins est balisée UMOINS qui a une autre priorité que '-' .
4. Faites un commit :
master
parser_work TP head
premier parseur simplification
point_virg decimals
5. On veut rajouter ces changements dans la branche de travail du parseur, pour ça on revient sur cette dernière, et on la merge avec master :
$ git checkout parser_work
master
parser_work TP head
premier parseur simplification
point_virg decimals
$ git merge master
master parser_work
TP head
premier parseur
simplification point_virg
merge decimals
6. On voudrait maintenant finir le fragment 0.0 du parseur, mais pour ça, il faut permettre à l’utilisateur de donner un fichier en argument :
— dans lexeur.l : supprimez la ligne sur \n et ajoutez le retour à la ligne parmis les séparateurs,
— dans main.c : créez un pointeur de fichier vers le chemin d’accès dpnné en premier argument de votre programme, puis donnez ce pointeur à la fonction yyparse .
7. Faites un commit, puis créez la branche de rendue parseur et tagg-ez le commit et revenez sur la branche de travail :
$ git add main.ml
$ git commit -m input
$ git checkout -b parser
$ git tag -a p0.0 -m ``premiere version faites en TP par <mon nom>''
$ git push
$ git checkout parser_work
p0.0
master
parser_work parser
TP head
premier parseur
simplification point_virg
merge input
decimals
8. On va maintenant pouvoir récupérer les décimaux que l’on avaient ajouter tout à l’heure. Pour ça il suffit de merger avec TP , après quoi on peut supprimer TP qui n’est plus utile :
$ git merge TP
$ git push
$ git branch -d TP
$ git push --delete TP
9. Rajoutez l’opérateur de pourcentage (_%_) (attention à sa priorité !), faites un commit, revenez sur la branche parser , mergez et placez le tag p0.1 :
p0.0 p0.1
master
parser_work parser head
premier parseur
simplification point_virg
merge
input merge mod
merge
decimals
Exercice 3 (Aparte : interpréteur). Il n’est pas demandé, dans le projet, de faire un interpreteur car ce serait plus difficile d’implémenter variables et fonctions que de faire un compilateur vers notre assembleur abstrait. Néamoins, pour les tout premiers fragments, il se trouve que c’est assez simples, et on va le faire pour se familiariser avec l’outil de parsing.
1. Revenez sur master, créez une nouvelle branche appelée interpreter . 2. Commencez par modifier le lexeur afin qu’il transmette la valeur des
entiers sont lus (pour l’instant il ne fait que dire qu’il a vu un entier...).
Pour ça, on modifie la ligne qui permet de reconnaître NUMBER afin de passer l’entier reconnu :
0|[1-9][0-9]* { printf("lex::NUMBER %s\n",yytext);
yylval=atoi(yytext); return NUMBER; } la variable yylval est celle qui contiendra le comptenu de token à sa création, la variable yytext est celle qui contient le lexeme lu (c’est donc une string).
3. Ensuite il faut exprimer les contenus créés à chaque règle : expression:
expression '+' expression { $$ = $1+$3; }
| expression '-' expression { $$ = $1-$3; }
| expression '*' expression { $$ = $1*$3; }
| '(' expression ')' { $$ = $2; }
| '-' expression %prec MOINSU { $$ = -$2; }
| NUMBER { $$ = $1; } // default semantic value
;
Dans les actions (entre les acolades), $1,$2,$3 désignent le contenu du premier, second et troisième token utilisé dans la rêgle. Les actions, ici, sont de type entier, c’est l’entier que l’on va mettre dans le token créé.
4. Vous pouvez tester et faire un commit (appelé interp dans la suite) si tout va bien.
5. Faites le merge avec p0.1 . Le programme résultant ne sera pas fonctionnel car on a typé nos sorties avec des entiers au lieux de flottants et car le modulo n’est pas écrit.
6. Par défaut les tokens de bison ont le type int , mais ici, on veut utiliser d’autres types associés aux tokens il faut, pour ça, ajouter un type union dans parseur.y avec :
%union { double dval; int ival; } ; et modifier le lexeur en conséquence : yylval.dval=...
7. Pour indiquer au parseur que le terminal expression et le non terminal NUMBER vont créer des tokens contenant des doubles il faut utiliser
%token <dval> NUMBER
%type <dval> expression
8. Corrigez tout ça, faites un commit, et tagg-ez le. Votre dépôt doit res- sembler à ça :
i0.0 i0.1
p0.0 p0.1
master parser_work
parser
interpreter
head
premier parseur
simplification interp
merge merge corrections
point_virg merge
input merge mod
merge
decimals
Dans les exercices suivants, on oubliera la branche “interpreter”. En effet, l’inter- préteur n’est pas demandé pour le projet contrairement au compilateur (l’inter- préteur devient difficile une fois que l’on parle de variable, très difficile lorsque l’on introduit les fonctions, et encore plus avec les exceptions).
Exercice 4 (Créer un AST en sortie). Avant de pouvoir faire un interpreteur, on
veut pouvoir manipuler notre AST. Ce n’est pas strictement nécessaire, surtout
au début, mais c’est très pratique pour s’y retrouver et séparer les problèmes.
1. Revenez sur la branche master , et téléchargez les fichiers AST.h et AST.c disponibles ici.
2. Vous y trouverez la structure de l’AST dans AST.h , et les fonctions pour la manipuler implémentées dans AST.c .
3. Nous alons utiliser ce type d’AST comme type de retour des expressions.
Pour expliquez ça au parseur, dans parseur.y , nous devons rajouter :
— un #include "AST.h" avecles autres,
— après les %{***%} , on ajoute la ligne
%parse-param {struct _tree* *pT}
qui permet à la fonction yyparse dans main.c de prendre un pointeur de retour vers notre ast vide en paramètre.
— juste après, comme à l’exercice précédent, on rajoute un type union :
5%union {
struct _tree* exp;
int num;
} ; %type <exp> expression
%token <num> NOMBRE
— il faut rajouter un non-terminal result qui serale terminal principal et ne contient qu’une expression (celà permet de metre le bon AST dans le pointeur de retour).
— il faut maintenant écrire les actions associées à la grammaire permet- tant de construire un AST. :
result: expression { *pT = $1; } expression:
expression '+' expression { $$ = newBinaryAST('+',$1,$3); }
| expression '-' expression { $$ = newBinaryAST('-',$1,$3); }
| expression '*' expression { $$ = newBinaryAST('*',$1,$3); }
| '(' expression ')' { $$ = $2; }
| '-' expression %prec MOINSU { $$ = newUnaryAST('-',$2); }
| NUMBER { $$ = newLeafAST($1); }
;
par exemple { $$ = newBinaryAST('+',$1,$3); } indique l’action effectuée lorsque l’on lit le pattern correspondant : on appelle la fonc- tion newBinaryAST qui renvoit un nouvel arbre syntaxique créé à partir de $1 et $3 qui sont eux-même les arbres renvoyés par les ex- pressions à gauche et à droite de la somme.
64. Comme dans l’exercice précédent, dans lexeur.l , il y a un unique chan- gement à faire : yylval devient yylval.num pour informer le parseur du champs ( num de type int ) associé au TOKEN NUMBER .
5. Cette complexité vient de l’utilisation sous-jacente duunionde C qui entre autre fait chevaucher les différents champs en mémoire
6. On passe de $1 à $3 car $2 est le retour (du lexeur) sur ’+’, que l’on n’utilise pas.
5. Vous pouvez tester et faire un commit (appelé intro_ast dans la suite) si tout va bien.
6. Faites une nouvelle branche ast et mergez avec p0.0 .
7. Ceci n’est pas encore notre compilateur, il faut encore générer du code, mais avant ça, nous allons nous entraîner à maîtriser l’AST. Pour pouvoir revenir sur la génération de code, veuillez créer une nouvelle branche code_gen puis revenir sur ast et mergez avec p0.1 .
8. Rajoutez l’AST du reste et des flottants.
9. Sur une branche temporaire séparée (ceci est un exercice, et n’est pas utile pour le projet), écrivez un programme qui évalue l’expression entrée en parcourant l’arbre syntaxique généré.
p0.0 p0.1
master
parser_work parser
ast code_gen
head tmp
premier parseur
simplification intro_ast
merge merge compl_ast
inerp_ast
point_virg merge
input merge mod
merge
decimals
Exercice 5 (Génération de code).
1. Revenez sur code_gen ,
2. Dans AST.c , créez une nouvelle fonction d’affichage code qui va parcourir l’arbre et va faire un affichage post-fixe de sa structure en affichant :
7— la ligne “ CsteNb n” après avoir quitté un noeud Nombre (n),
— la ligne “ AddiNb ” après avoir quitte un noeud Plus ,
— la ligne “ MultNb ” après avoir quitte un noeud Mult ,
— la ligne “ SubiNb ” après avoir quitte un noeud Moins ,
— la ligne “ NegaNb ” après avoir quitte un noeud Neg ,
3. Un fichier .js est sensé être donné en argument de notre compilateur, faites en sorte que soit généré automatiquement un nouveau fichier de même nom mais avec l’extension .jsm
4. Testez votre code à l’aide de la miniJSMachine.
7. Passez à la ligne après chaque affichage pour plus de lisibilité.
5. Commitez, mergez master avec cette branche, placez votre tag c0.0 , et revenez sur la branche code_gen .
6. Mergez avec la branche interpreter , et corrigez les conflits si besoin.
7. Complétez la génération de code à l’aide de la commande assembleur ModuNb pour le modulo.
8. Testez, commitez, mergez master avec cette branche, puis placez votre tag c0.1 .
c0.0 c0.1
p0.0 p0.1
master
parser_work parser
ast
code_gen
premier parseur
simplification intro_ast
merge
merge
merge merge compl_ast
code_0.0
code_0.1 merge
inerp_ast
point_virg merge
input merge mod
merge
decimals