Compilation séparée et make

Cette partie rappelle les motivations et les principes de la compilation séparée et de l'outil make. Vous connaissez sans doute déjà les notions qu'elle présente et vous pouvez probablement l'ignorer. Toutefois, n'hésitez pas à vous y référer si certains points du TP ne vous semblent pas clairs.

Programmer de facon modulaire

Idéalement, un programme qui utilise une bibliothèque de fonctions doit être indépendant de la facon dont est écrite cette bibliothèque. Autrement dit on doit pouvoir changer l'implémentation de la bibliothèque sans pour autant changer le code qui utilise cette bibliothèque. Prenons pour exemple une bibliothèque permettant de manipuler des nombres complexes. Nous pouvons penser à au moins deux representations possibles :

Un autre exemple interessant est celui des vecteurs ou des matrices que vous avez vu au précédent TP. De facon générale, il n'est pas envisageable que les programmes utilisant une bibliothèque d'objets (nombres complexes, vecteurs, matrices, ...) soient obligés de déclarer explicitement la structure de ces objets (structure, tableau, liste, ...). La solution est d'utiliser un nouveau type pour les objets complexes manipulés par la bibliotheque, on parle alors de type abstrait (car il abstrait les détails d'implémentation) ou de handle (dans certaines documentations en anglais). Dans le cas de nos complexes cela peut être :

typedef struct { double r,i; } complexe;

Il faut alors définir les fonctions qui vont avec, que nous pouvons regrouper en deux catégories :

Pour utiliser notre bibliothèque, on va alors se demander comment regrouper l'implémentation de cette bibliothèque (écrite dans un fichier indépendant, par exemple complexe.c) et un autre programme. La manière la plus simple de tout regrouper consiste à tout compiler ensemble :

gcc -Wall -Werror -o programme programme.c complexe.c
... nous allons voir qu'il nous faut pour cela des déclarations externes correspondant à nos fonctions et que nous pouvons compiler de manière un peu plus intelligente.

Retour sur la compilation d'un programme en C

Lorsque nous compilons un programme C avec gcc, la ligne de compilation habituelle est la suivante : gcc -Wall -Werror -o programme programme.c

Cette commande compile notre programme en enchaînant deux étapes principales :

A ce stade, nous pouvons classer les erreurs de compilation en deux catégories :

Decouper en plusieurs fichiers, prototypes, variables externes

La première étape de la compilation fonctionne du moment que le fichier fournit contient une suite syntaxiquement correcte de déclarations. Il est donc possible de produire un fichier .o contenant des déclaration de fonctions et variables sans que le fichier .c correspondant ne contienne de main.

La deuxième étape regroupe un ou plusieurs .o avec des fonctions de bibliothèques pour produire un exécutable. Dans cas, un des fichier doit contenir le main. On peut donc découper un programme en plusieurs fichiers regroupant ainsi les différentes parties par thème ou fonctionalité. Sur notre exemple des nombres complexes, cela pourrait donner :

Il reste tout de même un problème : dans les fichiers objet, pour que le compilateur puisse générer le code correspondant à l'appel d'une fonction contenue dans un fichier different, il faut savoir comment l'appeler. Le même problème se pose pour les variables globales. Pour résoudre ce problème, le langage C nous fournit les prototypes de fonctions et les déclarations externes.

Un prototype ressemble a une declaration de fonction dans laquelle on aurait supprime le corps, par exemple :

int additionne(int x, int y);
ou encore
extern int additionne(int x, int y);
dans le premier prototype, le extern est implicite : le compilateur considère par défaut les fonctions comme globales et un prototype n'est pas une déclaration. Un prototype permet de dire au compilateur que cette fonction existe, quel type elle retourne et quel type d'arguments elle prend. De la même manière le mot extern dans la déclaration d'une variable permet de dire au compilateur que cette variable est déclarée dans un autre fichier.

A titre d'exemple, un programme complet constitué de deux fichiers, correspondant à notre exemple sur les complexes, pourrait être :

Fichiers d'entête (ou encore headers, fichiers .h)

Afin d'eviter de redeclarer plusieurs fois les memes prototypes et les meme typesdef dans plusieurs fichiers sources, on regroupe generalement toutes les informations associee a un fichier .c dans un fichier .h (declarations de types, prototypes, etc.). Il suffit alors d'inclure le fichier .h dans toutes les sources utilisant les fonctions du .c associe. En reprenant l'exemple précédent, nous obtenons :

Ainsi tout programme ayant besoin des nombres complexes a juste besoin d'inclure complexe.h. C'est aussi comme ca que sont gérées les fonctions fournies par le systeme en standard (ex: malloc/free dans stdlib.h). En plus d'éviter de dupliquer du code dans toutes les sources ceci permet de changer simplement l'implémentation d'une partie du code en ne changeant que le .h et le .c concernés. De manière générale, on prend soin de mettre dans le .h le minimum d'inclusions d'autres .h necessaires à sa compilation (première étape). On mettra dans le .c associe toutes les autres inclusions dont cette partie du code a besoin.

Il reste juste un dernier problème : il ne faut pas inclure plusieurs fois le même contenu dans un code (typedef, prototypes, ...) sous peine d'obtenir une erreur (légitime) lors de la compilation. Or, après quelques inclusions en cascade il devient impossible de savoir quel fichier est inclus a quel endroit. C'est là que le préprocesseur vient nous aider.

Notions de base sur le préprocesseur

Lors de la compilation, la première chose effectuée par le compilateur est de passer le texte source du programme à travers un zinzin nommé préprocesseur. Le travail du préprocesseur consiste à éliminer les commentaires et tenir compte des directives qui lui sont addressées. Il est possible d'afficher un programme aprés le passage du préprocesseur avec la commande :

gcc -E programme.c

Les principales directives servent a :

Dependances

La question qui se pose maintenant est la suivante : quels fichiers sont-ils nécessaires à chaque ligne de compilation et quelles parties du processus de compilation faut-il refaire lorsqu'on modifies seulement l'un des fichiers ?

Il est possible d'étudier le cas de chaque fichier :

De manière générale, on dit qu'une cible (un objet que l'on souhaite créer) dépend du contenu d'un certain nombre de fichiers. Lorsqu'un ou plus des fichiers dont une cible dépend est modifié, il faut reconstruire la cible.

Pour connaître les dépendences relatives à un fichier source, il est possible :

make

Un outil merveilleux existe pour automatiser le processus de compilation : make Le principe de make est simple : make connaît un certain nombre de cibles, les dépendences qui vont avec et le moyen de les construire. Il suffit alors de demander à make de construire une cible pour qu'il le fasse en ne reconstruisant que ce qui est nécessaire.

make va chercher ses informations dans un fichier appele Makefile par defaut. Ce fichier contient des règles sous la forme suivante (on remarque que la ligne de dépendances est au format fournit par gcc -MM) :

cible: fichiers dont elle dépend
<TAB> commandes a utiliser pour reconstruire la cible
A titre d'exemple, un Makefile correspondant à notre programme manipulant des complexes pourrait être :
programme: programme.o complexe.o
	gcc -o programme programme.o complexe.o

programme.o: programme.c complexe.h
	gcc -Wall -Werror -c programme.c

complexe.o: complexe.c complexe.h
	gcc -Wall -Werror -c complexe.c

Il suffit alors d'invoquer 'make programme' pour que la compilation du programme final 'programme' se fasse. On peut aussi se contenter de 'make' car si aucune cible n'est précisée, make reconstruit la première du Makefile. Si on relance plusieurs fois cette commande on obtient le message :

make: « complexe » est à jour.
ou bien la même chose en anglais : make a detecté qu'il n'y a pas besoin de recompiler complexe car aucun fichier source n'a changé.

Si maintenant nous modifions l'un des fichiers :

> touch complexe_io.h
> make
gcc -Wall -Werror -c main.c
gcc -Wall -Werror -c complexe_io.c
gcc -o complexe main.o complexe.o complexe_io.o
Seules les parties nécessaires de la compilation ont été refaites.