Building a Project with CMake

Alexander Neville

28 January 024

CMake is a widely used build automation tool for C and C++ projects. CMake is not (strictly speaking) a build system, however it is able to generate configuration files for a number of different build systems, making it a platform and compiler agnostic build tool. Rather than simultaneously maintaining a makefile and a Visual Studio configuration for compilation on Unix and Windows respectively, specifying build instructions with CMake allows one to automatically generate build configurations for any system.

Example Makefile Project

Consider the small C project outlined in the tree below. It comprises a small library consisting of two source files, two corresponding header files and two source files for which an executable is to be generated.

.
|-- inc
|   |-- item.h
|   `-- list.h
|-- run
|   |-- item_test.c
|   `-- list_test.c
`-- src
    |-- item.c
    `-- list.c

A makefile could be used to compile the library and link it with each executable. Such a configuration might resemble this example:

CC      := gcc
BIN     := ./bin
OBJ     := ./obj
INCLUDE := ./inc
SRC     := ./src
APP     := ./run
SRCS    := $(wildcard $(SRC)/.c)
APPS    := $(wildcard $(APP)/.c)
TESTS   := $(wildcard ./tests/*.c)
OBJS    := $(patsubst $(SRC)/%.c,$(OBJ)/%.o,$(SRCS))
PROGS   := $(patsubst $(APP)/%.c,$(BIN)/%,$(APPS))
CFLAGS  := -I $(INCLUDE)
LDLIBS  :=
.PHONY: build
build: $(PROGS)$(OBJ)/%.o: $(SRC)/%.c | $(OBJ)
    $(CC) $(CFLAGS) -c $< -o $@
$(BIN)/%: $(APP)/%.c $(OBJS) | $(BIN)
    $(CC) $(CFLAGS) $^ -o $@ $(LDLIBS)
$(BIN):
    mkdir $@
$(OBJ):
    mkdir $@
clean:
    rm -rf $(OBJ) $(BIN)

Numerous pattern substitutions are required to keep the build organised, placing intermediate files neatly in temporary subdirectories, which can be removed to clean the project. Building the project is a matter of issuing the command make from the project root.

Migrating to CMake

The process of compiling this project is more easily achieved with CMake. The configuration for CMake resides in a number of CMakeLists files (often CMakeLists.txt for MS Windows compatibility). The directory structure with these files added:

.
|-- CMakeLists.txt
|-- inc
|   |-- item.h
|   `-- list.h
|-- makefile
|-- run
|   |-- item_test.c
|   `-- list_test.c
`-- src
    |-- CMakeLists.txt
    |-- item.c
    `-- list.c

A CMakeLists.txt file contains a set of commands, all of them documented copiously in CMake’s reference. The configuration file in the project root begins with the lines:

# ./CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(List LANGUAGES C)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

This snippet specifies the minimum version of CMake required to build the project, the name of the project itself and - optionally - the language in use. I have additionally enabled CMAKE_EXPORT_COMPILE_COMMANDS, which generates the compile_commands.json file used by the language server clangd to resolve references across source files in the project.

The next step is defining the targets for the build, in this case the two source files in the run directory. These files both reference header files in the ./inc directory and link the library defined in the ./src directory. Using CMake, the configuration for this library can be managed separately and is very simple. On account of CMake scope rules, the CMakeLists.txt files in nested subdirectories do not need to repeat the commands used above and the library build can be expressed with a single command:

# src/CMakeLists.txt
add_library(src item.c list.c)

Specifying the executable targets, linking the library and setting the include path is all handled in the main ./CMakeLists.txt file.

# ./CMakeLists.txt
add_executable(ItemTest "run/item_test.c")
add_executable(ListTest "run/list_test.c")
add_subdirectory(src)
foreach(executable ItemTest ListTest)
    target_link_libraries("${executable}" PUBLIC src)
    target_include_directories("${executable}" PUBLIC "{PROJECT_SOURCE_DIR}/inc" "${PROJECT_BINARY_DIR}")
endforeach()

Both executables are to be linked with the ./src library, with the ./inc directory appearing in the include path during compilation. A foreach loop is used to update both targets with these relationships.

Building the Project

Using these two files, CMake has a model of all of the targets and relationships involved in the build of the project. The original makefile had the additional advantage of separating object files and executables from the source code. The same can be achieved with CMake by performing an out-of-source build:

$ mkdir build
$ cmake -B build
-- The C compiler identification is GNU 13.2.1
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Configuring done (0.1s)
-- Generating done (0.0s)
-- Build files have been written to: /path/to/project/build
$ cmake --build build
[ 14%] Building C object src/CMakeFiles/src.dir/item.c.o
[ 28%] Building C object src/CMakeFiles/src.dir/list.c.o
[ 42%] Linking C static library libsrc.a
[ 42%] Built target src
[ 57%] Building C object CMakeFiles/ItemTest.dir/run/item_test.c.o
[ 71%] Linking C executable ItemTest
[ 71%] Built target ItemTest
[ 85%] Building C object CMakeFiles/ListTest.dir/run/list_test.c.o
[100%] Linking C executable ListTest
[100%] Built target ListTest

By creating an isolated build directory, the object and executable files generated during the build are conveniently separated from the source tree. Finally, run the newly created binaries to verify that everything is in order and then clean up by removing the build directory:

$ ./build/ItemTest
"Hello!" 1234 'T'
$ ./build/ListTest
[4, 4, 5, 3.200000, 't', "Hello!", "Hello!", "Hi!"]
$ rm -rf build

See Also

Or return to the index.