Table of Contents
Why Build Automation Matters
In software development you often repeat the same steps. You compile files, link them, run tests, and package the result. Doing this manually is error prone and time consuming. Build automation tools describe these steps once, in a text file that can be stored in version control, and then execute them reliably with a single command.
On Linux, two of the most common build automation systems for compiled languages are Make (configured with a Makefile) and CMake (configured with CMakeLists.txt). Both aim to automate builds, but they operate at different levels and solve slightly different problems.
Make focuses on how to turn source files into targets on your current system. CMake focuses on generating build systems, including Makefiles, Ninja files, or project files, for many platforms and compilers.
This chapter focuses on how to use Makefiles and CMake practically, without covering compilation details that belong in compiler toolchain topics.
Make and the `Makefile`
Make is a program that reads a file, usually named Makefile, which describes how to build targets from dependencies with commands. When you run make, it decides which targets are out of date and runs the necessary commands.
A minimal Makefile contains rules. A rule has three main parts: a target, its prerequisites, and the recipe that builds the target. In abstract form this is:
Make rule form
target: prerequisites
<TAB>command
<TAB>command
Make only accepts a literal tab character at the start of each command line in the recipe.
For example, to compile a simple C program main.c into an executable named main, you could write:
main: main.c
<TAB>gcc -o main main.c
Running make with this file in the same directory will call gcc -o main main.c if main does not exist or if main.c is newer than main.
Make compares file timestamps to decide if it must rebuild. If the target file is older than any of its prerequisites, Make will run the recipe.
Targets, Dependencies, and Phony Targets
Each rule in a Makefile describes how to create one or more targets from some prerequisites. Targets are usually files, such as executables or object files, but they can also be special names for actions.
Consider a slightly larger project:
app: main.o util.o
<TAB>gcc -o app main.o util.o
main.o: main.c util.h
<TAB>gcc -c main.c
util.o: util.c util.h
<TAB>gcc -c util.c
If you run make app, Make will build main.o and util.o first, because they are prerequisites for app. It will skip rebuilding any object file whose source and headers are older than the object file itself.
Sometimes you want a target that does not correspond to a real file, but only groups actions, such as clean which deletes generated files. For this, you declare a phony target:
.PHONY: clean
clean:
<TAB>rm -f app main.o util.o
The special target .PHONY tells Make that clean does not correspond to a file on disk. Make will always run the recipe when you request make clean, regardless of existing files named clean.
Phony targets are often used for clean, test, install, or run tasks.
Make Variables and Simple Patterns
Make supports variables that let you avoid repetition and adjust builds easily. A basic variable assignment is written as:
CC = gcc
CFLAGS = -Wall -O2
OBJ = main.o util.o
app: $(OBJ)
<TAB>$(CC) -o app $(OBJ)
main.o: main.c util.h
<TAB>$(CC) $(CFLAGS) -c main.c
util.o: util.c util.h
<TAB>$(CC) $(CFLAGS) -c util.c
Here $(CC), $(CFLAGS), and $(OBJ) are expanded before Make runs the commands.
Make also has automatic variables that are useful in recipes. Two common ones are:
Useful automatic variables
$@ is the name of the target.
$< is the first prerequisite.
Using these, you can write more generic rules:
main.o: main.c util.h
<TAB>$(CC) $(CFLAGS) -c $< -o $@
util.o: util.c util.h
<TAB>$(CC) $(CFLAGS) -c $< -o $@Make also supports pattern rules, which describe how to build many similar targets. For object files compiled from C sources, a pattern rule might look like:
%.o: %.c
<TAB>$(CC) $(CFLAGS) -c $< -o $@
Now any .o file that depends on a .c file of the same base name will use that rule. You just need to define which object files are part of your program:
CC = gcc
CFLAGS = -Wall -O2
OBJ = main.o util.o
app: $(OBJ)
<TAB>$(CC) -o app $(OBJ)
%.o: %.c
<TAB>$(CC) $(CFLAGS) -c $< -o $@
Make will infer that main.o depends on main.c and that util.o depends on util.c because of the pattern rule.
Organizing Makefiles in Real Projects
In larger projects, a Makefile often defines a default target, helper targets, and uses variables to isolate paths and options. The first target in the file is the default when you run make with no arguments, so it is common to make that target something like all.
A simple layout might be:
CC = gcc
CFLAGS = -Wall -O2
OBJ = main.o util.o
.PHONY: all clean
all: app
app: $(OBJ)
<TAB>$(CC) -o $@ $(OBJ)
%.o: %.c
<TAB>$(CC) $(CFLAGS) -c $< -o $@
clean:
<TAB>rm -f app $(OBJ)
If you run make it will build all, which builds app. Running make clean will remove the build artifacts.
Projects with subdirectories sometimes use recursive Makefiles, where a top-level Makefile calls make inside subdirectories. Another common approach is to let tools like CMake generate a complex set of Makefiles, instead of writing them all by hand.
Make by itself is portable among Unix-like systems, but complex Makefiles can grow hard to maintain when you build on different operating systems and compilers. This is where CMake becomes useful.
CMake as a Build System Generator
CMake is a build system generator. You describe your project in a platform neutral way in a CMakeLists.txt file. CMake processes this file and generates a native build system for a particular generator, such as Makefiles, Ninja files, or IDE project files.
On Linux, a common workflow is to generate Makefiles and then drive the build with make. The separation looks like this:
- Configure with CMake to generate the build system.
- Build with the generated tool, often
make.
A minimal CMakeLists.txt for a C or C++ project looks like this:
cmake_minimum_required(VERSION 3.16)
project(MyApp LANGUAGES C)
add_executable(myapp main.c util.c)
The project command names your project and specifies the languages used. The add_executable command declares an executable target and its source files. CMake will figure out how to compile and link them for your platform and compiler.
To use it in a simple workflow:
# In the source directory
mkdir build
cd build
cmake ..
make
The cmake .. command reads ../CMakeLists.txt and generates Unix Makefiles in the build directory. The make command then uses those generated files to build the myapp target.
Separating source and build directories keeps build artifacts out of your source tree and makes it easy to create several build configurations, for example Debug and Release, from the same sources.
Basic CMake Commands and Targets
CMake has its own scripting language and a set of commands used to describe targets and their properties.
The central concept is a target, like an executable or a library. In simple projects you usually work with add_executable and add_library.
To create a library and link it to an executable:
cmake_minimum_required(VERSION 3.16)
project(MyLibDemo LANGUAGES C)
add_library(mylib util.c)
add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE mylib)
The add_library command creates a library target compiled from util.c. The add_executable command creates an executable from main.c. The target_link_libraries command links myapp against mylib. The PRIVATE keyword tells CMake that the dependency is only used inside myapp.
CMake tracks dependencies automatically. If you change util.c and run make again in the build directory, CMake’s generated build system rebuilds what is necessary.
You can also control compile options on specific targets. For example:
target_compile_features(myapp PRIVATE c_std_11)
target_compile_definitions(myapp PRIVATE APP_VERSION="1.0")
target_compile_options(myapp PRIVATE -Wall -O2)These commands attach language features, preprocessor definitions, and extra compile flags to the given target. CMake will generate the appropriate compiler flags for the chosen compiler.
Out-of-Source Builds and Build Types
A common CMake practice is the out-of-source build. You keep sources in one directory and use separate build directories for each configuration. This avoids mixing generated files with source code and makes cleanup simple, because you can remove the build directory.
On Linux, a typical pattern is:
mkdir build-debug
cd build-debug
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
cd ..
mkdir build-release
cd build-release
cmake -DCMAKE_BUILD_TYPE=Release ..
make
The CMAKE_BUILD_TYPE cache variable selects a standard configuration. While the exact flags are platform dependent, on typical Unix-like systems:
Common CMake build types
Debug enables debug information and disables optimization.
Release enables optimization and disables debug features.
RelWithDebInfo enables optimization and keeps debug information.
MinSizeRel optimizes for small binary size.
You can query and change CMake cache variables by re-running cmake with different -D options, or by using the ccmake or cmake-gui tools that provide interactive configuration.
Combining Make and CMake in Practice
On Linux, Make and CMake often work together. In small or system-specific projects you might write a hand-crafted Makefile and use make directly. This gives you detailed control over every build step, but it also means you must maintain that logic yourself.
In larger, multi-platform, or multi-language projects, CMake is commonly used as the primary description of the build. Developers interact with it through a two-phase process:
First they configure the project by running cmake to generate a build system, often consisting of many Makefile fragments. Then they build by calling make or another generator-specific build tool in the build directory.
The choice between Make and CMake is influenced by requirements for portability, complexity, and integration with other tools. On Linux workstations and servers you will frequently encounter both. Many open source projects provide a CMakeLists.txt file and rely on CMake. Others use handcrafted Makefiles and expect you to run make with particular targets such as make, make test, or make install.
Understanding Makefiles allows you to read and adjust low-level build instructions locally. Learning CMake allows you to work comfortably with modern cross-platform projects that generate those low-level instructions for you.