CMake Variables and Logging
A primer on the fundamental building blocks of the CMake language: variables, string interpolation, and logging messages.
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 (likegreeter/CMakeLists.txt
) that are processed after theset()
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 withCMake Warning:
, but does not stop processing.FATAL_ERROR
: Prints an error message prefixed withCMake 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. UseSTATUS
for progress reports andFATAL_ERROR
for validation to create user-friendly error messages.
You now have the fundamental vocabulary to read and write basic CMake scripts.
The CMake Cache
Discover the CMake Cache, the mechanism for storing persistent user-configurable options, and learn how to create and manage cached variables.