6 points par GN⁺ 2025-06-21 | 1 commentaires | Partager sur WhatsApp
  • Un Makefile sert surtout à automatiser la compilation C/C++ dans les grands projets en ne relançant que les étapes de build nécessaires selon les fichiers modifiés
  • Make compare les horodatages du système de fichiers entre les cibles et leurs dépendances, et n’exécute une commande que si la cible n’existe pas ou si une dépendance est plus récente
  • Une règle de base se compose de targets: prerequisites et de commandes indentées avec une tabulation ; une indentation avec des espaces peut faire échouer make
  • Le flux de build peut être étendu avec des variables, des wildcards, des variables automatiques, des règles de motif, des conditions, des fonctions, des appels récursifs à make, include, .PHONY et .DELETE_ON_ERROR
  • Les exemples Cookbook construisent les sorties dans build/ à partir des sources C/C++ de src/ et mettent en place une gestion automatique des dépendances avec -MMD -MP et -include

Quand un Makefile est nécessaire

  • Un Makefile sert à déterminer quels fichiers doivent être recompilés dans un grand programme
  • Le cas central du tutoriel est la compilation de fichiers C/C++
  • Make peut aussi être utilisé hors compilation pour les tâches qui doivent exécuter une série de commandes selon les modifications de fichiers
  • Parmi les autres outils de build pour C/C++, le texte présente SCons, CMake, Bazel, Ninja
  • Pour Java, on trouve Ant, Maven et Gradle ; des langages comme Go, Rust et TypeScript ont leurs propres outils de build
  • Les langages interprétés comme Python, Ruby ou JavaScript pur n’ont pas besoin d’être recompilés quand un fichier change, donc un outil comme Makefile n’y est pas indispensable

Modèle d’exécution de base de Make

  • Les exemples consistent à créer un fichier Makefile dans un terminal où make est installé, puis à exécuter make dans ce répertoire
  • Une règle de base suit la forme ci-dessous
    • targets: prerequisites
    • command commençant par une tabulation
  • Une cible (target) est généralement un nom de fichier, et la commande correspond aux étapes nécessaires pour créer cette cible
  • Les prérequis (prerequisites) sont les fichiers qui doivent exister avant l’exécution de la commande ; on les appelle aussi dependencies
  • Les lignes de commande d’un Makefile doivent obligatoirement être indentées avec une tabulation ; utiliser des espaces fait échouer make

Comparaison temporelle entre cibles et dépendances

  • make hello exécute la commande associée à la cible hello, mais si le fichier hello existe déjà, la commande n’est pas exécutée
  • Les cibles sont directement liées aux noms de fichiers et, en général, les commandes d’une cible créent un fichier portant le même nom
  • Dans la règle blah: blah.c, la commande de compilation n’est exécutée que si blah n’existe pas ou si blah.c est plus récent que blah
  • Make ne suit pas directement l’historique des changements et utilise les horodatages du système de fichiers comme heuristique
  • Si l’on modifie un fichier puis que l’on remet son horodatage dans le passé, Make peut conclure à tort que le fichier n’a pas changé

Cible par défaut et clean

  • Si aucune cible n’est indiquée à make, c’est la première cible du Makefile qui est exécutée par défaut
  • La cible all sert à construire plusieurs cibles d’un coup ; si elle est placée en première règle, un simple make lance le build complet
  • clean est couramment utilisée pour supprimer les artefacts de build, mais ce n’est pas un mot réservé spécial de Make
  • Si clean n’est ni la première cible ni une dépendance d’une autre cible, elle doit être appelée explicitement, par exemple avec make clean
  • Si un fichier nommé clean existe réellement, la cible clean peut ne pas s’exécuter ; ce problème est résolu plus loin avec .PHONY

Variables et traitement des chaînes

  • Les variables Make ne peuvent contenir que des chaînes, et le tutoriel recommande en général d’utiliser :=
  • Les références de variables utilisent la forme $(name) ou ${name}
  • Les apostrophes et les guillemets n’ont pas de signification particulière pour Make lui-même ; ce sont simplement des caractères de la variable
  • Il existe principalement deux façons de définir une variable
    • = : variable récursive, développée au moment de l’utilisation
    • := : variable à expansion simple, qui ne développe au moment de la définition que les valeurs déjà définies
  • ?= n’assigne une valeur que si la variable n’est pas encore définie
  • += ajoute une valeur à une variable
  • Une variable non définie est traitée comme une chaîne vide
  • Les espaces en fin de ligne ne sont pas supprimés ; pour créer une variable contenant un seul espace, on peut utiliser une technique comme $(nullstring)

Wildcards et variables automatiques

  • Dans Make, * et % sont tous deux appelés wildcards, mais ils n’ont pas la même signification
  • * recherche les noms de fichiers correspondants dans le système de fichiers ; il est plus sûr de l’utiliser via la fonction wildcard, par exemple $(wildcard *.o), plutôt que directement dans une définition de variable
  • Si * ne correspond à aucun fichier, il peut rester tel quel lorsqu’il n’est pas utilisé dans la fonction wildcard
  • % est utilisé dans les règles et certaines fonctions, et la partie correspondante est appelée stem
  • Les variables automatiques les plus courantes sont les suivantes
    • $@ : nom de la cible
    • $? : dépendances plus récentes que la cible
    • $^ : toutes les dépendances
    • $< : première dépendance

Règles implicites et règles de motif

  • Make dispose de règles implicites pour la compilation C, ce qui permet parfois de construire sans écrire explicitement la commande de compilation
  • Quelques règles implicites représentatives ont la forme suivante
    • n.o est généré depuis n.c avec une commande du type $(CC) -c $(CPPFLAGS) $(CFLAGS) $^ -o $@
    • n.o est généré depuis n.cc ou n.cpp avec une commande du type $(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $^ -o $@
    • n est généré à partir de n.o via une commande d’édition de liens
  • Les variables importantes dans les règles implicites sont CC, CXX, CFLAGS, CXXFLAGS, CPPFLAGS et LDFLAGS
  • Les règles de motif statiques suivent la forme targets...: target-pattern: prereq-patterns ... et injectent dans le motif de dépendance le stem correspondant au % trouvé dans la cible
  • Les règles de motif incluent % dans la cible, comme %.o: %.c, et peuvent être utilisées comme des règles implicites personnalisées
  • Les règles à double deux-points :: permettent de définir plusieurs règles pour une même cible, mais elles sont rarement utilisées

Exécution des commandes et gestion des erreurs

  • Préfixer une commande avec @ empêche l’affichage de cette commande elle-même
  • Exécuter make -s revient à se comporter comme si chaque ligne était préfixée par @
  • Chaque ligne de commande est exécutée comme dans un nouveau shell, donc un cd .. sur une ligne n’affecte pas la suivante
  • Pour conserver le même état de shell, il faut écrire sur une seule ligne, par exemple cd ..; echo \pwd``, ou prolonger avec des antislashs
  • Le shell par défaut est /bin/sh, mais on peut le changer avec SHELL=/bin/bash
  • Pour utiliser le $ d’une variable de shell dans un Makefile, il faut écrire $$
  • La gestion des erreurs varie selon le mode d’exécution
    • make -k : continue les autres tâches possibles même en cas d’erreur
    • - devant une commande : ignore les erreurs de cette commande
    • make -i : applique l’ignorance des erreurs à toutes les commandes
  • Si l’on interrompt Make avec ctrl+c, Make supprime la cible la plus récente qu’il vient de créer

Make récursif et variables d’environnement

  • Lorsqu’on relance make depuis un Makefile, il faut utiliser la variable spéciale $(MAKE) plutôt que make
  • $(MAKE) transmet les options de make et gère correctement leurs effets
  • Au démarrage de Make, les variables d’environnement définies au moment de l’exécution sont automatiquement converties en variables Make
  • La directive export place une variable dans l’environnement des commandes shell de toutes les recipes
  • Pour utiliser une variable dans un sous-make, il faut l’export
  • .EXPORT_ALL_VARIABLES permet d’exporter toutes les variables
  • Si l’on indique plusieurs cibles d’un coup, comme make clean run test, elles sont exécutées dans l’ordre clean, run, test

Conditions et fonctions

  • Les conditions se composent de ifeq, ifneq, ifdef, ifndef, else et endif
  • ifdef n’étend pas la référence de variable et vérifie uniquement si la variable est définie
  • On peut utiliser $(MAKEFLAGS) et findstring pour vérifier si une option comme make -i a été transmise
  • Les fonctions Make servent principalement au traitement de texte et s’appellent sous la forme $(fn, arguments) ou ${fn, arguments}
  • Les fonctions principales sont les suivantes
    • subst : remplacement de chaîne
    • patsubst : remplacement de mots selon un motif
    • foreach : transformation d’une liste de mots séparés par des espaces
    • if : utilise le deuxième argument si le premier n’est pas vide, sinon le troisième
    • call : appelle une variable comme une fonction avec des paramètres tels que $(1), $(2)
    • shell : invoque le shell, mais remplace les sauts de ligne par des espaces
    • filter : sélectionne dans une liste les éléments correspondant à un motif donné
    • filter-out : sélectionne les éléments ne correspondant pas à un motif donné

include, vpath, .PHONY, .DELETE_ON_ERROR

  • include filenames... permet de lire un ou plusieurs autres Makefiles
  • include est utile pour lire des Makefiles générés par des options comme -M de gcc
  • vpath <pattern> <directories> permet d’indiquer dans quels répertoires chercher certains fichiers de dépendance
  • La variable VPATH permet de définir globalement un comportement similaire de chemin de recherche
  • L’antislash \ sert à découper une commande longue sur plusieurs lignes
  • Ajouter une cible à .PHONY évite que Make la confonde avec un fichier du même nom
  • .DELETE_ON_ERROR supprime la cible d’une règle si une commande de cette règle renvoie un code de sortie non nul
  • Contrairement à PHONY, .DELETE_ON_ERROR ne s’applique pas seulement à la cible juste avant, mais à toutes les cibles

Exemple du Makefile Cookbook

  • L’exemple Cookbook fournit un modèle de Makefile adapté à un projet de taille intermédiaire
  • Si l’on place des fichiers C/C++ dans le dossier src/, le Makefile détermine automatiquement les cibles de compilation et les dépendances
  • La configuration principale des variables est la suivante
    • TARGET_EXEC := final_program
    • BUILD_DIR := ./build
    • SRC_DIRS := ./src
  • SRCS utilise find et $(shell ...) pour rechercher les fichiers *.cpp, *.c et *.s
  • OBJS ajoute BUILD_DIR devant chaque fichier source puis le suffixe .o pour construire les chemins des artefacts de build
  • DEPS remplace .o par .d afin de produire la liste des Makefiles de dépendances
  • INC_DIRS recherche tous les répertoires sous src/, et INC_FLAGS ajoute le préfixe -I à chacun
  • CPPFLAGS := $(INC_FLAGS) -MMD -MP configure GCC pour générer les fichiers de dépendances .d
  • Les sources C et C++ utilisent chacune une règle de motif distincte pour produire des fichiers .o sous build/, et les répertoires nécessaires sont créés avec mkdir -p $(dir $@)
  • clean est déclarée en .PHONY et supprime le répertoire de build avec rm -r $(BUILD_DIR)
  • -include $(DEPS) inclut les fichiers .d tout en supprimant les erreurs lorsqu’ils n’existent pas encore, par exemple lors du premier build

1 commentaires

 
GN⁺ 2025-06-21
Avis sur Hacker News
  • Il existe quelques options make peu connues mais utiles. La synchronisation de la sortie fait en sorte que stdout/stderr ne soient affichés qu’une fois la cible terminée, ce qui réduit le problème des logs entremêlés et difficiles à lire lors des builds parallèles
    make --output-sync=recurse -j10
    Sur un système multi-utilisateur chargé, le seul nombre de tâches -j peut ne pas suffire ; on peut donc aussi limiter le parallélisme en fonction de la charge moyenne : make -j10 --load-average=10
    --shuffle, qui randomise l’ordre de planification des cibles, est également utile en CI pour repérer les dépendances manquantes dans un Makefile : make --shuffle # or --shuffle=seed/reverse

    • Si c’est « peu connu », ce serait bien que les auteurs de make rassemblent quelque part la liste des options et la distribuent avec le programme. Si c’était sous forme de fichier texte lisible par l’utilisateur, ou dans un langage de composition, l’accès à cette connaissance serait bien meilleur
    • Celle que j’utilise le plus souvent est -B, une option qui force à tout reconstruire sans condition
    • J’ai vu make -j mettre plusieurs fois des machines en état de DoS, donc je le considère presque comme un bug
    • Si c’est un système multi-utilisateur chargé, on pourrait penser que le planificateur du système d’exploitation devrait pouvoir gérer ça
    • Même si c’est utile, ce n’est pas portable. Mieux vaut ne pas l’utiliser en dehors de petits projets personnels non distribués
  • Vers 1985, au Graphics Lab de Boston University, j’ai vu quelqu’un piloter avec un Makefile la génération d’un moteur de rendu 3D pour l’animation. C’était quelqu’un venant du monde Lisp, qui travaillait sur de la génération procédurale précoce et un système d’acteurs 3D ; le Makefile faisait une dizaine de lignes au total et était très élégant
    Avec de simples dépendances sur les dates de fichiers, il générait des centaines d’animations ; Lisp créait la forme 3D de chaque image, puis Make générait les images
    C’était en 1985, une époque où presque tout ce que l’on tient aujourd’hui pour acquis en 3D et en animation n’existait pas, donc cela avait impressionné tout le monde. Par la suite, il a utilisé le moteur de rendu 3D d’Iron Giant, et je crois qu’il a aussi joué un rôle clé sur Coraline. C’était Brian Gardner

  • Make est l’un de ces outils que je suis vraiment heureux d’avoir appris tôt dans ma carrière. Je ne l’utilise plus beaucoup aujourd’hui, mais il m’a montré la puissance des systèmes déclaratifs par rapport aux systèmes impératifs
    J’ai compris que cette idée s’étendait naturellement à d’autres tâches. En regardant l’illustration en haut de ce site, l’auteur semble avoir eu un ressenti similaire : on comprend mieux une recette de cuisine en la voyant de manière déclarative, comme un Makefile, plutôt que comme une suite d’instructions scriptées à la manière d’une recette traditionnelle
    Article lié : https://blog.gpkb.org/posts/cooking-with-make/
    J’écris toujours les recettes de façon à pouvoir les lire comme des Makefile avant de les emporter en cuisine, et je me demande si quelqu’un les a déjà composées ou affichées de cette manière. Cela ferait gagner beaucoup de temps de ne pas avoir à convertir mentalement le script en Makefile à chaque nouvelle recette

    • Ce qui est intéressant dans cette approche, c’est que l’utilisateur interprète en pratique lui-même le graphe de dépendances et devient l’exécutant, ce qui lui donne davantage de contrôle. Dans l’exemple de la cuisine, une structure déclarative fait mieux apparaître les moments où plusieurs choix restent possibles pour la suite, et laisse la liberté de tenir compte de contraintes externes qui ne sont pas formellement écrites dans le Makefile, comme l’ordre qui facilite la vaisselle
      Bien sûr, le prix à payer est qu’il faut résoudre soi-même le graphe de dépendances. Quand on veut simplement suivre une séquence d’étapes déjà sérialisée à l’avance, cela demande plus de travail
  • L’article dit que la plupart des recettes ne sont pas marquées comme .PHONY, et s’en sert apparemment comme raison pour ne pas vraiment le traiter dans le tutoriel, mais c’est une excuse faible. Un outil devrait enseigner les bonnes pratiques
    Dans mon équipe, on utilise make comme exécuteur de tâches, et je me suis fait reprendre pour avoir ajouté et maintenu .PHONY sur toutes les recettes
    Le guide de style Makefile de Clark Grubb est bon : https://clarkgrubb.com/makefile-style-guide
    Je me demande si des gens utilisent ce guide de style. Je me demande aussi si, pour les recettes phony, vous les marquez à l’endroit de leur déclaration ou si vous gardez une énorme liste en haut du fichier. Ce serait bien d’avoir un linter pour imposer cela

    • Je l’ai lu, et dans l’ensemble c’est un bon document. Cela dit, je ne suis pas d’accord avec certains points
      Utiliser -o pipefail par inertie n’est pas une bonne idée. Pipefail est utile, mais il casse l’usage le plus courant que les gens font des pipelines : filtrer la sortie avec grep. Mieux vaut l’ajouter individuellement seulement dans les recettes qui en ont besoin
      Marquer les cibles qui ne sont pas des fichiers avec .PHONY est strictement correct, mais généralement pas nécessaire. Quand il y a beaucoup de cibles, cela ajoute une verbosité inutile au Makefile ; à mon avis, il vaut mieux ne l’ajouter que quand c’est nécessaire
      Pour les recettes qui produisent plusieurs fichiers de sortie, auparavant, quand les règles de motifs ne convenaient pas, les fichiers factices/fichiers indicateurs étaient la norme ; mais depuis GNU Make 4.3, les cibles groupées sont prises en charge nativement. C’est aussi inclus dans Ubuntu 22.04 LTS : https://www.gnu.org/software/make/manual/html_node/Multiple-...
  • Un point intéressant récemment : CMake a jugé que les Makefiles ne convenaient pas aux projets utilisant les modules C++20, et que Ninja était le bon choix. https://cmake.org/cmake/help/latest/manual/cmake-cxxmodules....
    En gros, il semble considérer qu’il est trop difficile, voire impossible, de définir statiquement les dépendances des cibles. Désormais, cela se traite dynamiquement avec des outils comme clang-scan-deps : https://llvm.org/devmtg/2019-04/slides/TechTalk-Lorenz-clang...

    • Corrigez-moi si je me trompe, mais cette limitation liée aux Makefiles semble être entièrement un choix de CMake, ou du moins le résultat d’un manque de bénévoles pour ajouter la prise en charge au générateur de Makefiles. Ninja lui-même ne prend pas en charge les modules C++ : https://github.com/ninja-build/ninja/issues/2457
      En réalité, comme Ninja exige que toutes les dépendances soient définies statiquement, il a moins de fonctionnalités que Make classique sur ce point
    • Franchement, les modules sont presque une catastrophe
  • Make a sa place comme outil de build pour de grands codebases en C. Mais on le traite souvent comme un « exécuteur de tâches » généraliste propre à chaque projet, et il n’est pas vraiment adapté à cela. Même de simples conditions y sont difficiles
    Par exemple, j’ai vu plusieurs tentatives bien intentionnées d’envelopper Terraform avec Make, et ça ne s’est jamais bien terminé

    • Make n’est pas un exécuteur de tâches généraliste. C’est plutôt une méthode générale pour transformer des scripts shell linéaires en dépendances déclaratives. C’est un outil général pour le shell
    • Je pense même qu’il n’est plus juste de parler d’outil de build pour grands codebases en C. Ces 20 dernières années, des systèmes de build plus robustes et mieux définis ont été créés ; il est temps de se mettre à jour
    • Existe-t-il un bon exécuteur de tâches généraliste ? Modif : je crois avoir complètement mal compris le terme « exécuteur de tâches »
  • just est un excellent outil moderne pour remplacer les parties où les Makefiles deviennent brouillons : https://github.com/casey/just

    • Il remplace l’aspect « liste de petits scripts shell » de Make, mais pas la partie vraiment utile : « n’exécuter que les règles qui doivent être relancées »
    • Il existe d’autres options : Task (Go) https://github.com/go-task/task, Cake (C#) https://github.com/cake-build/cake, Rake (Ruby) https://github.com/ruby/rake
      Dans un registre complètement différent, il y a aussi Makedown, évoqué sur HN il y a 8 mois : https://news.ycombinator.com/item?id=41825344
    • Même si ces outils se positionnent eux-mêmes comme des alternatives à make, personnellement je les vois comme des choses complètement différentes, qui ne se comparent pas vraiment. make est centré sur la génération d’artefacts et sur le fait de ne pas reconstruire ce qui existe déjà ; just est un lanceur de commandes
    • Le principal avantage d’utiliser Make comme lanceur de commandes est que c’est un outil standard installé « partout ». Même si les alternatives semblent plus agréables à utiliser, je n’ai jamais eu l’impression qu’elles apportaient un bénéfice assez important pour justifier l’installation d’un outil supplémentaire
    • Task est aussi une autre alternative. Cela dit, personnellement, je ne l’ai utilisé que pour de petits projets C perso, donc il m’est difficile de dire s’il tient bien à grande échelle : https://taskfile.dev/
  • Les Makefiles sont formidables, mais il vaut mieux éviter de trop s’y plonger. Il y a quelques années, j’ai essayé de créer un framework GNU Make pur, avant de réaliser que j’étais en fait en train de réinventer autoconf ; c’est seulement à ce moment-là que j’ai compris pourquoi GNU autotools avait été créé
    Les Makefiles sont une sorte de Turing tarpit étrangement lispien. GNU Make va même jusqu’à proposer des capacités de métaprogrammation, et il est difficile de résister à l’envie de métaprogrammer un système impie à l’intérieur d’un Makefile. Le fait que GNU Make soit aussi largement installé rend la tentation encore plus forte

  • Je suis l’une des personnes qui ont créé et maintiennent Task, une alternative à Make. Il existe depuis plus de 8 ans et continue d’évoluer, donc si vous cherchez quelque chose de nouveau, n’hésitez pas à l’essayer. Les questions sont aussi les bienvenues
    https://taskfile.dev/
    https://github.com/go-task/task

  • Je me demande si quelqu’un a déjà essayé tup : https://gittup.org/tup/ex_dependencies.html
    C’est un système de build qui détermine automatiquement les dépendances à partir des accès au système de fichiers, ce qui permet de l’utiliser avec n’importe quel compilateur ou outil