Previous Up Next

Chapitre 1  L’environnement des compilateurs

1.1  Qu’est-ce exactement qu’un compilateur ?

La présentation introductive des compilateurs est extrêmement simplifiée. La chaîne qui va du programme source à l’exécutable comprend un certain nombre de tâches qui regardent plus le système d’exploitation de la machine (le format exact des fichiers exécutables par exemple) que le processus de traduction du langage source vers des instructions machines. La compilation proprement dite est ce dernier processus, les autres tâches sont déléguées à des programmes spécialisés. Cette section n’entend pas décrire en détail les techniques mises en œuvre par ces outils, mais vous permettre d’en comprendre les principes.

1.1.1  Assembleur

Il est bien plus simple de définir une suite d’instructions de la machine par des symboles (par exemple de représenter une addition par « add » que par un code entier quelconque). Dans la pratique les programmes en langage machine se présentent sous la forme de fichiers de texte obéissant à des conventions simples. Ces fichiers sont donc écrits dans un langage particulier dit assembleur, qui sera lui même traduit en suite d’entiers par un programme particulier dit aussi assembleur. Cette transformation n’offre pas d’intérêt particulier, seulement des difficultés techniques qu’il est opportun de laisser régler par le fabricant de la machine ou le concepteur du système d’exploitation.

Soit, en première approximation, le compilateur transforme le langage source en assembleur, expression humainement compréhensible des instructions de la machine. Sous Unix, on peut produire un fichier assembleur en donnant une option au compilateur, généralement « -S ». Considérons le fichier suivant bonjour.c :

#include <stdio.h> int main (int argc, char **argv) { printf("bonjour\n") ; return 0 ; }

Sur un PC quelconque, on le compile ainsi :

# cc -S bonjour.c

Et on obtient le fichier d’assembleur suivant (quelques détails sont omis !) :

.LC0:
        .string "bonjour\n"
.text
        .align 4
.globl main
        .type    main,@function
main:
        subl    $24, %esp
        pushl   $.LC0
        call    printf
        addl    $28, %esp
        xorl    %eax, %eax
        ret

Chaque ligne de ce fichier s’interprète ainsi :

1.1.2  Édition de liens

Jusqu’ici j’ai prétendu que le travail de l’assembleur est de transformer le source d’assemblage en exécutable. Mais en fait j’ai menti. Il est impossible de procéder aussi directement : dans bonjour.s on peut remarquer l’instruction call printf, qui réalise l’appel de la fonction printf. L’instruction call prend en argument une adresse qui est celle du début du code de la fonction appelée ; logiquement cette adresse est présente dans le code assembleur sous forme d’un symbole.

Or la fonction printf ne fait pas partie de notre programme, son code est ailleurs. De fait cette fonction fait partie de la librairie standard de C et elle est présente sous forme compilée quelque part. L’assembleur utilisé seul ne peut traduire le symbole printf en une adresse. Son produit sera bien une suite d’entiers représentant des instructions, supplémentée par des indications sur les symboles utilisés (et non résolus), ainsi que sur les symboles définis (par exemple ici main). On appelle ce produit le code objet et les fichiers qui le contiennent portent généralement l’extension « .o ».

C’est un autre programme, dit éditeur de liens qui prend tous les fichiers objets et fabrique l’exécutable. L’éditeur de lien s’occupe essentiellement de mettre tous les fichiers objets les uns derrière les autres et de résoudre les références symboliques entre ces fichiers. En Unix, l’éditeur de liens est normalement le programme ld.

1.1.3  Chargement dynamique

Une fois encore j’ai menti par souci de simplicité.

Il y a encore deux éléments à considérer. Tout d’abord, lorsque l’on compile (sans les lier) de nombreux fichiers, on obtient logiquement de nombreux fichiers objets (les « .o »). C’est en particulier le cas pour la librairie standard. Il n’est pas très pratique de manipuler tous ces fichiers, on les regroupe donc dans une bibliothèque (en anglais library), que l’on appelle parfois aussi archive. En Unix c’est la commande ar qui fabrique ces bibliothèques, leur suffixe est traditionnellement « .a » et leur nom commence généralement par « lib ». Ainsi, la libraire standard de C (dite aussi libc) est généralement un fichier libc.a, qui contient le code de toutes les fonctions de la librairie standard.

Mon second mensonge est plus grave, la description de l’édition de liens de la section précédente présente l’édition de liens traditionnelle, dite également statique. Cette technique présente l’inconvénient, lorsque l’on utilise une librairie (et on en utilise forcément) de copier le code des fonctions de librairie dans l’exécutable. Dès lors, tous les programmes C qui utilisent printf contiennent une copie du code de printf, une copie du code de toutes les fonctions appelées par printf, etc. Par conséquent les fichiers exécutables deviennent systématiquement assez gros.

Une technique plus maligne dite chargement dynamique, consiste à reporter le chargement en mémoire du code des bibliothèques lors de l’exécution du programme. En gros cela revient, à la compilation, à remplacer l’édition de liens statique par l’ajout de code et d’informations suffisants pour aller chercher les symboles non encore résolus. L’inconvénient majeur de cette technique est que la compilation et l’exécution doivent de dérouler dans des environnements offrant les mêmes bibliothèques, enfin pour le moins des bibliothèques compatibles. Cela complique notablement la distribution de code exécutable et s’applique a fortiori à l’exécution des applets : l’environnement hôte doit fournir une machine virtuelle et des libraires compatibles avec celles de l’environnement de compilation.

1.1.4  En résumé

Le simple appel « cc bonjour.c » invoque en fait plusieurs programmes dont un seulement est le compilateur proprement dit. On peut voir ce qui se passe en donnant l’option « -v » à cc. En simplifiant un peu, on a :

# cc -v bonjour.c
Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.96/specs
gcc version 2.96 20000731 (Red Hat Linux 7.1 2.96-98)
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/cpp0 ... bonjour.c /tmp/cc9oRNcK.i
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/cc1 /tmp/cc9oRNcK.i -o /tmp/cck4wLml.s
 as -V -Qy -o /tmp/ccNUF1EX.o /tmp/cck4wLml.s
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/collect2 ...

On distingue les appels à l’assembleur (as) et à l’éditeur de liens (collect2). Le compilateur proprement dit est cc1.

On notera au passage que java pousse très loin l’idée du chargement dynamique, la tentative de chargement d’un code objet (.class en Java) plus vieux que son source (d’extension .java) allant jusqu’à provoquer une recompilation. Enfin, Caml, le langage de ce cours, suit plus ou moins le principe simple de l’édition de liens statique. Les fichiers objets portant cette fois l’extension .cmo.

Au passage encore, le fait que tant javac que ocamlc produisent du bytecode et non pas du code natif ne change pas grand chose à l’affaire.

1.2  La chaîne de compilation

Nous avons donc défini un compilateur au sens de ce cours comme un traducteur d’un langage source (de programmation) vers l’assembleur. On décompose cette traduction en traductions plus élémentaires. Tout ceci est idéalement représenté par un schéma (cf. figure 1.1).


Figure 1.1: Les phases d’un compilateur

Dans ce dessin, les flèches correspondent à des transformations et les boites à des résultats. La colonne de gauche correspond à la partie avant du compilateur (front-end) et la colonne de droite à la partie arrière (back-end).

En première approximation ces deux parties sont indépendantes, le front-end dépendant du langage de programmation et le back-end de la machine ciblée.

Dans la pratique de ce cours, la majeure partie du front-end va dépendre du langage source, car il implémente réellement la sémantique du langage compilé. Tandis que la plus grande partie du back-end peut sera réalisée dans un style générique, les sous-parties clairement dépendantes de la machine cible étant bien isolées.

1.3  Gestion des compilation et recompilations

Pour des raisons diverses, on en vient rapidement à répartir le source d’un programme en plusieurs fichiers. Une des plus convaincantes de ces raisons découle du principe d’abstraction. On construit un programme compliqué par l’assemblage de briques individuellement simples. C’est précisément ce que nous allons faire dans ce cours en écrivant un compilateur, dont les briques de bases (ou phases) seront reparties dans divers fichiers source. C’est pourquoi je prendrai l’exemple d’un gros programme écrit en Caml, langage d’implémentation de notre cours.

Une brique s’appelle aussi une unité de compilation, ou parfois un module, ce qui est légèrement impropre. Le principe d’abstraction conduit à séparer une brique B de programme en deux : d’une part un fichier d’implémentation (extension .ml) et d’autre part un fichier d’interface (extension .mli) qui contient les informations suffisantes pour compiler d’autres briques du programme qui utilisent B. Ces informations sont essentiellement les noms des fonctions de B utilisables de l’extérieur (dites fonctions exportées), leurs types, et des commentaires qui disent comment les utiliser. Pour des raisons d’efficacité les fichiers .mli sont compilés en des fichiers objets bien particuliers qui portent l’extension .cmi. Il en résulte une première dépendance : la compilation du fichier nom.ml étant l’occasion de vérifier que le module Nom définit bien ce qu’il affirme exporter dans nom.mli, il importe de compiler l’interface avant l’implémentation.

La séparation des unités de compilation en implémentation et interface mène à la propriété de compilation séparée : pour compiler b.ml qui utilise des fonctions de a.ml il n’est pas nécessaire d’avoir compilé a.ml, la compilation préalable de a.mli suffit. Si l’on se place maintenant dans le cadre d’un développement d’un programme zyva, dont le source est réparti en trois unités de compilation, A, B et Zyva. Supposons en outre que Zyva utilise des fonctions de A et de B, tandis que B utilise des fonctions de A. Pour fabrique le programme zyva, on pourra donc enchaîner les commandes suivantes :

# ocamlc a.mli    produire a.cmi
# ocamlc -c a.ml    l’option -c évite l’édition de liens, production de a.cmo
# ocamlc b.mli    
# ocamlc -c b.ml    
# ocamlc -c zyva.ml    
# ocamlc -o zyva a.cmo b.cmo zyva.cmo    édition de liens

(On aurait pu tout aussi bien compiler a.mli et b.mli en premiers.)

Mieux, si nous modifions a.ml et seulement lui, il suffit pour recréer zyva de procéder ainsi :

# ocamlc -c a.ml    inclus la vérification de la nouvelle implémentation par rapport à l’interface
# ocamlc -o zyva a.cmo b.cmo zyva.cmo    édition de liens

Dans la pratique il hors de question de gérer compilation et recompilations soi-même, on dispose pour ce faire d’un outil : make.

Je présente maintenant quelques principes, qui devraient suffire pour comprendre les exemples donnés en TP. Dans le principe make est un outil simple : à l’aide d’une description des règles de production de fichiers et de leur dépendances, contenue dans un fichier de nom Makefile, l’outil make invoque les commandes nécessaires à la production d’un fichier passé en argument. Soit ici, si on a écrit le Makefile idoine, la commande « make zyva » reconstruira zyva pour nous. Pour ce faire il analyse le graphe des dépendances entre fichiers et invoque les commandes nécessaires (typiquement des appels au compilateur), il s’agit là d’un bête tri topologique que vous connaissez déjà. Dans le cadre de notre exemple, il nous faut donc à la fois définir les règles de production et les dépendances. Voici quelques explications sur ce qu’on peut mettre dans le Makefile.

On dispose d’un outil spécifique pour produire ces dépendances, ocamldep, qui prend en argument les noms des fichiers sources. Généralement, on met les dépendances dans un fichier nommé .depend, fichier qui est inclus dans le Makefile ainsi :

include .depend

Il est pratique d’appeler ocamldep à l’aide de make, on utilise donc une cible depend correspondant à la règle suivante :

depend:
        ocamldep *.mli *.ml > .depend

On construira (et reconstruira) les dépendances par « make depend ». Notons que l’on ne peut pas appeler la cible .depend, car alors cette cible ne dépendrait de rien et elle serait toujours à jour dès qu’elle existe.


Previous Up Next