Écrire un Makefile

Posté le dim 05 février 2017 dans devtools
Article de la série build : Compilation avec GCC - Écrire un Makefile

Make est un outil qui permet d'exécuter des commandes et de gérer les dépendances qui existent entre les fichiers d'un projet.

Cet article est une introduction et n'a pas pour but de présenter la totalité du manuel de make.

Make n'est pas dévolu à la compilation de programmes écrit en C, il peut aussi bien être utilisé pour compiler des fichiers latex ou simplement pour exécuter des commandes shell.

Les commandes à exécuter ainsi que les dépendances entre fichiers sont décrites dans un fichier nommé makefile.

Les bases

Un makefile est composé d'une ou plusieurs règles. Une règle peut représenter un exécutable, une bibliothèque ou un groupe de commandes quelconques.

Lorsque l'on exécute la commande make, GNU make cherche dans le dossier courant après un fichier nommé GNUmakefile, makefile, Makefile (dans cet ordre).

Il est recommandé d'utiliser le nom Makefile, car le M est plus visible. Le nom GNUmakefile n'est à utiliser que si vous utilisez des spécificités de GNU make.

Une règle de makefile ressemble à ceci :

cible : dépendances
    recette
    …
    …

Avec le vocable anglais :

target : prerequisites
    recipe
    …
    …

Les commandes écrites sous les cibles doivent être indentées obligatoirement avec une tabulation.

Premier exemple

hello:
    gcc -o hello.bin hello.c

clean:
    rm hello.bin

Ce makefile est constitué de deux cibles :

  • La cible hello : permet de créer l'application nommée hello.bin
  • La cible clean : permet de supprimer cette application.

La commande make hello crée l'exécutable, la commande make clean le supprime. La commande make, sans target, exécutera uniquement la première target trouvée dans le makefile.

Voici le résultat de l'exécution :

$ make
gcc -o hello.bin hello.c
$ make clean
rm hello.bin
$

Second exemple

hello: hello.c mesfonctions.o
    gcc -o hello.bin hello.c mesfonctions.o

mesfonctions.o: mesfonctions.c mesfonctions.h
    gcc -c mesfonctions.c

clean:
    rm hello.bin mesfonctions.o

Nous avons ajouté une target mesfonctions.o qui compile le fichier mesfonctions.c.

Nous avons également ajouté des dépendances, il s'agit de la liste de fichiers qui suit la définition de la target.

La target hello dépend des fichiers hello.c et mesfonctions.o. La target mesfonctions.o dépend des fichiers mesfonctions.c et mesfonctions.h.

Avant d’exécuter la recette de la target hello, make va vérifier si ces dépendances sont à jour. make recherche la target mesfonctions.o.

La vérification de la target mesfonctions.o consiste à vérifier que la date et l'heure du fichier mesfonctions.o est plus récente que celles de ses dépendances. Si c'est la cas, make continue sans exécuter la recette de la cible mesfonctions.o, sinon il l'exécute.

Ce mécanisme permet de recompiler uniquement les fichiers qui ont été modifié depuis la dernière compilation.

Voici le résultat de l'exécution :

$ make
gcc -c mesfonctions.c
gcc -o hello.bin hello.c mesfonctions.o
$

Une modification du fichier mesfonctions.c recompile le tout :

$ echo " " >> mesfonctions.c 
$ make
gcc -c mesfonctions.c
gcc -o hello.bin hello.c mesfonctions.o
$

Une modification du fichier hello.c ne recompile pas le fichier mesfonctions.c :

$ echo " " >> hello.c 
$ make
gcc -o hello.bin hello.c mesfonctions.o
$

Si l'on modifie l'heure du PC, il faut supprimer tous les fichiers générés.

Si nous exécutons plusieurs fois make, hello.bin est systématiquement recompilé, même si nous ne modifions pas les fichiers sources !

$ make
gcc -o hello.bin hello.c mesfonctions.o
$ make
gcc -o hello.bin hello.c mesfonctions.o
$

Ceci est dû au fait que nous générons un fichier hello.bin non pas hello. Lorsque make 'vérifie' une target, il cherche un fichier qui porte le même nom que la target. Il faut donc veiller à ce que les noms des targets et des fichiers générés coïncident.

hello: hello.c mesfonctions.o
    gcc -o hello hello.c mesfonctions.o

mesfonctions.o: mesfonctions.c mesfonctions.h
    gcc -c mesfonctions.c

clean:
    rm hello mesfonctions.o

Maintenant deux appels consécutifs à make ne recompilent plus inutilement hello.

$ make
gcc -o hello hello.c mesfonctions.o
$ make
make: 'hello' is up to date.
$

Les variables

Les variables, parfois appelées MACRO, permettent de substituer du texte. Le nom d'une variable ne peut contenir les symboles ':', '#', '=' et l'espace.

Il est généralement conseillé de n'utiliser que des lettres, des nombres et le symbole underscore. Le nom d'une variable est case-sensitive.

Habituellement, les noms de variables sont en majuscules. Cependant, le manuel GNU Make fait les recommandations suivantes :

  • utiliser des noms en minuscules pour les variables à usage interne ;
  • réserver les noms en majuscules pour :
    • des paramètres contrôlant des règles implicites ;
    • des paramètres que l'utilisateur peut (re)définir par des options de la ligne de commande.

Définir une variable

Pour définir une variable, on débute une nouvelle ligne qui commence par le nom de la variable, suivi par '=', ':=' ou '::='. Tout ce qui se trouve à droite du signe d'affectation est le contenu de la variable.

objects = hello.o mesfonctions.o

Ceci définit une variable nommée objects dont le contenu est : hello.o mesfonctions.o. Les espaces entourant le signe d'affectation sont ignorés.

Pour utiliser une variable, on utilise la notation $(nom_variable)ou ${nom_variable}.

objects = hello.o mesfonctions.o
hello : $(objects)
        gcc -o hello.bin $(objects)

Si le nom de la variable ne fait qu'un caractère, les parenthèses ou les accolades ne sont pas nécessaires. On peut donc utiliser une variable x et y faire référence pas $x. Cette pratique est fortement découragée.

Il n'y a pas de limite sur la longueur du contenu d'une variable (à l'exception de la taille de la mémoire disponible).

Type de variable

Il existe deux catégories de variables : les variables expansées récursivement et les variables simplement expansées.

Variables expansées récursivement

Elles se définissent en utilisant le signe '=' ou la directive define.

Si une variable de ce type fait référence à une seconde variable, la seconde variable sera évaluée et son contenu la remplacera et ceci de manière récursive.

foo = $(bar)
bar = Hello $(who)
who = world !!!

all:
    echo $(foo)

Ce makefile affichera Hello world !!!, comme on s'y attend.

Ce type de variable a plusieurs inconvénients, dont un majeur, il n'est pas possible d'affecter une variable à elle-même.

CFLAGS = $(CFLAGS) -O

Ceci crée une boucle infinie !

Variables simplement expansées

Elles permettent de contourner les problèmes des variables expansées récursivement.

Les variables simplement expansées sont définies en utilisant le signe ':=' ou '::='. Les deux signes sont équivalents.

Cependant, le signe '::=' à été ajouté dans la norme 2012 du standard POSIX et n'est donc peut-être pas supporté par une ancienne version de make.

La valeur d'une variable simplement expansée est calculée une seule fois lors de sa définition.

x := Hello
y := $(x) world
x := !!!

est équivalent à

y := Hello world
x := !!!

Ajouter du contenu à une variable

Pour concaténer du contenu à une variable, quel que soit son type, on utilise le signe '+='.

objects = main.o foo.o bar.o utils.o
objects += another.o

Ce qui est l'équivalent de

objects = main.o foo.o bar.o utils.o
objects := $(objects) another.o

Si la variable n'est pas définie, avec l'utilisation du signe '+=', celui-ci agira comme '=' et définira donc une variable expansée récursivement.

Supprimer une variable

Pour supprimer le contenu d'une variable, il suffit de lui affecter un contenu vide.

Cependant, il y a une différence entre une variable non définie et une variable vide. Une variable non définie n'existe pas, alors qu'une variable vide existe, mais n'a pas de contenu.

Le mot-clef undefine permet de supprimer une variable.

Les variables d'environnement

Toutes les variables d'environnement vu par make lors de son démarrage sont transformées en variable make avec le même nom et la même valeur.

Il est ainsi possible de passer des options de compilation sans modifier le makefile. Il faut pour cela que le makefile n’écrase pas le contenu de cette variable par une affection avec = ou :=.

Target PHONY

Dans certains makefile écrits jusqu’à présent, nous avons une target clean qui n'a pas de dépendance et qui ne produit aucun fichier.

Si votre dossier contient un fichier nommé clean, cela va poser un problème.

Lors de l'appel de la commande make clean, make va détecter le fichier clean et considérer qu'il est à jour ! La recette ne sera jamais exécutée.

Pour résoudre ce problème, on déclare simplement clean comme dépendance à la cible spécial .PHONY.

Les dépendances de la cible .PHONY sont exécutées inconditionnellement.

.PHONY: clean

clean:
    rm -f *.o

Exemple

Dans l'exemple suivant, les variables CPPFLAGS,CFLAGS,LDFLAGS,LDLIBS sont vides. Ce qui suit le caractère # est en commentaire pour donner un exemple d'utilisation de ces variables.

CC = gcc
CPPFLAGS +=                     # -I/opt/XXXX/include
CFLAGS   +=                     # -O2 -std=c11
LDFLAGS  +=                     # -L/opt/XXXX/lib
LDLIBS   +=                     # -lpthread -lrt -lm -lXYZ

hello: hello.o mesfonctions.o
    $(CC) -o hello hello.o mesfonctions.o $(LDFLAGS) $(LDLIBS)

hello.o: 
    $(CC) -c hello.c $(CPPFLAGS) $(CFLAGS)

mesfonctions.o: mesfonctions.c
    $(CC) -c mesfonctions.c $(CPPFLAGS) $(CFLAGS)

.PHONY: clean mrproper
clean:
    rm -f *.o

mrproper: clean
    rm -f hello

STATIC PATTERN

Afin de ne pas devoir écrire explicitement une recette pour chaque fichier .c, il est possible d'utiliser une règle générique.

CC = gcc
CPPFLAGS +=                     # -I/opt/XXXX/include
CFLAGS   +=                     # -O2 -std=c11
LDFLAGS  +=                     # -L/opt/XXXX/lib
LDLIBS   +=                     # -lpthread -lrt -lm -lXYZ

hello: hello.o mesfonctions.o
    $(CC) -o $@ $^ $(LDFLAGS) $(LDLIBS)

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

.PHONY: clean mrproper
clean:
    rm -f *.o

mrproper: clean
    rm -f hello

Voici la signification des variables spéciales utilisée ci-dessus :

  • $@ : le nom de la cible
  • $< : le nom de la 1er dépendance
  • $^ : la liste de dépendance

La subtilité est ici : %.o : %.c. Ceci précise à make que lorsqu'il recherche une target se terminant par .o (d'extension .o), cette target dépend d'un fichier du même nom et d'extension .c.

Les règles implicites

Des recettes par défaut sont définies par make pour plusieurs langages, notamment le C, le C++, le fortran, TeX et quelques autres.

Pour utiliser une règle implicite, il suffit de définir une target sans recette, ou encore ne pas écrire la target.

Si l'on reprend l'exemple ci-dessus, on peut supprimer la recette de la target %.o : %.c, ou même supprimer cette target.

En rencontrant hello.o et mesfonctions.o, make va chercher à utiliser une de ces règles implicites. Dans ce cas ci, make trouvera des fichiers .c correspondant aux dépendances et utilisera donc la règle implicite du C.

Un fichier .o est fabriqué automatiquement à partir d'un fichier du même nom, l’extension du fichier désigne la recette qui sera utilisée.

fichier source recette utilisée
.c $(CC) $(CPPFLAGS) $(CFLAGS) -c Compilateur C
.cc / .cpp / .C $(CXX) $(CPPFLAGS) $(CXXFLAGS) -c Compilateur C++
.s $(AS) $(ASFLAGS) Assembleur

Un fichier .s est fabriqué automatiquement à partir d'un fichier du même nom et d’extension .S. La recette utilisée sera celle du préprocesseur : $(CPP) $(CPPFLAGS).

Un binaire sera fabriqué automatiquement depuis un fichier du même nom et d’extension .o. La recette utilisée sera celle de l'éditeur de liens (linker) : $(CC) -o $@ $^ $(LDFLAGS) $(LDLIBS).

Cette règle fonctionne pour un programme simple d'un fichier source. Elle fonctionne également avec plusieurs fichiers sources, un des fichiers sources devant définir le nom du binaire x: y.o z.o

Dans les cas plus complexes, il faut écrire les recettes de l'éditeur de liens.

Voici les variables utilisées par les règles implicites :

Variables Valeur par défaut
CC cc compilateur C
CXX g++ compilateur C++
CPP $(CC) -E préprocesseur C
CFLAGS options à passer au compilateur C
CXXFLAGS options à passer au compilateur C++
CPPFLAGS options à passer au préprocesseur, comme -I
LDFLAGS options à passer au linker, comme -L
LDLIBS options à passer au linker, comme -l
AR ar archiver utilisé pour créer des lib statiques
ARFLAGS rv options à passer à l'archiver
AS as l'assembleur
ASFLAGS options à passer à l'assembleur lorsque qu'il est invoqué explicitement sur fichier un '.s' ou '.S'

On peut maintenant réécrire l’exemple précédent :

CC = gcc
CPPFLAGS +=                     # -I/opt/XXXX/include
CFLAGS   +=                     # -O2 -std=c11
LDFLAGS  +=                     # -L/opt/XXXX/lib
LDLIBS   +=                     # -lpthread -lrt -lm -lXYZ

hello: mesfonctions.o

.PHONY: clean mrproper
clean:
    rm -f *.o

mrproper: clean
    rm -f hello

Wildcard

Si votre projet comporte beaucoup de fichiers, cela devient problématique de maintenir la liste des dépendances du binaire.

On peut alors être tenté de créer une variable objects = *.o qui désignerait tous les fichiers .o.

La variable objects contient la chaîne de caractères *.o et non l'ensemble des fichiers .o. De plus, si votre dossier ne contient aucun fichier .o, l’expansion de *.o est vide.

Voici comment procéder :

  • Utiliser $(wildcard *.c) pour lister les fichiers .c du dossier.
  • Utiliser la substitution de pattern pour transformer les .c en .o $(patsubst %.c,%.o,$(wildcard *.c))

patsubt comprend 3 arguments :

  • le premier est %.c, c'est le pattern de recherche,
  • le second est %.o, c'est le pattern de substitution,
  • le troisième la liste des fichiers à traiter.

Votre makefile devient ceci :

CC = gcc
CPPFLAGS +=                     # -I/opt/XXXX/include
CFLAGS   +=                     # -O2 -std=c11
LDFLAGS  +=                     # -L/opt/XXXX/lib
LDLIBS   +=                     # -lpthread -lrt -lm -lXYZ

objects := $(patsubst %.c,%.o,$(wildcard *.c))
hello : $(objects)

.PHONY: clean mrproper
clean:
    rm -f *.o

mrproper: clean
    rm -f hello

Dossier de rechercher des dépendances

Jusqu’à présent, les fichiers sources ont toujours été dans le même dossier que le fichier Makefile. Mais, lorsque le nombre de fichiers augmente, ou peut vouloir organiser ces sources dans différents sous-dossiers.

Le plus simple est alors d'utiliser la variable VPATH. Cette variable indique à make dans quels dossiers chercher les fichiers qu'il ne trouve pas dans le dossier contenant le Makefile.

VPATH contient simplement une liste de dossiers séparés par : : VPATH=sources:includes

Il existe également la directive vpath (en minuscule) qui permet également de spécifier une liste de dossiers pour des fichiers qui respectent un pattern donné : vpath %.h includes.

Lorsqu'une dépendance est trouvée dans un dossier référencé par VPATH, cela n'adapte pas la recette. Il faut donc écrire les recettes pour prendre en compte ces dossiers.

Notez également que les wildcard ou la substitution de pattern vu ci-dessus ne tiennent pas compte de VPATH. Lorsque l'on écrit $(wildcard *.c), on demande à make de faire la liste des fichiers .c du dossier dans lequel se trouve le makefile.

Exemple

Le projet est organisé comme suit :

  • le sous-dossier src contient toutes les sources de l'application ;
  • j'ai également décidé de déplacer chaque fichier .o dans un sous-dossier objs ;
  • l'application finale sera placée dans le sous-dossier dist.

Je définis des variables contenant les noms des dossiers src, objs et dist. Cela permet par la suite modifier plus facilement les noms de ces dossiers si besoin.

Analysons la ligne OBJS en plusieurs étapes :

  • $(wildcard $(SRCDIR)/*.c) : crée la liste des fichiers .c se trouvant dans le sous-dossier src. Si l'on ne précise pas le dossier $(SRCDIR), la liste sera vide, tous les fichiers sources étant dans le sous-dossier src.

  • $(patsubst $(SRCDIR)/%.c,%.o,...) : permettent de remplacer les .c par des .o dans la liste précédemment constituée. Notez également que le préfixe src/ présent devant chaque nom de fichier est supprimé. La substitution $(patsubst %.c,%.o,...) ne retire pas les préfixe src/.

La variable OBJS contient donc la liste de tous les fichiers sources mais avec l’extension .o.

La recette de %.o: %.c écrit bien les .o dans le sous-dossier objs. La recette $(APP) utilise une substitution pour indiquer à gcc que les fichiers .o se trouvent dans le sous-dossier objs.

APP= MyApp
SRCDIR= src
OBJDIR= objs
DISTDIR= dist
VPATH= $(OBJDIR):$(SRCDIR)
OBJS= $(patsubst $(SRCDIR)/%.c,%.o,$(wildcard $(SRCDIR)/*.c))

all: ressource $(APP)

$(APP): $(OBJS)
    gcc -o $(DISTDIR)/$(APP) $(patsubst %.o,$(OBJDIR)/%.o,$(OBJS))

%.o: %.c
    gcc -c $< -o $(OBJDIR)/$@

.PHONY: clean ressource
clean:
    @rm -rf $(DISTDIR) $(OBJDIR)

ressource:
    mkdir -p $(OBJDIR) $(DISTDIR)

 

$ make
mkdir -p objs dist
gcc -c src/hello.c -o objs/hello.o
gcc -c src/myfunction.c -o objs/myfunction.o
gcc -o dist/MyApp objs/hello.o objs/myfunction.o 
$ make
mkdir -p objs dist
gcc -o dist/MyApp objs/hello.o objs/myfunction.o

On peut constater que le fait de déplacer l'application dans le sous-dossier dist relance la phase de link. Il serait préférable de copier l'application dans le dossier dist.

$(APP): $(OBJS)
    gcc -o $(APP) $(patsubst %.o,$(OBJDIR)/%.o,$(OBJS))
    cp $(APP) $(DISTDIR)

Le fait de créer les .o dans le sous-dossier objs complexifie la ligne de link. Voici le makefile réécrit sans ce dossier.

APP= MyApp
SRCDIR= src
DISTDIR= dist
VPATH= $(SRCDIR)
OBJS= $(patsubst $(SRCDIR)/%.c,%.o,$(wildcard $(SRCDIR)/*.c))

all: $(APP)

$(APP): $(OBJS)
    gcc -o $(APP) $^
    mkdir -p $(DISTDIR)
    cp $(APP) $(DISTDIR)

%.o: %.c
    gcc -c $< -o $@

.PHONY: clean mrproper
clean:
    @rm -f $(APP)

mrproper: clean
    @rm -rf $(DISTDIR)
    @rm -f *.o

 

$make
gcc -c src/hello.c -o hello.o
gcc -c src/myfunction.c -o myfunction.o
gcc -o MyApp hello.o myfunction.o
mkdir -p dist
cp MyApp dist 
$ make
make: Nothing to be done for `all'.

Installation

Pour installer le résultat généré par make,

  • il faut copier les fichiers dans les dossiers de destinations,
  • modifier le propriétaire et le groupe des fichiers,
  • modifier les droits.

La commande install permet de réaliser ses 3 étapes en une seule. Le manuel de GNU Make recommande de ne pas utiliser directement la commande install, mais de passer par une variable $(INSTALL). Ceci permettra à l’utilisateur du makefile, de redéfinir la commande si nécessaire.

Par défaut, les programmes sont installés dans /usr/local.

La variable DESTIR est ajoutée devant chaque destination. Ceci permet à l'utilisateur de spécifier un path absolu comme dossier d'installation : make DESTDIR=$HOME/stage install.

INSTALL = install
INSTALL_PROGRAM = $(INSTALL)
INSTALL_DATA = $(INSTALL) -m 644

prefix = /usr/local
bindir = $(prefix)/bin
libdir = $(prefix)/lib

.PHONY: install
install:

    $(INSTALL_PROGRAM) hello $(DESTDIR)$(bindir)/hello
    $(INSTALL_DATA) libhello.a $(DESTDIR)$(libdir)/libhello.a

Le problème des makefiles

Le problème principal est pour moi la portabilité d'un makefile entre différentes distributions Linux/UNIX ou entre OS différents.

Par exemple, si votre makefile utilise des librairies tierses (qui ne font partie de l'OS), il faut en vérifier la présence, leurs emplacements et mettre à jour les variables telles que CPPFLAGS ou LDFLAGS.

Lorsque vous fournissez un package de source vous ne devriez pas imposer l'emplacement des bibliothèques tierses.

Les Autotools permettent de résoudre ces problèmes. Assez compliqué à mettre en oeuvre, aujourd'hui on préfère l'outil cmake.


Série build : Compilation avec GCC - Écrire un Makefile