A Guide to Makefiles
“This is my makefile guide. There are many like it, but this one is mine.”
Beware that I haven’t actually tested most of the examples here, so there might be some mistakes.
Why?
There are already better guides to makefiles, but most people don’t have time for that and just want to copy the right text to make their project work, so that’s what this one is for. Links to the documentation are peppered throughout so you can figure out where this seemingly arcane stuff is defined.
Even experienced software developers often mess up makefiles (often requiring workarounds on the part of package maintainers), so I’ll also try to document some of the footguns I’ve seen in the wild.
What is make?
Make is a command-line tool that is used to build outputs given a set of rules and dependencies. There are a few implementations of it, but the one that everyone uses is GNU Make.
Building a C program
In general, programs written in C are built with a process similar to the one below.
$ # Run the compiler on each source file
$ cc -c src/main.c -Iinclude -o src/main.o
$ cc -c src/foo.c -Iinclude -o src/foo.o
$ # Link the resulting object files
$ cc src/main.o src/foo.o -lSDL2 -o my-program
There are obviously a bunch of ways this could be automated and improved, but makefiles are one of the most standard ways to do this.
Writing a naïve makefile
#!/usr/bin/make -f
all: my-program
my-program: src/main.o src/foo.o
cc src/main.o src/foo.o -lSDL2 -o my-program
src/main.o: src/main.c
cc -c src/main.c -Iinclude -o src/main.o
src/player.o: src/player.c
cc -c src/foo.c -Iinclude -o src/foo.o
.PHONY: all
As you can see, a makefile is just a list of rules that map inputs (”prerequisites”) to outputs (”targets”). The standard target “all” specifies what to build by default, and it is considered a phony target because it does not result in the creation of a file.
The line at the top is a shebang. It is optional, but it allows the file to be run with “./Makefile” if it is marked as executable.
Writing a better makefile
#!/usr/bin/make -f
CFLAGS ?= -Iinclude
LDFLAGS ?= -lSDL2
SRC = $(wildcard src/*.c)
OBJ = $(SRC:.c=.o)
all: my-program
my-program: $(OBJ)
cc $^ $(LDFLAGS) -o $@
clean:
rm -f my-program $(OBJ)
.PHONY: all clean
This is much closer to what is seen in the real world.
- The wildcard function and substitution reference features are used to automatically detect the object files in the program and store them in the variables SRC and OBJ.
- The individual rules to build C files are not
included because GNU Make already has a
built-in rule for this.
- The build flags for the C compiler (-Iinclude) are included in CFLAGS as per the documentation.
- The automatic variables “$^” and “$@” are used to get the prerequistes and target names.
- The linking flags are moved to the conventional LDFLAGS variable.
- A clean rule is added to make it easy to remove build artifacts. This isn’t necessary, but it’s a standard target (like all), and most people will expect one.
This is good enough for most situations, but makefiles are often expected to install the program as well.
Writing installation targets
Programmers screw up their installation rules through various methods, but the most common ones I’ve seen are the following.
- Hardcoding an installation path
- Package tooling will often variables such as PREFIX to control where installations take place, so doing this means they’ll need to call sed in the build script to update the path. Gross.
- Installing to the wrong directory
- A lot of developers will install binaries to /usr/ instead of /usr/local/. This violates the filesystem hierarchy on standard Linux systems and generally shouldn’t be done. Oops.
- Writing an install rule but not an
uninstall rule
- This is a nuisance for developers more than packagers because it means that, if they install your program, they have to manually track down and delete each installed file to remove it. Ew.
- Installing binaries without setting them as executable.
#!/usr/bin/make -f
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
CFLAGS ?= -Iinclude
LDFLAGS ?= -lSDL2
SRC = $(wildcard src/*.c)
OBJ = $(SRC:.c=.o)
all: my-program
my-program: $(OBJ)
cc $^ $(LDFLAGS) -o $@
clean:
rm -f my-program $(OBJ)
install:
install -m755 my-program $(DESTDIR)$(BINDIR)/my-program
uninstall:
rm -f $(DESTDIR)$(BINDIR)/my-program
.PHONY: all clean install uninstall
This is generally what installation targets should look like. The documentation suggests defining INSTALL_PROGRAM and INSTALL_DATA variables. However, I’ve almost never seen this in makefiles, and it doesn’t simplify anything, so I don’t do it.
You’ll note that the paths are defined in PREFIX and BINDIR variables. Package tooling will often do something like “make PREFIX=/tmp/staging/usr” internally, so it’s important to leave the build and installation configurable through standard variables if possible.
Libraries can be a bit more complicated.
Building a library
I often find the following mistakes in makefiles for dynamic libraries.
- Installing to the wrong path on a multilib system.
- A lot of multilib systems expect 64-bit libraries to live in an alternate path (e.g., /usr/lib64).
- These are usually symlinked on modern distributions, but it’s often useful to check for this.
- Forgetting to include a SONAME in the library.
- Forgetting to include a version in the filename when installing the library.
Here is an example that attempts to correctly build and install a library.
#!/usr/bin/make -f
VERSION := 1.2.0 # The full library version
SOVER := 1 # Assuming versions 1.X.X maintain API compatibility
PREFIX ?= /usr/local
ifeq ($(shell uname -m), x86_64)
LIBDIR ?= $(PREFIX)/lib64
else
LIBDIR ?= $(PREFIX)/lib
endif
INCLUDEDIR ?= $(PREFIX)/include
CFLAGS ?= -Iinclude -fPIC
LDFLAGS ?= -lSDL2
SRC = $(wildcard src/*.c)
OBJ = $(SRC:.c=.o)
HEADERS = $(wildcard include/foo/*.h)
all: libfoo.a libfoo.so.$(VERSION)
libfoo.a: $(OBJ)
ar ruv $@ $^
ranlib $@
libfoo.so.$(VERSION): $(OBJ)
cc $^ -shared -Wl,-soname,libfoo.so.$(SOVER) $(LDFLAGS) -o $@
clean:
rm -f libfoo.so* libfoo.a $(OBJ)
install: all
install libfoo.a $(DESTDIR)$(LIBDIR)/libfoo.a
install libfoo.so.$(VERSION) $(DESTDIR)$(LIBDIR)/libfoo.so.$(VERSION)
ln -sf $(DESTDIR)$(LIBDIR)/libfoo.so.$(VERSION) $(DESTDIR)$(LIBDIR)/libfoo.so.$(SOVER)
install -d $(DESTDIR)$(INCLUDEDIR)/foo
install -m0644 $(HEADERS) $(DESTDIR)$(INCLUDEDIR)/foo
uninstall:
rm -f $(DESTDIR)$(LIBDIR)/libfoo.a
rm -f $(DESTDIR)$(LIBDIR)/libfoo.so*
rm -rf $(DESTDIR)$(INCLUDEDIR)/foo
.PHONY: all clean install uninstall
Note that the dynamic library is installed with a version number attached and a separate symlink for the SONAME. That is generally the standard way of installing shared library images.
Website makefile example
To demonstrate the versatility of makefiles, here’s one that doesn’t even process code.
#!/usr/bin/make -f
# This gets a list of pages.
# Note that this has no extension, so both .html and .md files will be found.
PAGES = $(wildcard pages/*)
OUTPUT = $(addprefix dist/,$(notdir $(PAGES:.md=.html)))
# Here, the "all" target has an action.
# In this case, it copies files from a "static" folder to the "dist" folder.
all: $(HTML) dist/sitemap.xml
cp -urv static/. dist
# The pipe means "dist" is an order-only prerequisite.
# This means the target must exist, but we don't need to rebuild if it changes.
dist/sitemap.xml: $(HTML) | dist
build-sitemap.sh $^ $@ # The HTML files are passed to the sitemap script.
# This is a general rule to template markdown pages.
dist/%.html: pages/%.md tmp/openring.html | dist
template-markdown.sh $< $@
# This is a general rule to template HTML pages.
# Note that this rule has the same target as the previous one.
# Based on the prerequisites, make will determine which action to run.
dist/%.html: pages/%.html tmp/openring.html | dist
template-html.sh $< $@
# Note that this will trigger the openring build rule if it's not yet compiled.
tmp/openring.html: tmp openring-template.html openring/openring
openring/openring \
-s https://some-website.com/feed.xml \
< templates/openring.html \
> tmp/openring.html
# If we include openring as a git submodule, we could use this makefile to build it automatically.
# In makefile-based C projects, make -C module-name is used in the exact same way.
openring/openring:
go build -C openring
dist:
mkdir dist
tmp:
mkdir tmp
# This is analogous to an "install" rule.
upload: dist
lftp -c "set ftp:list-options -a;\
open '$(shell cat credentials.txt)';\
lcd dist;\
cd public_html;\
mirror --reverse"
clean:
rm -rf dist tmp
.PHONY: clean upload
This makefile builds a Go program (openring), uses shell scripts to process a fictional website with markdown and HTML files, and allows the user to upload the website via FTP. I’m not claiming that makefiles are an ideal way to build websites — just that make can be used to deal with more complicated build steps.
This might seem silly, but a lot of tedious processes can be automated with makefile shenanigans.
- Processing program assets with ImageMagick
- Creating an ISO image for an embedded device after building a bunch of software
- Uploading the current build artifacts
- Fetching git submodules if they’re not present
- Making a phony rule to install the software’s
dependencies
- I’m not a fan of this personally.
- Running a bunch of C source code through a janky DIY
preprocessor
- Please do not do this either.
This is tedious. Why bother?
The main benefit is that you get a lot of control over your build process, and makefiles make it easy to do things like build multi-language projects, apply weird processing to your inputs and outputs, handle submodules, etc. They also map very closely to the actual commands run on the system which make them easy to debug.
Most people prefer to use more featureful build drivers like CMake and Meson that provide package resolution, configuration, dependency checking, and other trappings. I personally don’t find that they make my life easier unless I’m integrating with other projects that use those systems or the build process is gnarly. Different strokes for different folks, I guess.
Glossary
- GNU: A big collection of software (including make).
- make: A command-line tool that builds makefiles.
- makefile: A text file that uses rules to map prerequisites to targets — usually to build a program.
- rule: A structure containing a target (output), prerequisite (input), and recipe (action).
- target: The output that is created by a rule.
- phony target: A symbolic target that is not a file.
- standard target: A target that is expected to be included in a makefile.
Other resources
- GNU make: The official documentation for GNU make.
- Makefile Tutorial: A nifty tutorial that walks through Makefile features.
- Practical Makefiles, by example: A guide by John Tsiombikas that concisely explains how to implement proper makefiles including configuration scripts and cross-platform handling.