by Stephen Lane-Walsh
Make is a very simple build system, native to all UNIX variants (which includes Linux and OSX). Make is also ridicuously old (44 years) to be as relevant as it is. Arguably, it is not much more than a shell script with a pre-defined structure. Make adds “rules”, which are analogous to functions, that can be used to create or update files. Make is the primary build system in many simple software projects, however it is more common to find Makefiles generated by other tools such as CMake
or autotools
.
Make operates on scripts, normally named Makefile
. When make
is run in a directory, it reads the Makefile
and builds whatever targets are requested. If no targets are specified, it will run the default target, usually all
.
The flexibility of make comes from the structure of rules.
target: depend
code
target
can either be a filename or a “phony” name. If the target is a file, it will be built when the timestamp is older than that of its dependencies, or if the file does not exist. If the target is a phony name, such as all
, clean
, or test
, it will always be run.depend
is an optional list of other targets needed by this target. If the main target is requested, all targets it depends on are built as well.code
is an optional set of shell commands used to build the target. Many special variables can be used to generalize your targets.Here is a basic Makefile
that compiles both a C++ library and executable, and links t hem against SDL2
.
The code used in this example can be found here:
https://github.com/WhoBrokeTheBuild/build-systems/tree/main/make
Note There are many ways to find SDL2
, this example uses pkg-config
and the standard name on Ubuntu. This might change based on your distribution.
# Targets that don't create files are called phony, so we declare
# them explicitly
.PHONY: all clean install run gdb valgrind test
# Set the default install location
PREFIX ?= /usr/local
# Set the standard compiler and linker flags
CXXFLAGS += -g -Wall $(shell pkg-config --cflags sdl2)
LDFLAGS += $(shell pkg-config --libs-only-L sdl2)
LDLIBS += $(shell pkg-config --libs-only-l sdl2)
# Set compiler and linker flags specific to the executable
EXE_CXXFLAGS = $(CXXFLAGS) -Iinclude
EXE_LDFLAGS = $(LDFLAGS) -Llib
EXE_LDLIBS = $(LDLIBS) -lexample
# Set compiler flags specific to the executable
LIB_CXXFLAGS = $(CXXFLAGS) -Iinclude
# Configure our target files
EXE_TARGET = bin/example
LIB_TARGET = lib/libexample.a
EXE_SRCDIR = src/example
EXE_OBJDIR = obj/example
LIB_SRCDIR = src/libexample
LIB_OBJDIR = obj/libexample
# Gather our source files, and a list of object file counterparts
EXE_SOURCES = $(wildcard $(EXE_SRCDIR)/**/*.cpp $(EXE_SRCDIR)/*.cpp)
EXE_OBJECTS = $(patsubst $(EXE_OBJDIR)/%.cpp, $(EXE_OBJDIR)/%.o, $(EXE_SOURCES))
LIB_SOURCES = $(wildcard $(LIB_SRCDIR)/**/*.cpp $(LIB_SRCDIR)/*.cpp)
LIB_OBJECTS = $(patsubst $(LIB_OBJDIR)/%.cpp, $(LIB_OBJDIR)/%.o, $(LIB_SOURCES))
# Gather our test source files, and create a target for each
TEST_SOURCES = $(wildcard tests/*_test.cpp)
TEST_TARGETS = $(patsubst tests/%.cpp, bin/%, $(TEST_SOURCES))
# The general rules for building all .cpp files into .o files
# The proper way to do this would involve some flags to build a
# dependency file, usually a .d, which could track which header
# files have changed as well.
$(EXE_OBJDIR)/%.o: $(EXE_SRCDIR)/%.cpp
$(CXX) $(EXE_CXXFLAGS) -c -o $@ $<
$(LIB_OBJDIR)/%.o: $(LIB_SRCDIR)/%.cpp
$(CXX) $(LIB_CXXFLAGS) -c -o $@ $<
# The rule for building our executable target
$(EXE_TARGET): $(LIB_TARGET) $(EXE_OBJECTS)
$(CXX) $(EXE_LDFLAGS) -o $@ $(EXE_OBJECTS) $(EXE_LDLIBS)
# The rule for building our library target
$(LIB_TARGET): $(LIB_OBJECTS)
$(AR) rcs $@ $^
all: $(EXE_TARGET) $(LIB_TARGET)
# Remove all targets and objects
clean:
rm -f $(EXE_TARGET) $(LIB_TARGET) $(TEST_TARGETS)
rm -f $(EXE_OBJECTS) $(LIB_OBJECTS)
# Install into the proper directory, as pointed to by
# the standard DESTDIR/PREFIX variables
install: $(EXE_TARGET) $(LIB_TARGET)
install -d $(DESTDIR)/$(PREFIX)/bin/
install $(EXE_TARGET) $(DESTDIR)/$(PREFIX)/bin/
install -d $(DESTDIR)/$(PREFIX)/lib/
install $(LIB_TARGET) $(DESTDIR)/$(PREFIX)/lib/
# Run our executable target
run: $(EXE_TARGET)
./$(EXE_TARGET)
# Run our executable target, but with gdb
gdb: $(EXE_TARGET)
gdb --args $(EXE_TARGET)
# Run our executable target, but with valgrind
valgrind: $(EXE_TARGET)
valgrind $(EXE_TARGET)
# The rule to build each test, linking them against our library target
bin/%_test: tests/%_test.cpp $(LIB_TARGET)
$(CXX) $(EXE_CXXFLAGS) $(EXE_LDFLAGS) -o $@ $< $(EXE_LDLIBS)
# Build and run all of our tests
test: $(TEST_TARGETS)
$(addsuffix ;,$(TEST_TARGETS))
Here is the directory structure assumed by that Makefile
:
├── bin
├── include
│ └── example.hpp
├── lib
├── Makefile
├── obj
│ ├── example
│ ├── libexample
├── src
│ ├── example
│ │ └── main.cpp
│ └── libexample
│ └── example.cpp
└── tests
└── example_test.cpp
Requirement | Ranking | Notes |
---|---|---|
Cross-Platform | Partial | Make is not natively supported on Windows.While you can install it, there are numerous problems with doing so. In addition, Make greatly benefits from the tools found on UNIX systems such as pkg-config , find , etc.You generally have to install a whole suite of UNIX tools on windows to make it worthwhile. Visual Studio does have an equivalent, NMake , however it is not cross-platform at all. |
External Dependency Management | Partial | Tools such as pkg-config can be invoked to get the flags needed to build against most dependencies, as long as they are installed in the system. Finding and linking against them manually is a painful process. |
Target Dependency Chains | Full | Make has full support for complex dependency chains. However, to link against a target you will need to specify the proper flags to the compiler yourself.Additionally, a well-built dependency chain benefits highly from running make in parallel ( make -l or make -j# where # is the number of cores available). |
Tests | Partial | While Make has no built-in support for testing, the target test is often used to run whatever tests the project provides. e.g. make test .Building tests can be done easily with a wildcard rule, however linking them against the correct object files or libraries can be cumbersome. |
Asset Management | Full | Make can be configured to ‘build’ any file. Adding rules to compile %.glsl to %.spv would be easy. Rules to copy files would simply use cp . |
Packaging | Partial | There is no built-in support for making packages, however packages be created using standard tools such as tar , dpkg-deb , rpmbuild , etc. |
Performance | Full | Make is not known for its speed, but a Makefile written to take advantage of parallel compilation will build fast enough. |
Distributed Config | Full | Make can run recursively, using make -C to run make in a subdirectory. |
Includes/Macros | Full | Make has full support for includes, often with the file extension .mk . e.g. include Common.mk |
Run Targets | Full | Run targets can be easily added as phony targets with the proper command to run the target. See the above example. |
Conditionals | Full | Make has full support for if statements. e.g. ifeq ($(CXX), g++) Additionally, make can invoke subshells in the form of $(shell command) which can be used to query information about the system. |