💾 Archived View for cdaniels.net › posts › guide-to-make.gmi captured on 2022-04-28 at 17:27:35. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2022-01-08)
-=-=-=-=-=-=-
Published 2017-07-15
In this article, you will learn how to use the Make build system to compile software projects. This article is targeted at those who have experience programming, but are not deeply familiar with the shell or commandline build systems. However, this article does assume you have basic familiarity with the UNIX commandline.
I decided to write this article because, in my experience observing my peers, and in 2 semesters of TAing an intro to UNIX class, Make seems to perpetually be a sticking point for new users. This article is in particular targeted at my peers and students who have often completed one or more semesters of a computing degree, and are competent programmers, but have not had the benefit of UNIX experience.
The true purpose and utility of Make are often neglected in courses and articles which discuss it. Make is often regarded and used as simply a tool for chaining shell commands together. While Make can indeed do this, using make as a glorified shell script parser leaves many of it's most powerful and useful features neglected.
Make is as much a tool for executing commands and scripts as it is a dependency resolution system. In Make, instructions on how to build a piece of software are split up into "targets", each of which may have zero or more "dependencies". A given target cannot be executed until all of it's dependencies have, and multiple targets can share overlapping dependencies.
Consider the following:
If you then wished to execute "A", figuring out the most efficient order in which to execute the tasks B-F without repeating or executing a task without it's dependencies, is a quite difficult problem, the complexity of which grows multiplicatively with the number of targets and dependencies.
Make uses a very simple syntax. Targets are strings which start in column zero and are terminated by a ":", and followed by a list of dependencies. The command to be executed within a target are indented below the target name by a single tab character (8 spaces will not work).
Consider the example from the previous section encoded as a Makefile:
A: B C F echo "executing A" B: C echo "executing B" C: F D echo "executing C" D: F echo "executing D" F: echo "executing F"
Executing this make file with the command "make A" yields the following output
echo "executing F" executing F echo "executing D" executing D echo "executing C" executing C echo "executing B" executing B echo "executing A" executing A
Thus, we can see that F->D->C->B->A was the most efficient order in which to execute the tasks such that each task's dependencies were executed before the task itself.
Notice that the commands being executed are displayed alongside their output, this can be turned off by preceding the command with an @ symbol, for example:
A: B C F @echo "executing A" B: C @echo "executing B" C: F D @echo "executing C" D: F @echo "executing D" F: @echo "executing F"
yields the output:
executing F executing D executing C executing B executing A
Consider the following files comprising a simple C program:
main.c:
#include "main.h" int main() { hello(); printf("Hello from main.c!\\n"); }
main.h:
#ifndef MAIN_H #include<stdio.h> #include "module.h" #define MAIN_H #endif
module.c:
#include "module.h" void hello() { printf("Hello from module.c!\\n"); }
module.h:
#ifndef MODULE_H #include<stdio.h> void hello(); #define MODULE_H #endif
The corresponding Makefile might look like:
helloprog: main.o module.o gcc -o helloprog main.o module.o main.o: module.o main.c main.h gcc -o main.o -c main.c main.h module.o: module.c module.h gcc -o module.o -c module.c module.h clean: -rm module.o -rm main.o -rm helloprog -rm module.h.gch -rm main.h.gch
Lets dissect line-by-line...
"helloprog: main.o module.o" - the "helloprog" target requires that "main.o" and "module.o" be built before it can run.
"gcc -o helloprog main.o module.o" - this gcc command produces a program named helloprog by linking the object files main.o and module.o. For those unfamiliar with C, .o files are analogous to .class files in Java.
"main.o: module.o main.c main.h" - the target main.o requires that module.o be built first, and *also* depends on the contents of the files main.c and main.h. This is one of the clever features of make - if main.o is already present and main.c and main.h have not changed since the last time the target was built, the target can simply be skipped - more on this later.
"gcc -c main.c main.h" - generate the object files for main
"module.o: module.c module.h" - the target module.o depends on the files module.c and module.h. Notice that it depends on no other Make target.
"clean:" - it is a convention that a Makefile should provide a target named "clean" which removes any build artifacts such as object files, binaries, debug symbols, and so on. Notice that the lines in this target are preceded by "-", this instructs make to proceed even if one of the command fails. Without the "-" character, if say module.o did not exist, but main.o did, the command "rm module.o" would fail, causing "make clean" to exit without removing main.o.
In GNU make, variables are assigned in one of four ways. The simplest is with the ":=" operator, and take the form "VARNAME := value". The other three methods are beyond the scope of the tutorial, but more information can be found at [1].
[1] - What is the difference between the GNU Makefile variable assignments =, ?=, := and +=?
Variables can be assigned the output of shell commands, for example "THEDATE := $(shell date)" would set THEDATE to the output of the shell command date.
Variables are accessed using the syntax "$(VARNAME)". Note that variables can be accessed from anywhere in the Makefile, but only defined outside of targets.
Consider the example below...
Makefile:
VARNAME:=a string THEDATE:=$(shell date) mytarget: @echo "the date is $(THEDATE)" @echo "VARNAME is: $(VARNAME)"
Output:
the date is Sun May 28 18:18:00 EDT 2017 VARNAME is: a string
NOTE: What I refer to as "special variables" are actually referre to as "Automatic Variables" in the Make documentation.
Automatic variables allow you to quickly write very powerful Makefiles, and write rules so they may be easily copy-pasted or otherwise re-used. For the sake of brevity, I shall not reproduce the full list of automatic variables here, but rather provide a link[2] to the pertinent page in the Make documentation, plus the following example...
[2] - Make Manual: Automatic Variables
Makefile:
myrule: otherrule coolrule @echo "target name is: $@" @echo "first prerequisite: {body}lt;" @echo "all prerequisites: $^" otherrule: @echo "hello from otherrule" coolrule: @echo "hello from coolrule"
Output:
hello from otherrule hello from coolrule target name is: myrule first prerequisite: otherrule all prerequisites: otherrule coolrule
Note that there are other special variables, but these are the ones I find to be the most useful.
Armed with this information, let's rewrite our simple C program's Makefile to be a little cleaner...
Makefile:
helloprog: main.o module.o gcc -o $@ $^ main.o: main.c main.h gcc -c $^ module.o: module.c module.h gcc -c $^ clean: -rm module.o -rm main.o -rm helloprog -rm module.h.gch -rm main.h.gch
Notice that all three of the compilation-related rules are considerably shorter and easier to type. This is especially handy when writing a target which has many dependencies which later change - any changes can be made in one place.
One of the most powerful, but also most arcane features of Make is pattern rules[3]. Pattern rules allow you to generalize the command to build a particular type of file beyond one single instance of that file. A pattern rule's target and dependencies can be defined in terms of a single base part of file name. As an example, a pattern rule to build a C .o file might associate %.o (the target) with the dependencies %.h and %.c.
[3] - Make Manual: Pattern Rules
Consider a trivial example:
%.baz: %.txt cp {body}lt; $@
And a shell session with this Makefile:
[cad@kronos][11:09][~/Desktop/tmp] (zsh) $ make bar.baz cp bar.txt bar.baz [cad@kronos][11:09][~/Desktop/tmp] (zsh) $ make foo.baz cp foo.txt foo.baz [cad@kronos][11:09][~/Desktop/tmp] (zsh) $ ls bar.baz bar.txt foo.baz foo.txt Makefile
As you can see, "%" is a sort of context-sensitive variable we can use to associate a single common action with different possible input and output files. As an example, in most C programs, every C source code file will be compiled into an object file in the same way, it is thus redundant to specify a separate rule for each and every C file individually.
Now, adding in what we have learned about pattern rules, let's re-write our ideomatic C Makefile one again to use them:
helloprog: main.o module.o gcc -o $@ $^ %.o: %.c %.h gcc -c $^ clean: -rm module.o -rm main.o -rm helloprog -rm module.h.gch -rm main.h.gch
In this very simple example, we haven't saved all that much space, we just got rid of one single rule. However, keep in mind that pattern rules will scale to arbitrarily many files. With the Makefile above, we could have the helloprog target depend on dozens of object files, but still have all the object files generated by a single pattern-rule.
As an aside, you should also familiarize yourself with Make's Implicit Rules[4] which can sometimes be used to simplify your Makefile even further; Implicit rules are built-in pattern rules that know how to build common file types such as C object files, and equivalents for other languages. Personally, I do not make use of implicit rules, because I feel that they make the build process too opaque and intractable to developers not deeply familiar with Make. Nevertheless, implicit rules are a longstanding and mature feature of Make which any C developer should be familiar with.
[4] - Make Manual: Implicit Rules
In the modern age, much of software development is moving away from Make. It is becoming increasingly common for new languages to implement first-party build and dependency management tools. Even in cases where language developers do not do so, community projects often spring up to create language-specific build tools. Many of these build tools offer much more advanced features than Make does, as they can be tailored to their specific language, rather than acting as a general-case solution. Nevertheless, Make is still widely used for C and C++ projects large and small, and it is unlikely that Make will be going away any time soon in that space. Make also has the benefit of being a general-case solution, which makes it useful for tasks beyond compiling C and C++ code (case in point, this website is generated by a Makefile[5]).
[5] - Building a Static Site Generator in 44 Lines of Make
Some popular build systems for Java include
All three of these tools are fairly similar, and solve more or less the same problems: managing dependencies and compiling projects. I have personally only used Maven, and even then not much, so I am unqualified to make an educated recommendation between the three. Anecdotally, Maven seems to be the most popular for large-scale enterprise applications.
For Rust, the first-party build and dependency management system is Cargo[6].
For C and C++, CMake[7] has become popular in recent years. CMake is a tool for generating and executing Makefiles in an automated fashion, but does not handle dependency installation and management as Cargo, Gradle, Maven and Ant do.
There are may other language-specific build tools aside from these - you should investigate the ones available for your language of choice... it is frequently better to use a purpose-build solution rather than a general purpose one.
Finally, in the general-purpose build system space, Make has a new competitor: Sake[7]. Sake offers many of the same features and benefits of Make, with a few key differences:
Notes for New Make Users - Alexander Gromnitsky