Using CMake for Cross-Platform Development

Using CMake for Cross-Platform Development

Source | Network

For C/C++ developers, managing projects with complex third-party dependencies can become very tricky, especially when cross-platform support is needed.

CMake, as a cross-platform build management tool, provides a mature solution for finding and including third-party dependencies, creating build systems, testing programs, and installation. By writing a single CMakeLists.txt file and executing the same command, you can create executables or link libraries on different systems. After getting familiar with CMake, I believe this compilation experience can barely match half of modern languages like Rust and Go, with the other half lacking in package management, which I won’t discuss here. Of course, if you’re just solving algorithm problems, you don’t need such a complex tool as CMake; simple use of gcc or clang will suffice.

Like C++, CMake has evolved over the years, with many improvements made to its design, resulting in significant differences compared to older versions, hence the term modern CMake. The traditional way of using CMake is still valid, but just like modern C++, the modern way of using CMake is clearer in some concepts, more user-friendly, and less error-prone.

# A simple example of a modern CMake project
cmake_minimum_required(VERSION 3.12)
project(myproj)
find_package(Poco REQUIRED COMPONENTS Net Util)
add_executable(MyEXE)
target_source(MyEXE PRIVATE "main.cpp")
target_link_library(MyEXE PRIVATE Poco::Net Poco::Util)
target_compile_definition(MyEXE PRIVATE std_cxx_14)

1. Target and Configuration Around Target

A C/C++ project is usually aimed at generating executables or link libraries, which are collectively referred to as<span>target</span> in modern CMake. The creation commands are<span>add_library()</span> and <span>add_executable()</span>. The types of link libraries are further divided into many kinds, with the most common being <span>SHARED</span> and <span>STATIC</span>. You declare them in the command by adding keywords:<span>add_library(MyLib SHARED)</span>, where the first argument is the name of the <span>target</span>, and subsequent configurations will need to use this name.
In a <span>CMakeLists.txt</span>, there can be multiple <span>target</span>s, and most related configurations revolve around these targets. For example, specifying the source files for a <span>target</span>:
target_source(MyLib PRIVATE "main.cpp" "func.cpp")
In CMake, the <span>PRIVATE</span> keyword is used to describe the “scope of application” of parameters. There are also two other possible values: <span>INTERFACE</span> and <span>PUBLIC</span>. These will be introduced in detail in the next section, and can be ignored for now.
When transforming an existing project into a CMake project, there are usually many source files. You can use CMake’s <span>file</span> command to traverse and get all the source files:
file(GLOB_RECURSE SRCS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)

The first argument of the command <span>GLOB_RECURSE</span> indicates a recursive search in subfolders, the second argument <span>SRCS</span> is the name of the variable that stores the results, and the third argument is the matching pattern for target files. After finding the matching cpp files, their paths will be saved in the SRCS variable as a string array, and can be used as follows:

target_source(MyLib PRIVATE ${SRCS})
In addition to source files, when configuring a <span>target</span>, you usually also need to specify the header file directories:
target_include_directories(MyLib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include/)

Language features required at compile time:

target_compile_features(MyLib PRIVATE std_cxx_14)

And macro definitions at compile time:

target_compile_definitions(MyLib PRIVATE LogLevel=3)

If you have some parameters you want to pass directly to the underlying compiler (like gcc, clang, cl), you can use:

target_compile_options(MyLib PRIVATE -Werror -Wall -Wextra)
The configurations done through commands like <span>target_source</span> and <span>target_*</span> are only effective for the specified target. In traditional CMake, these configurations are usually defined as global variables, such as using <span>include_directories()</span> and <span>set_cxx_flags()</span>. The issue with the traditional method is low flexibility; when multiple targets exist, they cannot be configured separately, leading to the accidental pollution of a target’s properties. Therefore, the modern CMake configuration method based on targets is much more manageable, similar to introducing namespaces.

2. Build Specification and Usage Requirement

Dependencies are very common in software development. C/C++ introduces dependencies through include header files, which can be called after dynamic or static linking. An executable program may depend on link libraries, and link libraries may also depend on other link libraries. A tricky problem arises: how do users know what conditions are required to use these external dependency libraries? For example, the code in their header files may require enabling C++17 support in the compiler, or when there are many dynamic link libraries, it may only need to link a small part of them, and what indirect dependencies need to be installed, along with their version requirements…
The simplest and most straightforward solution to these problems is textual explanation. The author of the dependency library can specify usage requirements in a README, website, or even in the header files, but this approach is obviously inefficient.
CMake provides a solution: when configuring a target, you can specify the type of configuration, divided into build specification and usage requirement, which will affect the scope of application of the configuration. Build specification type configurations only need to be satisfied at compile time and are declared with the PRIVATE keyword; usage requirement type configurations need to be satisfied at the time of use, i.e., when using the already compiled target of this project in other projects, this type of configuration is declared with the INTERFACE keyword. In actual projects, many configurations need to be satisfied both at compile time and at use, which are declared with the PUBLIC keyword.
Let’s look at an example. We wrote a library that statically linked Boost at compile time, using C++14 features in our implementation files, and utilized Boost’s header files and functions. Subsequently, we released this library, which includes header files and precompiled dynamic link libraries. Although our implementation code used C++14, the header files provided externally only used C++03 syntax and did not include any Boost code. In this case, when other projects use our library, their compilers do not need to enable C++14 support, and the development environment does not need to install Boost. Our library’s CMake configuration can be written as follows:
target_compile_features(MyLib PRIVATE cxx_std_14)
target_link_libraries(MyLib PRIVATE Boost::Format)

Here, we use PRIVATE to indicate that C++14 support is only needed at compile time, and linking to the Boost library is also only needed at compile time. However, if the header files we provide externally also use C++14, we need to use PUBLIC, changing it to:

target_compile_features(MyLib PUBLIC cxx_std_14)
target_link_libraries(MyLib PRIVATE Boost::Format)

When the library is header-only, our project does not need to be compiled separately, so there is no build specification; we can configure it with the INTERFACE modifier:

target_compile_features(MyLib INTERFACE cxx_std_14)
It is important to note that usage requirement type configurations, which are configured with INTERFACE or PUBLIC modifiers, are transitive. For example, if LibA depends on LibB, it inherits LibB’s usage requirements, and when LibC depends on LibB, both LibA’s and LibB’s usage requirements will be inherited. This is very useful in the presence of multi-level dependencies.
Now a question arises: how can users know about these targets and their <span>PRIVATE</span>, <span>INTERFACE</span>, and <span>PUBLIC</span> attributes?

3. Finding and Using Link Libraries

For users, a major issue is how to find dependencies and understand how to use them. The C/C++ standard does not specify the installation location and form of libraries. The solution provided by CMake to find dependencies not only locates header file directories and link library paths but also retrieves the library’s usage requirements.
The command to find third-party libraries in CMake is <span>find_package</span>, which works in two ways: one is based on the Config File search, and the other is based on the Find File search. When executing <span>find_package</span>, CMake is actually looking for these two types of files and retrieves library information from them.
1. Finding Dependencies via Config File
Config Files are CMake scripts provided by the dependency developers, usually released together with precompiled binaries for downstream users. In the Config File, the targets included in the library are described, along with version information, header file paths, link library paths, compile options, and other usage requirements.
CMake has specific naming conventions for Config Files. For a command like <span>find_package(ABC)</span>, CMake will only look for <span>ABCConfig.cmake</span> or <span>abc-config.cmake</span>. The default search paths for CMake depend on the platform; on Linux, the search paths include <span>/usr/lib/cmake</span> and <span>/usr/lib/local/cmake</span>, where you can find a lot of Config Files. Generally, when installing a library, its accompanying Config File will be placed here.
On Windows, there are no standardized installation directories for libraries, so they may be installed in various unusual places. Additionally, on Linux, libraries may not be installed in the aforementioned default locations. In these cases, CMake also provides a solution: for the command <span>find_package(Abc)</span>, if CMake cannot find the Config File, users can provide the <span>Abc_DIR</span> variable, and CMake will search for the Config File in the path pointed to by <span>Abc_DIR</span>.
2. Finding Dependencies via Find File
Config Files seem great, as they are CMake scripts written by developers, and users only need to find the Config File to obtain the library’s usage requirements. However, the reality is that not all developers use CMake, and many libraries do not provide Config Files for CMake usage. In this case, we can still use Find File.
For the command <span>find_package(ABC)</span>, if CMake does not find the Config File, it will try to search for <span>FindABC.cmake</span>. Find Files serve the same purpose as Config Files, but they are written by others rather than the library developers. If the library you are using does not provide a Config File, you can search online for a Find File or write one yourself and then include it in your CMake project.
The good news is that CMake has already provided many Find Files for us. On the CMake Documentation page, you can see that well-known libraries like OpenGL, OpenMP, and SDL have official Find scripts written for us, so we can directly call the <span>find_package</span> command. However, since the installation locations of libraries are not fixed, these Find scripts may not always find the libraries. In such cases, you can set the corresponding variables according to CMake’s error messages, usually needing to provide the installation path, so that the Find File can retrieve the library’s usage requirements. Whether it’s a Config File or a Find File, the goal is not just to find the library but to inform CMake how to use it.
The bad news is that there are many libraries for which CMake has not provided Find Files; in this case, you will need to write one yourself or search for one.
Once written, place it in the project directory and modify the CMAKE_MODULE_PATH variable:
list(INSERT CMAKE_MODULE_PATH 0 ${CMAKE_SOURCE_DIR}/cmake)
Now the Find File in the ${CMAKE_SOURCE_DIR}/cmake directory can be found by CMake.
However, a new question arises: how should Config Files and Find Files be written?
Imported Target
In C/C++ projects, our most basic requirement for dependencies is to know their link library paths and header file directories. This can be accomplished with CMake’s <span>find_library</span> and <span>find_path</span> commands:
find_library(MPI_LIBRARY
  NAMES mpi
  HINTS "${CMAKE_PREFIX_PATH}/lib" ${MPI_LIB_PATH}
  # If libmpi.so is not found in the default paths, it will look in MPI_LIB_PATH; downstream users can set this variable value
)
find_path(MPI_INCLUDE_DIR
  NAMES mpi.h
  PATHS "${CMAKE_PREFIX_PATH}/include" ${MPI_INCLUDE_PATH}
  # If mpi.h is not found in the default paths, it will look in MPI_INCLUDE_PATH; downstream users can set this variable value
)
In the early days of CMake, dependency developers declared these two items in CMake scripts using global variables. For instance, a library named Abc would create the variables Abc_INCLUDE_DIRS and Abc_LIBRARIES in its CMake script for downstream users to use. Although this command is not officially mandated, it has become a common practice. To this day, many libraries still provide such global variables for compatibility with older CMake usage.
In modern CMake, providing a target in CMake scripts is clearly better because targets have properties. We not only need to find the library but also understand how to use it; using targets allows us to obtain more information about the library, including header file directories and link library paths.
Thus, modern CMake provides a special target called Imported Target, created with the command <span>add_library(Abc STATIC IMPORTED)</span>, which indicates an external dependency that already exists and does not need to be compiled. The second parameter indicates the type, such as whether it is a static or dynamic library. Developers tend to use a namespace-style naming convention for Imported Targets, such as Boost::Format, Boost::Asio, etc. Similarly, a CMake script can have multiple Imported Targets.
We can treat Imported Targets like regular targets, calling commands like <span>target_link_libraries</span> to specify their usage requirements. However, there is another configuration method; as mentioned earlier, we can use <span>PRIVATE</span>, <span>INTERFACE</span>, and <span>PUBLIC</span> to modify target properties, which can be viewed as syntactic sugar. In CMake, most target properties have corresponding private and interface variable versions. For example, when configuring header file directories with <span>target_include_directories</span>, if we use <span>PRIVATE</span> modifier, the value is written to the target’s INCLUDE_DIRECTORIES variable; if we use <span>INTERFACE</span>, it is written to INTERFACE_INCLUDE_DIRECTORIES variable; and if we use <span>PUBLIC</span>, it will be written to both variables. In CMake, we can modify these variable values directly using <span>set_target_properties</span> instead of using target commands.
For Imported Targets, when libraries are precompiled, we need to specify the exact location of dynamic link libraries through a special variable, IMPORTED_LOCATION. This variable can be set using <span>set_target_properties</span>. In actual production environments, due to the differences between Release and Debug environments, IMPORTED_LOCATION actually has multiple versions, such as IMPORTED_LOCATION_RELEASE and IMPORTED_LOCATION_DEBUG. After setting these, CMake will choose the correct link library for downstream users based on these variables in the corresponding environment.
# Imported Target for spdlog library
set_target_properties(spdlog::spdlog PROPERTIES
  IMPORTED_LINK_INTERFACE_LANGUAGES_RELEASE "CXX"
  IMPORTED_LOCATION_RELEASE "${_IMPORT_PREFIX}/lib/spdlog/spdlog.lib"
)
Another advantage of using Imported Targets is that when we introduce a dependency, we only need to link its Imported Target without manually adding its header file directories. This is because the header file directories of the dependency are already included in its target’s INTERFACE properties, and the INTERFACE properties are transitive, so:
find_package(spdlog REQUIRED)
add_executable(MyEXE)
target_source(MyExe "main.cpp")
target_link_libraries(MyExe SPDLog::spdlog)
No need for <span>target_include_directories</span>, the header file directories of spdlog will be added automatically.
3. Handling find_package
Returning to the <span>find_package</span> command, this command can specify many parameters, such as version and specific modules. For example, with the SFML multimedia library, which includes network, audio, graphic modules, etc., if I only need the graphic module, other modules’ corresponding link libraries do not need to be linked. Thus, the CMake script can be written as follows:
# Require the graphic module of SFML library with major version 2
find_package(SFML 2 COMPONENTS graphics REQUIRED)
# The target name provided by SFML is sfml-graphics
target_link_libraries(MyEXE PRIVATE sfml-graphics)
For the <span>find_package</span> command, these parameters for versions, modules, etc., need to be handled in the Config File or Find File. In cases of version mismatches or non-existent modules, downstream users should be prompted. On this front, CMake has also considered the needs of dependency developers by providing the <span>FindPackageHandleStandardArgs</span> module. By including this module in CMake scripts, you can use <span>find_package_handle_standard_args</span> command to inform CMake how to obtain the current package’s version variable and how to determine whether the library has been found, as shown in the following CMake script for RapidJSON:
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(RapidJSON
    REQUIRED_VARS RapidJSON_INCLUDE_DIR
    VERSION_VAR RapidJSON_VERSION
)
This script declares that the version value of the current library should be taken from the <span>RapidJSON_VERSION</span> variable, while the <span>RapidJSON_INCLUDE_DIR</span> variable can be used to indicate whether the library has been found. When this script is executed, CMake first checks whether the <span>RapidJSON_INCLUDE_DIR</span> variable is empty. If it is empty, it indicates that the library was not found, and CMake will directly report an error to downstream users. If this variable is not empty, and if downstream users provided a version number when calling <span>find_package</span>, CMake will compare it with the value from the <span>RapidJSON_VERSION</span> variable. If the version does not meet the requirements, it will also report an error.

4. Using CMake to Compile

After CMake generates the build environment, the underlying make, ninja, MSBuild compilation commands are different, but CMake provides a unified method for compilation:

cmake --build .

Using the –build flag, CMake will call the underlying compilation commands, which is very convenient for cross-platform development.

For Visual Studio, its Debug and Release environments are based on configuration, so the CMAKE_BUILD_TYPE variable is invalid and needs to be specified when building:

cmake --build . --config Release

5. Drawbacks of CMake

The drawbacks of CMake are quite apparent: the entry cost is very high, and its syntax design is poor. Functions like <span>find_package</span> do not return results but instead produce side effects on global variables or targets, making their behavior difficult to predict without consulting the documentation. Additionally, in CMake, the distinctions between variables, targets, and strings are unclear, which can be confusing, leaving users unsure of when to use <span>${}</span> to read values.
Furthermore, the tutorials on the official website are outdated. Although they are usable, they do not demonstrate how to create projects using modern CMake methods. It is recommended to refer to the materials provided at the end of this article instead of the official website’s tutorial.
I hope to introduce the specific creation methods for Config Files, library installation, and testing based on ctest when I have more time. However, I also hope that a better alternative tool will emerge before I update.

References:

cmake-buildsystem
cmake-packages
It’s Time To Do CMake Right

Copyright Notice: Author | Night Breeze

https://ukabuer.me/blog/more-modern-cmake/

Copyright belongs to the original author. This is for academic discussion and research. If there are any copyright issues, please contact us promptly, thank you!

Finally,
The author has collected some embedded learning materials,
Reply with 【1024】 in the public account to get the download link~
Recommended Good Articles Click the blue text to jump
☞ Collection | Comprehensive Programming in Linux
☞ Collection | Learn Some Networking Knowledge
☞ Collection | Manual C Language
☞ Collection | Manual C++ Language
☞ Collection | Experience Sharing
☞ Collection | From Microcontrollers to Linux
☞ Collection | Power Control Technology
☞ Collection | Essential Mathematical Knowledge for Embedded Systems
☞ Collection | MCU Advanced Collection
☞ Collection | Advanced Embedded C Language Collection
☞ Collection | Experience Sharing

Leave a Comment