Cours d'introduction à la compilation.

C. Jard. Notes de cours.



Plan du cours :

 1. Définition. Structure.
 2. Analyse lexicale. Automates
 3. Analyse syntaxique. Grammaires
 4. Analyse syntaxique LL(1)
 5. Analyse sémantique. Calcul de types
 6. Génération de code
7. Analyse de flot. Optimisation de code et architecture machine

Exercices des travaux pratiques (K. Heydemann) :

1. Automates et expressions rationnelles (énoncé)
2. Grammaires
3. Analyse LR et LALR


Bibliographie :

COURS 1. Introduction. Structure d'un compilateur.

1.1. Définition

Le compilateur s'interpose entre le programmeur et la machine. Il a pour rôle de traduire un langage source en un langage cible (ou objet). Il a aussi la fonction
essentielle de produire des diagnostics et filtrer ainsi les programmes qui pourront s'exécuter correctement (rôle de vérificateur de qualité).

programme                                ---> diagnostic
source             ---> compilateur --->  programme objet

L'habitude est de travailler avec des formats textuels en entrée, d'où l'importance de la manipulation de chaînes de caractères (analyse lexicale).

Le compilateur va en fait représenter le texte source en une forme intermédiaire abstraite qui va capturer la structure pertinente du programme. On parlera
souvent de l'arbre abstrait d'un programme. Cette notion s'étend actuellement à la notion de meta-modélisation d'un langage.
 

1.2. Pourquoi enseigner la compilation ?

1.3. Structure d'un compilateur

La tâche d'un compilateur peut être grossièrement divisée en deux sous-tâches : L'analyse elle-même peut être raffinée en trois phases : On a généralement la séquence :
programme source -> analyse lexicale -> analyse syntaxique -> analyse sémantique -> génération de code -> programme cible

Ces différentes phases vont travailler sur une représentation interne du programme source, qui va progressivement se rapprocher du langage cible, dans la structure
générale suivante :

texte -> analyseur syntaxique -> représentation abstraite -> décompilateur -> texte
                                                               |              ^
                                                               ----------l

1.3.1. Analyse lexicale

L'analyse lexicale a pour tâche de lire les caractères du programme source et de reconnaître les constituants syntaxiques qu'ils représentent (les "unités lexicales").
Il est capable de distinguer comme unités des objects comme les nombres, les symboles de ponctuations, les opérateurs d'expressions, les mots-clés réservés,
les identificateurs, etc...Reconnaître ces unités a pour but de simplifier l'arbre syntaxique qui n'aura plus à se préoccuper de structurer les caractères, mais les
suites d'unités lexicales.

Dans les langages à format libre (non tabulés), l'analyseur lexical ignore les espaces, les retour-lignes, les tabulations,  les commentaires, qui servent seulement
à séparer les unités.

Exemple de la boucle (en Pascal) :

for i := 1 to 10 do sum:=sum+term[i]; (* sum array *)

Sortie de l'analyseur lexical :

<for, mot-clé> <i, identificateur> <:=, affectation> <1, constante-entière> <to, mot-clé> <10, constante-entière> <sum, identificateur>
<:=, affectation> <sum, identificateur> <+, plus> <term, identificateur> <[, crochet-ouvrant> <i, identificateur> <], crochet-fermant>

Il ne fait effectivement qu'une toute petite partie du travail. Ne regarde pas la structure syntaxique de la phrase (ex. une affectation a un membre gauche et
un membre droit). Ne fait pratiquement aucune vérification (ex. term est un tableau).

En général, l'analyseur lexical reconnaît les mots-clés du langage et les symboles réservés (représentés par exemple par un index dans une table). Il a
aussi pour tâche de construire la "table des symboles" contenant l'ensemble des identificateurs. Ils sont généralement représentés par un index (il peut
y avoir des identificateurs identiques, distingués par leur portée). Des techniques de stockage et de recherche rapide sont en général mises en oeuvre (table de hashage).

L'analyse est quelquefois difficile à faire au vol, caractère par caractère.

Exemple de la boucle en Fortran :

DO 1 I = 1

qui sera ici interprétée comme une affectation DO1I = 1, car il n'y a pas de virgule après I. Il faut donc que l'analyseur regarde "en avant" (look ahead)pour décider du symbole (a noter que ce langage est peu sûr dès la phase lexicale, puisque non robuste à une faute de frappe : exemple de la perte de la sonde Mariner).

Autre exemple :

position := initiale + vitesse*60

unités lexicales ? (id.aff.id.plus.id.mult.cste)
 

1.3.2. Analyse syntaxique

L'analyseur syntaxique structure la suite d'unités lexicales en catégories "grammaticales"; on présente généralement cette structure à l'aide d'un arbre, l'arbre syntaxique. Rappelons que le partage du travail entre l'analyseur lexical et syntaxique est un peu arbitraire. L'efficacité de l'analyseur lexical est cruciale (c'est pourquoi on utilisera des codages rapides comme les automates).

Sur notre exemple, l'arbre est le suivant :

(boucle-for, id(i), from(expression(constante-entière(1))),
 to(expression(constante-entière(10))), corps(bloc(instruction(
 affectation(gauche(id(sum)),droit(expression(somme(gauche(id(sum)),
 ref-tab(id(term),index(expression(id(i))))))))

Arbre syntaxique de l'autre exemple ?

La reprise en cas d'erreur est un aspect important d'un compilateur. C'est une question difficile qui consiste à corriger en ligne le programme. Il n'y a pas de théorie spéciale, mais des langages plus ou moins adaptés (ex. continuer l'analyse de la catégorie courante en ignorant tous les symboles jusqu'à le symbole marqueur de fin de la catégorie : parenthèsage début-construction/fin-construction; non seulement on gagne en lisibilité, bien que beaucoup de "sucre" syntaxique, mais aussi en robustesse). L'analyseur syntaxique va se débarrasser de ce sucre syntaxique.

1.3.3. Analyse sémantique

Elle a pour but de simplifier l'arbre abstrait en ne gardant que les informations vraiment pertinentes pour produire un code exécutable (ne retient que la "quintescence" du programme. Elle doit aussi effectuer le plus possible de vérifications (portée des identificateurs, typage, traitement du polymorphisme, conversion de types, etc...).

Il existe un continuum entre la forme et le fond. La forme concerne la syntaxe, le fond concerne la sémantique (le sens de ce l'on écrit). On distingue la sémantique "statique" (ce que le compilateur peut calculer ou vérifier) et la sémantique "dynamique" (ce que révèle l'exécution). Pour des raisons de sûreté, on aimerait que le maximum de choses soit détecté statiquement à la compilation (penser aussi que l'on compile rarement et que l'on exécute souvent). Même si la performance des
compilateurs sur ce sujet augmente, elle reste limitée à la barrière de la calculabilité.

Exemple :

while (n>1) do
    if n pair then n:=n/2 else n:=(3*n+1)/2

On pourra vérifier statiquement que n reste entier (en utilisant une axiomatisation de l'arithmétique), mais on ne pourra que constater que le programme s'arrête et ne boucle pas (ce qui reste une conjecture mathématique fameuse).

Exemple de table des symboles :

id,          type,   adresse
position réel    id_1
initiale  réel     id_2
vitesse  réel    id_3

Sortie de l'analyseur sémantique :

(affectation-flottante(id_1,somme-flottante(id_2, mult-flottante(id_3,conv-ent-flottant(60)))))
 

1.3.4. Génération de code

La sortie de l'analyseur sémantique est indépendante de la machine d'exécution cible. IL va falloir raffiner. On découpe généralement en 3 phases, les 2 premières restant assez indépendantes de la machine : Pour définir le code intermédiaire, on suppose une machine idéale représentative de la classe visée (exemple de la machine à pile pour une calculette). La traduction de l'arbre abstrait doit être simple : on traverse l'arbre en ré-écrivant chaque noeud (la traduction est locale, on peut traduire chaque noeud en partant de la traduction de ses fils).

Exemple (machine :=, conv, + et * binaires) :

Code intermédiaire
temp_1 := Ent-vers-Fl(60)
temp_2 := id_3*temp_1
temp_3 := id_2+temp_2
id_1 := temp_3

L'optimisation utilise des techniques très générales (simplifications, ré-ordonnancement d'instructions, ...) qui sont aussi souvent locales (on balaye le code intermédiaire avec une fenêtre).

Code amélioré
temp_2 := id_3*60.0
id_1 := id_2 + temp_2

Le code cible final dépend bien sûr de la machine cible.

Exemple d'utilisation des registres de l'unité de calcul du processeur (assembleur).

Code cible
MOVF id_3,R2
MULF #60.0,R2
MOVF id_2,R1
ADDF R2,R1
MOVF R1,id_1

L'idée générale est de rester le plus longtemps possible indépendant de la machine cible et de factoriser (notion de compilateur multi-cibles). De plus en plus fréquemment, le langage cible est aussi un langage source (par exemple C : Java vers C, ...). Ceci est justifié par l'augmentation de performances des compilateurs (mais attention aux différents niveaux d'optimisation et de traitement des erreurs à la compilation et à l'exécution).
 

1.4. Schéma global

Source -> Analex -> Anasynt -> Anasem -> Genecode -> Optim -> Geneobjet

avec en parallèle : gestion de la table des symboles, gestion des erreurs.

Il existe des outils pour la construction des compilateurs (compilateur de compilateurs) :

Autres outils associés aux compilateurs :

Les points à retenir :



COURS 2. Analyse lexicale. Automates.

 

2.1. La notion de langage formel

2.1.1. Monoïde libre

On va former des chaînes de caractères (mots) à partir d'éléments de base (le vocabulaire, ou alphabet) et des opérateurs de composition (la séquence ou concaténation, l'alternative ou le choix, l'itération ou l'étoile).

Définition : un vocabulaire V est un ensemble fini de symboles.

Définition : un mot sur un vocabulaire V est une séquence finie d'éléments de V.

Il y a un mot particulier, la séquence vide, ou mot vide, que l'on note généralement e.

Etant donné un vocabulaire V, on note V* l'ensemble des mots sur V. On peut munir V* d'une loi de composition interne : la concaténation qui consiste à former un nouveau mot en mettant deux mots à la suite.

Définition : l'opérateur de concaténation "." est définie par :
. : V*xV* -> V*
    (a,b)     -> a.b, noté ab

Remarque : |ab| = |a|+|b|
On note an le mot composé de n occurrences de a. a0 est le mot vide.

Cette opération de concaténation est associative et admet un élément neutre e. V* a donc une structure de monoïde. On l'appelle le monoïde libre (car la concaténation ne tient pas compte d'une quelconque signification des symboles).

Remarque : sur V = {chat, te, chatte}, chat.te est différent de chatte.

Définition : un langage formel L est une partie de V*  (un langage est tout simplement un ensemble de mots).

Exemple : V={a,b}, L = {anbn | n>0} = {ab,aabb,aaabbb, ...}

2.1.2. Opérations langagières

Un langage peut être défini à partir d'autres langages. On retient ici trois opérations de composition sur les langages : l'union, le produit et la fermeture.

Définition : L'union L1+L2 de deux langages L1 et L2  est l'union ensembliste classique :
L1+L2 = {m | m dans L1 ou m dans L2}

Définition : Le produit L1L2 de deux langages L1 et L2 est défini par :
L1L2 = {m1m2 | m1 dans L1 et m2 dans L2}

Définition : La fermeture L* d'un langage L est une itération : c'est l'union infinie des Li, avec Li = Li-1L et L0 = e

Exemples :

2.2. Expressions rationnelles (ou régulières)

C'est une notation permettant de coder les opérations langagières définies plus haut. Les expressions sont définies récursivement par les règles suivantes : Pour alléger l'écriture, on adopte des conventions de parenthésage avec les priorités décroissantes *, produit et +.

Exemples :

Remarque : tous les langages ne peuvent pas être décrits dans cette notation, bien qu'elle soit assez puissante pour définir des langages de cardinalité infinie.
Exemple : L = {anbn | n>0} = {ab,aabb,aaabbb, ...}

Sa puissance sera en pratique suffisante pour décrire le langage des unités lexicales.

Remarque : la représentation n'est pas canonique. Plusieurs expressions peuvent représenter le même langage.
Exemple : a(b+c) = ab + ac

Ceci suggère la possibilité de manipulations algébriques.
 

2.3. Utilisation directe des expressions en reconnaissance

Le premier algorithme historique (Thompson, 1968).
On associe à chaque élément du vocabulaire apparaissant dans l'expression, deux variables booléennes : mark et flag. flag = true va indiquer les prochains symboles à reconnaître. mark = true va indiquer que le symbole a été reconnu.

Algorithme :

Initialisation flag = true pour les "premiers" symboles à reconnaître dans l'expression. Les autres sont à false.
tant qu'il reste des caractères à lire faire
  (1) mettre toutes les mark à false
  lire un caractère sur l'entrée
 (2) mettre à true les mark des symboles qui correspondent au caractère lu. Si aucune ne peut être positionnée, la reconnaissance échoue
 (3) mettre à true les flag des symboles qui suivent directement les symboles ayant mark à true. Les autres sont mis à false
fait

Exemple : reconnaître cabcd dans (ab + c)*d
 
 
a
Flag, Mark
b
Flag, Mark
c
Flag, Mark
d
Flag,Mark
T,F F,F T,F T,F
T,F F,F T,T T,F
T,F F,F T,T T,F
T,F F,F T,F T,F
T,T F,F T,F T,F
F,T T,F F,F F,F
F,F T,F F,F F,F
F,F T,T F,F F,F
T,F F,T T,F T,F
T,F F,F T,F T,F
T,F F,F T,T T,F
T,F F,F T,T T,F
T,F F,F T,F T,F
T,F F,F T,F T,T

Il semble donc que le problème de l'analyse lexicale soit résolu, et ceci avec un algorithme simple qui ne nécessite pas de retour arrière. Il n'en est rien en fait car cet algorithme naïf est extrêmement inefficace pour des expressions rationnelles complexes. La raison est qu'à chaque lecture de caractère, il faut examiner et mettre à jour toutes les variables associées aux symboles de l'expression. C'est pourquoi on va utiliser une forme compilée des expressions (les automates) et des algorithmes plus sophistiqués. Les automates vont "inverser" le procédé : le caractère lu va indiquer directement l'état suivant du reconnaisseur. La mémoire du reconnaisseur va être éclatée en états; le changement d'état va directement être piloté par le caractère lu : notion de transition étiquetée par le caractère d'entrée.
 

2.4. Automates d'état finis déterministes

2.4.1. Exemples

Exemple : (ab + c)*d

peut être représenté par le graphe suivant (on donne ici la forme textuelle où une transition est représentée par un triplet (état départ, étiquette, état arrivée); dans le cours, on utilise la forme graphique) :

Initial : 1
Accepteurs : 3
(1, c, 1)
(1, a, 2)
(1, d, 3)
(2, b, 1)

Noter la notion d'état initial (habituellement, on considère que cet état est unique, sans perte de généralité) et la notion d'état terminal (ou accepteur).

L'intuition est qu'un automate est une machine. La machine démarre dans l'état initial. La lecture d'un symbole (caractère) active la transition correspondante et fait  changer l'état de la machine. On considère que la chaîne d'entrée est reconnue lorsque la machine arrive dans un état accepteur. Si avant cela, aucune transition ne peut être activée, la chaîne est rejetée et n'est pas reconnue.

Autre exemple : donner l'automate qui reconnait les mots sur {a,b}* qui comptent un nombre impair de a.
 

2.4.2. Automates déterministes

Définition : un automate déterministe M à nombre fini d'états est un quintuplet M= (Q, V, d, q0, F) où : Exemple précédent : Q = {1,2,3}, V = {a,b,c,d}, q0 = 1, F = {3}, d = {(1,c)->1, (1,a)->2, (1,d)->3, (2,b)->1}
Déterministe veut dire ici que l'état suivant est uniquement déterminé par l'entrée courante (en rappelant que la machine peut rejeter un mot si aucune transition n'est activable). Pour éviter cette notion brutale de rejet, on peut systématiquement compléter l'automate est faisant en sorte que tous les éléments du vocabulaire soient étiquette d'une transition de sortie de l'état. Dans le cas d'un symbole à rejeter, on fait passer l'automate dans un état non accepteur absorbant.

Exemple de complétude de notre exemple :

Initial : 1
Accepteurs : 3
(1, c, 1) (1, a, 2) (1, d, 3) (1, b, 4)
(2, b, 1) (2, a, 4) (2, c, 4) (2, d, 4)
(4, a, 4) (4, b, 4) (4, c, 4) (4, d, 4)
 

2.4.3. Langage accepté par un automate déterministe

La relation de transition d peut être étendue aux mots d* : QxV* -> Q récursivement de la façon suivante (q dans Q, a dans V, w dans V*)  : Le langage associé à l'automate est alors : L = {w dans V* | d*(q0,w) dans F}
 

2.5. Des expressions rationnelles aux automates déterministes

La théorie affirme que les langages définis par les expressions rationnelles ne sont rien d'autre que les langages associés aux automates finis déterministes. La preuve habituelle est de construire un automate par composition des briques de base définies par l'expression. Cela est conceptuellement simple, mais nécessite de faire apparaître un nouveau type d'objet, les automates à mouvement spontané, qui sont ensuite abstraits et minimisés. Comme ces objets ne sont pas utilisés en compilation, on préfère ici donner une construction directe, fondée sur la notion de dérivée, introduite par Brzozowski en 1964. Cet algorithme, et ses variantes, est concrètement utilisé dans des outils comme les éditeurs de texte, la fonction Unix grep, le générateur LEX.

Pour une expression rationnelle E, on note d(E) = {e} si le langage de E contient e, d(E) = {} sinon.
Remarque :

d(E) peut être facilement calculé sur la structure de E en utilisant les règles suivantes : La dérivée d'une expression E par rapport à un symbole d'entrée a, notée a-1E, représente l'expression qui reste à vérifier après avoir reconnu a (intuitivement, cela représente bien une notion d'état). Formellement  (a-1 est traité comme un opérateur préfixe prioritaire sur les autres) : Exemple : a-1(aba + bb) = ba

Les états de l'automate sont construits en calculant explicitement les dérivées de l'expression, puis les dérivées des dérivées et ainsi de suite... Il existe une transition étiquetée a dans l'automate entre l'état E et l'état a-1E.

La dérivation par rapport à un mot est définie classiquement par (a dans V, w dans V*) :

La convergence de l'algorithme est obtenue par le théorème suivant :

L'ensemble des dérivées d'une expression rationnelle est fini, modulo associativité, commutativité, et idempotence de +; c'est-à-dire que
{F | il existe w : F = w-1E} possède un nombre fini de classes d'équivalence.

Algorithme :

Exemple  :  E = (a+b)*a, les mots qui se terminent par a. 1: (a+b)*a
2: (a+b)*a + {e}
Etat initial : 1
Etat accepteur : 2
(1, b, 1)
(1, a, 2)
(2, a, 2)
(2, b, 1)

Exemple : (ab+b)*ba

1: (ab+b)*ba
2: b(ab+b)*ba
3: (ab+b)*ba+a
4: b(ab+b)*ba + {e}

Etat initial : 1
Etat accepteur : 4
(1, a, 2)
(1, b, 3)
(2, b, 1)
(3, b, 3)
(3, a, 4)
(4, b, 1)
 

2.6. Programmation par automate

Programmer une machine automate déterministe est aisé. On peut représenter l'automate par une table dont l'entrée est un couple dans QxV et le contenu létat d'arrivée dans Q. Un simple parcours par indirection dans la table permet de faire progresser l'automate au gré des entrées. On en profite généralement pour effectuer une action lors de l'activation de la transition. Dans l'exemple précédent, ce peut être de compter le nombre de b. Lorsque l'automate est incomplet et qu'un symbole est rejeté, on positionne généralement une variable d'erreur. C'est dans l'action aussi que l'on construit la table des symboles. Un outil comme LEX construit l'automate à partir d'une expression rationnelle et laisse le programmeur remplir des actions.

La récupération d'erreur dans cette phase d'analyse lexicale est assez rudimentaire. En général, elle consiste à ignorer le caractère fautif. Dans le cas d'une faute de frappe où un caractère est remplacé par un autre, cela conduit souvent à une cascade d'erreurs. Pour y pallier, l'analyseur syntaxique peut tenir au courant l'analyseur lexical d'un ensemble possible de symboles attendus, afin que celui-ci effectue la correction appropriée.
 

2.7. Automates non déterministes

2.7.1. Notion de non-déterminisme

La motivation pour écrire un automate déterministe est d'abord un argument de simplicité d'écriture. Prenons par exemple l'expression : (a+b)*a qui désigne les mots se terminant par a. Elle se code directement dans l'automate :

Etat initial : 1
Etat accepteur : 2
(1, a, 1) (1, b, 1) (1, a, 2)

Il a par contre une allure désagréable lorsque on veut s'en servir en reconnaisseur : dans quel état se trouve l'automate après avoir lu a ? La réponse est que les deux états 1 et 2 sont possibles. Le non-déterminisme est défini par le fait que plusieurs transitions portant la même étiquette sortent d'un état. Dans le contexte des automates finis, il se trouve que l'on peut toujours transformer un automate déterministe en un automate déterministe en utilisant un algorithme de déterminisation.

Voici la version déterministe de notre exemple (un peu moins "naturelle") :

Etat initial : 1
Etat accepteur : 1_2
(1, b, 1) (1, a, 1_2) (1_2, a, 1_2) (1_2, b, 1)

Il ne faut pas croire que cette équivalence déterministe/non-déterministe est naturelle. C'est un phénomène très particulier aux langages rationnels, ce qui en fait un paysage très "régulier". Ceci va permettre de définir une notion de représentation canonique d'un langage rationnel par un automate déterministe à nombre minimum d'états et de conduire au résultat théorique fondamental qui exprime que l'égalité de deux langages rationnels est décidable et un algorithme consiste à vérifier l'isomorphisme des automates canoniques associés.

Ce n'est en général plus vrai dès que l'on sort de cette classe. A titre d'exemple, considérons les langages infinitaires (formés de mots de longueur infinie) et définissables par des automates finis. Il suffit pour cela de changer l'interprétation des états accepteurs : pour qu'un mot infini soit reconnu, on demande que ce mot "passe" infiniment par un état accepteur (ou état répété). Ces automates ont été introduits par Buchi en 1968.

Exemple : les mots sur le vocabulaire {a,b} ayant un nombre infini de a.

Etat initial : 1
Etat répété : 2
(1, b, 1) (1, a, 2) (2, a, 2) (2, b, 1)

Exemple : les mots sur le vocabulaire {a,b} ayant un nombre fini de a.

Etat initial : 1
Etat répété : 2
(1, b, 1) (1, a, 1) (1, b, 2) (2, b, 2)

On peut se convaincre qu'il n'existe pas d'automate déterministe correspondant.

La définition formelle des automates finis éventuellement non-déterministes reprend la définition du cas déterministe mais avec une relation de transition qui peut rendre plusieurs états : d : QxV -> P(Q).    La relation de transition se généralise aux mots comme précédemment. Mais cette fois-ci un mot est reconnu si il peut laisser l'automate dans un état accepteur (dans le cas déterministe, l'état dans lequel se trouve l'automate après avoir lu un mot était unique).
 

2.7.2. Déterminisation

Soit M=(Q,V,d,q0,F) un automate indéterministe. Il existe au moins un automate déterministe M'=(Q',V,d',q'0,F') tel que le langage défini par M soit le même que celui défini par M'.

M' est facilement défini par :

On se limite en pratique aux états accessibles à partir de q'0 . Noter bien que la déterminisation est de complexité exponentielle (2|Q|).

Reprendre l'exemple :

Etat initial : 1
Etat accepteur : 2
(1, a, 1) (1, b, 1) (1, a, 2)
--------------------------------
Etat initial : 1
Etat accepteur : 1_2
(1, b, 1) (1, a, 1_2) (1_2, a, 1_2) (1_2, b, 1)

2.8. Minimisation

Considérons le langage (a+b)*aa(a+b)* et un automate non déterministe associé :

Etat initial : 0
Etat accepteur : 2
(0, a, 0) (0, a, 1) (0, b, 0)
(1, a, 2)
(2, a, 2) (2, b, 2)

En appliquant l'algorithme précédent, on trouve :

Etat initial : 0
Etats accepteurs : 0_1_2, 0_2
(0, a, 0_1) (0, b, 0)
(0_1, a, 0_1_2) (0_1, b, 0)
(0_1_2, a, 0_1_2) (0_1_2, b, 0_2)
(0_2, a, 0_1_2) (0_2, b, 0_2)

alors l'automate suivant est plus petit en nombre d'états et reconnait le même langage :

Etat initial : 0
Etat accepteur : 2
(0, a, 1) (0, b, 0)
(1, a, 2) (1, b, 0)
(2, a, 2) (2, b, 2)

Il est possible de disposer d'un algorithme permettant de calculer le plus petit automate équivalent à un automate fini déterministe.

Etant donné un automate déterministe, on dit que 2 états p et q sont i-équivalents ssi pour tout mot m de longueur inférieure ou égale à i :

Autrement dit, ces états acceptent ou rejettent les mots de longueur inférieure ou égale à i.  Soit Ri, la relation de i-équivalence. On a une caractérisation récursive de Ri par : Algorithme : Exemple :

Etat initial : 0
Etats accepteurs : 3, 4
(0, a, 1) (0, b, 2)
(1, a, 1) (1, b, 3)
(2, a, 2) (2, b, 4)
(3, b, 3) (3, a, 4)
(4, b, 4) (4, a, 3)

Le résultat est l'automate :

Etat initial : 0
Etat accepteur : 3_4
(0, a, 1_2) (0, b, 1_2)
(1_2, a, 1_2) (1_2, b, 3_4)
(3_4, a, 3_4) (3_4, b, 3_4)
 


Les points à retenir :



COURS 3. Analyse syntaxique. Grammaires.

3.1. Spécification de la syntaxe

La Backus-Naur form (BNF) est un meta-langage formel, largement utilisé pour représenter les règles définissant le texte de tous les programmes syntaxiquement corrects (la "grammaire"). On commence par une introduction par l'exemple (on verra le modèle formel des grammaires plus tard).

<phrase> -> <sujet> <predicat>   (<> définit l'unité syntaxique, -> est la décomposition : peut se dire "se compose de ", "se ré-ecrit en", ...).

On continue le raffinement :

<sujet> -> <nom> | <pronom>  (retenir cette notion d'alternative "|", équivalent à la juxtaposition de 2 règles <sujet> -> <nom> et
<sujet> -> <pronom>. Ceci sera générateur d'ambiguités qu'il faudra pouvoir lever à l'analyse.)

<predicat> -> <verbe transitif> <objet> | <verbe intransitif>
<nom> -> le ( chat | chien | mouton) ("(" est un meta-symbole)
<pronom> -> je | tu | il
<verbe transitif> -> aime | déteste | mange
<objet> -> les biscuits | l'herbe | le soleil
<verbe intransitif> -> dort | parle | court

On voit ici la notion d'unité terminale. Ce sont des chaînes de caractères, ou pratiquement des unités lexicales en compilation.

A partir d'une grammaire complète, on peut générer des phrases du langage en ré-écrivant l'unité <phrase> et en effectuant, au hasard par exemple, les choix proposés.

Exemple de dérivation :

<phrase>
<sujet> <predicat>
<nom> <predicat>
le mouton <predicat>
le mouton <verbe transitif> <objet>
le mouton mange <objet>
le mouton mange les biscuits

D'autres phrases possibles sont : je parle, le chien déteste le soleil

mais aussi : je dort (qui n'est pas correct du point de vue de l'orthographe) ou le chat mange le soleil (ce qui n'a pas forcément de sens).

Considérons la grammaire suivante :

S -> S+T | T
T -> a | b

(S et T sont des symboles non-terminaux, +, a et b sont terminaux) Noter la définition récursive de S, permettant de définir les langages de cardinal infini à partir d'un nombre fini de règles.

Exemple : montrer que b+a+a est un "mot" du langage défini par cette grammaire.

Codage de la priorité des opérateurs :

Deuxième exemple :

<expression> -> <terme> | <expression> + <terme>
<terme> -> <primaire> | <terme> * <primaire>
<primaire> -> a | b | c

Trouver la génération de a+b*c
Montrer que la grammaire interprète la priorité à l'opérateur * : a + (b*c)

Trouver la génération de a+b+c
Montrer que la grammaire interprète l'associativité à gauche : (a+b)+c

En pratique, on utilise une notation étendue (itération *, option []).
 

3.2.  Premières notions de grammaire formelle

3.2.1. Introduction

Elles sont apparues bien avant les langages de programmation. Les grammaires ne vont capturer qu'un aspect réduit des langages (la syntaxe). Elles seront étendues pour permettre l'analyse sémantique (notion de grammaire attribuée). La BNF ne définit qu'une forme réduite de grammaire.

Formellement, une grammaire est un quadruplet G=(N,V,S,P) où :

Une production est simplement une règle de substitution de chaînes : a -> b. Cela signifie que chaque occurrence de la chaîne a dans la chaîne d'entrée peut être remplacée par la chaîne b.

Notation : soit U l'ensemble N union T, U+ est l'ensemble des chaînes formées par concaténation de membres de U. U*=U+ + {e}. e désigne la chaîne vide. On demande que a soit dans U+ et b dans U*.

Constater que nos exemples de BNF peuvent se formaliser ainsi.
 

3.2.2. Classification de Chomsky

Lorsqu'il n'y a pas de restriction sur la forme de a et b, la grammaire est dite de type 0 (ou libre). Elles apparaissent trop générales pour les langages de programmation (problèmes de calcul, de décidabilité, d'utilisation, ...).

On se restreint en général aux grammaires de type 1 (ou contextuelles), en se restreignant aux règles  de la forme : a A b -> agb avec a, b et g dans U*,
g non vide et A un symbole non terminal simple. Une autre alternative à la définition est d'imposer |a| <= |b| (mais ne visualise pas la notion de "sensibilité" au contexte).

Il n'est pas difficile de trouver des langages qui ont besoin de cette puissance, même si en pratique les aspects contextuels sont souvent traités par un calcul extérieur à la grammaire. Ceci mène à la notion de grammaire sans contexte ("context-free").

Type 2 : les règles sont limitées à la forme A -> g où A est un non terminal simple. Cette classe est très importante en compilation et correspond exactement à ce que l'on peut exprimer dans la notation BNF.

La hiérarchie de Chomsky se complète avec les grammaires de type 3, en se restreignant aux règles de la forme : A -> a et A -> aB où A et B sont des non-terminaux, a est un symbole terminal. Ces grammaires sont aussi appelées "régulières" ou d'état fini. Elles décrivent en fait des langages que l'on peut aussi définir par des automates d'état fini. Très simples, elles sont généralement utilisées pour l'analyse lexicale, sous la forme d'automates comme on l'a vu au cours précédent.

La complexité des algorithmes de manipulation augmente bien sûr en fonction de la position de la grammaire dans la hiérarchie.
 

3.3.  Et la sémantique ?

La plupart des langages de programmation ne définissent pas suffisamment précisément ce que les programmes calculent (problèmes de la portabilité). Il est donc important de pouvoir raisonner sur ce que va faire un programme, sans avoir à l'exécuter sur une machine donnée. C'est le rôle d'une sémantique formelle de pouvoir répondre à des questions de correction, d'équivalence, etc...

On peut distinguer 3 niveaux d'abstraction différents :

En pratique, c'est bien d'avoir ces différentes vues et de prouver leur cohérence.
 

3.4. Introduction au "parsing"

Reprenons l'exemple de la grammaire <expression>. Parser est l'activation des règles de la grammaire sur la chaîne d'entrée jusqu'à avoir traité toute la chaîne. Il y a en général plusieurs ordres d'activation possibles (la façon de construire l'arbre syntaxique n'est pas unique). L'étude détaillée de plusieurs algorithmes de "guidage" sera conduite plus tard.

Essayons de "parser" a*b+c :

on peut obtenir la suite de dérivations suivante :

a*b+c
<primaire> * b + c
<primaire> * <primaire> + c
<primaire> * <primaire> + <primaire>
<terme> * <primaire> + <primaire>
<terme> * <primaire> + <terme>
<terme> + <terme>
<expression> + <terme>
<expression>

Mais on aurait pu essayer une autre stratégie :

a*b+c
<primaire> * b + c
<primaire> * <primaire> + c
<primaire> * <primaire> + <primaire>
<primaire> * <terme> + <primaire>
<primaire> * <expression> + <primaire>
<primaire> * <expression> + <terme>
<primaire> * <expression>
<terme> * <expression>
<expression> * <expression>

ce qui conduit à un blocage, qui ne doit pourtant pas indiquer que l'expression est syntaxiquement incorrecte.

On distingue deux grandes familles de stratégies :

La stratégie top-down est facile à programmer, mais se révèle souvent inefficace. Bottom-up est plus complexe, mais on verra que des compilateurs existent pour générer le code d'analyse.
 

3.4.1. Top-down parsing

S -> AB : réduire le sous-but A, puis le sous-but B
S -> A|B : mettre en compétition la résolution des sous-buts A et B.

Dès que le sous-but commence avec un ou plusieurs symboles terminaux, on essaie de mettre ces symboles en correspondance avec la chaîne d'entrée. Si ce n'est pas possible, le sous-but n'est pas satisfait et il n'est pas nécessaire de continuer sur cette "branche". Si il y a correspondance, on continue la décomposition jusqu'à satisfaction de tous les symboles terminaux. Dans ce cas le sous-but est satisfait. Sinon, il n'est pas satisfait.

L'existence de l'alternative induit du back-tracking. Il est souvent mis en oeuvre par des procédures récursives.

On regardera les algorithmes plus tard. Il faut retenir ici les notions de dérivation droite et gauche ("rightmost derivation" et "leftmost derivation").

Considerons l'arbre syntaxique de l'expression a*b+c :

<expression> (<expression>+<terme>)(<terme> (<terme>*<primaire> (<primaire>  a| b)) | <primaire> c)

Exemple de dérivation gauche (profondeur gauche d'abord) :

<expression>
<expression>+<terme>
<terme>+<terme>
<terme>*<primaire>+<terme>
<primaire>*<primaire>+<terme>
a*<primaire>+<terme>
a*b+<terme>
a*b+<primaire>
a*b+c

Exemple de dérivation droite (profondeur droite d'abord) :

<expression>
<expression>+<terme>
<expression>+<primaire>
<expression>+c
<terme>+c
<terme>*<primaire>+c
<terme>*b+c
<primaire>*b+c
a*b+c

Noter que de nombreux sous-buts infructueux risquent d'etre explores avant que les symboles de la chaine d'entree permettent d'en valider ou d'en invalider.

3.4.2. Bottom-up parsing

On part de la chaîne d'entrée et on remplace les chaînes des parties droites des règles par les chaînes correspondantes de la partie gauche, jusqu'à n'obtenir que l'axiome. Les règles de production doivent être renversées. Conventionnellement, on renverse la stratégie droite.

Exemple :

Chaine                                                 Production inversee
a*b+c                                                  <primaire> -> a
<primaire>*b+c                             <terme> -> <primaire>
<terme>*b+c                                  <primaire> -> b
<terme>*<primaire>+c              <terme> -> <terme>*<primaire>
<terme>+c                                       <expression> -> <terme>
<expression>+c                             <primaire> -> c
<expression>+<primaire>        <terme> -> <primaire>
<expression>+<terme>             <expression> -> <expression>+<terme>
<expression>

3.5. Etude des grammaires algebriques (type 2)

3.5.1. Derivation non contextuelle

Soit une grammaire G = (N,V,S,P), on note u ->* v, le fait que u peut se re-ecrire en v en utilisant les regles P de production. On note u->k v la re-ecriture en exactement k pas.  Une grammaire est algebrique ssi les regles de production sont de la forme A -> u, avec A dans N et u dans (N union V)* et u non vide.

La propriete fondamentale de ces grammaires est que la re-ecriture est independante du contexte :

Theoreme :

Si ...A...B... ->* w  alors w = ...u...v..., avec A,B dans N et A -> u, B -> v.

Se demontre aisement par recurrence sur la longueur des derivations. On voit donc que l'ordre dans lequel va se faire les derivations va pouvoir etre choisi arbitrairement.
 

3.5.2. Arbres syntaxiques

Definition :

Un arbre est un arbre syntaxique pour bune grammaire algebrique ssi :

Exemple : S -> aSb | vide

S(a,S(a,S(vide),b),b)

Le "mot des feuilles" est obtenu en concatenant ses feuilles dans l'ordre de leur decouverte par un parcours descendant de gauche a droite.
Un mot appartient au langage defini par la grammaire si et seulement si il est un mot des feuilles pour un arbre syntaxique de la grammaire.

3.5.3. Ambiguite

Une grammaire est dite ambigue si on peut trouver, pour un meme mot, deux arbres syntaxiques differents.

Exemple :

E -> E - E  | 1

1-1-1 est le  mot des feuilles des deux arbres suivants :  E(E(E(1)-E(1))-E(1)) et E(E(1)-E(E(1)-E(1)))

Exemple de levee d'ambiguite pour le meme langage :

E -> E - T | T
T -> 1

ou encore :

E -> T - E
T -> 1

Noter alors qu'une grammaire non ambigue peut porter une information importante, ici l'associativite a droite ou a gauche.
 

3.5.4. Formes normales


Une grammaire algebrique peut etre mise en forme normale de Chomsky si ses regles s'ecrivent sous les seules formes suivantes :

Une grammaire algebrique peut etre mise en forme normale de Greibach si ses regles s'ecrivent sous les seules formes suivantes :
 


Les points à retenir :



COURS 4. Analyse syntaxique LL(1)

4.1. Presentation informelle

On s'interesse dans ce cours aux grammaires de type 2:  G = (N, V, S, P). On note a ->+ b pour a->* b et a <> b. a ->k b pour une reecriture en exactement k pas.

On rappelle que l'analyse syntaxique d'un mot m = a1...an c'est construire l'arbre syntaxique de m si m est dans le langage L(G) defini par la grammaire ; et  produire des diagnostics d'erreur les plus precis possibles dans le cas contraire.

Pour construire l'arbre syntaxique de m on peut chercher a construire une derivation gauche en partant de l'axiome S ->+ m. Imaginons que l'on ait deja reconnu les j premiers symboles du mot m. Si la connaissance des k prochains symboles aj+1 .. aj+k suffit toujours a selectionner la regle de la grammaire qui doit etre appliquee a la prochaine derivation, on dit que la grammaire est LL(k). C'est une notion de "fenetre" de reconnaissance. En d'autres termes, une grammaire est dite LL(k) si une analyse top-down peut etre conduite dans laquelle la decision de la regle a appliquer ne depend que des k prochains symboles de l'entree. Le terme LL est derive du fait que l'entree est lue de gauche a droite et que l'analyseur conduit une derivation gauche ("from Left to right using Leftmost derivation)". Les grammaires LL(1) sont particulierement simples puisqu'il suffit de ne regarder qu'un seul symbole en avance.

Exemple :
S -> a S b | vide est LL(1)

En effet, si le prochain symbole a lire (symbole de pre-lecture) est a, on utilise la premiere regle, sinon on utilise la seconde.

S (a)aabbb : 1
aSb a(a)abbb : 1
aaSbb aa(a)bbb : 1
aaaSbbb aaa(b)bb : 2
aaabbb aaabbb : ok

Le non-determinisme comme (A -> aB | aC) pose evidemment des problemes pour une analyse LL(1). Des difficultes peuvent etre cachees comme la recursivite gauche que l'on verra plus tard. Heureusement, des transformations automatiques ont ete concues pour essayer de rendre une grammaire donnee LL(1). Lorsqu'on y arrive, l'analyse sera particulierement efficace, puisque le texte ne sera balaye qu'en une seule passe (pas de back-track).

L'analyse LR(k) est definie de la meme maniere, mais dans le contexte d'une analyse montante bottom-up. Les analyseurs LR(k) sont plus difficiles a ecrire a la main et sont plutot le resultat de l'application d'un compilateur de compilateur comme Yacc. La classe des grammaires LR(k) est plus grande que celle des LL(k). Il est a noter que toutes les grammaires LR(k) peuvent etre transformees en LR(1).

A toute grammaire LL(1), on peut associer un ensemble de procedures recursives. Une procedure recursive utilise une memoire a priori non bornee formee par la pile des appels de la procedure. La lecture de l'entree s'effectue symbole par symbole en utilisant par exemple une variable Lexlu. L'avancee de la tete de lecture sur le prochain symbole a lire est effectuee par la procedure AvTete : il s'agit de l'interface avec l'analyseur lexical realise par un automate. A chaque non terminal de la grammaire, on associe une procedure. La construction est systematique.

Sur notre exemple :

proc S
  cas Lexlu :
    "a" : AvTete; S; si Lexlu <> "b" alors erreur; AvTete;
    "b" : ;
     autre : erreur

Regarder la trace des appels. Lors de l'execution du 4eme appel a S, la pile contient 3 appels de S en attente de retour correspondant a la detection d'un a sur la bande d'entree. Lors des retours on realise un appariement des a rencontres dans la premiere partie avec les b de la seconde partie du mot.

En pratique on parametre la procedure d'erreur pour donner des diagnostics pertinents (b attendu, ou a ou b attendus).

4.2. Traduction systematique de la grammaire

A chaque non-terminal A, on associe une procedure A sans parametre, capable de reconnaitre un mot de la grammaire d'axiome A, en prefixe de la bande d'entree.

Avant execution de A : a1...aj, Lexlu pointe sur aj+1
Apres execution de A : a1...aj...ai, Lexlu pointe sur ai+1, le mot aj+1..ai a ete structure.

Pour les A-regles de la forme A -> g1 | ... | gn, on produit la procedure :

proc A
   cas Lexlu :
      selection 1 : traitement g1;
      ...
      selection n : traitement gn;
      autre : erreur;

4.2.1. Les traitements

Soit la regle : A -> aBbCcdDE  (un exemple du cas general).

Le traitement associe prend en compte les arbres syntaxiques des non-terminaux et verifie que les terminaux prennent leur place dans le mot des feuilles. Apres avoir execute A, on obtient la situation suivante : [...][a (A)][... (B)][b (A)][... (C)][c (A)][d (A)][... (D)][... (E)] ([] groupe les paquets, (B) veut dire structure par le non terminal B).

On en déduit la procédure suivante :

proc A
   AvTete;   -- La tête de lecture est forcement positionnée sur un "a"
   B;
   si Lexlu <> b alors erreur; AvTete;
   C;
   si Lexlu <> c alors erreur; AvTete;
   si Lexlu <> d alors erreur; AvTete;
   D;
   E;

La règle A -> vide produit une procédure qui ne fait rien.

4.2.2. Les sélections

Appelons Premiers(g) l'ensemble des symboles terminaux qui peuvent apparaitre après une séquence de dérivations à partir de g.
Premiers(g) = { a | g ->* a w }. A l'exception donc de la règle A -> vide, pour sélectionner une règle, il faut que Lexlu soit dans Premiers(g). Appelons Suivants(A) l'ensemble des symboles terminaux qui peuvent apparaitre juste après A dans une quelconque suite de dérivations à partir de l'axiome.
Suivants(A) = { a | S ->* uAav }. Pour sélectionner une règle A -> g telle que g se dérive en vide, il est nécessaire que Lexlu appartienne à Suivants(A).

4.3. Cas des grammaires LL(1)

Dans le cas d'une grammaire LL(1), ces conditions permettent de sélectionner sans ambiguité une règle :

- Lexlu dans Premiers(g) (cas où g ne se dérive jamais en vide) ou Lexlu dans Suivants(A) (cas où g peut se dériver en vide) : sélection de la règle A -> g

Définition :

Une grammaire est LL(1) si, pour chaque non-terminal A -> g1 | .. | gn, les 3 conditions suivantes sont vérifiées :

Montrer que dans ce cas, le principe de sélection est sans ambiguité.

Exemples de grammaires non LL(1). Supposons que "a" est sous la tête de lecture.

Exemple :

A -> B C | D E              : violation de la condition 1
D -> a F | d
B -> a | b G

Exemple :

S -> A B
A -> F G | vide             : violation de la condition 2
B -> a D
F -> c X | vide
G -> b Z | vide

Exemple :

S -> A B
A -> a C | vide            : violation de la condition 3
B -> a D

Montrer  qu'une grammaire comportant une règle récursive à gauche n'est pas LL(1). [ Violation condition 1 ]
Ce cas est assez fréquent, mais peut être éliminé systématiquement :

A -> A g | g'

se remplace en :

A -> g' A'
A' -> g A' | vide

Il est clair que la transformation de la grammaire vient modifier les arbres syntaxiques qui s'éloignent alors de la syntaxe naturelle que l'on voulait modéliser.

4.4. Test de LL(1)

Rendre une grammaire algébrique LL(1) n'est pas toujours possible. De façon plus générale, il existe des langages algébriques qui, pour aucun k, ne sont LL(k).

Exemple : considérons le langage des palindromes sur {a,b}, décrit par la grammaire :

S -> a S a | b S b | a | b | vide

Pour une valeur donnée de k, on ne peut évidemment pas savoir avec une pré-lecture de k symboles si on est arrivé ou pas au milieu du mot.

Malheureusement, décider si une grammaire algébrique peut être rendue LL(1) est un problème indécidable.

On peut néanmoins déterminer si une grammaire est dans une forme LL(1) ou non. Il s'agit de calculer la fonction Null qui décide si un mot g peut se réecrire en vide ou non. Il faut aussi calculer les ensembles Premiers et Suivants, afin de voir si les trois conditions de la définition sont satisfaites.

La fermeture transitive de la composition des relations suit et terminé-par permet de calculer les ensembles Suivants.

Exemple des expressions arithmétiques :

E -> E add T | T
T -> T mul F | F
F -> id | ( E )
add -> + | -
mul -> * | :
id -> a | b

non LL(1) et transformable en LL(1) suivante :

E -> T E'
E' -> add R E' | vide
T -> F T'
T' -> mul F T' | vide
add -> + | -
mul -> * | :
id -> a | b

Relation immédiate :
E -> T -> F -> [ ( , id -> [ a, b] ]
E' -> add -> [ +, - ]
T' -> mul -> [ *, : ]

qui donne :
Premiers(E) = {a,b,(}, Premiers(E') = {+,-}, Premiers(T) = {a,b,(}, Premiers(T') = {*,:}
Premiers(F) = {a,b,(}, Premiers(add) = {+,-}, Premiers(mul) = {*,:}, Premiers(id) = {a,b}

Heuristiques d'élimination des défauts :

4.5. Points de génération

Il s'agit d'insertion de procédures dans le code de l'analyseur. Ces points peuvent être définis directement dans la grammaire : on dit que la grammaire est décorée. L'appel de la procédure est placée comme si c'était un non-terminal.

Exemple :  $p1 A $p2 -> $p3 a $p4 B $p5 C $p6 b $p7 | $p8 b $p9 A $p10

proc A
   p1;
   cas Lexlu :
     a : p3; AvTete; p4; B; p5; C; si Lexlu <> b alors erreur; p6; AvTete; p7;
     b : p8; AvTete; p9; A; p10;
     autre : erreur;
   p2
 

4.6. Yacc (manuel Unix)

NAME
     yacc - yet another compiler-compiler

SYNOPSIS
     /usr/ccs/bin/yacc  [ -dltVv ]  [ -b file_prefix ]  [ -Q [y  | n ]  ]  [ -P parser ]  [ -p sym_prefix ] file

DESCRIPTION
     The yacc  command converts a context-free grammar into a set of  tables  for  a simple automaton that executes an LALR(1) parsing algorithm. The grammar may be  ambiguous;  specified precedence rules are used to break ambiguities. The output file, y.tab.c, must be compiled by the C compiler to produce a function yyparse(). This program must be loaded with the lexical  analyzer  program,  yylex(),  as  well  as main()  and yyerror(), an error handling routine. These routines must be supplied by the user; the  lex(1)  command  is useful for creating lexical analyzers usable by yacc .

OPTIONS
     The following options are supported:

     -b file_prefix Use file_prefix instead of y as the prefix for all output  files.   The code file y.tab.c, the header file y.tab.h (created when -d is  specified),  and the  description file y.output (created when -v is specified), will be changed to  file_prefix.tab.c, file_prefix.tab.h, and file_prefix.output, respectively.
     -d        Generates the file y.tab.h with the #define statements   that  associate  the  yacc   user-assigned "token  codes"  with  the   user-declared   "token names." This association allows source files other than y.tab.c to access the token codes.

     -l        Specifies that the code produced in  y.tab.c  will not  contain  any  #line  constructs.  This option should only be used  after  the  grammar  and  the associated actions are fully debugged.

     -P parser Allows you to specify the parser  of  your  choice instead  of /usr/ccs/bin/yaccpar. For example, you can specify: example% yacc -P ~/myparser parser.y

     -p sym_prefix Use sym_prefix instead of yy as the prefix for all external  names  produced  by  yacc  .  The  names affected include the functions yyparse(),  yylex() and  yyerror(),  and  the variables yylval, yychar and yydebug. (In the remainder  of  this  section, the  six  symbols cited are referenced using their default names only as a  notational  convenience.) Local names may also be affected by the -p option; however, the -p option  does  not  affect  #define symbols generated by yacc .

     -Q[y|n]   The -Qy option puts the version stamping  information in y.tab.c. This allows you to know what version of yacc  built the file. The -Qn option  (the default) writes no version information.

     -t        Compiles runtime debugging code by default.   Runtime debugging code is always generated in y.tab.c under conditional compilation control. By default, this  code  is  not  included when y.tab.c is compiled. Whether or not the -t option is  used,  the runtime  debugging  code  is  under the control of YYDEBUG , a preprocessor symbol. If YYDEBUG has  a non-zero   value,   then  the  debugging  code  is  included. If its value is 0, then  the  code  willl   not  be included. The size and execution time of     program produced  without  the  runtime  debugging code will be smaller and slightly faster.

     -V        Prints on the standard error  output  the  version information for yacc .

     -v        Prepares  the  file  y.output,  which  contains  a description  of the parsing tables and a report on conflicts generated by ambiguities in the grammar.

OPERANDS
     The following operand is required:

     file      A path name of a file containing instructions  for which a parser is to be created.

EXAMPLES
Example 1: Using The yacc  Command

     Access to the yacc  library is obtained with library  search operands to cc. To use the yacc  library main, example% cc y.tab.c -ly

     Both the lex  library and the yacc  library contain main. The access the yacc  maiin, example% cc y.tab.c lex.yy.c -ly -ll This ensures that the yacc  library is  searched  first,  so that its main is used.

     The historical yacc  libraries  have  contained  two  simple functions  that  are  normally coded by the application programmer. These library functions are similar to the  follow ing code:

      #include <locale.h>
     int main(void)
     {
             extern int yyparse();
             setlocale(LC_ALL, "");

             /* If the following parser is one created by lex, the
                application must be careful to ensure that LC_CTYPE
                and LC_COLLATE are set to the POSIX locale.  */
             (void) yyparse();
             return (0);
     }

     #include <stdio.h>

     int yyerror(const char *msg)
     {
             (void) fprintf(stderr, "%s\n", msg);
             return (0);
     }

ENVIRONMENT VARIABLES
     See environ(5) for descriptions of the following environment variables  that  affect  the  execution  of yacc : LC_CTYPE, LC_MESSAGES, and NLSPATH.

     yacc  can handle characters from EUC primary and  supplementary  codesets  as one-token symbols.  EUC codes may only be single character  quoted  terminal  symbols.  yacc   expects yylex() to return a wide character (wchar_t) value for these one-token symbols.

EXIT STATUS
     The following exit values are returned:

     0         Successful completion.

     >0        An error occurred.

FILES
     y.output  state transitions of the generated parsery.tab.c   source code of the generated parser

     y.tab.h   header file for the generated parser

     yacc.acts temporary file

     yacc.debug temporary file

     yacc.tmp  temporary file

     yaccpar   parser prototype for C programs

ATTRIBUTES
     See attributes(5) for descriptions of the  following  attributes:
     ____________________________________________________________
    |       ATTRIBUTE TYPE        |       ATTRIBUTE VALUE       |
    |_____________________________|_____________________________|
    | Availability                | SUNWbtool                   |
    |_____________________________|_____________________________|

SEE ALSO
 cc(1B), lex(1), attributes(5), environ(5)

     Programming Utilities Guide

DIAGNOSTICS
     The number of reduce-reduce and  shift-reduce  conflicts  is reported  on  the  standard  error  output;  a more detailed report is found in the y.output  file.  Similarly,  if  some rules are not reachable from the start symbol, this instance is also reported.

NOTES
     Because file names are fixed, at most one yacc  process  can be active in a given directory at a given time.
 


Les points à retenir :



COURS 5. Analyse sémantique. Calcul de types

5.1. Typage

En programmation, il ne suffit pas de déterminer les types des objets de base d'un langage, mais il faut aussi définir comment se déterminent les types des objets que le programmeur définit. C'est la tâche de ce que l'on appelle un système de types. Les types permettent de représenter le caractère pertinent d'une expression (il s'agit d'une abstraction du point de vue de son utilisation). Les types permettent de classer les objets informatiques et de caractériser les opérations que l'on peut leur appliquer. C'est un système de sécurité permettant de vérifier à la compilation que l'on affecte bien des objets de même type (et éviter ainsi les problèmes de confusion en mémoire). On distingue vérification de type et inférence de type. Dans le premier cas, le programmeur a défini les types des objets de base et le compilateur vérifie que les instructions qui les manipulent sont cohérentes (préservent le type). Dans le cas d'inférence de type, on confie au compilateur le soin de calculer le type attendu en fonction des manipulations qui sont effectuées (c'est particulièrement utile en cas de polymorphisme où un objet peut avoir plusieurs types dépendant du contexte).

Le typage se définit à partir de :

5.1.1. Langage de type

En dépit de la grande variété des sémantiques des langages de programmation, on retrouve un peu partout les mêmes lois de formation des types.

Notation des expressions de type :

Exemples : On pourrait rajouter les lois de formation des types spécifiques des langages de programmation orientés objet (classes, héritage), et la formation par symbole de type (typedef de C).

Noter que les bornes des tableaux ne sont pas notées dans le type. On pourrait bien sûr le faire en raffinant la définition, mais on ne pourra pas de toute façon calculer leurs valeurs statiquement : cf la question de la calculabilité. Noter aussi que le type tableau n'est pas très différent du type fonction.

Le type fonction peut être combiné récursivement (fonction de fonction). Cela ne gêne pas le vérificateur de type. Cela conduit à manipuler des objets d'ordre supérieur ( exemple des fonctions passées en paramètres et qui implique un schéma d'exécution particulier). Cela est possible en ML et interdit en C.
 

5.1.2. Système de type

Un système de type peut être défini à l'aide de règles de déduction qui expriment comment associer chaque construction d'un langage de programmation à un type. Les règles se présentent généralement comme un système formel de preuve avec des règles de la forme générale :

hypothèse1 ... hypothèse n
---------------------------   si condition
       conclusion

Les hypothèses et la conclusion ont la forme construction : type, puisqu'il s'agit associer un type à une construction.

L'information de type est obtenue à partir des déclarations. On peut aussi l'obtenir au vu de la syntaxe des expressions. Le système utilise les unités lexicales remontées par l'analyseur lexical.

Exemples d'axiomes :

-----------------                                                    --------------------------                               ------------  si decl X t
entier(X) : entier                                                     bool(B) : booleen                                       X : t

Tableaux :
                                                                                                     t : tableau(t,t')  i : t'
---------------------- si decl T t[entier(X)]                                   --------------------
T : tableau (t, entier)                                                                         t[i] : t

Noter que les bornes du tableaux ne sont pas considérées dans le type. Le type est une abstraction calculable à la compilation.

Fonctions :

                                                                                                     f : fonction (t, t')     x : t
---------------------- si decl F t : t'                                              -------------------------
F : fonction (t, t')                                                                              f(x) : t'

A noter que le cas des fonctions n-aires peut être ramené au cas des fonctions unaires, par l'opération de curryfication qui fait correspondre
D1xD2x...xDn -> D0  à D1 -> (D2 -> ( ... (Dn -> D0) ... ))

Structures :
                                                                                                         X : structure (a1 t1, ..., an tn)
--------------------------- --- si decl X struct{a1 t1, ... an tn}         ---------------------------------
X : structure (a1 t1, ..., an tn)                                                              X.ident(ai) : ti

Unions :
                                                                                                         X : union (a1 t1, ..., an tn)
--------------------------- --- si decl X union{a1 t1, ... an tn}         ---------------------------------
X : union (a1 t1, ..., an tn)                                                              X.ident(ai) : ti

Pointeurs :
                                                   X : pointeur (t)                            X : t
------------------ si decl X * t    -----------------                   -----------------
X : pointeur(t)                                *X : t                                  &X : pointeur(t)

5.1.3. Portée

Les conditions de la forme decl x t doivent être interprétées en accord avec le mécanisme de portée du langage analysé. En pratique, à chaque bloc du programme, on peut associer une table de symboles qui synthétise les déclarations qui ont ce point dans leur portée.

5.1.4. Surcharge

Un nom d'opération est surchargé si plusieurs opérations portent de même nom. C'est le cas par exemple du + (flottant ou entier), ou du = en C qui sert à tout. C'est une facilité, mais qui demande à ce que le compilateur infère le type de l'expression.

5.1.5. Polymorphisme

Une fonction est polymorphe si elle peut être appliquée à des arguments de types différents. Par exemple, la fonction qui calcule le nombre d'éléments d'une liste peut être la même quel que soit le type des éléments de la liste. C'est en cela que le polymorphisme se distingue de la surcharge. Le polymorphisme change la nature de la vérification de type.

5.1.6. Vérification statique/Dynamique

On dit que la vérification de types est statique si elle est faite au seul vu de la structure du programme (par le compilateur) sans avoir à l'exécuter. Elle est dynamique autrement, et on peut imaginer des méthodes mixtes.

Une école de pensée actuelle consiste à transporter des vérifications faites classiquement à la compilation vers des vérifications dynamiques. Ceci permet d'aborder par exemple les problèmes du code mobile (les applets Java), de la vérification de sécurité, détection des virus, ...

On demande néanmoins que le typage soit de la bonne puissance, c'est-à-dire que aucun programme qui passe avec succès la vérification statique des types ne peut échouer à la vérification dynamique des types (c'est pourquoi le typage ne regarde pas en général le dépassement des bornes de tableau).

5.2. Inférence de types

Nous avons vu que la vérification de types consiste à vérifier qu'il est possible d'étiqueter l'arbre de syntaxe abstraite du programme par des types de telle sorte que les règles du système de type soient satisfaites à tous les noeuds de l'arbre. Cela suggère de construire l'arbre de syntaxe abstraite du programme puis de tenter de l'étiqueter avec des types. On pourrait le construire en utilisant une grammaire attribuée. Cependant, il se peut que tout l'arbre de syntaxe abstraite ne serve pas pour les autres phases de la compilation, et créer explicitement l'arbre entre la phase d'analyse syntaxique et celle de vérification de types éloigne deux phases qui appartiennent enfin à la même activité : vérifier que le programme source est bien formé. Ce n'est que pour des raisons de limitation de la puissance de la classe de langage formel considérée dans la phase d'analyse syntaxique que celle-ci ne peut pas vérifier les types par des moyens uniquement syntaxiques. On va donc augmenter les descriptions purement syntaxiques du langage source par des attributs qui représentent le typage pour intégrer la vérification des types à l'analyse syntaxique. C'est la prototype d'une manière de faire très courante qui consiste à sous-traiter à des attributs les aspects contextuels que ne peuvent pas traiter les grammaires algébriques.

On peut souhaiter ne pas fournir toutes les déclarations dans le programme source, et laisser le compilateur les inférer. L'objectif est de garantir une certaine concision des programmes. On construit toujours une table des synboles, mais au lieu de la construire à partir des déclarations, on la construit à partir des occurrences d'utilisation. L'inférence de type proprement dite revient à faire remonter des feuilles de l'arbre syntaxique du programme des tables de symboles élémentaires qui sont progressivement unifiées pour ne former qu'une table de symboles.

Un bon exemple est ML et ses variantes comme CAML. CAML est doté d'un système de type à la fois simple et puissant, notamment parce qu'il offre à la fois des types polymorphes et leur inférence automatique.
 


Les points à retenir :



COURS 6. Génération de code

6.1. Introduction

Considérons le cas de l'évaluation d'expressions arithmétiques. La notation usuelle est la notation infixe :

a*b - (c*(d+e) - f)

Mais l'arbre syntaxique peut être parcouru en profondeur d'abord, et traverser les noeuds dans l'ordre postfixe suivant :

a b * c d e + * f - -

L'intérêt de cette représentation est qu'elle peut être directement interprétée par une machine à pile.
Cette machine à pile peut être vue comme la machine virtuelle cible du compilateur. Dans une telle machine, on empile les opérandes, les opérateurs combinent les deux valeurs en sommet de pile et les remplacent par le résultat.

Exemple d'exécution :
a
a b
a*b
a*b c
a*b c d
a*b c d e
a*b c d+e
a*b c*(d+e)
a*b c*(d+e) f
a*b c*(d+e)-f
a*b-(c*(d+e)-f)

et le code généré correspondant :

push a; push b; effectuer *; push c; push d; push e; effectuer +; effectuer *; push f; effectuer -; effectuer -;

Dans ce cours, nous donnons une présentation intuitive de ce que fait un compilateur de langage impératif. On définit la correspondance entre un programme en langage source, et le programme obtenu par compilation pour un calculateur cible. Le langage source choisi est un Pascal ou C légèrement appauvri. Le calculateur cible est une machine abstraite spécialement adaptée. La question plus précise de la mise en oeuvre d'une telle machine abstraite sera vue plus tard.

6.2. Fonctions de compilation

6.2.1. Les concepts du langages et leur traduction

6.2.2. Architecture de la P-machine

Il s'agit d'une machine abstraite, originellement faite pour le langage Pascal, et particulièrement représentative de la notion de machine virtuelle pour un langage impératif. Cette machine possède une mémoire STORE (tableau de 0 à maxstr), dont la première partie est organisée en pile; le sommet de pile est repéré par le pointeur SP (il s'agit d'un registre spécialisé de la machine). La taille de la pile évolue au cours de l'exécution. Certaines P-instructions rangent le contenu d'adresses explicites au sommet de la pile, ce qui a comme conséquence d'allonger la pile. D'autres copient le contenu du sommet de pile à une adresse explicite et raccourcissent ainsi la pile. Elle possède aussi une mémoire d'instruction CODE (tableau de 0 à codemax). L'instruction courante est repérée par le pointeur PC (registre pointeur de code). Ce registre contient l'adresse de la prochaine instruction à exécuter. Toutes les instructions de la machine occupent une cellule de la mémoire de programme, de sorttte qu'en dehors des branchements, le contenu du registre PC est incrémenté d'une unité à chaque pas. Le cycle de base de la machine est donc :

do
    PC := PC +1;
    exécuter l'instruction de la cellule CODE[PC-1]
od

Le registre PC est initialisé à 0, adresse du début de programme.

6.2.3. Affectations et expressions

On donne les instructions et leur effet sur la mémoire.

add     :    STORE[SP-1] := STORE[SP-1] + STORE[SP]; SP := SP - 1
sub     :    STORE[SP-1] := STORE[SP-1] - STORE[SP]; SP := SP - 1
mul     :    STORE[SP-1] := STORE[SP-1] * STORE[SP]; SP := SP - 1
div     :    STORE[SP-1] := STORE[SP-1] / STORE[SP]; SP := SP - 1
neg    :    STORE[SP] := - STORE[SP]

and     :    STORE[SP-1] := STORE[SP-1] and STORE[SP]; SP := SP - 1
or       :    STORE[SP-1] := STORE[SP-1] or  STORE[SP]; SP := SP - 1
not      :    STORE[SP] := not STORE[SP]

equ     :    STORE[SP-1] := STORE[SP-1] = STORE[SP]; SP := SP - 1
geq     :    STORE[SP-1] := STORE[SP-1] >= STORE[SP]; SP := SP - 1
leq     :    STORE[SP-1] := STORE[SP-1] =< STORE[SP]; SP := SP - 1
les     :    STORE[SP-1] := STORE[SP-1] <  STORE[SP]; SP := SP - 1
grt     :    STORE[SP-1] := STORE[SP-1] >  STORE[SP]; SP := SP - 1
neq     :    STORE[SP-1] := STORE[SP-1] <> STORE[SP]; SP := SP - 1

Il y a aussi les instructions de lecture et d'écriture en mémoire. ldo charge une donnée à partir d'une cellule donnée par son adresse absolue; ldc charge une constante donnée dans la P-instruction elle-même. ind charge par indirection, à partir de la cellule en sommet de pile; sro écrit dans une cellule dont l'adresse absolue est donnée; sto écrit dans la cellule dont l'adresse est obtenue indirectement à partir de la cellule immédiatement sous la sommet de pile.

ldo  q  :    SP := SP + 1; STORE[SP] := STORE[q]
ldc q    :   SP := SP + 1; STORE[SP] := q
ind       :   STORE[SP] := STORE[STORE[SP]]
sro q    :   STORE[q] := STORE[SP]; SP := SP - 1
sto q    :   STORE[STORE[SP - 1]] := STORE[SP]; SP := SP - 2

En traduisant les affectations, il convient de prendre garde au fait qu'une désignation de variable dans la partie gauche d'une affectation sera traduite différemment d'une désignation de variable apparaissant en partie droite. En partie gauche, on a besoin de l'adresse de la variable pour en modifier le contenu; en partie droite, on a besoin de la valeur pour calculer celle de l'expression. La fonction de codage sera indexée par G ou D : codeG produit des P-instructions pour le calcul d'une valeur gauche, codeD pour le calcul d'une valeur droite.

La traduction d'une affectation x := y donne la séquence :

calculer la valeur gauche de x
calculer la valeur droite de y
sto

Nous considérons comme acquis que les vérifications de types ont déjà été effectuées.

Les fonctions de codage utilisent un deuxième paramètre : une fonction @, qui assigne à toutes les variables déclarées une adresse dans la mémoire STORE. En réalité, pour une variable déclarée dans une procédure, il s'agit d'une adresse relative. Pour l'instant, nous considérons @(x) comme étant l'adresse de x, realtive au début de STORE.

La traduction des instructions d'affectation est définie par :

codeD (e1 = e2)  = codeD e1; codeD e2; equ
codeD (e1 + e2)  = codeD e1; codeD e2; add
---
codeD x  = codeG x; ind             (x désigne une variable)
codeG x  = ldc @(x)
codeD c  = ldc c                              (c constante)
codeD(-e)  = codeD e; neg

code(x:=e)  = codeG x; codeD e; sto

Exemple :

Soit un programme contenant trois variables entières a,b,c, avec @(a)=5, @(b)=6 et @(c)=7. La traduction de l'affectation a:=(b+(b*c)) donne :

code(a:=(b+(b*c)))  =
codeG a; codeD (b+(b*c)); sto =
ldc 5; codeD (b+(b*c)); sto =
ldc 5; codeD b; codeD (b*c); add; sto =
ldc 5; codeG b; ind; codeD b; codeD c; mul; add; sto =
ldc 5; ldc 6; ind; codeG b; ind; codeG c; ind; mul; add; sto =
ldc 5; ldc 6; ind; ldc 6; ind; ldc 7; ind; mul; add; sto

Regarder l'évolution de la pile.

6.2.4. Flot de contrôle

On donne ici le schéma de traduction pour les instructions if à une ou deux branches (if then else), ainsi que pour les boucles while et repeat.

On considères deux nouvelles instructions de la P-machine (ujp pour le branchement inconditionnel, fjp pour le branchement conditionnel). Avec le signification suivante :
 

ujp q    :    PC := q
fjp q    :    if STORE[SP] = false
                   then PC := q;
                SP := SP - 1

On peut alors définir les fonctions de codage :

code (if e then st1 else st2)  = codeD e; fjp l1; code st1; ujp l2; l1: code st2; l2:

(voir graphiquement dans la mémoire d'instruction)

code (if e then st)  = codeD e; fjp l; code st; l:

code (while e do st)  = l1: code D e; fjp l2; code st; ujp l1; l2:
code (repeat st until e)  = l: code st; codeD e; fjp l

Exemple :

Avec @(a)=5, @(b)=6 et @(c)=7
Générer le code pour les instructions :

if (a>b) then c:=a else c :=b

ldc 5; ind; ldc 6; ind; grt; fjp l1; ldc 7; ldc 5; ind; sto; ujp l2; l1: ldc 7; ldc 6; ind; sto; l2:

while (a>b) do c := c+1; a := a-b

l1: ldc 5; ind; ldc 6; ind; grt; fjp l2; ldc 7; ldc 7; ind; ldc 1; add; sto; ldc 5; ldc 5; ind; ldc 6; ind; sub; sto; ujp l1; l2:

sachant que lel schéma de traduction de la séquence est très simple :

code (st1;st2)  = code st1; code st2
 

Considérons maintenant une sélection simplifiée de la forme :

case e of
  0 : st0;
  1 : st1;
...
  k : stk

Une traduction fréquente consiste à utiliser un branchement indexé. La P-instruction ixj correspondante ajoute à l'adresse cible la valeur contenue dans la cellule en sommet de pile.

ixj q    :    PC := STORE[SP] + q; SP := SP - 1

La traduction est la suivante :

code (e, st0, ..., stk) = codeD e; neg; ixj q; l0: code st0; ujp q'; l1: code st1; ujp q'; ... lk: code stk; ujp q'; ujp lk; ...; ujp l1; q: ujp l0; q':

Noter que les dernières instructions forment la table des branchements.

6.2.5. Implantation en mémoire des variables de type simple

Pour fixer l'affectation des variables déclarées à des cellules en mémoire, il faut faire certaines hypothèses sur la taille des cellules et la consommation en mémoire du programme. A chaque variable de type simple (entier, réel, booléen, pointeur ou énuméré), on associe une cellule mémoire pour recevoir sa valeur. Dans les vrais compilateurs, on cherche aussi à compacter dans une seule cellule plusieurs petites valeurs comme les booléens. Cela complique évidemment le code pour les manipuler. Il y a aussi la notion de multiple précision pour manipuler des nombres réels très précis.

Dans notre cas, le schéma d'affectation est simple. Pour chaque variable déclarée, on affecte les adresses au début de la pile, en séquence dans l'ordre de leur déclaration. En général, les adresses sont relatives (on verra plus loin l'utilité de cette notion). Par exemple, on peut considérer que les variables sont stockées à partir de l'adresse 5. Ce qui donne la fonction d'allocation suivante, pour les déclarations var x1 : t1; ... xn : tn;, @(xi) = i+5;

6.2.6. Les tableaux

Considérons d'abord les tableaux statiques : var a : array [-5..5,1..9] of integer

Combien de cellules mémoires ce tableau occupera-t-il ? 99, que l'on peut ranger par ligne (a[-5,1] ... a[-5,9], a[-4,1] ... a[-4,9], ...)

Pour un tableau array[l1..u1, ..., lk..uk] of integer, on a nbcell = X(i=1..k) (ui-li+1)

Pour chaque type t, on peut obtenir son nbcell(t) (nbcell(t)=1 pour les t simples).

Pour une déclaration de variable var x1 : t1; ... xn : tn; on a @(xi) = 5 + S(j=1..i-1) nbcell(tj)

Dans l'exemple des tableaux statiques, les grandeurs k, li, ... sont connues dès la compilation, donc aussi les fonctions nbcell et @. Ainsi si on veut ranger 0 dans la première cellule d'un tableau a, on peut générer le code : ldc @(a); ldc 0; sto
Ceci ne marche pas pour générer du code pour l'affectation a[i,j] := 0, dans laquelle i et j sont des variables qui ne recoivent des valeurs qu'à l'exécution. Dans ce cas, il faut générer auparavent le code qui calcule l'expression : (i-(-5))*(9-1+1)+j-1 = (i+5)*9+j+1.

Dans le cas général, calculons l'adresse de l'élément [i1,i2,...,ik] relative par rapport au début du tableau. Le tableau étant rangé par ligne,
l'adresse de l'élément cherché est :

(i1-l1)*nbcell(array[l2..u2, ..., lk..uk]) +
(i2-l2)*nbcell(array[l3..u3, ..., lk..uk]) +
...
(ik-1 - lk-1)*nbcell(array[lk..uk]) +
(ik-lk)

soit en remplaçant par les valeurs de nbcell connues à la compilation :
(i1-l1)*d2*d3* ...*dk +
(i2-l2)*d3*d4* ... *dk +
...
(ik-lk)

ou encore :

i1*d2*d3* ...*dk + i2*d3* ...*dk + ik -
(l1*d2*d3* ...*dk + l2*d3* ...*dk + lk)  où la deuxième partie est connue à la compilation.

Pour un type d'élément quelconque t, soit Dj = X(i=j+1..k) di, et h = S(j=1..k) ij*Dj, alors
@([i1, ..., ik]) est de la forme h*nbcell(t) - d*nbcell(t).

On va considérer une P-instruction particulière pour faciliter les calculs : ixa q qui permettra d'obtenir successivement les adresses des sous-tableaux :

ixa q    :    STORE[SP-1] := STORE[SP-1]+STORE[SP]*q; SP := SP - 1

et aussi la décrémentation dec q :

dec q    :    STORE[SP] := STORE[SP] - q

@([i1, ..., ik]) va être calculé par le code généré ainsi :

codeG c[i1, ..., ik] = ldc @(c); codeI [i1, ..., ik] nbcell(t)
codeI [i1, ..., ik] n = codeD i1; ixa n*D1; codeD i2; ixa n*D2; ... codeD ik; ixa n*Dn; dec n*d;

Ce code ne vérifie que les indices appartiennent bien aux intervalles permis par la déclaration de tableau. Comme, ce n'est pas en général décidable à la compilation, il faut considérer une instruction de vérification chk p q :

chk p q     :     if (STORE[SP]<p) or (STORE[SP]>q) then error ("value out of range")

Exemple :

var i,j : integer;
       a : array [-5..5,1..9] of integer;

et supposons que @(i)=5, @(j)=6, @(a)=7

d1=11, d2=9, n=1 et d=-44
La traduction de a[i+1,j] := 0 est :

code(a[i+1,j] := 0) = ldc 7;ldc 5; ind; ldc 1; add; chk -5 5; ixa 9; ldc 6; ind; chk 1 9; ixa 1; dec -44; ldc 0; sto

Cette méthode ne marche plus pour les tableaux dynamiques dont les bornes ne sont connues qu'à l'exécution. Le compilateur doit générer aussi le code pour l'allocation en mémoire. Ce cas complexe n'est pas traité dans ce cours.

6.2.7. Pointeurs et allocation dynamique

Pour l'instant nous n'avons considéré que l'allocation des objets qui sont introduits par une déclaration : celle-ci donne un nom à un objet et une adresse relative est statiquement attribuée à ce nom. Lorsqu'un langage de programmation admet des pointeurs, ceux-ci peuvent être utilisés pour accéder à des objets anonymes. Les pointeurs permettent des réaliser des structures chainées, pouvant croitre ou decroitre dynamiquement. Les différents objets sont alors créés par une instruction spéciale comme le new. La sémantique relatice à la durée de vie des objets crées dynamiquement n'est pas toujours claire. On confie souvent à la machine la responsabilité de récupérer les zones de mémoire occupées par des objets qui sont devenus inaccessibles (pointeur réaffecté par exemple). C'est le rôle de ce que l'on appelle un "ramasse-miette" (garbage collection en anglais). Les objets créés dynamiquement sont alloués dans une zone située dans la partie supérieure de la mémoire, appelée tas (en anglais "heap"). Quand des objets sont créés dynamiquement, le tas croit en direction de la pile. Un nouveau registre de la P-machine (NP) pointe vers l'adresse la plus basse occupée par le tas.

La P-instruction new doit trouver en sommet de pile la taille de l'objet à créer, et en dessous l'adresse de la variable qui contiendra le pointeur vers le futur objet. Cette adresse permettra de trouver indirectement l'adresse du nouvel objet.

new    :    if NP - STORE[SP] <= SP then erreur("memory overflow")
                                                          else NP := NP - STORE[SP]; STORE[STORE[SP-1]] := NP; SP := SP - 2

La traduction d'une instruction du langage source new(x) est donc :

code(new(x)) = codeG x; ldc nbcell(t); new                 (x est du type *t)

Pour déréférencer (adresser un objet à partir d'un pointeur) on utilise simplement la P-instruction ind.

6.3. Les procédures

La déclaration d'une procédure comprend : Lorsque la procédure est une fonction, il faut encore donner le type du résultat.

Une procédure est appelée à l'exécution d'une instruction référençant son nom. Une fois appelée, une procédure peut à son tour appeler une autre procédure. Dès qu'une procédure a terminé de traiter ses instructions, on quitte la procédure, et la procédure qui l'avait appelée poursuit son exécution au delà de l'appel. Si on considère l'enchaînement des appels lors de l'exécution, on peut observer qu'il s'agit d'un arbre. La racine est étiquetée par le programme principal. Un noeud intérieur est étiqueté par un nom de procédure p, son noeud père par le nom de la procédure qui a exécuté l'appel de p. Les noeuds fils correspondent aux procédures appelées par le père successivement dans l'ordre de leur appel. Lorsqu'une procédure p est activée, les procédures traversées depuis la racine jusqu'à ce noeud sont dites vivantes (elles ne sont pas encore terminées).

Exemple :

program h;
 var i : integer;
 proc p
 [
   proc r
   []
 if i>0 then i:= i-1; p
              else r
 ]
  i := 2;
  p;
  p

Arbre : h (p(p(p(r))),p(r))

A un programme donné peuvent correspondre plusieurs arbres d'appels (cela dépend de l'éxécution). Un arbre d'appels peut être infini.

Les noms de paramètres ou introduits par des déclarations locales sont des noms locaux pour lesquels il faudra réserver dynamiquement de la place lors de l'appel. La durée de vie de ces variables est exactement la durée de la procédure. Cette place peut être libérée en quittant la procédure. Ceci va être mis en oeuvrenpar une pile dans la mémoire. Le traitement des noms des variables globales est plus compliqué. Il faut utiliser des règles de visibilité pour retrouver la définition correspondant à une référence à une variable. Par exemple, la définition d'une variable sera cherchée dans les variables locales de la procédure. Si échec, il faudra chercher dans la procédure englobante et ainsi de suite. Il s'agit d'une relation DEF/USE qui peut être calculée sur la structure du programme.

Exemple :

proc p(a)[ var b; var c; proc q [ var a; var q; proc r [ var b; b;a;c ] a; b; q; ] proc s [ var a; a; q; ] a; q; ]

Trouver les DEF/USE

Ce mécanisme est une liaison statique. Dans certains langages, on préfère la liaison dynamique. Dans ce cas, un accès à un nom global fait intervenir la dernière instance de ce nom.

Exemple :

var s; proc p [ proc q [ var x; p ] x; q; ] p

A l'appel de p depuis l'extérieur, x se rapporte à la définition externe (statique comme dynamique). Par contre, l'appel de p depuis q, lie x à la déclaration dans q (statiquement la liaison serait la déclaration externe).

Dans le cas de liaison statique que l'on considère ici, on peut faire correspondre à chaque chemin d'instanciation, l'arbre des prédécesseurs statiques : un noeud de l'arbre est une instance de procédure, et son père est la dernière instance créée de la procédure englobante.

Dans notre exemple du programme h, l'arbre des prédécesseurs statiques associé au chemin h-p-p-p-r est h(p,p,p(r)).
 

6.3.1. Organisation de la mémoire

Une suite de zones mémoire est réservée à chaque instant pour les instances courantes, dans l'ordre de leur apparition sur le chemin d'instanciation. Afin d'implanter efficacement le retour de la procédure, les prédécesseurs dynamiques sont liés entre eux. Pour accéder efficacement aux variables globales, on lie entre eux les prédécesseurs statiques de l'intérieur vers l'extérieur. La pile d'exécution contient ainsi à la fois le chemin d'instanciation courant et l'arbre des prédécesseurs statiques courant. Ce mécanisme réalise donc à la fois le retour des procédures et l'accès aux variables globales. Pour une instance de procédure, une zone de mémoire est réservée, le bloc d'activation ("stack frame" en anglais). Sa structure est la suivante : Après ces cellules qui constituent la zone de gestion des appels, se trouvent la zone des données. Celle-ci contient une zone pour les valeurs, les adresses ou descripteurs des paramètres effectifs (suivant qu'ils sont passés par référence ou par valeur), et une zone pour les variables locales de la procédure. Enfin, nous trouvons la pile locale que nous avons déjà rencontrée lors de l'évaluation des expressions. Sa taille maximale est calculable statiquement.

Exemple :

program h; [ var x; proc p [ ... proc q [ ... r ... ] proc r [ var y; proc s [ x+y; q ] s ] r ] p ]

Après le deuxième appel de s, montrer l'allure de la pile. Sachant que l'arbre des prédécesseurs statiques est

6.3.2. Adressage des variables

On a vu que des zones de données doivent être créees dynamiquement. On ne peut donc plus affecter statiquement des adresses aux variables du programme. A chaque instance de procédure est affecté un bloc d'activation. Les variables allouées dans ce bloc recoivent alors une adresse relative au début du bloc, qui elle peut être déterminée statiquement. On calcule donc l'adresse absolue de la variable en utilisant des P-instructions qui permettent de récupérer l'adresse de début du bloc d'activation à partir du registre MP. Nous avons besoin de connaître la profondeur d'imbrication d'une construction du programme. La profondeur du programme principal est 0. La profondeur d'une définition ou d'une utilisation d'une unité de programme est n+1, si n est la profondeur d'imbrication de cette unité de programme. C'est une propriété statique.

Exemple :

Dans le programme précédent :

définitions : p(1), q(2), r(2), s(3)
utilisations : p(1), q(4), r dans p (2), r dans q (3), s(3)

Considérons les accès aux variables non locales x et y depuis la procédure s. Supposons que la définition de x dans le programme principal soit visible dans s. La profondeur d'imbrication de cette définition est égale à 1. Les profondeurs des utilisations de x et y dans s sont 4. On voit qu'en suivant 3 fois le pointeur PrS, on atteint le bloc d'activation du programme principal, où se trouve x. La bonne instance de y accédée dans r est obtenue en suivant une fois ce pointeur PrS (la différence entre les profondeurs de l'utilisation et de la définition est de 1). On a en fait le résultat suivant : un accès depuis une profondeur n à une variable définie à un eprofondeur m (m<=n), nécessite de suivre (n-m) fois le pointeur PrS avant d'arriver au bloc d'activation contenant la définition cherchée. On doit ajouter à l'adresse de début de ce bloc l'adresse relative pour obtenir l'adresse absolue de la variable globale. On utilise les P-instructions suivantes (lod lit les valeurs, lda les adresses) :

lod p q    :    SP : = SP + 1; STORE[SP] := STORE[base(p,MP)+q)
lda p q    :    SP := SP + 1; STORE[SP] := base(p,MP)+q
str p q    :    STORE[base(p,MP)+q] := STORE[SP]; SP := SP - 1

p = différence des profondeurs d'imbrication; q = adresse relative
base(p,a) = if p=0 then a else base(p-1,STORE[a+1])

6.3.3. Appel et retour d'une procédure

Considérons d'abord l'appel de procédure q depuis p. Le bloc d'activation de p est en sommet de pile. Admetttons que les pointeurs dans le bloc ont été correctement remplis, à savoir que PrS pointe sur la bonne instance de la procédure englobant p, et PrD sur l'instance qui a appelé p. Maintenant p appelle une procédure q. Voici les actions à faire avant d'exécuter q : Les deux premières tâches sont effectuées par le programme appelant par la P-instruction mst. L'appelant évalue également les paramètres, puisque pour cela les variables doivent être évaluées dans l'environnement du site d'appel. La P-instruction cup exécute les points 4 à 6 et également dans l'appelant. C'est alors que le dernier point est exécuté par l'appelé à l'aide de l'instruction ssp ("set stack pointer" en anglais).

mst p    :    STORE[SP+2] := base(p,MP); STORE[SP+3] := MP; SP := SP + 4  (les paramètres peuvent être évalués à partir de STORE[SP+1])
cup p q    :    MP := SP-(p+3); STORE[MP+3] := PC; PC := q (p donne la place requise pour les paramètres, q est l'adresse de début de la procédure)
ssp p    :    SP := MP+p-1 (p est la taille statique de la zone de données)

Une fois l'exécution du corps d'une procédure complètement terminée, on doit libérer la place qui a été allouée pour son bloc d'activation, et effectuer le branchement de retour vers l'appelant. La P-instruction retf se charge de cette tâche pour les fonctions et retp pour les procédures.

retf    :    SP := MP; PC := STORE[MP+3]; MP := STORE[MP+2]
retp    :    SP := MP - 1; PC := STORE[MP+3]; MP := STORE[MP+2]

Pour ce qui concerne la transmission des paramètres, il faut distinguer le passage par valeur du passage par référence. La traduction d'un paramètre effectif requiert des informations sur les paramètres formels correspondants. Ces informations sont fournies par l'analyse sémantique réalisée avant la génération de code. Le code généré pour ces paramètres formels s'exécute dans le contexte suivant : la procédure appelante a déjà exécuté la P-instruction mst ; le registre SP pointe sur la cellule immédiatement en dessous de la zone des paramètres. La transmission des paramètres occupe alors les cellules suivantes avec des adresses ou des valeurs. Ainsi SP est incrémenté, de façon à toujours pointer sur la dernière cellule libre. Si un paramètre formel x d'une procédure est spécifié comma var, alors le paramètre effectif correspondant doit être une variab à nouveau un paramètre var. Transmettre le paramètre consiste alors à calculer l'adresse, autrement dit la valeur gauche du paramètre effectif, et à la ranger en utilisant l'adresse relative qui a été affectée à x. Chaque accés au paramètre formel dans le corps de la procédure nécessite alors une indirection à travers son adresse relative.


Les points à retenir :



COURS 7. Analyse de flot. Optimisation de code et architecture machine.

 

7.1. Machines abstraites versus machines réelles

Du point de vue des machines abstraites, on a vu que l'organisation de la mémoire était adaptée aux objets à gérer : il existe un ou plusieurs piles pour les objets dont la durée de vie correspond à des intervalles de temps emboîtés, et un tas pour les autres. Les instructions machines sont complexes, et capables de rélaiser partiellement une construction du langage source.

Du point de vue des machines réelles, la mémoire est essentiellement hiérarchisée en fonction de la rapidité des accès: on trouve les registres du processeur, un cache, une mémoire principale et une mémoire auxilliaire. Les registres se trouvent généralement sur la puce du processeur, et leur accès est très rapide. A l'inverse, un accès à la mémoire principale s'effectue en général à travers le bus système situé entre le processeur et la mémoire: c'est accès est nettement plus long. La tâche de génération de code est donc de maintenir autant que possible les objets dans des registres.

La mémoire principale est généralement une mémoire linéaire, qui devra accueillir le code généré, les piles et le tas. Sur les ordinateurs actuels, les octets sont souvent les plus petites unités adressables.

Il existe aussi plusieurs classes de registres. Il y a les registres universels pour les opérations arithmétiques et logiques sur les entiers et les adresses. On trouve aussi des registres flottants pour les opérations en virgule flottante (qui nécessitent un matériel ou du micro-code spécialisé). Quelquefois, les registres pour les données et les adresses sont séparés (du à leur longueyr différente). On trouve enfin le registre du compteur d'instruction (PC)  et le registre du code condition (CC) qui contient le résultat des comparaisons et notamment les indicateurs de signe, de débordement, de retenue, ...

Le jeu d'instructions varie beaucoup d'un calculateur à l'autre. Cela peut varier de quelques dizaines à un millier. On distingue en général :

Les langages d'assemblage offrent une notation pour les instructions.  Nous proposons ici une notation simplifiée pour parler des instructions. Une instruction correspondant à une opération binaire op sera écrite : <resultat> := <operande 1> op <operande2>

<operande> ::= Ri (contenu du registre Ri) | M[< Adr> ] (contenu de la cellule mémoire adressée) | c (valeur de la constante)

<resultat> ::= Ri (registre cible de l'opération) | M[<Adr> ] (cellule mémoire pour le résultat)

Sur la plupart des processeurs, les modes d'adressage possibles forment un sous ensemble du langage suivant :

<Adr> ::= c (valeur d'une constante) | Ri (contenu du registre) | ++Ri (contenu du registre après pré-incrémentation) |
                    --Ri (contenu du registre après pré-décrémentation) | Ri++ (contenu du registre, post-incrémentation par effet de bord) |
                     Ri-- (contenu du registre, post-décrémentation par effet de bord) | M[<Adr>] (contenu de la cellule adressée) |
                     <Adr> + <Adr> (somme des adresses) | <Adr>*c |<Adr>.<Size> (restriction à une partie d'un registre)
<Size> ::= L | W | B (mot long, mot, octet)

En plus de la génération de code proprement dite, on ajoute les tâches suivantes :

7.2. Classification des architectures

7.2.1. CISC ("Complex Instruction Set Computer")

Ces architectures sont très proches des machines abstraites.  Elles se caractérisent par des modes d'adressage nombreux et complexes pour traiter efficacement les accès aux données structurées comme les tableaux, les listes, les blocs d'activation, ... Elles possèdent aussi une grande variété d'opérations. Les temps d'exécution sont variables suivant les instructions. Il y a peu de registres.

La réalisation de ces jeux d'instructions exige d'utiliser soit une grande quantité de matériel, soit de gros micro-programmes. Il reste peu de place en général sur la puce pour mettre des registres. La tâche d'allocation des registres est importante, mais difficile.  L'essentiel de l'optimisation doit être conduit au niveau de la génération de code intermédiaire.

7.2.2. RISC ("Reduced Instruction Set Computer")

Derrière l'introduction de ce concept se trouve l'idée d'accélérer l'exécution de chaque instruction individuelle, grâce à leur simplification. Les propriétés caractéristiques sont les suivantes : Ces architectures ont un petit nombre seulement d'instructions. Elles possèdent de nombreux registres, avec des unités fonctionnelles pouvant travailler en parallèle.
 

7.2.3. Parallélisme

Imaginons un processeur équipé de plusieurs unités fonctionnelles travaillant en parallèle sur les registres. Chaque mot d'instruction est suffisamment long pour contenir les sous-instructions destinées à toutes les unités fonctionnelles ; celles-ci seront donc pilotées indépendamment les unes des autres à partir de sous-mots du mot d'instruction. Ce sera le travail du compilateur de rassembler le code généré dans une séquence de mots d'instructions longs ou très longs ("Very Long Instruction Words", VLIW), cette séquence étant la plus courte possible sans déformer la sémantique du programme (cela s'appuie sur une analyse sémantique de dépendance).

Le parallélisme peut être aussi exploité pour former des "pipelines". Dans un pipeline d'instructions peuvent se trouver à un instant donné plusieurs instructions, à diverses phases de leur exécution. Par exemple, le partage d'une instruction peut se faire en quatre phaes : (1) charger et décoder les instructions, (2) charger les opérandes, (3) exécuter l'instruction, (4) écrire le résultat dans un registre. A chaque phase correspond un étage du pipeline responsable de son exécution, et l'exécution d'un étage prend un cycle. Dans une situation idéale, lorsuqe le pipeline est régulièrement alimenté, il y a recouvrement, et une instruction est exécutée à chaque cycle (bien qu'elle dure en fait 4 cycles).

Exemple :
        1    2    3    4    5    6    7
1    B1 B2 B3 B4
2           B1 B2 B3 B4
3                  B1 B2 B3 B4
4                         B1 B2 B3 B4

Une telle situation n'est possible que si les instructions successives ne requièrent pas simultanément les mêmes ressources matérielles, et aucune instruction n'a besoin d'un résultat d'une instruction précédente, et qui n'est pas encore disponible. Dans certainemachines, cette dernière situation était détectée dynamiquement et bloquait l'entrée du pipeline. Mais c'était très couteux. On préfère confier au compilateur le soin de réordonner les instructions de façon à intercaler entre deux instructions dépendantes un nombre suffisant d'instructions qui n'en dépendent pas, et éventuellement remplir les délais non utilisés par des instructions "NOP".

Ces tâches requièrent  une représentation du flot de contrôle du programme.

7.3. Graphe de flot de controle

Le graphe de flot de contrôle d'une procédure est un graphe orienté (N,E,s) dont les noeuds sont étiquetés, et les arcs dirigés. A chaque instruction simple p de la procédure correspond un noeud np, étiqueté par cette instruction. Aux instructions composées correspondent des sous-graphes suivants :

cfg(while B do S) =  0 <B>-> 2
                                        ^    | 1
                                         |   v
                                          cfg(S)

cfg(if B then S1 else S2) =  -1<B>2-
                                                   |                 |
                                               cfg(S1)   cfg(S2)
                                                   |_______|
                                                          |
                                                         v
cfg(S1;S2) = cfg(S1) -> cfg(S2)

Les arcs qui correspondent aux branchements explicites conduisent du noeud du branchement au noeud de la cible. s est l'unique noeud d'entrée du graphe de contrôle de la procédure, il correspond à la première instruction exécutée. Les noeuds possédant plus d'un prédécesseur sont appelés des jonctions, et ceux qui possèdent plusieurs successeurs sont des embranchements. Le graphe de contrôle est en général connexe (sinon, cela veut dire qu'il y a du code mort).

Exemple : construire le cfg du programme :

read a;
read b;
while a <> b do
   if a < b
   then t:=a; a:=b; b:=t
  else a:=a-b

Les séquences d'instructions simples sont en général rassemblées afin d'obtenir des blocs de base, contenant au plus une jonction au début et un embranchement à la fin. On obtient alors le graphe des blocs de base.
 

7.4. Génération de code avec allocation de registres


On va générer du code pour les arbres d'expressions, intégrant l'allocation des registres et le choix des instructions pour un modèle simple de machine. Il faut tenir compte du besoin en registres, et produire les échanges nécessaires avec la mémoire lorsque le besoin en registres dépasse le nombre de registres disponibles.

Considérons par exemple l'arbre correposndant à l'affectation f := (a+b)-(c-(d+e)) : :=(f,-(+(a,b),-(c,+(d,e))))

Prenons une machine très simple dans laquelle il y a correspondance entre les opérations dans l'arbre et les instructions. La machine cible dispose de  registres généraux. Les instructions sont à deux adresses, avec le format suivant :

Ri := M[V]
M[V] := Ri
Ri := Ri op M[V]
Ri := Ri op Rj

Supposons que notre machine ait 2 registres.
Un code généré possible est :

R0 := M[a]; R0 := R0 + M[b]; R1 := M[d]; R1 := R1 + M[e]; M[t1] := R1; R1 := M[c]; R1 := R1 - M[t1]; R0 := R0 - R1; M[f] := R0

Un échange avec la mémoire est utilisé car aucun registre n'est disponible pour la soustraction c-(d+e).  On aurait pu par contre générer le code suivant :

R0 := M[c]; R1 := M[d]; R1 := R1 + M[e]; R0 := R0 - R1; R1 := M[a]; R1 := R1 - R0; M[f] := R1

Cette séquence ne nécessite pas d'échange avec la mémoire, et économise 2 instructions. La raison en est que le sous-arbre pour c-(d+e), dont l'évaluation nécessite deux registres, est évalué avant le sous-arbre de a+b, qui ne demande qu'un seul registre.

C'est le principe de l'algorithme. Soit t un arbre correspondant à une expression e1 op e2; soient t1 et t2 les sous-arbres pour e1 et e2. t1 utilise pour son évaluation r1 registres, et t2 r2 registres. Soit r le nombre de registres disponibles et r>=r1>r2. Après l'évaluation de t1, tous les registres qui ont servis sont libres, à l'exception du registre qui contient le résultat, et il y a un nombre suffisant de registres pour évaluer t2. Par conséquent, l'arbre entier peut être évalué en utilisant r1 registres. Si t1 et t2 requièrent le même nombre r1 de registres, il faut au total r1+1 registres pour évaluer t, si l'on maintient le résultat de l'un des sous-arbres dans un registre pendant que l'autre sous-arbre est évalué. Lorsuqe l'évaluation d'un arbre demande plus de r registres, il faut ranger en mémoire un ou plusieurs résultats intermédiaires. L'algorithme se déroule en 2 phases : une phase de marquage, pour noter les besoins en registres de sous-arbres, et une pahse de génération, où est produit le code, avec éventuellement des écritures en mémoire de résultats intermédiaires, sur la base des besoins en registres que l'on vient de calculer.

Marquage : on oublie pour l'instant quel 'on a qu'un nombre borné de registres.  On utilise une visite ascendante. Les feuilles de gauche sont marquées par 1, puisqu'une telle feuille devra être envoyée dans un registre. Par contre, les feuilles de droite sont utilisées comme opérandes, et ont donc une consommation en registres égale à 0. Le raisonnement précédent donne les beoins en registres des noeuds intérieurs :

regnb(op(t1,t2)) = max(r1,r2) si r1<>r2, r1+1 sinon. Avec r1=regnb(t1), r2=regnb(t2)

Le marquage obtenu sur notre exemple est : := 2 (f,- 2 (+ 1 (a 1 ,b 0 ),- 2 (c,+ 1 (d 1 ,e 0 ))))

Génération : produit un code de taille optimale, à l'aide d'une visite de l'arbre. On produit l'instruction OP pour l'opération op à la racine de l'arbre op(t1,t2) après avoir produit le code pour t1 et t2. L'ordre dans lequel on évalue les deux sous-arbres t1 et t2 est déterminé par leurs besoins en registres. Les résultats intermédiaires des sous-arbres avec des besoins en mémoire trop élevés sont rangés en mémoire pour être relus ultérieurement. Les registres disponibles sont gérés dans une pile RSTACK, initialisée avec l'ensemble de tous les registres disponibles. Au début du traitement d'un sous-arbre t, le registre en sommet de pile est le registre du résultat de t. Après traitement d'un sous-arbre, tous les registres de la pile sont libérés, sauf le registre en sommet de pile. Une deuxième pile TSTACK permet de gérer des adresses des résultats intermédiaires rangés en mémoire. POur les deux piles, on dispose des opérations push, pop, top et exchange; exchange permute les deux éléments les plus haut dans la pile.

Algorithme :

var R: registre, T: adresse;
var RSTACK: pile de registre;
var TSTACK: pile de adresse;
proc gen_code (t:arbre);
 cas t :
    (feuille a,1) : emit(top(RSTACK) := a);
    op((t1,r1),(feuille a,0)) : gen_code(t1); emit(top(RSTACK) := top(RSTACK) OP a);
    op((t1,r1,(t2,r2)) : cas
         r1 < min(r2,r) : exchange(RSTACK); gen_code(t2); R := pop(RSTACK); gen_code(t1); emit(top(RSTACK) := top(RSTACK) OP R); push(RSTACK,R);
                                      exchange(RSTACK)
         r1 >= r2 ^ r2<r : gen_code(t1); R := pop(RSTACK); gen_code(t2); emit(R := R OP top(RSTACK)); push(RSTACK,R)
         r1>=r ^ r2>=r : gen_code(t2); T := pop(TSTACK); emit(M[t] := top(RSTACK)); gen_code(t1); emit(top(RSTACK) := top(RSTACK) OP M[T]); push(TSTACK,T)
 

7.5. Ordonnancement des instructions

La séquence d'insturcitons d'un bloc de base est ordonnée linéairement. En respectant certaines conditions, on peut changer cet ordre, sans que l'effet final de la séquence soit pour autant modifié. Ces conditions sont données par les dépendances entre les instructions. Les dépendances consistent en accès successifs (en lecture ou en écriture) à des composantes de l'état de la machine. Les modifications sont appelées définitions, et les accès en lecture des utilisations. Notons X:= une définition et :=X une utilisation de X.

Exemple :

a : X :=
b: X :=
c:     := X
d: X :=

Un échange des instructions a et b changerait la valeur fournie à l'utilisation dans c, de même que l'échange de b et c, ou c et d.

Il existe plusieurs formes de dépendances :

Comment calculer de telles dépendances ? Avec les registres, il n'y a pas de problèmes, car ils sont nommés par un numéro absolu. Au contraire, les cellules mémoire sont susceptibles d'être adressées à l'aide de valuers dynamliques (c'est ce que l'on appelle un problème d'alias).

Le graphe de dépendance d'un bloc de base est un graphe orienté, étiqueté et acyclique. Les noeuds sont étiquetés par les instructions du bloc. Un arc joint le noeud a au noeud b si a doit être exécuté avant b, c'est à dire si a se trouve avant b dans la séquence et si l'une des conditions suivantes est satisfaite :

Le graphe de dépendance est construit par un algorithme qui parcourt le bloc en partant de la fin. Sa complexité est quadratique.

Exemple :

1: D1 := M[A1+4];
2: D2 := M[A1+6];
3: A1 := A1+2
4: D1 := D1+A1
5: M[A1] := A1;
6: D2 := D2+1;
7: D3 := M[A1+12];
8: D3 := D3+D1;
9: M[A1+6] := D3

Graphe :
1 -> 4,3,5
2 -> 3,5,6
3 -> 4,7,5,9
4 -> 8
5 -> 7
7 -> 8,9
8 -> 9

Le graphe de dépendance représente les différents degrés de liberté laissé pour ré-ordonner les instructions : tout tri topologique du graphe doit donner un ordre admissible. L'écriture dans une cellule suivie d'une lecture donne une dépendance, même si on lit une autre cellule. Si une analyse d'alias permet de déterminer que dans toute exécution, les adresses effectives de ces cellules seront différentes, l'arc de dépendance disparaît (2,5), (1,5) et (5,7) peuvent disparaitre de cette façon.

Pour générer du code en pipeline, nous considérons maintenant pour simplifier que si une instruction b dépend d'une instructiona, b peut être exécutée au plus tôt après un retard d'un cyclen soit 2 cycles après a. Le problème de trouver le code pipeliné le plus court est NP-complet: on va utiliser une heuristique. L'heuristique consiste à effectuer un tri topologique en guidant les choix. On rappelle que le tri topologique consiste à partir des minimaux du graphe, puis à choisir parmi les successeurs qui n'ont pas été encore choisis. On retire le successeur choisi et on recommence. Lors du choix, l'heuristique ne va considérer que les canidats qui ne présentent aucune collision avec les instructions déjà ordonnées, et si il n'en existe pas, on insère une instruction NOP. Si il y a plusieurs candidats sans collision, on utilise les critères suivants dans l'ordre :

Pour notre exemple, on obtient :
 

2: D2 := M[A1+6];
1: D1 := M[A1+4];
6: D2 := D2+1;
3: A1 := A1+2;
    NOP
7: D3 := M[A1+12];
4: D1 := D1+A1
8: D3 := D3+D1;
5: M[A1] := A1;
9: M[A1+6] := D3
 

La réalité est malheureusement plus compliquée car il faut tenir compte des délais minimums imposés entre les instructions.

Le dernier problème est celui des mots d'instructions longs. Si l'on suppose n unités fonctionnelles travaillant en parallèle, le problème revient à réordonner une séquence d'instructions en n nouvelles séquences.  Le parallélisme potentiel à l'intérieur des blocs de base est en général restreint. Il devient alors nécessaire de prendre en compte des ordonnacnements qui traversent les frontières des blocs de base.  La technique consiste à diviser le graphe de flot de contrôle d'un eprocédure en sous-chemins disjoints. Après une évaluation de la fréquence d'utilisation des chemins (mesures préalables ou estimations), on refabrique des chemins plus longs en concaténant des sous-chemins de fréquence proche. On en traverse jamais une boucle. Le sous-chemin obtenu est alors réordonné, ce qui peut amener à déplacer des instructions des blocs de base au delà des points de fusion ou d'embranchement. Il faut alors insérer du code de compensation dans les blocs de bas qui débouchent dans ce chemin, ou qui en partent.

Exemples de déplacement de code :

x := ; if e then S1 else S2 -> if e then x := ; S1 else X := ; S2  correct dans le cas où il n'y a pas de dépendance entre X := et le if.

Peut être raffiné si X n'apparaît pas dans S1 ou S2. Dans ce cas, on peut considérer la transformation inverse qui remonte X := devant le test.

S1; X := ; S3  ->  S1; X := ; S3
      ^                                          ^
      S2                             S2; X :=

On peut concevoir des transformations plus complexes pour déplacer un branchement ou faire des échanges de branchements.  C'est toujours une recherche très active de trouver des transformations préservant la sémantique et calculables à la compilation.
 


Les points à retenir :