Skip to content

Latest commit

 

History

History
733 lines (547 loc) · 33.8 KB

cmake_install.md

File metadata and controls

733 lines (547 loc) · 33.8 KB

Reusing projects with CMake

Video

We talked about CMake before, why we use it, what it is and how to configure it to build various libraries and binaries. However, until now we targeted the development of a single project. If we want to develop multiple interdependent projects or make our CMake project reusable by others what we know so far is not enough.

One way of doing this within the CMake world is to install a project and find it using the find_package command from another CMake project.

As an example, some dependent_project might have an executable print_hello, implemented in a print_hello.cpp file that uses a library from another core_project by including its header and calling a function core_project::PrintHello() from it:

dependent_project/print_hello.cpp

#include "core_project/some_library.hpp"

int main() {
  core_project::PrintHello();
  return 0;
}

To make this work we can use an already-installed core_project by calling find_package(core_project REQUIRED) from the CMakeLists.txt file of the dependent_project:

dependent_project/CMakeLists.txt

cmake_minimum_required(VERSION 3.16..3.24)
project(dependent_project
    VERSION 0.0.1
    DESCRIPTION "Sample project that uses the core project"
    LANGUAGES CXX)

# Set CMAKE_BUILD_TYPE if user did not provide it.
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE INTERNAL "Build type")
endif()
message(STATUS "CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}")

find_package(core_project REQUIRED)

add_executable(print_hello print_hello.cpp)
target_link_libraries(print_hello PRIVATE core_project::some_library)

After the find_package is called, the print_hello executable can use the functionality from some_library which is part of the core_project.

Note how we do not explicitly create the some_library before linking it to our new executable print_hello as it gets exported from the previously-installed core_project when we call the find_package command. The dependent_project builds when we call the commands very similar to those we already know, with a tiny addition of providing CMake with a CMAKE_PREFIX_PATH variable that points to the /tmp/install folder where, in this example, we chose to install the core_project:

# Note that -B flag requires at least CMake version 3.13
cmake -DCMAKE_PREFIX_PATH="/tmp/install"  -S . -B build
cmake --build build -j 12

And as a final proof, we can of course run our executable print_hello to get a "Hello World!" message printed to the terminal:

./build/print_hello
Hello World!

So let's dive in and see how all of this works!

Disclaimer

This is quite a nuanced topic and I'd like to start with a disclaimer that there are many ways to structure CMake projects and even more ways to make these projects talk to each other. My aim for today is to show the concept here and not to cover all the possible details.

As a result, I show just one way that I grew to like. I'm sure there are other ways equally good if not better to achieve very similar behavior and I am always super interested to know what works for you!

That being said, I believe that the concepts we're covering here are more of less universal and can be applied to any concrete CMake project design with minor adaptations.

What does find_package do?

With that out of the way, we can start our deep dive by understanding what the find_package does under the hood. And, just as in all the other topics we've already covered, there is of course no black magic here.

It is logical that in order to use the core_project::some_library target find_package must somehow provide us with it. What this really means is that the find_package command must somehow include all the CMake code that creates the needed core_project::some_library target as well as sets the required compiler and linker flags for it.

For now let us assume that core_project has already been installed into /tmp/install folder and try to understand all of the steps that find_package takes to allow us to use the core_project's targets. We'll get to how it was installed there in the second half of this tutorial.

Search modes

If we read CMake docs, there are two modes in which find_package operates: the module mode and config mode.

Module mode

By default when we call find_package(core_project) in our CMakeLists.txt, CMake first tries to use the module mode and looks for a file Findcore_project.cmake in the path stored under the CMAKE_MODULE_PATH CMake variable as well as among the default modules provided with the CMake installation. Once found, CMake includes that file, which, in CMake world, means that it executes all the code from that file.

If CMake is unable to find the required Find*.cmake file, or if we explicitly provide CONFIG or NO_MODULE as part of the find_package signature, it switches to the config mode instead.

find_package(core_project CONFIG REQUIRED)

🚨 We must note that while module mode is the default one, in modern CMake we want to use config mode most of the time.

Config mode

So what makes config mode better? On the surface config mode works in a very similar manner to the module mode.

For a call find_package(core_project CONFIG) or find_package(core_project NO_MODULE) CMake looks for a file core_projectConfig.cmake or for core_project-config.cmake using quite a comprehensive search procedure starting with the path specified in the CMAKE_PREFIX_PATH CMake variable. Note that the names of these files are case sensitive and in the second option core_project-config.cmake the name of the project is translated into lowercase, which, in our case does not make any difference but does play a role should your project name have capital letters.

However, the similarities end on this surface level. The full command for config mode has many more settings than the module mode has, and, even more importantly, it is possible to generate the needed config files almost completely automatically. This was not the case in the module mode, where most of the Find*.cmake files historically had to be written by hand. As you might imagine, this makes the config mode setup much more maintainable and versatile. To the degree of my knowledge that's the main reason why we prefer it to the module mode.

So in today's tutorial, I'm going to focus exclusively on the config mode as a more modern way, but please let me know if you'd like to hear about the module mode too as it might still be useful to understand when working with legacy code.

How do the config files look like?

Now that we know which files find_package looks for and where it looks for them, we can take a peek inside these files.

As we've mentioned before, because we call find_package(core_project), we expect to find a file core_projectConfig.cmake in a subfolder of the folder stored in the CMAKE_PREFIX_PATH CMake variable. In our case, as our core_project was installed into /tmp/install folder, we can inspect that folder to find the file that matches our description in one of its subfolders: /tmp/install/share/cmake/core_project/core_projectConfig.cmake. This file is not very large, and its most important part is that it includes another some_library_export.cmake CMake file:

include ( "${CMAKE_CURRENT_LIST_DIR}/some_library_export.cmake" )

What are the export files?

Looking around the same folder, we can easily find this some_library_export.cmake file, which has a lot more auto-generated code in it. Today, we don't care about most of this code, and focus on the relevant parts only, starting with the creation of an IMPORTED library:

# Create imported target core_project::some_library
add_library(core_project::some_library STATIC IMPORTED)

The keyword IMPORTED simply indicates that the actual binary file for this library will be provided at a later point.

A bit lower within the same some_library_export.cmake file, there is another relevant part of auto-generated code:

# Load information for each installed configuration.
get_filename_component(_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH)
file(GLOB CONFIG_FILES "${_DIR}/some_library_export-*.cmake")
foreach(f ${CONFIG_FILES})
  include(${f})
endforeach()

Here, CMake is instructed to look for additional CMake files to include and if we look around the folder that contains the some_library_export.cmake file we will find more files that match this pattern, for example some_library_export-release.cmake. This name depends on the configuration we used during build, having a release suffix in our case as we use the Release configuration by default.

Looking into this new file, we can find the code that sets all the relevant properties of our IMPORTED library target, including its language, its binary file location, and the location of its headers:

# Import target "core_project::some_library" for configuration "Release"
set_property(TARGET core_project::some_library APPEND PROPERTY IMPORTED_CONFIGURATIONS RELEASE)
set_target_properties(core_project::some_library PROPERTIES
  IMPORTED_LINK_INTERFACE_LANGUAGES_RELEASE "CXX"
  IMPORTED_LOCATION_RELEASE "${_IMPORT_PREFIX}/lib/libsome_library.a"
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include/"
)

❓ Actually, I have a couple of questions to you here: ❓

  • Which library kind is core_project::some_library?
  • How do you think this code would change if the library would be a header-only one?

The rest of the code in these files is not as relevant to us as it mostly performs some checks so that CMake is sure that all the necessary files are actually present on the system. But by all means, feel free to read into it when you have the time.

So, you see, we've found the commands that create the targets as well as those that set their relevant properties.

As a final piece of the puzzle, if we examine the lib and include folders in our CMAKE_PREFIX_PATH, we'll eventually find the needed binary and header files /tmp/install/lib/libsome_library.a and /tmp/install/include/core_project/some_library.hpp.

Summary of reusing targets

We have now traced the full path that CMake takes in order for us to be able to use targets from another project. To recap, we start with a call to find_package, which looks for a *Config.cmake file, that includes the necessary export files that point towards the headers and relevant binary files:

graph TD
  find[<code>find_package#40;core_project#41;</code>] --> configs[Config file:<br><code>.../core_project/core_projectConfig.cmake</code>] --> export[Export files:<br><code>.../core_project/some_library_export.cmake</code><br><code>.../core_project/some_library_export#8722;release.cmake</code>] --> headers[Binary and header files:<br><code>.../lib/libsome_library.a</code><br><code>.../include/core_project/some_library.hpp</code>]

Loading

How to make core_project available to dependent_project

So far so good. We now understand how a library within a dependent_project is able to use the library from the core_project after a find_package call. Now is a great time to figure out how these export files are actually created as well as how the header files and all the binary files land into the install folder in the first place.

We'll actually follow the diagram that we've just introduced, but in reverse, starting at the bottom and making it all the way up to its top.

Project skeleton for core_project

Before we learn how to to install the project we will have to define it so that this example does not get too abstract. We've already done very similar things before, so feel free to refer to the CMake tutorial if we rush too fast over some details here.

Let's start with the C++ code. For the sake of our example, our some_library has a single function PrintHello that is declared in a header file some_library.hpp:

core_project/core_project/some_library.hpp:

#pragma once

namespace core_project {

void PrintHello();

} // namespace core_project

With the implementation of this function, that just prints "Hello, world!" to the terminal, living in the corresponding source file some_library.cpp:

core_project/core_project/some_library.cpp

#include "core_project/some_library.hpp"

#include <iostream>

namespace core_project {

void PrintHello() {
  std::cout << "Hello, world!" << std::endl;
}

} // namespace core_project

And just to test that our library actually works, let's also add an example executable to print_hello.cpp file that includes some_library.hpp and calls it:

core_project/examples/print_hello.cpp:

#include "core_project/some_library.hpp"

int main() {
  core_project::PrintHello();
  return 0;
}

The CMake part of the core_project is pretty standard too - it declares a new project, chooses build type to be Release, if the user did not provide one, and delegates the target creation to other CMakeLists.txt files within the core_project and examples folders:

core_project/CMakeLists.txt:

cmake_minimum_required(VERSION 3.16..3.24)
project(core_project
    VERSION 0.0.1
    DESCRIPTION "Project to illustrate installation"
    LANGUAGES CXX)

# Set CMAKE_BUILD_TYPE if user did not provide it.
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
message(STATUS "CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}")

add_subdirectory(core_project)
add_subdirectory(examples)

These other CMakeLists.txt files are, again, nothing special and we've seen their kind before.

In the core_project/CMakeLists.txt we use a LIBRARY_NAME CMake variable for convenience, create our library target, set its C++ standard to C++17, indicate to it and its descendants where to look for the includes, and, finally, create an alias for this library that is prefixed by the ${PROJECT_NAME}. Strictly speaking, this alias is not required but it is a good practice to use the library in the same way from within the project as well as in other projects. We will see why a bit later.

core_project/core_project/CMakeLists.txt:

set(LIBRARY_NAME some_library)
add_library(${LIBRARY_NAME} ${LIBRARY_NAME}.cpp)
target_compile_features(${LIBRARY_NAME} PUBLIC cxx_std_17)
target_include_directories(${LIBRARY_NAME} PUBLIC
  $<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}>
  $<INSTALL_INTERFACE:include/>
)
add_library(${PROJECT_NAME}::${LIBRARY_NAME} ALIAS ${LIBRARY_NAME})

As for the examples/CMakeLists.txt, it creates an executable print_hello and links it to the core_project::some_library target:

core_project/examples/CMakeLists.txt:

add_executable(print_hello print_hello.cpp)
target_link_libraries(print_hello PRIVATE core_project::some_library)

For now we have done nothing new, we've already done very similar things before in the previous lectures on CMake. So we already know that we can easily configure and build our project with CMake as follows:

# Note that -B flag was introduced in CMake version 3.13
cmake -S . -B build
cmake --build build -j 12

Once it builds, we can run the example binaries without any issues:

./build/examples/print_hello
Hello world!

But we are still unable to use this library from our dependent_project aren't we? All of the special export and Config files are missing!

💡 We say that we install a CMake package when we make CMake create the config and export files as well as copy all the headers and binaries into the needed folders where they can be found by other projects.

And for now our core_project does not know how to install itself!

Installing a package

This installation process usually involves a couple of steps. It must:

  1. Copy all the needed headers into some include folder
  2. Copy all of the binary libraries into a lib folder and all executables into a bin folder (typically)
  3. Create the appropriate *_export.cmake files that create and configure the imported libraries
  4. Create the *Config.cmake file that includes all of the needed export files

And there is a special CMake command that we can call to kick-off all of these steps: cmake --install, which also gets an optional --prefix flag that sets the folder into which this package will be installed. We call this command after configuring and building the project:

# Note that --install flag was introduced in CMake version 3.15
cmake  -S . -B build                         # Configure.
cmake --build build -j 12                    # Build.
cmake --install build --prefix /tmp/install  # Install.

Here, we aim to install our package into some temporary install folder, but a more standard location, like /usr/local can be used if the --prefix flag is omitted. Note that in such a case we have to call this command with sudo rights.

💡 However, if we call the install command for our package now, nothing happens!

To actually perform all of the actions we've just talked about we need to write some more CMake code. But fear not, there is a number of different built-in CMake install commands that help us in most of these steps.

Let's look at all of these steps in detail.

1. Copying headers

Our first step is to copy the headers over. To this end, we add the following code to the end of core_project/core_project/CMakeLists.txt file:

install(
    DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    DESTINATION include  # Must appear before FILES_MATCHING!
    FILES_MATCHING PATTERN "*.hpp"
)

This install command finds all the *.hpp files in the folder specified under DIRECTORY, in our case core_project/core_project and copies that directory and all the matching files into the include folder, as the DESTINATION option specifies. If you also need to copy other header files, like *.h for example, you can always adapt the FILES_MATCHING PATTERN.

If we try installing our package after adding this CMake code to our project we'll be able to find the only header of our core_project installed into the expected location: /tmp/install/include/core_project/some_library.hpp:

cmake  -S . -B build                         # Configure.
cmake --build build -j 12                    # Build.
cmake --install build --prefix /tmp/install  # Install.
/tmp/install
└── include
    └── core_project
        └── some_library.hpp

We only have one header for now but I hope it is easy to imagine that we can do this for any number of headers and in any number of folders. I prefer to add these install statements on a per-folder basis. This way we do not have to add too much code while keeping the folder structure intact. That being said, I've also seen people adding a single similar install statement to the top-most CMakeLists.txt file of the project instead with minor modifications.

2. Copying binaries

Now that the headers are all installed, we need to do the same for all the binary files. We do this by adding another install command to core_project/core_project/CMakeLists.txt:

install(TARGETS ${LIBRARY_NAME})

As well as one more to core_project/examples/CMakeLists.txt for installing our example binaries:

install(TARGETS print_hello)

This will automatically install all executables to a bin folder and all of the binary libraries to a lib folder.

After running the install command again, we'll see that we now also have all of the binaries installed:

λ › tree /tmp/install
/tmp/install
├── bin
│   └── print_hello
├── include
│   └── core_project
│       └── some_library.hpp
└── lib
    └── libsome_library.a

With this, all of our binary and header files are where we want them to be. But we still lack the *_export.cmake files to tell CMake where to find them!

3. Creating export files

One classical way to do create these export files is to change the install(TARGETS ...) commands slightly for each of our library targets (one in our case) by adding an EXPORT entry to them:

install(
    TARGETS ${LIBRARY_NAME}
    EXPORT ${LIBRARY_NAME}_export  # <-- this one
)

Now this command not only installs the binary library file into the lib folder but it also associates it with an *_export.cmake file.

But this command alone is not enough for us as it does not actually copy the created _export file to the install folder, we can see it by running install again and not finding the _export file in the install folder. However, we do find it in the build folder!

So what remains is to copy this already generated file to the install folder, which we can do by using yet another install command that copies our export file to the /tmp/install/share/cmake/core_project folder:

install(EXPORT ${LIBRARY_NAME}_export
    FILE ${LIBRARY_NAME}_export.cmake
    NAMESPACE ${PROJECT_NAME}::
    DESTINATION share/cmake/${PROJECT_NAME}
)

Running our install procedure again with all of this new code, we'll be able to see this file where we expect to find it within the install folder tree:

λ › tree /tmp/install
/tmp/install
├── bin
│   └── print_hello
├── include
│   └── core_project
│       └── some_library.hpp
├── share
│   └── cmake
│       └── core_project
│           ├── some_library_export-release.cmake
│           └── some_library_export.cmake
└── lib
    └── libsome_library.a

Note also the NAMESPACE option that adds the project prefix to all targets that we export within the export files. Super handy to disambiguate our targets from any other ones. Also, this matches our own ALIAS that we created before, keeping everything nice and consistent.

4. Creating config files

Finally, with the export files in-place we only need to create the missing config files so that find_package can find all the export files we've just created. This process is a bit convoluted and involves a couple of steps.

To create the core_projectConfig.cmake file we typically call configure_package_config_file from the CMakePackageConfigHelpers that we include into our main CMakeLists.txt file:

include(CMakePackageConfigHelpers)

# Create a config file that CMake looks for when we call FindPackage(core_project)
configure_package_config_file(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in
 "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
 INSTALL_DESTINATION "share/cmake/${PROJECT_NAME}"
)

For this to work we need to create a "template file" that, in its simplest form includes all the export files we created before:

${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in

@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/some_library_export.cmake")

check_required_components(core_project)

We can specify all of the export files by hand, like we just did, but we can also use a glob expression that matches any file that ends in *_export.cmake:

${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in

@PACKAGE_INIT@

# Automatically include all exported library files
file(GLOB EXPORT_FILES "${CMAKE_CURRENT_LIST_DIR}/*_export.cmake")
foreach(EXPORT_FILE ${EXPORT_FILES})
    include(${EXPORT_FILE})
endforeach()

check_required_components(core_project)

Passing this cmake.in file into configure_package_config_file we create a ${PROJECT_NAME}Config.cmake in the current build folder. Note that this file is still not "installed", i.e., it is not present in the install folder just yet!

Before we install it though, we'll also need to generate another very similar file ${PROJECT_NAME}ConfigVersion.cmake by calling the write_basic_package_version_file command:

# Create a versioned config file that CMake uses to compare version of the package.
write_basic_package_version_file(
 ${PROJECT_NAME}ConfigVersion.cmake
 VERSION ${PACKAGE_VERSION}
 COMPATIBILITY AnyNewerVersion
)

This file is needed if we want to specify a version of our package. In our example we choose a version to match our project version as well as what the compatibility of this version is. This plays a role when we specify a version within the find_package(core_project 1.0) command. We will not go into more details here but I'm sure after this tutorial everyone should be able to look it up in the docs.

Finally, our last step is to copy the just-generated config files into the install folder, which we can do with yet another install(FILES ...) command, providing all of our config files in the build folder into it and, as before, specifying their install destination:

# Copy these files into the install directory.
install(
 FILES
 ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake
 ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake
 DESTINATION
 share/cmake/${PROJECT_NAME}
)

And now we're done with installing! If we run cmake --install command again and look at how the install folder tree looks like, it will contain all of our headers, all our binary files as well as the export and config files needed to configure all of the targets, should someone look for them.

How to use the installed package

One final thing to do now is to add a find_package call to our dependent_project of course. We've already seen this at the start of this tutorial:

cmake_minimum_required(VERSION 3.16..3.24)
project(dependent_project
    VERSION 0.0.1
    DESCRIPTION "Sample project that uses the core project"
    LANGUAGES CXX)

# Set CMAKE_BUILD_TYPE if user did not provide it.
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE INTERNAL "Build type")
endif()
message(STATUS "CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}")

find_package(core_project REQUIRED CONFIG)

add_executable(print_hello print_hello.cpp)
target_link_libraries(print_hello PRIVATE core_project::some_library)

And, just as before, we can configure and build this project. Because we've installed our core_project into a custom folder, /tmp/install in our case, we need to tell CMake where to look for packages by providing it with a CMAKE_PREFIX_PATH:

cd dependent_project
cmake -DCMAKE_PREFIX_PATH="/tmp/install"  -S . -B build
cmake --build build -j 12

Note that we don't need to provide an explicit CMAKE_PREFIX_PATH value if we installed our core_project in a standard system-wide location, like /usr/local.

Running these commands builds our dependent_project without issues and we can run the binary that we've just built too:

./build/print_hello
Hello World!

Summary

Now we've fully closed the loop on this topic. We started by understanding how find_package works under the hood - what files it looks for in which folders. We then went bottom up and learned how to configure a project to install all of these files ending with a fully working minimal two-project example that we can return to whenever we need to setup a more complex installation procedure.

As always, I believe that learning new things comes from doing, so I urge you to play around with these examples, which you can find on my GitHub as complete projects.

There is a number of questions I urge you to think about. How would you install more libraries than one? What changes in the generated export files if we make the library header-only? How to install dependent_project too? If you are able to answer all of these questions, then, for my money, you understand this topic quite well!

The CMake installation process is not a thing cast in stone. It changes over the years and, just as we move to a more target-based build, we are probably also going to be moving away from what is presented here and towards a more target-based install. But as it stands now, I believe that what we've covered today is still the most popular way to install packages and can be found in most CMake projects in the wild with slight differences here and there, which, after this tutorial, should be easier to spot and understand.