Make et Makefile : petit résume rapide


(Vos commentaires sont les bienvenus : mailto:jciehl@bat710.univ-lyon1.fr)

Make est outil très général permettant, entre autre, d'automatiser la compilation d'un projet.

    Supposons que le projet soit constitué des sources C suivants : main.c, structure.c, et operation.c . Il est possible de compiler ce projet de trois manières différentes, les parties suivantes précisent ces différentes étapes.

1. une seule commande

gcc -o prog -Wall main.c structure.c operation.c

(rappel des options de gcc : man gcc, info gcc, ou gnome help system sujet info:gcc)

2. une commande pour chaque fichier source et une autre pour créer l'éxécutable :

gcc -c -Wall main.c
gcc -c -Wall structure.c
gcc -c -Wall operation.c

    Les commandes précédentes compilent chaque fichier source et créent les fichiers objets correspondants qui s'appellent : main.o, structure.o et operation.o. Un fichier objet est le résultat de la compilation d'un fichier source et contient les instructions machines associées à chaque fonction du fichier source, ainsi que la liste des fonctions appelées qui ne se trouvent pas dans le fichier source (par exemple, les fonctions des librairies standards printf, scanf, malloc, etc. et les fonctions que vous avez écrites mais qui se trouvent dans les autres fichiers sources (voir l'utilisation du mot clé extern)).

gcc -o prog main.o structure.o operation.o

    Cette dernière commande crée l'éxécutable prog en assemblant (cette partie de la compilation s'appelle l'édition de liens) les ensembles d'instructions associés à chaque fichier objet. C'est à ce moment que le compilateur vérifie qu'il connait l'ensemble d'instructions associé à chaque fonction.

    Le cycle de travail sur le projet nécessitera généralement plusieurs corrections d'erreurs. Il faudra donc, après chaque correction, recompiler le fichier source modifé (après avoir enregistré les modifications ...). La compilation de ce fichier source produira un fichier objet et il faudra bien sur recréer l'éxécutable.

Prenons un exemple :
supposons que l'on ait modifié main.c, la commande suivante permet de le recompiler (et de créer une nouvelle version de main.o) :
gcc -c -Wall main.c

    il faut encore reconstruire l'éxécutable, afin de tenir compte des modifications apportées à main.c (et donc main.o). Par contre, les objets structure.o et operation.o n'ont pas été modifiés, il n'est donc pas nécessaire de les recompiler.
gcc -o prog main.o structure.o operation.o

    On peut maintenant vérifier le bon fonctionnement du programme.

    Un projet "moyen" comporte rapidement une dizaine de fichiers sources, et il devient vite pénible de taper toutes les commandes nécessaires à la compilation du projet. De plus, le risque d'erreur ou d'oubli augmente très rapidement, d'ou l'utilité, voire la nécessité, d'un outil permettant d'automatiser la construction du projet.

3. Make

    L'utilisation de make est relativement simple, une fois que l'on a compris que son rôle est de produire automatiquement la séquence de commandes permettant de construire un projet. Pour créer l'éxécutable, la première fois, il faut que make génère la séquence de commandes décrite dans la partie 2 :
gcc -c -Wall main.c
gcc -c -Wall structure.c
gcc -c -Wall operation.c
gcc -o prog main.o structure.o operation.o
    De la même manière, après la modification de main.c, make doit générer la séquence de commandes suivante :
gcc -c -Wall main.c
gcc -o prog main.o structure.o operation.o
    Le raisonnement qui nous a permis d'écrire cette séquence de commande est basé sur les dépendances (ou les relations) entre ces fichiers :
main.c produit main.o
et
main.o, structure.o et operation.o permettent de construire le projet prog.
    Il suffit de décrire ces relations à Make, ainsi que les commandes associées pour qu'il puisse produire la séquence de commandes correcte. Ces relations sont décrites dans un fichier texte, nommé Makefile ou makefile. Ce fichier est constistué des descriptions des relations entre les fichiers sources et les fichiers objets ainsi que des relations entre les fichiers objets et le projet.

    Une relation s'écrit de la manière suivante dans le fichier makefile :
produit : source
    commande
    Cette règle indique plusieurs choses à make. Premièrement, que produit est crée à partir de source et que c'est commande qui permet de le faire. On peut donc décrire la relation entre main.c et main.o, qui est produit par la compilation de main.c :
main.o : main.c
    gcc -c -Wall main.c
    De même, la relation entre les fichiers objets et le projet s'écrit :
prog: main.o structure.o operation.o
    gcc -o prog main.o structure.o operation.o
    Pour achever la création du makefile, il ne reste plus qu'à écrire les règles pour structure et operation :
structure.o : structure.c
    gcc -c -Wall structure.c

operation.o : operation.c
    gcc -c -Wall operation.c
    Un dernier détail, comme le makefile est composé de plusieurs relations, il faut indiquer à make laquelle construire en priorité : par convention, c'est tout simplement la première. Il suffit donc d'écrire la règle décrivant la construction du projet au début du fichier makefile. Le makefile complet ressemblera donc à :
prog: main.o structure.o operation.o
    gcc -o prog main.o structure.o operation.o

main.o : main.c
    gcc -c -Wall main.c

structure.o : structure.c
    gcc -c -Wall structure.c

operation.o : operation.c
    gcc -c -Wall operation.c
    Lors d'une modification, make se base sur la date de modification des fichiers pour déterminer les mises à jours à effectuer. Dans le scénario précédent, le projet complet est compilé et éxécuté. Lors de ce premier appel à make, ni les fichiers objets, ni l'éxécutable n'existent, ils sont donc créés en utilisant la commande associée à leur règle. Ensuite, main.c est modifié, main.c devient donc plus récent que main.o et prog. Make analyse les régles définies dans le makefile et vérifie les dates des fichiers. Comme main.c est plus récent que main.o, ce dernier ne peut être le produit de la version actuelle de main.c, il faut donc le recompiler, la commande associée à la règle main.o sera donc éxécutée (gcc -c -Wall main.c). Après la compilation, la date de main.o est plus récente que celle de prog, il faudra donc récréer prog avec la commande correspondante (gcc -o prog main.o structure.o operation.o).

3. Quelques idées d'utilisation

    Make ne pose aucune contrainte sur la compilation. Il ne sait pas qu'il est en train de compiler un projet, vous pouvez donc en profiter pour lui faire faire autre chose, comme effacer les fichiers temporaires, créer une archive de votre projet, ou définir plusieurs versions du même projet ou de plusieurs projets dans le même makefile.

    Les sections suivantes présentent quelques règles à ajouter au makefile. Pour indiquer à make que l'on veut éxécuter une règle particulière il suffit de lui indiquer : make regle, ce qui éxécutera les commandes associées à la règle regle.

    3.1 Effacer les fichiers temporaires

    Lors de modifications importantes d'un projet, on a souvent besoin de recompiler la totalité des sources, ou de nettoyer le répertoire de travail. Il est toujours possible de taper une commande rm dans un terminal, mais on peut aussi le faire faire à make. Comment écrire cette règle ? Quelle relation existe-t-il entre un fichier et un fichier effacé ? La réponse est simple : aucune. Mais on peut quand même écrire une règle qui éxécute toujours sa commande, il suffit de ne pas indiquer de source et de donner à la règle un nom qui n'est pas un fichier.

    Pour effacer les fichiers objets du projet, il suffit de d'écrire la règle suivante qui s'appelle clean par convention :
clean :
    rm -f prog *.o
    L'option -f (force) indique juste à rm de ne pas signaler les erreurs éventuelles (plus de détail sur les options de rm, man rm). Le *.o est un joker qui indique à rm d'effacer tous les fichiers se terminant par .o, c'est à dire les fichiers objets.

    make clean éxécutera donc la commande rm -f prog *.o associée à la règle clean.

    3.2 Archiver le projet

    Les règles sans sources (voir ci-dessus) permettent d'éxécuter une commande quelconque, on peut en profiter pour archiver le projet, par exemple. On peut compléter le makefile par la règle :
zip:
    tar -zcvf prog.tar.gz main.c structure.c operation.c Makefile
    La commande tar permet de créer une archive compressée, il suffit d'indiquer le nom de l'archive à créer et la liste des fichiers à archiver (plus de détails sur l'utilisation de tar, man tar).  Vous pouvez aussi envoyer l'archive par mail (man mail) ou la copier sur disquette.

    make zip éxécutera donc la commande tar -zcvf prog.tar.gz main.c structure.c operation.c Makefile associée à la règle zip.

    3.3 Plusieurs versions du projet

    Dans le TP3, il était demandé de fournir plusieurs versions du même programme. On peut créer plusieurs projets séparés, ou tout simplement décrire les différentes versions dans le même makefile. Le TP3 était composé des sources C suivants : blob.c tga.c tri_blob.c. Voici le fichier Makefile fourni avec le sujet :
blob: blob.o tga.o tri_blob.o
    gcc -o blob blob.o tga.o tri_blob.o -lm

blob.o: blob.c
    gcc -Wall -c blob.c

tga.o: tga.c
    gcc -Wall -c tga.c

tri_blob.o: tri_blob.c
    gcc -Wall -c tri_blob.c

tarball:
    tar -zcvf blob.tar.gz blob.c tga.c tri_blob.c blob.h tga.h Makefile

clean:
    rm *.o blob
    Ce Makefile crée un éxécutable nommé blob à partir de blob.o, tga.o et de tri_blob.o. Le travail demandé consistait principalement a écrire de nouvelles fonctions de tri. Supposons que le tri par insertion se trouve dans le source tri_insertion.c, et que le tri par tas se trouve dans tri_tas.c. On veut créer une version de blob, blob_insertion, compilée avec le tri par insertion et une autre compilée avec le tri par tas, blob_tas. Les deux nouvelles règles :
blob_insertion:	blob.o tga.o tri_insertion.o
gcc -o blob_insertion blob.o tga.o tri_insertion.o -lm

tri_insertion.o: tri_insertion.c
gcc -Wall -c tri_insertion.c
permettent de créer l'éxécutable blob_insertion qui est bien blob compilé avec la fonction de tri par insertion. De même, il suffit de rajouter les règles suivantes pour la version tri par tas :
blob_tas:	blob.o tga.o tri_tas.o
gcc -o blob_tas blob.o tga.o tri_tas.o -lm

tri_tas.o: tri_tas.c
gcc -Wall -c tri_tas.c
    Au final, le Makefile ressemblera à :
blob:	blob.o tga.o tri_blob.o
gcc -o blob blob.o tga.o tri_blob.o -lm

blob_insertion: blob.o tga.o tri_insertion.o
gcc -o blob_insertion blob.o tga.o tri_insertion.o -lm

blob_tas: blob.o tga.o tri_tas.o
gcc -o blob_tas blob.o tga.o tri_tas.o -lm


tri_tas.o: tri_tas.c
gcc -Wall -c tri_tas.c

tri_insertion.o: tri_insertion.c
gcc -Wall -c tri_insertion.c

blob.o: blob.c
gcc -Wall -c blob.c

tga.o: tga.c
gcc -Wall -c tga.c

tri_blob.o: tri_blob.c
gcc -Wall -c tri_blob.c

tarball:
tar -zcvf blob.tar.gz blob.c tga.c tri_blob.c blob.h tga.h Makefile

clean:
rm *.o blob
    Les trois premières règles (blob, blob_insertion, blob_tas) correspondent aux trois versions du projet et les règles suivantes décrivent la compilation des différents sources des projets.

4. Makefile avancé

    La rédaction d'un makefile est relativement délicate, il est courant d'oublier de rajouter un objet dans les sources d'une règle ou dans la liste des objets à compiler pour créer l'éxécutable. De même, l'écriture des règles de compilation est particulièrement longue. Heureusement make dispose de fonctionnalités supplémentaires permettant d'automatiser une partie de la rédaction. Les deux sections suivantes présentent l'utilisation des variables afin de simplifier l'écriture des makefile.

    4.1 variables

    Des variables existent dans make, ce sont simplement des chaînes de caractères. On peut par exemple définir une variable indiquant quel compilateur utiliser, quelles options de compilation utiliser ou encore une liste de fichiers.
    Pour définir une variable, il suffit de lui donner un nom et une valeur. Un nom de variable est une suite de caractères ne contenant pas `:', `#' et `='. Une valeur est une chaîne de caractères quelconque. Pour substituer une variable à sa valeur, il suffit d'écrire $nomvariable. Quelques exemples :
CC= gcc
blob:	blob.o tga.o tri_blob.o
$(CC) -o blob blob.o tga.o tri_blob.o -lm
...
ou
CC= gcc
objets= blob.o tga.o tri_blob.o
blob:	$(objets)
$(CC) -o blob $(objets) -lm
...
    Les parenthèses autour des noms de variables ne sont pas obligatoires mais permettent d'éviter certains problèmes lors de la substitution de la variable par sa valeur (c'est un simple remplacement de caractères).

    4.2 variables automatiques et règles implicites

    Il existe un autre type de variables : les variables automatiques, leurs noms sont imposés et make défini leurs valeurs. Voici les plus utilisées :
$@ : produit (ou but) de la règle
$< : nom de la première dépendance (ou source)
$? : toutes les dépendances plus récentes que le but
$^ : toutes les dépendances
$+ : idem mais chaque dépendance apparait autant de fois qu'elle est cité et l'ordre d'apparition est conservé.

    La variable $@ représente le produit (ou le but) d'une règle, en reprenant l'exemple précédent :
CC= gcc
objets= blob.o tga.o tri_blob.o
blob:	$(objets)
$(CC) -o $@ $(objets) -lm
    La variable $^ représente toutes les dépendances d'une règle, par exemple tous les objets lors de la création du projet :
CC= gcc
objets= blob.o tga.o tri_blob.o
blob:	$(objets)
$(CC) -o $@ $^ -lm
    La variable $< représente la première dépendance, on peut l'utiliser pour compiler un source C :
CC= gcc -Wall
blob.o: blob.c
$(CC) -o $@ -c $<
    L'utilisation des variables automatiques est particulièrement puissante lors de la définition de règles implictes qui s'appliquent à plusieurs fichiers. Par exemple, il possible, avec une seule règle implicite, de compiler tous les sources C :
%.o: %.c
     $(CC) -o $@ -c $<
    Le but est défini par un pattern, ou un joker :  %.o . Cette règle indique que chaque fichier .o nécessaire à la création du projet est obtenu à partir du fichier .c correspondant et que la commande $(CC) -o $@ -c $< permet de créer le fichier objet à partir du fichier source. Les variables automatiques permettent de retrouver le nom du fichier concerné par la règle.

En résumé, il est donc possible de compiler un projet quelconque avec un Makefile de quelques lignes, par exemple blob :
CC= gcc        # compilateur
CFLAGS= -Wall  # options de compilation pour les sources C

objets= blob.o tga.o tri_blob.o
blob: $(objets)
    $(CC) -o $@ $^ -lm

%.o: %.c
    $(CC) $(CFLAGS) -o $@ -c $<

    4.3 Génération automatique des noms de fichiers

    Il est possible d'utiliser des substitutions dans les noms de fichiers, par exemple pour générer automatiquement les noms des fichiers objets à partir des noms des fichiers sources, en remplaçant l'extension .c par .o :

SRC= main.c struct.c operation.c

# nommage automatique des fichiers objets d'apres les noms des sources C
OBJ= $(SRC:.c=.o)

dans cet exemple $(OBJ) contiendra main.o struct.o operation.o

exemple du makefile précedent avec nommage automatique des objets a partir des fichiers sources .c :

CC= gcc        # compilateur
CFLAGS= -Wall  # options de compilation pour les sources C

sources= blob.c tga.c tri_blob.c
objets= $(sources:.c=.o)

blob: $(objets)
    $(CC) -o $@ $^ -lm

%.o: %.c
    $(CC) $(CFLAGS) -o $@ -c $<


5. Génération automatique des dépendences

    Lorsque un projet prends un petit peu d'importance, de nombreux headers (.h) sont utilisés. Lorsque l'on modifie un header, il est nécessaire de recompiler tous les sources qui l'utilisent. Il est évidement possible d'ajouter les fichiers .h comme dépendance des sources, mais c'est particulièrement fastidieux. Le compilateur est le mieux placé pour savoir quels sont les headers utilisés par chaque source et il est même possible de récupérer ces dépendances au format des régles de make. Il suffit de stocker cette nouvelle règle dans un fichier (.d par convention) et de l'inclure dans le makefile. Pour vous convaincre, essayer sur un de vos sources :

    gcc -o source.d  -MM source.c

    cat source.d



    regardez les options -MM dans le manuel de gcc

    (la suite, bientôt ...)

    exemple de makefile complet :

    3 projets différents sont décrits,
    archivage de l'ensemble,
    numérotation automatique du projet,
    génération automatique de changelog
    ...


# correction TP5 systemes d'exploitation 2003 : partage de fichiers
CFLAGS= -g -Wall -D_REENTRANT

LDFLAGS=

LIB= -lpthread

CC= gcc $(CFLAGS)
LD= gcc

# serveur d'annonce
ASRC=annonce.c \
    annonce_sha1_f.c \
    mtfifo.c \
    critique.c \
    socket.c \
    fichier.c \
    util.c \
    debit.c \
    version.c \
    sha1.c \
    sha1_f.c

# nommage automatique des fichiers objets d'apres les noms des sources C
AOBJ= $(ASRC:.c=.o)

# nommage automatique des fichiers de dependance d'apres les noms des sources C
ADEP= $(ASRC:.c=.d)

# serveur de fichiers
FSRC=serveur_fichier.c \
    serveur_fichier_sha1.c \
    serveur_fichier_sha1_f.c \
    mtfifo.c \
    critique.c \
    socket.c \
    fichier.c \
    util.c \
    debit.c \
    version.c \
    sha1.c \
    sha1_f.c
   
# nommage automatique des fichiers objets d'apres les noms des sources C
FOBJ= $(FSRC:.c=.o)

# nommage automatique des fichiers de dependance d'apres les noms des sources C
FDEP= $(FSRC:.c=.d)

#client
CSRC=client_sha1.c \
    client_sha1_f.c \
    mtfifo.c \
    critique.c \
    socket.c \
    fichier.c \
    util.c \
    debit.c \
    version.c \
    sha1.c \
    sha1_f.c

# nommage automatique des fichiers objets d'apres les noms des sources C
COBJ= $(CSRC:.c=.o)

# nommage automatique des fichiers de dependance d'apres les noms des sources C
CDEP= $(CSRC:.c=.d)

# ensemble des fichiers de dependance
DEP= $(ADEP) $(FDEP) $(CDEP)


# numero de version auto
include build

#
BIN=annonce  \
    serveur_fichier \
    client

#
VER=annonce.version \
    serveur_fichier.version \
    client.version
   
   
.PHONY: all
all: $(BIN) $(VER) changelog

# numerotation des compilations completes
build:
    echo BUILD= 1 > build

rebuild: build
    @echo BUILD= `expr $(BUILD) + 1` > build

# genere un fichier C avec une chaine de caracteres decrivant la version du projet
version.c: build
    @echo "char version_id[]= \"build $(BUILD)\";"  > $@

# conserve les commentaires sur les versions du projet
changelog: build
    @echo -- changelog build $(BUILD)
    @echo -e --\\n$(USER)@`hostname -s` --  build $(BUILD)  $(HOSTTYPE) --  `date` > $@.tmp
    @if test -f $@; then cat $@ >> $@.tmp; fi
    @mv $@.tmp $@


annonce.version: annonce
    @echo $< --  $(USER)  build $(BUILD)  --  `date` > $@

annonce: $(AOBJ)
    @echo -- build $(BUILD)
    $(LD) $(LDFLAGS) -o $@ $+ $(LIB)

#
serveur_fichier.version: serveur_fichier
    @echo $< --  $(USER)  build $(BUILD)  --  `date` > $@

serveur_fichier: $(FOBJ)
    @echo -- build $(BUILD)
    $(LD) $(LDFLAGS) -o $@ $+ $(LIB)

#
client.version: client
    @echo $< --  $(USER)  build $(BUILD)  --  `date` > $@

client: $(COBJ)
    @echo -- build $(BUILD)
    $(LD) $(LDFLAGS) -o $@ $+ $(LIB)


#
%.o: %.c
    $(CC) -o $@ -c $<

%.d: %.c
    $(CC) -MM -MD -o $@ $<


.PHONY: clean
clean: rebuild
    rm -f $(BIN) $(OBJ)
$(DEP) *.i *.s

.PHONY: tarball
tarball:
    @echo -- build $(BUILD)
    -tar -zcf annonce_b$(BUILD).tar.gz Makefile build changelog *.[ch]

# inclusion des dependances
-include $(DEP)


6. Conclusion

    Il existe de nombreuses fonctionnalités, lisez le manuel : info make