WhoBrokeTheBuild's Development Blog

Blog

View My GitHub Profile

6 May 2020

Build Systems: Make

by Stephen Lane-Walsh

Make

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

Example

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

Review

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.
This post is part of my series on Game Engine Development.
  1. Building a Game Engine
  2. Build Systems
    • Make
    • Autotools
    • Meson
    • Visual Studio
    • CMake
tags: Game Engine - Build Systems - C++