CMake Tutorial: Basic Concepts and Building Your First Project

Lian Granot
Lian Granot

8  min read | min read | 14/09/2023

What is CMake?

CMake stands for cross-platform make. It is a tool designed to manage the build process of software using compiler-independent methods. It was created to support complex directory hierarchies and applications that depend on several libraries.

Unlike traditional build systems, CMake does not build the software directly. Instead, it generates build scripts in various formats, including Unix makefiles and project files for integrated development environments (IDEs) like Microsoft Visual Studio and Xcode. This makes CMake a versatile tool that can be used in diverse development environments.

One of the main advantages of using CMake is that it allows developers to write a set of directives once and then generate the appropriate build scripts for their specific environment. This eliminates the need for maintaining separate build scripts for each platform, reducing the potential for errors and inconsistencies.

This is part of a series of articles about Vulnerability Management

Basic Concepts of CMake for Beginners

CMakeLists.txt

At the heart of any CMake project is the CMakeLists.txt file. This is where all the project’s build instructions are defined. It is a script written in the CMake language, and it specifies how sources and libraries are to be built.

The CMakeLists.txt file is organized hierarchically, meaning each directory in your project can contain its own CMakeLists.txt file. When CMake is run, it processes the CMakeLists.txt files in the order they appear, starting from the root directory.

CMake Variables

Variables in CMake are used to store and manipulate information. They can be defined using the set command and referenced by enclosing their name in ${}. Variables can store strings, lists, or boolean values.

CMake also provides a number of predefined variables that you can use in your CMakeLists.txt files. These include variables to represent the source and binary directories of your project, platform-specific variables, and more.

CMake Commands

CMake language is made up of commands. These commands do everything from defining variables, adding targets, specifying build rules, to locating libraries and headers. Some of the most common CMake commands include add_executable, add_library, target_link_libraries, and find_package.

Each command in CMake has a specific purpose and syntax. Understanding how these commands work and when to use them is key to successfully managing your build process with CMake.

Creating Your First CMake Project

Now that we’ve covered some basic concepts, let’s see how to create your first CMake project.

Note: Please ensure you have make and C++ compiler installed on your system. On Ubuntu, you can install them using the following commands:

apt install g++
apt install cmake

Writing the Initial CMakeLists.txt

Start by creating a new directory for your project. Within this directory, create a new text file named CMakeLists.txt. This file will contain all the instructions CMake needs to build your project.

The first line should specify the minimum version of CMake that’s required to build your project. After this, you can use the project command to define the name of your project. Next, you will want to add an executable with the add_executable command. This command tells CMake to generate a build rule for an executable program.

Here is a simple example:

cmake_minimum_required(VERSION 3.10)
project(HelloWorld)
add_executable(HelloWorld helloworld.cpp)
  • The first line indicates that the minimum required version of CMake to build this project is 3.10.
  • The second line sets the project name to HelloWorld.
  • The third line tells CMake to build an executable named HelloWorld from the source file helloworld.cpp.

Configuring and Generating Build Files with CMake

After writing the CMakeLists.txt file, the next step is to configure and generate build files with CMake. This is done by running the CMake command in a separate directory where you want the build files to be generated, often called a build directory.

Here is an example, assuming you’re in the directory where you want to generate the build files:

r mkdibuild
cd build
cmake ..

In this example, the mkdir command creates a new directory named build. The cd command navigates into the build directory. Finally, the cmake .. command runs CMake with the parent directory as the source directory.

CMake will now process the CMakeLists.txt file(s), perform system checks, and then generate the necessary build files for your chosen build system. If CMake encounters any issues during this process, it will report them, and you can adjust your CMakeLists.txt file(s) accordingly.

The output should look something like this:

root@Ubuntu/home/ubuntu/helloworld/build# cmake ../
-- The C compiler identification is GNU 9.4.0
-- The CXX compiler identification is GNU 9.4.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/helloworld/build

Building the Project

Once the build files have been generated, you can build your project by running the appropriate command for your build system.

For example, if you’re using Unix makefiles, you would run the make command within your build directory. This will compile your code, link libraries, and create the final executable as you specified in your CMakeLists.txt files.

root@Ubuntu:/home/ubuntu/helloworld/build# make
Scanning dependencies of target HelloWorld
[ 50%] Building CXX object CMakeFiles/HelloWorld.dir/helloworld.cpp.o
[100%] Linking CXX executable HelloWorld
[100%] Built target HelloWorld
root@Ubuntu:/home/ubuntu/helloworld/build#

Congratulations, you have just created and run your first CMake project!

Understanding Targets in CMake

Targets are one of the fundamental concepts in CMake. They denote the final products built by CMake, and can be executables, libraries, or custom commands.

Defining Executable Targets

Executable targets are the independent programs you create in CMake. The add_executable() function is used to define them. This function takes the name of the executable as its first argument and a list of source files as subsequent arguments.

For example, add_executable(myprog main.cpp) would declare an executable target named myprog that is built from the source file main.cpp.

The source files can be spread across multiple add_executable() calls for the same target, and CMake will concatenate them in the order they are listed. This is useful when your source files are scattered across several directories.

Defining Library Targets

Library targets are collections of object files that can be linked into other targets. There are two types of library targets in CMake: static and shared.

  • A static library is linked at compile time. This means that the code from the library is incorporated into the final executable when it is built. The advantage of this approach is that the executable is standalone and doesn’t require any additional libraries to be installed on the system where it runs.
  • A shared library is linked at runtime. This means that the code from the library is not incorporated into the final executable, but is instead loaded as needed when the program is run. The advantage of this approach is that multiple programs can share the same library, saving disk space and memory. However, the shared library must be available on the system where the executable runs.

add_library() is the function used to define library targets. It takes the name of the library as its first argument, the type of the library as its second argument (either STATIC or SHARED), and a list of source files as subsequent arguments.

For instance, add_library(mylibrary STATIC library.cpp) would declare a static library target named mylibrary that is built from the source file library.cpp.

Let’s add two files, library.cpp and library.h, in the same folder as helloworld.cpp:

#include <iostream>
#include "mylibrary.h"

void printMessage() {
    std::cout << "This is my library!" << std:endl;
}

Like executable targets, the source files for a library can be spread across multiple add_library() calls for the same target.

The updated CMakeLists.txt file will look like this:

cmake_minimum_required(VERSION 3.12)
project(HelloWorld)

set(CMAKE_CXX_STANDARD 14)

#Add the library
add_library(mylibrary mylibrary.cpp mylibrary.h)

#Build the executable
add_executable(helloworld helloworld.cpp)

#Link the executable with the library
target_link_libraries(helloworld mylibrary)

Execute CMake again to see if it compiles correctly:

root@Ubuntu:/home/ubuntu/helloworld/build# cmake ../
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/helloworld/build
root@Ubuntu:/home/ubuntu/helloworld/build#

root@Ubuntu:/home/ubuntu/helloworld/build# make
Scanning dependencies of target mylibrary

[ 25%] Building CXX object CMakeFiles/mylibrary.dir/mylibrary.cpp.o
[ 50%] Linking CXX static library libmylibrary.a
[ 50%] Built target mylibrary
Scanning dependencies of target helloworld
[ 75%] Building CXX object CMakeFiles/helloworld.dir/helloworld.cpp.o
[100%] Linking CXX executable helloworld
[100%] Built target helloworld
root@Ubuntu:/home/ubuntu/helloworld/build#

Setting Target Properties
Target properties are a powerful feature of CMake that allow you to control how a target is built. You can set properties such as the include directories, compile options, and link flags for a target.

target_include_directories(), target_compile_options(), and target_link_libraries() are some of the functions used to set target properties.

By setting these properties, you can customize the build process for each target, giving you fine-grained control over how your project is built.

Managing Dependencies with CMake

Managing dependencies is a critical aspect of any build system. CMake provides several facilities for managing dependencies between targets, and for integrating third-party libraries into your project.

Using target_link_libraries for Linking Libraries

target_link_libraries() is a function that allows you to specify which libraries a target should link against. It takes the name of the target as its first argument, and a list of libraries as subsequent arguments.

For example, target_link_libraries(myprog mylib) would specify that the myprog target should link against the mylib library.

This function is also used to specify dependencies between targets. If one target depends on another, you can use target_link_libraries() to ensure that the dependent target is built before the dependent one.

Finding Packages with find_package

The find_package() function in CMake is used to locate and configure packages that are required by your project. It takes the name of the package as its first argument, and an optional version number as its second argument.

When find_package() is called, CMake searches for the specified package in several locations, including the CMAKE_MODULE_PATH and CMAKE_PREFIX_PATH directories. If the package is found, find_package() sets several variables that you can use to configure your project.

Including Directories with include_directories

The include_directories() function in CMake is used to add directories to the include path for a target. This is useful when your project has header files in several directories.

include_directories() takes a list of directories as its arguments, and adds them to the include path for all targets that are defined after it is called.

This function is a global setting, meaning that it affects all targets in your project. However, you can also set the include directories for individual targets using the target_include_directories() function.

Debugging CMake Scripts

CMake provides several tools that can help you find and fix bugs in your scripts.

Using message() for Debugging

The message() function in CMake allows you to print messages to the console during the configuration phase.

You can use message() to print the values of variables, which can be very useful for understanding the flow of your script and for finding bugs.

For example, message(STATUS "The value of myvar is: ${myvar}") would print a message to the console showing the value of myvar.

The output will look like this:

cmake_minimum_required(VERSION 3.12)
project(HelloWorld)

set(CMAKE_CXX_STANDARD 14)

#Add the library
add_library(mylibrary mylibrary.cpp mylibrary.h)

#build the executable
add_executable(helloworld helloworld.cpp)

set(myvar "Hello, CMake!")

message(STATUS "The value of myvar is: ${myvar}")

#Link the executable with the library
target_link_libraries(helloworld mylibrary)

Please see the output below for the execution of CMake:

root@Ubuntu:/home/ubuntu/helloworld/build# cmake ../
-- The value of myvar is: Hello, CMake!
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/helloworld/build
root@Ubuntu:/home/ubuntu/helloworld/build#

Leveraging CMake’s –trace and –debug-output Options

CMake also provides command-line options that can assist in debugging: --trace and --debug-output.

The --trace option enables verbose output from CMake, showing each command that is executed during the configuration phase. This can be very helpful for understanding the flow of your script.

Here is an example of trace output:

root@Ubuntu:/home/ubuntu/helloworld/build# cmake ../ --trace
Running with trace output on.
/home/ubuntu/helloworld/CMakeLists.txt(1):  cmake_minimum_required(VERSION 3.12 )
/home/ubuntu/helloworld/CMakeLists.txt(2):  project(HelloWorld )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeSystem.cmake(1):  set(CMAKE_HOST_SYSTEM Linux-5.15.0-1036-aws )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeSystem.cmake(2):  set(CMAKE_HOST_SYSTEM_NAME Linux )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeSystem.cmake(3):  set(CMAKE_HOST_SYSTEM_VERSION 5.15.0-1036-aws )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeSystem.cmake(4):  set(CMAKE_HOST_SYSTEM_PROCESSOR x86_64 )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeSystem.cmake[...]
make(1):  set(CMAKE_C_COMPILER /usr/bin/cc )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeCCompiler.cmake(2):  set(CMAKE_C_COMPILER_ARG1  )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeCCompiler.cmake(3):  set(CMAKE_C_COMPILER_ID GNU )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeCCompiler.cmake(4):  set(CMAKE_C_COMPILER_VERSION 9.4.0 )
/home/ubuntu/helloworld/build/CMakeFiles/3.16.3/CMakeCCompiler.cm[...]
set(CMAKE_CXX_INFORMATION_LOADED 1 )
/home/ubuntu/helloworld/CMakeLists.txt(4):  set(CMAKE_CXX_STANDARD 14 )
/home/ubuntu/helloworld/CMakeLists.txt(7):  add_library(mylibrary mylibrary.cpp mylibrary.h )
/home/ubuntu/helloworld/CMakeLists.txt(10):  add_executable(helloworld helloworld.cpp )
/home/ubuntu/helloworld/CMakeLists.txt(12):  set(myvar Hello, CMake! )
/home/ubuntu/helloworld/CMakeLists.txt(14):  message(STATUS The value of myvar is: ${myvar} )
-- The value of myvar is: Hello, CMake!
/home/ubuntu/helloworld/CMakeLists.txt(17):  target_link_libraries(helloworld mylibrary )
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/helloworld/build

The --debug-output option, on the other hand, provides more detailed information about the configuration process. It shows which files are being processed, which commands are being executed, and the values of variables at each step.

root@Ubuntu:/home/ubuntu/helloworld/build# cmake ../ --debug-output
Running with debug output on.
-- The value of myvar is: Hello, CMake!
Called from: [1] /home/ubuntu/helloworld/CMakeLists.txt
-- Configuring done
-- Generating /home/ubuntu/helloworld/build
Called from: [1] /home/ubuntu/helloworld/CMakeLists.txt
-- Generating done
-- Build files have been written to: /home/ubuntu/helloworld/build
root@Ubuntu:/home/ubuntu/helloworld/build#

Runtime Security for C Language Devices with Sternum

The vast majority of IoT/embedded devices use C code, and are prone to related memory and code vulnerabilities, including vulnerabilities stemming from supply chain threats related to the use of CMake.

Sternum’s patented EIV™ (embeddded integrity verification) technology protects from these with runtime (RASP-like) protection that deterministically prevents all memory and code manipulation attempts, offering blanket protection from a broad range software weaknesses (CWEs)—for any library included as part of your C project.

Embedding itself directly in the firmware code, EIV™ is agentless and connection agnostic. Operating at the bytecode level, it is also universally compatible with any IoT device or operating system (RTOS, Linux, OpenWrt, Zephyr, Micirum, FreeRTOS, etc.) and has low overhead of only 1-3%, even on legacy devices.

EIV’s runtime protection features are also augmented by (XDR-like) threat detection capabilities of Sternum’s Cloud platform, it’s AI-powered anomaly detection and extended observability features.

The video below shows Sternum EIV™ in action, providing out of the box mitigation of heap coruption attack.

Related content: Read our guide to vulnerability management tools

JUMP TO SECTION

Enter data to download case study

By submitting this form, you agree to our Privacy Policy.