CMake Variables and Logging

A primer on the fundamental building blocks of the CMake language: variables, string interpolation, and logging messages.

Greg Filak
Updated

So far, we've treated CMake as a tool where we call a few commands like add_library() and project(). While this is true, it's also an incomplete picture. CMake isn't just a list of commands; it's a full-fledged scripting language with its own syntax, rules, and idioms.

We've already had a glimpse of this with ${PROJECT_SOURCE_DIR}, which looks suspiciously like a variable. To move beyond simple projects and write flexible and maintainable build scripts, we need to understand the grammar of the CMake language.

In this lesson, we'll introduce three important building blocks: variables, string interpolation, and logging.

Creating Variables

At its heart, CMake is a language for manipulating strings. The primary way we store and manage these strings is with variables. A variable can hold a file path, a compiler flag, a list of source files, or just a simple piece of text.

Setting Variables with set()

The workhorse command for creating and modifying variables is set(). Its most basic form is this:

set(VARIABLE_NAME "Value")

Let's add a version number to our project's CMakeLists.txt:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(Greeter)

add_subdirectory(greeter)
add_subdirectory(app)

Here, we created a variable named GREETER_VERSION and assigned it the string value "1.0".

Updating Variables

We can update the value of an existing variable by using set() again. We pass the same name as the first argument, and the new value as the second argument:

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")
project(Greeter)

set(BUILD_STEP "1")
add_subdirectory(greeter)

set(BUILD_STEP "2")
add_subdirectory(app)

Unsetting Variables with unset()

We can unset a variable by using set() without a second argument or, more explicitly, by using unset():

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(Greeter)

set(BUILD_STEP "1")
add_subdirectory(greeter)

set(BUILD_STEP "2")
add_subdirectory(app)

# Unset the variable:
set(BUILD_STEP)

# Alternatively:
unset(BUILD_STEP)

Dereferencing Variables

To use the value stored in a variable, you "dereference" it using the ${VARIABLE_NAME} syntax. CMake will replace this placeholder with the variable's string content before executing the command.

For example, the project() command we introduced earlier allows us to attach optional metadata to our project, such as a description, a website URL, and a version number.

Each piece of metadata is provided as two arguments, in the form of a key-value pair. An example is provided below, where we've also added some optional white space and used our new GREETER_VERSION variable:

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(Greeter)
project(
  Greeter
  VERSION ${GREETER_VERSION}
  HOMEPAGE_URL "https://www.studyplan.dev"
  DESCRIPTION "A program that says hello to people"
)

add_subdirectory(greeter)
add_subdirectory(app)

Variable Scope

Where you set() a variable determines where it can be used. When you call add_subdirectory(greeter), CMake creates a new scope. CMake's default scoping behaviour is generally going to be recognisable for those familiar with C++, or most other programming languages:

  • Variables flow down: Variables set in a parent scope (like the root CMakeLists.txt) are visible in all child scopes (like greeter/CMakeLists.txt) that are processed after the set() command.
  • Variables do not flow up: Variables set in a child scope are not visible in the parent scope.

Let's demonstrate this with our modular project.

First, let's update our root CMakeLists.txt to print our version variable. We'll introduce the message() command here, which is CMake's equivalent of C++ expressions that might involve std::cout, printf(), or std::print():

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(
  Greeter
  VERSION ${GREETER_VERSION}
  HOMEPAGE_URL "https://www.studyplan.dev"
  DESCRIPTION "A program that says hello to people"
)

message("Root scope version: ${GREETER_VERSION}")

add_subdirectory(greeter)
add_subdirectory(app)

Now, let's try to access that variable from within the library's CMakeLists.txt:

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_library(GreeterLib src/Greeter.cpp)
target_include_directories(GreeterLib PUBLIC
  ${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_features(GreeterLib PUBLIC cxx_std_20)

message("Library scope version: ${GREETER_VERSION}")

If we configure our project now (by running cmake .. from the build directory), we'll see both messages print correctly, proving that the variable flowed down into the subdirectory:

cmake ..
Root scope version: 1.0
Library scope version: 1.0
-- Configuring done (0.1s)
-- Generating done (0.1s)
-- Build files have been written to: D:/Projects/cmake/build

Now let's try the reverse. Let's set a variable inside the library's scope and try to access it from the application's scope.

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_library(GreeterLib src/Greeter.cpp)
target_include_directories(GreeterLib PUBLIC
  ${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_features(GreeterLib PUBLIC cxx_std_20)

set(LIBRARY_MESSAGE "This is the GreeterLib")

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(
  Greeter
  VERSION ${GREETER_VERSION}
  HOMEPAGE_URL "https://www.studyplan.dev"
  DESCRIPTION "A program that says hello to people"
)

add_subdirectory(greeter)
add_subdirectory(app)

message("Library scope message: ${LIBRARY_MESSAGE}")

When we configure now, we'll see that LIBRARY_MESSAGE is empty when accessed from the parent scope. It only existed within the greeter subdirectory's scope and was discarded when CMake finished processing that file:

cmake ..
Library scope message:
-- Configuring done (0.1s)
-- Generating done (0.1s)
-- Build files have been written to: D:/Projects/cmake/build

There are ways in components can communicate with each other outside of these scoping rules, and we'll see some examples of that later in the course.

However, we should be cautious when implementing such designs. To keep complexity under control, a component's ability to modify the state of its parent or siblings should be extremely limited.

String Interpolation with Variables

As we've seen, any time you use ${VAR} inside a double-quoted string, it gets replaced by the variable's value. This is called string interpolation.

A common use case for this is to construct file system paths:

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

set(SRC_DIR "src")
set(MAIN_FILE "main.cpp")

add_executable(GreeterApp src/main.cpp)
add_executable(GreeterApp "${SRC_DIR}/${MAIN_FILE}")

target_compile_features(GreeterApp PUBLIC cxx_std_20)
target_link_libraries(GreeterApp GreeterLib)

Quoted vs. Unquoted Arguments

How you use quotes when setting a variable is important. The following are not equivalent:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

# ...

# Quoted argument: The variable is a single string
set(MY_VAR "a b c")
message("Quoted: ${MY_VAR}")

# Unquoted arguments: The variable becomes a list
set(MY_VAR a b c)
message("Unquoted: ${MY_VAR}")

If we run this, the output shows a subtle but important difference:

cmake ..
Quoted: a b c
Unquoted: a;b;c
-- Configuring done (0.1s)
-- Generating done (0.0s)
-- Build files have been written to: D:/Projects/cmake/build

When you pass multiple unquoted arguments to set(), CMake combines them into a single string, separated by semicolons. This is CMake's syntax for a list, which we'll introduce soon.

To deal with this, we should follow a simple convention: if you intend for a variable to be a single string, always enclose its value in double quotes.

Logging and Printing Messages

The message() command is our primary tool that our build script can use to report what is happening. It's how you print variable values, show status updates, and report errors.

We've already used it in its simplest form but, more commonly, we'd pass a "mode" as the first argument to our message() command. Using the STATUS mode would look like this:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(
  Greeter
  VERSION ${GREETER_VERSION}
  HOMEPAGE_URL "https://www.studyplan.dev"
  DESCRIPTION "A program that says hello to people"
)

message(STATUS "Configuring Greeter Version: ${GREETER_VERSION}") 

add_subdirectory(greeter)
add_subdirectory(app)
cmake ..
-- Configuring Greeter Version: 1.0
-- Configuring done (0.1s)
-- Generating done (0.1s)
-- Build files have been written to: D:/Projects/cmake/build

Common Message Modes

  • NOTICE: Just prints the text. This is the default behavior if no mode is provided.
  • STATUS: Prints the message prefixed with --. This is the standard way to display non-critical information to the user during configuration.
  • WARNING: Prints a warning message prefixed with CMake Warning:, but does not stop processing.
  • FATAL_ERROR: Prints an error message prefixed with CMake Error: and immediately stops all processing.

The following CMakeLists.txt demonstrates some of these. It also includes our first example of conditional logic, which we'll cover in more detail later in this chapter:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(
  Greeter
  VERSION ${GREETER_VERSION}
  HOMEPAGE_URL "https://www.studyplan.dev"
  DESCRIPTION "A program that says hello to people"
)

if(DEFINED GREETER_VERSION)
  message(STATUS "Configuring Version: ${GREETER_VERSION}")
else()
  message(FATAL_ERROR "Project version must be specified")
endif()

add_subdirectory(greeter)
add_subdirectory(app)

If we attempt to configure our project without setting GREETER_VERSION, we'll see the FATAL_ERROR message is now blocking us:

cmake ..
CMake Error at CMakeLists.txt:10 (message):
  Project version must be specified

-- Configuring incomplete, errors occurred!

Verbosity and Log Levels

In most systems where logging is important, developers have the option of controlling the verbosity of the output. This lets them control how much information they want to see being reported.

By default, the system will output all messages that use one of the modes we listed above, but we have the option of using less important modes. These more verbose modes are, in in decreasing level of importance, VERBOSE, DEBUG, and TRACE:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

message(STATUS "This is a status message")
message(VERBOSE "This is verbose message")
message(DEBUG "This is debug message")
message(TRACE "This is trace message")

set(GREETER_VERSION "1.0")

# ...

Messages that use these less important modes are hidden by default:

cmake ..
-- This is a status message
-- Configuring Version: 1.0
-- Configuring done (0.1s)
-- Generating done (0.1s)
-- Build files have been written to: D:/Projects/cmake/build

Why is that useful? The main reason is that, most of the time, people don't want to be flooded with minor messages - they just want the most important information. However, the option to see those less important messages can sometimes be very useful.

The main use case is when the system isn't behaving correctly. Asking the system to provide more detailed information in such scenarios can help us understand what is going wrong and how to fix it.

The --log-level Option

We ask for this additional information by changing the default verbosity, or "log level", such that messages that were previously hidden will now be displayed.

To specify the verbosity we want to see when configuring our project, we use the --log-level setting in the command line. By setting it to VERBOSE, we see messages with VERBOSE mode and higher. Less important messages (DEBUG and TRACE) are still hidden:

cmake .. --log-level=VERBOSE
-- This is a status message
-- This is verbose message
-- Configuring Greeter Version: 1.0
-- Configuring done (0.1s)
-- Generating done (0.1s)
-- Build files have been written to: D:/Projects/cmake/build

The --log-level options are, in increasing level of detail, ERROR, WARNING, NOTICE, STATUS , VERBOSE, DEBUG, and TRACE.

Each option includes messages in the previous categories so, for example, if we set it to WARNING, we will see both WARNING and ERROR messages. If we set it to TRACE, we will see everything.

If no option is set, STATUS is the default.

CMake-Provided Variables

In addition to defining our own variables, there are also a lot of variables that CMake automatically defines for us. We've already used two of those - PROJECT_SOURCE_DIR and CMAKE_CURRENT_SOURCE_DIR - which were helpful when defining our include directories.

There are many more system-provided variables. For example, a side effect of our project() command is that it automatically defines a PROJECT_NAME variable.

If we provide additional metadata to the command, such as a verion number, then variables such as PROJECT_VERSION, PROJECT_VERSION_MAJOR, PROJECT_VERSION_MINOR, and PROJECT_VERSION_PATCH will also be available.

We don't want to have two variables for the same information, as failing to keep them in sync can result in bugs. Therefore, we should remove our custom GREETER_VERSION variable, and rely on the system-provided PROJECT_VERSION as our single source of truth:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

set(GREETER_VERSION "1.0")

project(
  Greeter
  VERSION ${GREETER_VERSION}
  VERSION "1.0" 
  HOMEPAGE_URL "https://www.studyplan.dev"
  DESCRIPTION "A program that says hello to people"
)

if(DEFINED GREETER_VERSION)
if(DEFINED PROJECT_VERSION)
  message(STATUS "Configuring Version: ${GREETER_VERSION}")
  message(STATUS "Configuring Version: ${PROJECT_VERSION}")
else()
  message(FATAL_ERROR "Project version must be specified")
endif()

add_subdirectory(greeter)
add_subdirectory(app)

When there is some generic information you need for your CMakeLists.txt file, it is likely that CMake has a variable for that. We'll cover the most common and useful ones as we go through the course, but a full list is available on the official site.

Summary

In this lesson, we took our first steps into the CMake scripting language. These concepts are the bedrock upon which all complex build scripts are built.

  • The set(VAR "value") Command: Create and update variables. Remember to quote your strings.
  • Using ${VAR} to Dereference Variables: The syntax to use a variable's value.
  • Scope Matters: Variables flow down into subdirectories but not back up. This keeps components isolated.
  • The message(<mode> "...") Command: The main tool for debugging and communication. Use STATUS for progress reports and FATAL_ERROR for validation to create user-friendly error messages.

You now have the fundamental vocabulary to read and write basic CMake scripts.

Next Lesson
Lesson 14 of 18

The CMake Cache

Discover the CMake Cache, the mechanism for storing persistent user-configurable options, and learn how to create and manage cached variables.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate