CMake and Make are two different build systems for C/C++. But how do they compare? And which should you choose? To answer those questions, let's compare the two at a practical level. By that I mean that I'm going to build actual C++ code using both build systems, so you can see them both in action.

The rules are simple:

  1. I'll build the same code using both build systems
  2. And, I'll try to get both build systems to do roughly the same thing, within reasonable limits

This is the first in a series of videos comparing different C/C++ build systems. So click here to subscribe to the newsletter if you want to see the rest of the series.

NOTE: Watching the video above is highly recommended. The following is just a summary.

CMake vs Make - Minimal Build Scripts

I've borrowed some example code from The CMake Tutorial. It's a basic hello world example that's been split into two source-files. Let's compare the absolute minimal build scripts for both:

Here's the CMake script:

cmake_minimum_required(VERSION 3.24)

add_executable(${PROJECT_NAME} Main.cpp Hello.cpp)

And here's a minimal Make script:

# Minimal Makefile for hello_world

hello_world.exe: Main.cpp Hello.cpp Hello.h
	g++ -o [email protected] Main.cpp Hello.cpp
	strip [email protected] -o $@

CMake and Make work on different principles. With CMake you specify build targets, and assign source files and various parameters. With Make, you create a set of build rules. Each rule has a target file, a set of dependencies (to the right of the colon), and the commands needed to perform the rule's build task.

WARNING: Do NOT use the build script above. It's very inflexible.

Imagine having to manually keep the dependencies list up-to-date, or having a large project with many source files. Maintaining the build script would be a nightmare. Let's fix this...

A Cleaner CMakeLists.txt Build Script

Let's tweak the CMakeLists.txt file before fixing the Makefile:

cmake_minimum_required(VERSION 3.24)

# Our Project

add_executable(${PROJECT_NAME} ${SOURCE_FILES})

All I've done is moved the source-files out of add_executable() into a SOURCE_FILES variable.  It's not necessary for such small projects, but is a bit tidier. It's also more flexible for larger projects where some source files might depend on what platform you're targeting.

Makefile Attempt Two

Now let's fix up the Makefile. I've added some variables naming the compiler tools that we're using:

# Set the compiler tools (change here for cross-compiling)
CC = gcc
CXX = g++
LD = $(CXX) # Use the C++ compiler as linker for convenience
STRIP = strip

This makes life much easier if we ever have to change the compiler for some reason (e.g., for cross-compiling). Using these variables means that we only need to change the compiler tools in one place.

Next, the target executable (hello_world.exe) and source files are written to variables:

# The executable to build
TARGET = hello_world.exe

# The source files
SRC = Main.cpp Hello.cpp

Each source file is compiled to an object file. We build the list of object files using pattern substitution:

OBJS = $(patsubst %.cpp, %.o, ${SRC})

The single build rule is split in two: A generic build rule for compiling source files (*.cpp) to object files (*.o), and a rule to link all the object files into the final program:

	$(LD) $(LDFLAGS) -o [email protected] $(OBJS)
	strip [email protected] -o $@

%.o: %.cpp
	$(CXX) -c $(CFLAGS) -o $@ $<

Automated Change Tracking For Incremental Builds

Now we want to enable Make to know which object files need to be recompiled if a source or header file changes. The key to this is the following set of compile flags:


These flags tell the compiler to generate dependency (*.d) files for each object file. The dependency files list every file that an object file is dependent on. We import these into our Makefile as follows:

-include $(DEPS)

We use pattern substitution again, to generate the list of *.d dependency files:

DEPS = $(patsubst %.cpp, %.d, ${SRC})

I also added an all rule and clean rule:

# The default build rule
.PHONY: all
all: $(TARGET)

.PHONY: clean
	rm $(OBJS) $(DEPS) $(TARGET) $(TARGET).debug

They're marked as .PHONY because no all or clean files will be built.

The build script is now a lot longer. But, it's but also much more flexible. However, it's not on-par with the CMake script...

Makefile Attempt Three

One annoyance, is that the Makefile writes its output files in-between the source-files, which is messy. With CMake we build the code in a sub-directory to keep that mess out of our source tree. Let's improve the Makefile to do the same.

First, we create a BUILD_DIR and OBJS_DIR to store the fully built executable, and intermediate object files:

BUILD_DIR = build

Next, the pattern substitution lines need to be updated to write the object and dependency files to OBJS_DIR:

# Generate the lists of compiled object and depencency files
OBJS = $(patsubst %.cpp, $(OBJS_DIR)/%.o, ${SRC}) 
DEPS = $(patsubst %.cpp, $(OBJS_DIR)/%.d, ${SRC}) 

Every instance of $(TARGET) needs to be changed to $(BUILD_DIR)/$(TARGET), so that the compiled executable is written to the new build directory. Finally, the generic build rule needs to be updated to write the object files into OBJS_DIR:

$(OBJS_DIR)/%.o: %.cpp
	mkdir -p "$(dir $@)"
	$(CXX) -c $(CFLAGS) -o $@ $<

Notice that the build rule above includes a mkdir line. This makes sure that the output directory exists before the compiler attempts to write to it. It only works on POSIX shells. So, Windows users must use a POSIX shell.

This new Makefile is a big improvement. But, it's still not on par with the CMakeLists.txt script.

Makefile Attempt Four

The CMake build script is able to build both debug and release versions. Let's rework the Makefile yet again to be able to do the same.

First, we add a CFG parameter, so that you can specify the debug or release version from the command line. For example, make CFG=release would build the release version.

Next, we set the debug version to be the default as follows:

# Setting a default configuration if invoked with just "make": 
CFG ?= debug

?= means assign this value if the variable (i.e., CFG) isn't set already.

After this, we set different compiler flags for the debug and release versions:

# Config specific settings
ifeq ($(CFG),debug)

The release version enables optimizations (-O3), and each rule also sets macros so that the code can detect whether it's being built for the debug or release version.

We want the debug and release versions to be written to separate sub-directories. So, BUILD_DIR is updated to include the build type:

BUILD_DIR = build/$(CFG)

Finally, I've added an "inform" rule, which prints the current build type. It'll also check if CFG is set to a valid value, and spit out an error if not:

.PHONY: inform
ifneq ($(CFG),release) 
ifneq ($(CFG),debug) 
	@echo "Invalid configuration "$(CFG)" specified." 
	@echo "Possible choices for configuration are 'CFG=release' and 'CFG=debug'" 
	@exit 1
	@echo "Configuration "$(CFG)
	@echo "--------------------------------------------------------------------------------" 

The inform rule is triggered by adding "| inform" to the other build rule's dependency lists. The pipe symbol ("|") makes sure it gets run at most once.

Now, we finally have a Makefile that's roughly equivalent to the CMakeLists.txt script. It's a lot longer, but does roughly the same thing. The screenshot below shows the separate debug and release directories.

Debug & Release Directories

What do you Think?

So, what do you think? Which do you prefer? CMake? Or Make? Let us know which one and why in the comments below.

Bear in mind that this is pretty simple code. It doesn't even link to a third-party library, which pretty much any sizable program does. So, I think I need to do the comparison again with code that does use a third-party library.

Build Script Examples/Templates

The build scripts for both CMake & Make are available in the Kea Campus at Creator tier or higher (link). On the Make side there's an even more advanced template that is ready for cross-compiling (in the Kea Campus).

Click here for more.