Vous êtes sur la page 1sur 11

1.

Les compilateurs : introduction

Que fait un compilateur ?

Le but d’un compilateur est de traduire un programme source écrit dans un certain langage en
un programme cible dans un autre langage. Le plus souvent, le langage cible est le langage
machine, mais pourrait aussi traduire un texte formaté avec LATEX en HTML.

Le plus souvent, un compilateur génère un code dans un langage intermédiaire à partir du


code source. Ensuite, il traduit le code intermédiaire dans le langage cible. Ainsi, on peut
développer des compilateurs C, Java et Scheme pour les trois machines P7, Risk et Y (voir
figure 1) Souvent, la traduction code intermédiaire-code machine est plus simple. Il est donc
plus rapide de développer six « demi-»compilateurs que neuf compilateurs complets.

C Java Scheme

Code intermédiaire

P7 Risk Y

Figure 1

Certains compilateurs sont optimisants, soit en temps d’exécution, soit en espace mémoire
utilisé. Ils peuvent être à une passe ou à plusieurs passes. Dans ce dernier cas, chaque passe
parcourt complètement la structure fournie par la passe précédente.

On attend quelques critères importants pour un compilateur, notamment :


• le code doit être facilement debugable
• le code produit doit être efficace soit en temps, soit en espace mémoire utilisé, soit en
taille du code. Il faut noter que ces critères sont souvent contradictoires.
• il doit effectuer un certain nombre de contrôles de types ou les manipulations hors
tableau…
• le code doit être le plus portable possible.
Les phases de la compilation.

La compilation se décompose grosso modo en 6 phases : 3 phases d’analyses et 3 phases pour


la génération du code. La figure 2 montre le schéma de fonctionnement d’un compilateur. On
voit que le gestionnaire de la table des symboles et le gestionnaire des erreurs interagissent
avec toutes les phases.

programme source

analyseur
lexical

analyseur
syntaxique

analyseur sémantique

gestionnaire de la table
gestionnaire des erreurs
des symboles
générateur de code
intermédiaire

optimiseur
de code

générateur
de code

programme cible

Figure 2 : Les 6 phases d’un compilateur.

Nous allons regarder brièvement le rôle de chaque phase. Les trois premières seront ensuite
étudiées en détail.

L’analyse lexicale

Intuitivement, le rôle de l’analyseur lexical (également appelé scanner en anglais) est de


trouver les « mots » du programme. Il décompose le texte d’entrée (le programme) en lexème
(ou token). Un lexème appartient à une unité lexicale. Une unité lexicale est un ensemble de
lexèmes, définissant un élément générique (par exemple, l’ensemble des entiers où chaque

2
entier est un lexème de cette unité lexicale). Ces lexèmes sont définis par un modèle (ou
pattern).

Le pattern donne la forme que doivent avoir les lexèmes de l’unité lexicale. Par exemple, pour
un nombre entier, le pattern sera : un « 0 » ou un chiffre différent de « 0 » suivi d’un certain
nombre de chiffres.

Formellement, une unité lexicale est un ensemble de lexème respectant un pattern :

Unité lexicale = { lexème | pattern(lexème) }

Le plus souvent, le nombre de lexèmes appartenant à une unité lexicale est infini. Une unité
lexicale ne peut donc pas toujours être donnée en extension. Celle-ci sera souvent exprimée
sous forme d’expression régulière (voir Erreur ! Source du renvoi introuvable.). De façon
plus générale, on dira que le scanner identifie des tokens (ou jetons) dont le « type » est son
unité lexicale et sa valeur est son lexème.

L’analyseur lexical rempli également la table des symboles en y insérant tous les symboles
utilisés dans le programme et n’appartenant pas au langage, par exemple les identificateurs,
les constantes, etc… (voir 0)

Exemple :

« x3 := 7 + 41 » va être décomposé de la manière suivante :

x3 := 7 + 41
Unité lexicale lexème
id x3
assign :=
const 7
op +
const 41

Table des symboles : x3


7
41

Résultat : id(x3) assign const(7) op(+) const(41)

L’analyseur lexical se chargera généralement aussi de la suppression des commentaires et des


espaces blancs (en fait, ceux-ci peuvent être vus comme des unités lexicales qui sont
ignorées).

L’analyse syntaxique

Intuitivement, le rôle de l’analyseur syntaxique (également appelé parser) est de trouver les
« phrases » du programme source, à partir des « mots » donnés par l’analyseur lexical.
Formellement cela consiste à créer l’arbre syntaxique. Celui-ci décrit la structure du

3
programme. L’analyse syntaxique se fait sur la base d’une grammaire (voir chapitres sur les
grammaires).

Les feuilles de l’arbre seront les unités lexicales, tandis que les nœuds de l’arbre
correspondront à des règles de la grammaire. S’il y a lieu, les feuilles feront référence à un
élément de la table des symboles.

Exemple :
Soit la grammaire : Assign → id := Exp
Exp → Exp + Exp2 | Exp2
Exp2 → Exp2 * Atom | Atom
Atom → id | (Exp) | const

Les unités lexicales sont « := », « + », « * », « ( », « ) », id et const où id et const sont définies


par les expressions régulières suivantes :
• id =def l . (l + c)*
• const =def c . c*
• l =def a + b + … + z
• c =def 0 + 1 + … + 9

Attention ! Dans cet exemple, « + », « * », « ( » et « ) » sont des unités lexicales de la


grammaire. De plus, les mêmes symboles sont utilisés comme opérateurs pour définir les
expressions régulières (voir chapitre sur les expressions régulières pour plus de détails). La
définition de c dit que cela peut être un « 0 » ou un « 1 » ou … ou un « 9 ». const est un
chiffre suivi d’un certain nombre éventuellement nul de chiffres.

En reprenant « x3 := 7 + 41 », on obtient l’arbre syntaxique suivant :

4
Assign

id := Exp

Exp + Exp2

Exp2 Atom

Atom const

const

x3
7
41

L’analyse sémantique

Intuitivement, le rôle de l’analyse sémantique est de comprendre les « phrases » données par
l’analyseur syntaxique sous la forme d’un arbre. En réalité, il effectuera quelques contrôles et
quelques modifications de l’arbre.

Les contrôles porteront par exemple :


• sur la visibilité des variables, c'est-à-dire vérifier qu’elles sont déclarées et visibles
aux endroits où elles sont utilisées.
• sur le mode, c'est-à-dire vérifier les types utilisés. Ceci peut éventuellement amener
des modifications de l’arbre syntaxique par l’ajout de nœud spéciaux. Par exemple,
pour le transtypage d’un réel vers un entier, on pourrait rajouter un nœud
« RéelVersEntier » qui indique qu’une conversion doit être faite.

Exemple :
x := y + z * 60,7 où x, y et z sont des entiers

5
Arbre syntaxique créé par l’analyseur syntaxique (Attention ! Il n’est pas complet, mais
a été simplifié à ses nœuds les plus important) :

Assign

id := Exp

id + Exp2

id * nb

x
y
z
60,7

6
Arbre syntaxique transformé par l’analyse sémantique :

Assign

id := Exp

id + Exp2

id * FtoI

nb

x
y
z
60,7

La gestion de la table des symboles

La table des symboles regroupe tous les symboles définis dans le programme. Elle conserve
également les propriétés importantes de ces symboles. Par exemple, pour une variable, on
retiendra son type, sa portée, son emplacement mémoire. La table des symboles est utilisée
tout au long de la compilation, depuis son remplissage par l’analyseur lexical, jusqu’à la
génération de code. De par son utilisation importante, les algorithmes de consultation et de
mise à jour de la table doivent être optimisés le plus possible.

La gestion des erreurs

La gestion des erreurs est une partie importante d’un compilateur. Il existe deux stratégies :
soit on s’arrête dès qu’il y a une erreur, soit on essaie de se resynchroniser, et de déterminer le
plus précisément possible la cause de l’erreur. Une bonne gestion des erreurs permet de
détecter un maximum d’erreurs en même temps, ce qui permet de toutes les corriger avant de
réessayer de compiler. Malheureusement, on peut avoir d’autres messages d’erreur dus à une
mauvaise resynchronisation.

Chaque phase détecte son propre type d’erreur :

7
• L’analyse lexicale détecte fort peu d’erreurs, essentiellement les fautes de frappe. Il
s’agit des caractères interdits ou de « mots » ayant une forme illégale pour lui. Par
exemple : « £ » ou « µ » ou « 3xyz ». Toutes les inversions de caractères ne seront
pas détectées ici (« 3xyz » au lieu de « x3yz » le sera, mais pas « fi » au lieu de
« if »).
• L’analyse syntaxique détecte les erreurs de construction du programme (« Syntax
error »). Par exemple, en C, une accolade manquante, un « ; » manquant ou encore
« fi(x == 4) ; » où fi est pris pour un identificateur par l’analyseur lexical, mais
donne alors une construction illégale.
• L’analyse sémantique détectera les erreurs de type, les constantes trop longues, les
variables non-déclarées, …

La production du code intermédiaire

Il s’agit ici de produire un programme équivalent au code source dans un langage plus proche
du langage machine. En général, il utilisera un adressage symbolique et un set d’instructions
réduit et standard. La mémoire nécessaire pour stocker les variables du programme sera
également calculée.

Le but de cette étape est d’effectuer un certain nombre de choix (mémoire à prévoir, « type »
d’instructions à utiliser, …) dans un langage indépendant du langage cible. Ceci permet une
portabilité plus grande des quatre premières phases du compilateur.

L’optimisation

L’optimisation est probablement la plus grosse partie du compilateur, et la plus compliquée.


L’optimisation peut se faire soit sur la taille du code, soit sur la mémoire utilisée par le
programme, soit encore sur le temps d’exécution. Il faut remarquer que ces critères sont
souvent mutuellement exclusifs.

Parmi les optimisations courantes, on peut citer :


• Le code constant dans une boucle peut être sorti de celle-ci. Il s’agit du code qui ne
dépend ni directement, ni indirectement des paramètres de la boucle.
• Les calculs et transtypages portant uniquement sur des constantes peuvent être
faits à la compilation. Les multiplications et divisions par 1 ou par 0 (en cas de
division par 0, il faudra renvoyer une erreur), ainsi que les additions et
soustractions de 0.
• Les parties de code inaccessible peuvent être supprimées. L’inaccessibilité du code
est souvent due à un test donnant toujours le même résultat. Il faut pour cela
pouvoir évaluer les résultats possible des tests, ce qui peut donner lieu à la
suppression de tests et de code. Le code inaccessible peut aussi être le corps d’une
fonction qui n’est jamais appelée (exprès ou à cause d’un test).

Pour cette phase, il faut donc disposer d’algorithmes d’analyse de code assez détaillée. On
voit que certaines optimisations peuvent se faire d’office car elles ne modifient pas les autres
paramètres d’optimisation.

8
Exemple :

1. (en reprenant l’exemple de l’analyse lexicale)

x := y + z * 60,7 où x, y et z sont des variables entières

⇒ temp1 : = FtoI(60,7)
temp2 := z * temp1
temp3 := y + temp2
x := temp3

⇒ temp2 := z + 60
x := y + temp2

2. suppression de code inutile

if(cond1 || true)
instr_then ;
else
instr_else ;

On peut supprimer l’évaluation de cond1, le test et le code du else.

3. sortie du code constant

for(int i = 0 ; i < n ; ++i)


{
x = f() ;
V[i] = x ;
}

Si f ne modifie pas i, l’appel de f peut-être déplacé. Dans ce cas-ci, la taille du code


ne sera pas diminuée, mais bien le temps d’exécution car la fonction ne sera appelée
qu’une seule fois.

La production du code final

Cette phase produit le code final équivalent au programme de départ. Elle dépend fortement
du langage de destination.

Fonctionnement intuitif des analyseurs lexical et syntaxique

Tout au long de la conception des analyseurs lexical et syntaxique, différents formalismes


seront utilisés. Ainsi, pour l’analyse lexicale, nous utiliseront des expressions régulières (voir
chapitre sur les expressions régulières) dont nous ferons un automate fini déterministe
équivalent à ces expressions régulières.

9
Exemple :

L’automate suivant définit l’unité lexicale des Id. Les Id sont définis comme suit :
Id → l . (l + c)* où l et c sont respectivement les lettres et les chiffres
Si on rencontre un caractère spécial, on arrive dans un état d’erreur.

l, c
l

c ou caractère spécial caractère spécial

erreur

tout caractère

L’analyseur lexical fonctionnera en simulant la progression à travers un automate. Il devra


reconnaître les strings les plus long possible. Par exemple, si on a « abc » le nom de la
variable est « abc » et non pas « a » ou « ab ».

L’analyse syntaxique fonctionne avec une grammaire. Celle-ci spécifie la structure que doit
avoir le programme.

Exemple :

1. Soit la grammaire : S → 0 S 1 | ε
ε est un string spécial, de taille nulle.

Le langage défini par cette grammaire est L(G) = {ε, 01, 0011, …} = {0n1n | n ≥ 0}

Nous verrons que ces strings ne peuvent être définis par une expression régulière.

2. Prenons la grammaire décrivant une addition


Ass → id := Exp
Exp → nb + nb

Nous allons utiliser ici un automate à stack, ou analyseur descendant.

L’analyseur syntaxique construira l’arbre syntaxique dans l’ordre suivant :

1. On commence avec
Ass

10
2. On réécrit la règle au sommet du stack. Id
:=
Exp

3. On devrait avoir un id. Si on a un id en input, on pop, et on crée le nœud. :=


Exp

4. On devrait avoir un « := », si on l’a, on pop et on ajoute le nœud à l’arbre. :=


Exp

nb
5. On réécrit la règle au sommet du stack. +
nb

6. On peut poper trois fois si on a ce qu’il faut en input.

id := nb + nb

Ass

id := Exp

nb + nb

En conclusion :
• L’analyse lexicale découpe le texte en token en utilisant des expressions régulières.
Le principe est aussi utilisé pour les éditeurs de texte « intelligents » (avec
indentation automatique…)
• L’analyse syntaxique crée l’arbre syntaxique et fait ce que l’analyseur lexical ne sait
pas faire. Il utilise les grammaires context-free (voir chapitre sur les grammaire
context-free).

11