Compilation Cours de Master 1ère année Ph. Clauss Références bibliographiques ● Compilateurs : Principes, techniques et outils A. Aho, R. Sethi, J. Ullman ● Advanced Compiler Design and Implementation. Steven S. Muchnick ● Lex & Yacc J.R. Levine, T. Mason, D. Brown ● Modern Compiler Design D. Grune, H.E. Bal, C.J.H. Jacobs, K.G. Langendoen 2 I : Introduction 3 Compilation L'objectif ? Hommes et machines n'utilisent pas le même langage Langage de programmation Langage machine haut niveau : supporte une syntaxe riche et expressive, modularité, structuration structuratio bas niveau : reflète l'architecture matérielle au niveau binaire, opérations explicites sur les unités fonctionnelles et mémoires ⇒ Plus généralement, compilation = outils de traduction et de transformation d'un langage source vers un langage cible 4 Différents niveaux de langage langage machine : une suite binaire directement interprétable par les microprogrammes de la machine. Les opérations et opérandes de chaque instruction dans le jeu d'instructions de la machine sont spécifiées par un code machine (formé de 0 et 1). langage d'assemblage : proche du langage machine : mais les instructions sont représentées par des symboles mnémoniques (beaucoup plus faciles à retenir que le code machine). Ex : ADD R1,R2 Son exécution nécessite une traduction : le traducteur est appelé assembleur. assembleur langage de commande (script) : permet de communiquer avec le système d'exploitation. Son rôle essentiel est de permettre de lancer l'exécution de codes exécutables (en langage machine). 5 Différents niveaux de langage langage de haut-niveau : adapté à l'expression des algorithmes et structures de données, indépendant du jeu d'instructions de la machine. Son exécution nécessite une traduction dont la difficulté est proportionnelle au niveau du langage. langage intermédiaire : langage interne à un compilateur pour la représentation intermédiaire d'un programme, commune à tous les langages sources pris en compte par le compilateur. Le compilateur effectue des analyses et des transformations (optimisations) sur cette représentation intermédiaire, avant de générer le code binaire final. Exemples : - compilateur GCC, 3 langages intermédiaires (RTL, GENERIC, GIMPLE) - compilateur CLANG-LLVM, langage LLVM-IR (bitcode) - bytecode Java, .NET langage de macro-commandes (pre-processing) : langage d'instructions au compilateur (inclusion de fichiers, substitution de formes 6 mnémoniques de suites d'instructions en langage source, ...) Les différents outils de traduction On peut distinguer 3 types d'outils : - Interpréteur : traduit et exécute un programme écrit dans un langage source en code machine « à la volée » - Machine virtuelle ou compilateur dynamique (JIT, « Just-In-Time » compiler) : prend en entrée un programme en langage intermédiaire (bytecode ou bitcode), le traduit, l'optimise et l'exécute à la volée. - Compilateur (statique) : prend en entrée un programme en langage source, le traduit, l'optimise, et génère un programme écrit en langage objet (un code écrit en langage objet ou code objet contient du code écrit en langage machine ainsi que d'autres informations nécessaires à l'édition de liens et au déboggage). 7 Les cousins du compilateur Avec l'utilisation des techniques de génie logiciel (modularité, généricité), la création d'un code exécutable peut requérir plusieurs autres outils) - Préprocesseur : traduction des macro-commandes, suppression des commentaires, inclusion de fichiers séparés, ... - Éditeur de liens : Regroupement de plusieurs code objets (éventuellement issus de différents compilateurs) en un code exécutable. 8 Différents niveaux de compréhension d'un texte (ou d'un programme) Initialement un texte (ou un programme) = une suite de caractères 1er niveau : reconnaître les mots du texte, déterminer si chaque groupe de caractères appartient à un lexique transformer la suite de caractères en une suite de mots = analyse lexicale 2ème niveau : exhiber la structure du texte, déterminer s'il est conforme à une syntaxe ou une grammaire transformer la suite de mots en une structure hiérarchique = analyse syntaxique 3ème niveau : comprendre le sens du texte = analyse sémantique 9 Structure d'un compilateur = Plusieurs sous-tâches interconnectées (phases) Une phase prend en entrée une représentation du texte source et produit en sortie une autre représentation du même texte source. Une décomposition typique d'un compilateur comporte 6 phases 10 Structure d'un compilateur 11 Table des symboles La table des symboles est une structure de données contenant un enregistrement pour chaque identificateur, muni de champs permettant de stocker des informations associées. Cette structure permet d'accéder rapidement à ces informations. Sa gestion consiste à enregistrer les identificateurs utilisés dans le programme source et à collecter les informations associées à chaque identificateur (type, portée (région du programme dans laquelle il est valide), ...). 12 Traitement des erreurs Chaque phase peut rencontrer des erreurs. Cependant, une phase doit les traiter de façon à ce que que la compilation puisse continuer et que d'autres erreurs puissent être détectées. Un compilateur qui s'arrête à la première erreur n'est pas très utilisable. 13 Analyse lexicale Analyse lexicale (scanning) - transforme un flot de caractères en un flot d'unités lexicales (tokens) dont chacun représente une suite de caractères. Une unité lexicale (token) peut être : un mot clé, un identificateur, une constante, un opérateur ou un séparateur. Exemple : position := initiale + vitesse * 60 devient : ID AFF ID PLUS ID MULT CST 14 Analyse lexicale Certaines unités lexicales comme les constantes ou les identificateurs ont une “valeur lexicale” qui est la suite des caractères qu'elles représentent (lexème). Exemple : (ID,”position”) AFF (ID,”initiale”) PLUS (ID,”vitesse”) MULT (CST,”60”) Lorsqu'un identificateur est rencontré, il est entré dans la table des symboles (s'il n'y est pas déjà). 15 Analyse syntaxique Analyse syntaxique (parsing) - vérifie que la suite de tokens est une construction permise (conforme à la grammaire définissant le langage source) Exemple : CST AFF PLUS MULT ID ID ID est une construction incorrecte 16 Analyse syntaxique - rend explicite la structure hiérarchique Exemple : ID AFF (ID PLUS (ID MULT CST)) - produit un arbre de syntaxe abstraite (syntaxe abstraite ≠ syntaxe concrète) 17 Analyse sémantique Analyse sémantique - vérifie la cohérence des types (et éventuellement décore ou enrichit l'arbre par exemple par des opérations de conversion de type) 18 Génération de code intermédiaire Génération de code intermédiaire - construit une représentation intermédiaire du programme source = un code pour une machine abstraite (dont les instructions sont plus évoluées qu'une machine standard) . Ce code peut prendre plusieurs formes. Parmi les formes possibles : code à 3 adresses (séquences d'instructions a au plus 3 opérandes) Exemple : temp1 := EntierVersReel(60) temp2 := id3 * temp1 temp3 := id2 + temp2 id1 := temp3 19 Optimisation de code Optimisation de code tente d'améliorer l'efficacité du code intermédiaire. Exemple : temp1 := id3 * 60.0 id1 := id2 + temp1 id4:= id3 temp2:= id4 * 60.0 id5:= id5 + temp2 temp1:= id3 * 60.0 id1:= id2 + temp1 id5:= id5 + temp1 20 Génération de code Génération de code - produit du code objet : . choisit les emplacements mémoires pour les données . sélectionne le code machine pour implémenter les instructions du code intermédiaire . alloue les registres (un aspect important de la performance) Exemple : en utilisant les registres R1 et R2 MOVF id3, R2 MULF #60.0, R2 MOVF id2, R1 ADDF R2, R1 MOVF R1, id1 21 Compilation Regroupement de phases - en pratique, il arrive souvent que l'on regroupe plusieurs phases : 1) Parties frontale (frontend) et finale (backend) Les différentes phases sont souvent réunies en une partie frontale (indépendante de la machine) et finale (dépendante de la machine). 22 Compilation 2) Passes On implémente habituellement plusieurs phases en une seule passe (une lecture de la représentation en entrée + une écriture de la représentation en sortie). Il est préférable d'avoir peu de passes pour réduire le temps de compilation. Par exemple: L'analyseur syntaxique peut faire appel à l'analyseur lexical chaque fois qu'il a besoin d'une nouvelle unité lexicale. L'analyseur syntaxique peut produire directement du code intermédiaire (on parle de traduction dirigée par la syntaxe) syntaxe Ainsi, on peut souvent regrouper les quatre premières phases en une passe. L’optimisation comprend souvent de nombreuses passes, qui se répètent 23 Compilation Objectif de ce cours Comprendre/Maîtriser la construction d'un compilateur. NB: Cette construction s'appuie sur des résultats de la théorie des langages : * Expression rationnelle et automate (pour l'analyse lexicale) * Grammaire algébrique et automate à pile (pour l'analyse syntaxique) * Grammaire attribuée (pour la génération de code intermédiaire) 24 Compilation NB: Il existe des outils pour la construction de compilateurs : * LEX (flex) (pour l'analyseur lexical) * YACC (bison) (pour l'analyseur syntaxique) * LLVM (http://www.llvm.org) (pour une infrastructure complète de compilateur) 25 II : Analyse lexicale 26 Rôle d'un analyseur lexical Lexèmes (tokens) code source analyseur lexical analyseur syntaxique ... table des symboles ● ● ● ● Séparer l'analyse lexicale de l'analyse syntaxique Réduire la complexité du compilateur et la complexité de conception de ces deux modules Augmenter la flexibilité du compilateur : portabilité, maintenabilité Augmenter l'efficacité du compilateur Implémentation Utiliser un générateur d'analyseurs lexicaux (Flex) ● Écrire l'analyseur lexical dans un langage évolué ● Mots et langages formels Un mot sur un alphabet A est une suite finie de symboles pris dans A A peut être l'ensemble des caractères, dans ce cas les lexèmes sont des mots alphabet mots code ASCII { 0, 1 } caractères analyse lexicale caractères lexèmes analyse syntaxique lexèmes programmes Le mot vide est noté ε, sa longueur est 0 Un langage formel est un ensemble de mots Expressions rationnelles On forme des expressions à partir des symboles (éléments de l'alphabet) et de ε en utilisant - l'union notée | (ou U) - la concaténation (pas de symbole) - l'itération notée * On utilise souvent des opérations supplémentaires (abréviations) : + pour l'itération au moins une fois ? pour l'option zéro ou une fois . pour tout l'alphabet [...] pour une union de symboles Priorités entre opérateurs : itération, concaténation, union Expressions rationnelles Identificateurs en C lettre = A | B | ... | Z | a | b | ... | z chiffre = 0 | 1 | ... | 9 id = (lettre | _) (lettre | _ | chiffre)* Nombres sans signe en C chiffre = 0 | 1 | ... | 9 chiffres = chiffre chiffre* frac = . chiffres exp = (E | e) (+ | - | ε) chiffres | ε num = (chiffres (frac | . | ε) | frac) exp Les mêmes avec les abréviations chiffre = 0 | 1 | ... | 9 chiffres = chiffre+ frac = . chiffres exp = ((E | e) (+ | -) ? chiffres)? num = (chiffres (frac | .)? | frac) exp Lexèmes ou tokens Définition des espaces blancs delim = \b | \t | \n ws = delim* On dit habituellement lexème pour "catégorie de lexèmes" : expression lexème attribut expression lexème attribut < relop lt ws - - <= relop le if if - == relop eq else else - != relop ne id id pointeur > relop gt num num valeur >= relop ge etc. Automates finis Les analyseurs lexicaux utilisent des graphes appelés automates finis - les sommets sont appelés états - les transitions sont orientées et étiquetées - certains états sont finaux et comportent une action lettre | _ | chiffre 11 lettre _ 12 autre Identificateurs en C 13 return(ID) Automates finis 0 autre 1 < 2 return(relop, lt) 3 return(relop, le) = > 4 autre = = 7 = 5 return(relop, gt) 6 8 return(relop, ge) return(relop, eq) ! 9 = 10 Opérateurs de comparaison return(relop, ne) Automates finis chiffre 1 4 15 E|e chiffre . +|- 16 . 20 chiffre chiffre chiffre 17 18 autre chiffre 21 chiffre E|e chiffre 2 2 23 autre chiffre 24 . . 25 chiffre Nombres sans signe en C chiffre 26 autre 19 Implémentation en C Un programme C qui reconnaît dans le début d'une chaîne de caractères un des lexèmes définis par les automates /* Recherche de l'état initial du prochain automate */ int state = 0, start = 0 ; int lexical_value ; int fail() { forward = token_debut ; switch(start) { case 0 : start = 11 ; break ; case 11 : start = 14 ; break ; case 14 : start = 22 ; break ; case 22 : recover() ; break ; default : /* erreur */ } return start ; } token nexttoken() { while (1) { switch(state) { case 0 : c = nextchar() ; if (c==blank||c==tab||c==newline) { state = 0 ; debut ++ ; } else if (c == '<') state = 1 ; else if (c == '>') state = 4 ; else if (c == '=') state = 7 ; else if (c == '!') state = 9 ; else state = fail() ; break ; /* ... cas 1 - 10 ... */ case 11 : c = nextchar() ; if (isletter(c)||c=='_') state = 12 ; else state = fail() ; break ; case 12 : c = nextchar() ; if (isletter(c)||c=='_') state = 12 ; else if (isdigit(c)) state = 12 ; else state = 13 ; break ; case 13 : retract(1) ; install_id() ; return(gettoken()) ; /* ... cas 14-26 ... */ } } } Automates déterministes Un automate fini est déterministe si : - il possède au plus un état initial - de chaque état part au plus une flèche étiquetée par un symbole donné Exemple d'automate non déterministe : a|b a 1 2 a Exemple d'automate déterministe : b 1 a a b 2 Automates déterministes Un automate déterministe représentant un ensemble de mots peut être utilisé pour reconnaître l'un des mots dans une chaîne de caractères : il suffit de partir de l'état initial, de suivre les flèches et de voir si l'état dans lequel on arrive est final. Tout automate fini est équivalent à un automate fini déterministe : il existe un automate fini déterministe qui reconnaît le même langage. Utilisation de Flex spécification Flex flex lex.yy.c compilateur C caractères (code source) a.out 4 étapes : - créer sous éditeur une spécification Flex (expressions rationnelles) - traiter cette spécification par la commande flex - compiler le programme source C obtenu - exécuter le programme exécutable obtenu lexèmes ... Spécifications Flex Un programme Flex est fait de trois parties : déclarations %% règles de traduction %% fonctions auxiliaires en C Les règles de traduction sont de la forme p1 { action1 } p2 { action2 } ... pn { actionn } où chaque pi est une expression rationnelle et chaque action une suite d'instructions en C. Exemple %{ /* Partie en langage C : définitions de constantes, déclarations de variables globales, commentaires... */ %} delim [ \t\n] letter [a-zA-Z] %% {delim}* { /* pas d'action */ } if { return IF ; } then { return THEN ; } else { return ELSE ; } {letter}({letter}|[0-9])* { yyval = install_id() ; return ID ; } ([0-9]+(\.[0-9]*)?|\.[0-9]+)((E|e)(\+|-)?[0-9]+)?{ yyval = install_num() ; return NUMBER ; } Exemple "<" { yyval = LT ; return RELOP ; } "<=" { yyval = LE ; return RELOP ; } %% install_id() { /* fonction installant dans la table des symboles le lexème vers lequel pointe yytext et dont la longueur est yylength. Renvoie un pointeur sur l'entrée dans la table */ } install_num() { /* fonction calculant la valeur du lexème (→ attribut) */ } Spécifications Flex Les commentaires /* ... */ ne peuvent être insérés que dans une portion en C : - dans la partie déclaration, seulement entre %{ et %} ; - dans la partie règles, seulement dans les actions ; - dans la partie fonctions auxiliaires, n'importe où. Dans les règles pi { actioni } les expressions rationnelles pi ne peuvent pas contenir d'espaces blancs (ou alors dé-spécialisés). Dans la partie règles, si une règle commence par un espace blanc, elle est interprétée comme du langage C et est insérée dans lex.yy.c au début de la fonction qui renvoie le prochain lexème (utilisable pour déclarer des variables locales). Segmentation du code source par l'analyseur lexical (1/3) L'analyseur lexical produit par Flex - commence à chercher les lexèmes au début du code source ; - après chaque lexème reconnu, recommence à chercher les lexèmes juste après Exemple : si piraté est reconnu, raté n'est pas reconnu Si aucun lexème n'est reconnu à partir d'un point du code source l'analyseur affiche le premier caractère sur la sortie standard et recommence à chercher à partir du caractère suivant Segmentation du code source par l'analyseur lexical (2/3) Si plusieurs lexèmes sont reconnus à partir d'un point du code source (conflit) 1. Deux lexèmes de longueurs différentes C'est le plus long qui gagne Exemple : [a-zA-Z_] [a-zA-Z_0-9]* Segmentation du code source par l'analyseur lexical (3/3) 2. Deux lexèmes de même longueur Ils commencent au même point et terminent au même point : s'il y a conflit, c'est qu'ils sont issus de deux règles différentes. C'est la règle qui apparaît la première dans la spécification Flex qui gagne Exemple : conflit entre [a-zA-Z_] [a-zA-Z_0-9]* et while Mettre l'exception avant la règle En dehors de ce cas, l'ordre des règles ne joue pas III : Analyse syntaxique 49 Analyse syntaxique Introduction Rôle de l'analyse syntaxique : - déterminer si une suite de tokens est conforme à la grammaire - déterminer les erreurs de nature syntaxique et le cas échéant les réparer En pratique, d'autres tâches sont réalisées simultanément à l'analyse syntaxique : - ajouter des informations à la table des symboles - effectuer des vérifications de type - produire du code intermédiaire 50 Analyse syntaxique Méthodes d'analyse syntaxique La plupart des méthodes d'analyse tombent dans 2 classes : - méthodes descendantes (top-down parsing) - méthodes ascendantes (bottom-up parsing) 51 Analyse syntaxique Dans la suite nous illustrons les différences entre ces 2 méthodes par l'exemple du langage des expressions arithmétiques Le langage est décrit par la grammaire algébrique suivante : G0 : E → E + T | T T→T * F | F F → ( E ) | id Par convention, les lettres majuscules (ici E, T et F) sont des symboles non-terminaux et les autres symboles (ici * + ( ) et id) sont des symboles terminaux (identifient des tokens). Nous considérons l'analyse de la chaîne : (id+id)*id 52 Analyse syntaxique * Pour les analyses descendantes, - l'arbre de syntaxe est construit de la racine jusqu'aux feuilles. - l'analyse s'appuie sur des dérivations gauche de la suite de tokens : on remplace toujours en premier le non-terminal le plus à gauche. 53 Analyse syntaxique G0 : E → E + T | T T→T * F | F F → ( E ) | id (id+id)*id Dérivation gauche de cette chaîne : E g T g T * F g F * F g (E) * F g (E + T) * F g (T + T) * F g (F + T) * F g (id + T) * F g (id + F) * F g (id + id) * F g (id + id) * id NB : A chaque étape, on sélectionne une règle qui permet de dériver le reste de la chaîne (Ce qui peut a priori nécessiter des retours-arrière. On peut éviter les retours-arrière en utilisant l'information des premiers symboles qui dérivent d'une règle) 54 Analyse syntaxique * Inversement pour les analyses ascendantes, - l'arbre de syntaxe est construit en remontant à la racine depuis les feuilles. - l'analyse s'appuie sur des dérivations droite de la suite de tokens : on remplace toujours en premier le non-terminal le plus à droite. 55 Analyse syntaxique G0 : E → E + T | T T→T * F | F F → ( E ) | id (id+id)*id Dérivation droite de la chaîne : E d T d T * Fd T * id d F * id d (E) * id d (E + T) * id d (E + F) * id d (E + id) * id d (T + id) * id d (F + id) * id d (id + id) * id NB: A chaque étape, on sélectionne une partie au début de la chaîne qui dérive d'un non-terminal et on la remplace par ce non-terminal. L'inverse de la suite de règles utilisées est une dérivation droite de la chaîne 56