Noah Loomans

Tutoriel pour Makefile 2.0 2021-06-16

Le nouveau tutoriel pour Makefile, relu et amélioré, pour débutants comme pour néophytes. Il couvrira le pattern matching, la compilation depuis et vers différents répertoire, les dépendences de header et les variables.

Traductions : English | français | Português (Brasil)
Traduction par : Tristan Blaudez

Prérequis :

  • Maîtrise basique des shells POSIX et leurs variables d’environnement.
  • Connaissance des arguments de GCC, notamment -c, -o, et -W.
  • Bases en C : connaître la différence entre un exécutable, un fichier objet et un fichier header

Les bases

Il est vivement recommandé de reproduire les étapes de ce tutoriel par vous-même. Commencez par cloner ce dépôt git :

git clone https://noahloomans.com/tutorials/makefile

Ce dépôt contient le Makefile suivant :

foo: main.o greeter.o
	gcc -o foo main.o greeter.o

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

greeter.o: greeter.c
	gcc -c -Wall -Wextra -Werror -o greeter.o greeter.c

Ce Makefile contient 3 règles. Une règle consiste d’une cible, d’un montant arbitraire de prérequis et d’une recette.
La syntaxe est la suivante :

cible: prérequis
	recette

Une règle décrit comment make doit créer quelque chose. La première règle du Makefile, qui a pour cible foo, décrit comment nous pouvons linker notre exécutable. Les deuxième et troisième règles décrivent comment compiler les fichiers objets nécéssaires à la création de l’exécutable.

Quand vous écrivez make foo, make cherche une règle qui pourra compiler foo, dans notre cas, il s’agit de la première règle. Avant d’exécuter la recette, make vérifiera d’abord si cette dernière a réellement besoin d’être exécutée et si d’autres règles ne doivent pas d’abord être exécutée. En premier lieu, make commence par vérifier si chaque prérequis existe et est à jour.

À la première exécution, le prérequis main.o n’existera pas. Make cherchera alors une règle qui possède une cible compatible, dans notre cas, il s’agit de la règle main.o. Pour cette règle, le même procédé s’applique comme si vous aviez écrit make main.o. Make voit que main.c existe mais pas main.o, il compilera donc main.o en exécutant la recette. En revanche, si main.o existe déjà, alors make compare les dates d’édition pour déterminer si main.c est plus récent que main.o et recompiler ce dernier si c’est le cas.

À la première exécution, la même procédé s’applique à greeter.o

Exercice: Compilez le binaire foo en exécutant make foo. Refaites-le une seconde fois. Maintenant, modifiez légèrement main.c et recompilez encore foo. Prêtez attention aux commandes qu’exécute make.

Question: Vous avez exécuté make foo 3 fois, à quel(s) moment(s) greeter.o a-t-il été compilé ?




Variables et pattern matching

Écrire une nouvelle règle pour chaque fichier objet est à la fois contraignant et sujet à erreurs. Il est possible de réécrire le précédant Makefile de manière plus élégante :

foo: main.o greeter.o
	gcc -o $@ $^

%.o: %.c
	gcc -c -Wall -Wextra -Werror -o $@ $^

Ce Makefile fonctionne exactement1 comme le premier Makefile.

La règle %.o s’occupera maintenant de la compilation de tous les fichiers objets. Le % est un pattern matching. Voici ce que dit le manuel de GNU Makefile à ce sujet dans la section 10.5.4 How Patterns Match:

Un pattern cible est composé d’un ‘%’ entre un préfixe et un suffixe qui peuvent être vides. Le pattern “matche” un nom de fichier seulement si ce dernier commence par le préfixe et se termine par le suffixe, sans chevauchement. Le texte entre le préfixe et le suffixe s’appelle la souche. Ainsi, quand le pattern ‘%.o’ matche le nom de fichier ‘test.o’. la souche résultante est ‘test’. Les prérequis de la “règle-pattern” sont transformés en nom de fichier en remplaçant le caractère ‘%’ par la souche. Donc, si dans le même exemple un des prérequis se présente sous la forme ‘%.c’, il sera transformé en ‘test.c’.

C’était un sacré pavé, donnons un exemple pour mieux comprendre. Quand make cherche une règle pour compiler main.o, il matchera le pattern %.o. La souche, main, sera alors injectée dans %.c pour devenir main.c. Concordément, make prétendra que la cible est main.o et que le prérequis est main.c. Le même procédé s’applique à greeter.o et à chaque fichier se terminant en .o.
Notez que % n’est pas une déclaration préprocesseur. Tout se passe durant le runtime ce qui signifie que % s’applique également sur des fichiers qui n’ont jamais été mentionnés dans le Makefile.

La nouvelle version de notre Makefile contient également deux prétendues variables automatiques, à savoir $@ et $^. Ces variables nous permettent d’écrire des recettes génériques qui fonctionnent avec les “règles-pattern”. $@ correspond à la cible de la règle et $^ à tous ses prérequis.

Question : Il y a un fichier nommé stray.c dans le repo d’exemple. En utilisant le Makefile ci-dessus, que se passerait-il si nous exécutions make stray.o ?




Les Variables

Jusqu’à présent nous n’avons écrit que la liste des fichiers objets et les flags de compilation de gcc. C’est suffisant pour les petits Makefiles, mais faisons un peu de nettoyage :

NAME := foo
OBJFILES := main.o greeter.o
LDFLAGS ?=
CFLAGS ?= -Wall -Wextra -Werror

$(NAME): $(OBJFILES)
	$(CC) $(LDFLAGS) -o $@ $^

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

Les variables définies avec := sont toujours définies sans conditions. Les variables définies avec ?= sont définies seulement si elles ne l’étaient pas avant. Make hérite des variables d’environnement du shell. Par exemple, si nous exécutons cette commande dans le shell, nous pouvons modifier la variable CFLAGS utilisée par make :

# Compile le programme en mode debug (-g) et n'arrête pas 
# la compilation en cas de warnings.
CFLAGS="-Wall -Wextra -g" make

La variable CC est un peu spéciale dans le sens qu’elle est prédéfinie comme étant cc. L’idée est simple, si vous écrivez du code corforme aux standards, pourquoi devriez-vous spécifier quel compilateur utiliser pour compiler votre code ? cc est le compilateur C par défaut sur la plupart des systèmes et si l’utilisateur souhaite compiler votre programme avec un compilateur différent, il peut redéfinir la variable CC :

# Utilise le compilateur clang et compile avec le 
# flag -fsanitize=address (spécifique à clang) afin
# de détecter la corruption de mémoire
export CC=clang
export CFLAGS="-Wall -Wextra -g -fsanitize=address"
export LDFLAGS="-g -fsanitize=address"
make

Note: Il se peut que vous voyez également l’opérateur = utilisé dans certains Makefiles sauvages. C’est également une syntaxe correcte mais qui fonctionne de manière légèrement différente de :=. Pour plus d’informations, jetez un œil à la documentation officielle

Les Dossiers

Jusque ici dans nos Makefiles, tous les fichiers source et les fichiers objets étaient présents à la racine du dossier. Il est possible d’organiser un peu mieux notre dépôt en les déplaçant dans différents dossiers.

Regardons à nouveau la règle pour nos fichiers objets :

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

Le pattern % ne marche pas seulement pour les suffixes mais également pour les préfixes. Nous pouvons utiliser cela pour compiler notre fichier objet dans un dossier différent de celui du fichier source.

# Compile le fichier objet dans le dossier obj/
# à partir du fichier source qui se trouve dans le dossier src/
obj/%.o: src/%.c
	$(CC) -c $(CFLAGS) -o $@ $^

Notez qu’ici make n’est pas conscient des différents dossiers. Il se contente chercher un fichier avec obj/ au début et .o à la fin puis les remplace respectivement par src/ et .c. Puisque les variables automatiques $@ et $^ correspondent aux formes “traitées” de ces fichiers, ce qui sera exécuté ressemblera à :

cc -c -Wall -Wextra -Werror -o obj/example.o src/example.c

Cependant, nous avons négligé un détail : le compilateur ne créera pas de dossier obj/. Cela peut-être résolu en exécutant la commande mkdir -p qui crée un dossier si celui-ci n’existe pas déjà. Pour savoir quel dossier créer, il est possible d’utiliser la commande $(dir ...).

obj/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) -c $(CFLAGS) -o $@ $^

Le @ au début de la ligne est un choix esthétique. Il permet de ne pas afficher les appels à mkdir et de ne pas surcharger l’output

À présent, pour que tout fonctionne, nous devons nous assurer que chaque fichier objet défini comme prérequis soit référencé avec obj/ comme préfixe; sinon, make ne les trouvera pas. Notre Makefile ressemble maintenant à ça :

NAME := foo
OBJFILES := obj/main.o obj/greeter.o
LDFLAGS ?=
CFLAGS ?= -Wall -Wextra -Werror

$(NAME): $(OBJFILES)
	$(CC) $(LDFLAGS) -o $@ $^

obj/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) -c $(CFLAGS) -o $@ $^

Lectures complémentaires : Il est possible d’éviter d’avoir à écrire le préfixe obj/ à chaque fois en utilisant les Références de Substitution

Question : Imaginez que tous nos fichiers .c possèdent un préfixe ft_ et se trouvent dans le dossier src/. Notre fichier source main serait donc src/ft_main.c. Comment pourrait-on créer des fichiers objets sans le préfixe ft_ ? Pour que src/ft_main.c soit compilé en obj/main.o ?




Un peu de nettoyage

Jusque alors, toutes les règles de notre Makefile servaient à créer quelque chose, défini par la cible. C’est pratiquement toujours ce que nous souhaitons faire, à quelques exceptions près. Prenez make clean par exemple ; dans la plupart des Makefiles2, une telle règle nettoierai tous les fichiers créés par le Makefile. Voici ce qu’une implémentation naïve d’un tel procédé donnerai :

NAME := foo
OBJFILES := obj/main.o obj/greeter.o
LDFLAGS ?=
CFLAGS ?= -Wall -Wextra -Werror

$(NAME): $(OBJFILES)
	$(CC) $(LDFLAGS) -o $@ $^

obj/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) -c $(CFLAGS) -o $@ $^

clean:
	rm -f $(NAME) $(OBJFILES)

Le problème est que make pensera maintenant que notre règle clean a pour cible un fichier nommé clean. Laissez-moi vous montrer pourquoi cela est un problème :

$ touch clean
$ make clean
make: 'clean' is up to date.

Make n’a pas voulu exécuter la règle clean car un fichier nommé clean existait déjà. Pour régler cela, nous devons déclarer la cible clean comme étant “fausse”. Make possède une règle “magique” nommée .PHONY; tous les prérequis de cette règle sont marqués comme étant “faux”, et make ne vérifiera plus qu’il n’existe pas de fichier portant le même nom. À la place, make présumera que les “fausses” cibles ont toujours besoin d’être recompilées. Nous voici donc avec le Makefile suivant :

NAME := foo
OBJFILES := obj/main.o obj/greeter.o
LDFLAGS ?=
CFLAGS ?= -Wall -Wextra -Werror

$(NAME): $(OBJFILES)
	$(CC) $(LDFLAGS) -o $@ $^

obj/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) -c $(CFLAGS) -o $@ $^

clean:
	rm -f $(NAME) $(OBJFILES)

.PHONY: clean

Question : Imaginez que notre règle .PHONY soit définie ainsi : .PHONY: clean $(NAME). Nous exécutons make deux fois. Que se passera-t-il la seconde fois ?




Les finitions

Il nous reste deux choses à voir qui peuvent être utiles dans notre Makefile. Tout d’abord, recompiler notre projet avec make ne marchera pas si vous avez modifié un fichier header, puisque make n’est pas au courant de leur existence.

La manière la plus simple de régler cela est de dire à make que chaque fichier objet dépend aussi de chaque fichier header. Voici à quoi cela ressemblerait :

NAME := foo
HEADERFILES := src/greeter.h
OBJFILES := obj/main.o obj/greeter.o
LDFLAGS ?=
CFLAGS ?= -Wall -Wextra -Werror

$(NAME): $(OBJFILES)
	$(CC) $(LDFLAGS) -o $@ $^

obj/%.o: src/%.c $(HEADERFILES)
	@mkdir -p $(dir $@)
	$(CC) -c $(CFLAGS) -o $@ $<

Nous avons fait trois changements. D’abord, nous avons ajouté une variable HEADERFILES qui contient une liste de fichiers header. Puis, nous l’avons ajoutée comme prérequis de la règle obj/%.o. Finalement, nous avons substitué le second $^ par un $<

Notre règle obj/%.o a maintenant plusieurs prérequis : Les fichiers source src/%.c correspondants ainsi que tous les fichiers headers définis par HEADERFILES. Puisque make vérifie que chaque prérequis est plus récent que la cible, la cible sera recompilée dès qu’un fichier header est modifié. La même procédé sera appliqué pour chaque fichier objet, leur causant d’être tous recompilés si un fichier header est modifié.

Les variables automatiques $^ et $< sont légèrement différentes l’une de l’autre : $^ correspond à tous les prérequis de la règle tandis que $< correspond uniquement au premier d’entre eux. Dans notre cas, cela signifie que $< ignore les fichier headers et correspond uniquement au fichier source. C’est exactement ce que nous voulons puisque les fichiers headers n’ont pas à être ajouté comme input du compilateur.

Le mot de la fin

Et voilà, j’espère que ce tutoriel vous aura été utile! Merci à Pepijn Holster de m’avoir aidé à l’écrire et merci également à tous ceux qui m’ont aidé à l’améliorer.

Vous voulez traduire ce tutoriel? Je serai ravi de recevoir des traductions! Envoyez-moi un email (en anglais) à inbox@noahloomans.com ou envoyez-moi un MP sur Slack (nloomans) si vous êtes un étudiant dans le 42 Network. Je vous aiderai à commencer.


  1. À une différence près, la deuxième règle correspondera à n’importe quel fichier .o, pas seulement main.o et greeter.o 

  2. Notez que la règle clean des Makefiles que nous écrivons à 42 marchent de manière légèrement différente.