Why and how you should modularize your cmake library
The CMake building system and the modules
CMake is an amazing building system that handles many of the issues that the Makefile and automake systems handle with a sometimes obscure language and very steep learning curve.
Indeed starting with CMake is pretty simple, most of the time a few lines of set(THIS)
, add_executable
and target_do_that()
are enough.
But as your project becomes more complex and your requirements more articulated, the CMake language and syntax itself becomes more tricky.
The most impotant issue CMake handles with great profit is, in my opinion, handling the dependency from other libraries and framework, by using the find_package()
macro, along with the target_link_library
with modularized libraries.
When a library meets the cmake guidelines for packaging, it is enough to find the library using find_package()
and then declare the component name with the proper library namespace in the target_link_library()
to have also all the compilation, linking, and other definitions imported directly in the target, instead of manually adding libraries, options and macros in a long list of target_do_that
declarations.
This is the first reason why you really should provide your library with cmake modules.
The second reason is simply that this makes the life of the maintainers of the project using your library a lot easier, especially when versions slightly changes how the library is meant to be invoked. For example, in version 1.0 you may require your library to define the HAVE_THAT definition during compilation, but perhaps version 1.1 requires something else. Requiring the client to adapt his findLibrary.cmake
file to different versions is a real pain. This happens, for example, when you are using a new version of boost that your current version of CMake doesn’t support.
For this reason, make your user a favour: package you library with support for cmake!
Converting a project to CMake modules
There are a few logical steps to complete to have the neccessary files installed.
- First of all, you need to have all your files correctly installed in the right positions, using the
install()
statement. - At least two files are required to be generated and installed in the correct
lib/cmake
system subdirectory. They are the config file and the target file for your project. - Additionally, files for version check and debug building can be generated and installed in the same directory
- Properties for compiling, linking, including etc… must be set for each installed target. This information will be included in the config files discussed above.
Let’s comment each step.
Installing files
Very important before starting the modularization is to have a consistent and correct installation of your package. Each binary must go in the correct binary directory, as well as any library, share, configuration, etc.
Here we’re considering linux, but it is important also on MacOS. For Windows, more research must be done, because as you know, there isn’t a well defined filesystem hierarchy and I’m not sure how CMake could find the module. Please drop me a note if you know.
You don’t need to have the packed with the CPack
module, but make install
must correctly produce a correct installation. You can always check the installation layout by checking the install_manifest.txt
file.
In a shared library, you have a bunch of instruction on how to produce it, where are the include directories, linked libraries, compilation options, etc. All of them can be PRIVATE
, meaning that the PROPERTY will be used only during build, or they can be PUBLIC
, meaning that it will be used for both building and inherited by the imported package. For example, if you specify libFOO
as linking library, the option -lFOO
will be inherited by any using library if it is in the PUBLIC
section, or not if it is in the PRIVATE
section. It can be specified in the INTERFACE
section too, in this case it will be inherited by the client project but NOT used when building the library.
So we assume you already have something like this in your CMakeFiles.txt
.
add_library(mylib SHARED mylib.cpp mylib.h include/this.h include/that.h this.cpp that.cpp)
target_include_directories(mylib
INTERFACE $<INSTALL_INTERFACE:include>
PRIVATE ${CMAKE_SOURCE_DIR}/src/include/)
target_link_libraries(mylib
PRIVATE this
PUBLIC that)
install(TARGETS mylib
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES "${CMAKE_BINARY_DIR}/include/this.h" # source directory
DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/mylib/" # target directory
Important Note
I’m assuming that the GNUInstallDirs
module is included in the cmake project, so that ${CMAKE_INSTALL_INCLUDEDIR}
and ${CMAKE_INSTALL_LIBDIR
are defined. If not, you should include it or define the proper variables.
For example:
include(GNUInstallDirs)
set(INSTALL_EXPORTS_DIR ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME})
set(INCLUDE_INSTALL_DIR ${CMAKE_INSTALL_INCLUDEDIR})
Generating the export files
The files are generated with the help of a module helper, CMakePackageConfigHelpers
.
The config file, named after your project’s name, must be generated from a template. Call it mylibConfig.cmake.in
, if mylib is the name of your package.
Put the following .in
file in your cmake/
directory or in any switable location, and adjust the paths accordingly.
@PACKAGE_INIT@
include("${CMAKE_CURRENT_LIST_DIR}/@TARGETS_EXPORT_NAME@.cmake")
check_required_components("@PROJECT_NAME@")
Then put the following lines to generate and install them.
set(TARGETS_EXPORT_NAME "${PROJECT_NAME}Targets")
include(CMakePackageConfigHelpers)
configure_package_config_file(
${CMAKE_SOURCE_DIR}/cmake/mylibConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
INSTALL_DESTINATION ${INSTALL_EXPORTS_DIR}
PATH_VARS CMAKE_INSTALL_INCLUDEDIR)
write_basic_package_version_file(
${CMAKE_BINARY_DIR}/mylibConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
${CMAKE_BINARY_DIR}/mylibConfigVersion.cmake
DESTINATION ${INSTALL_EXPORTS_DIR})
Note that INSTALL_EXPORTS_DIR must be defined somewhere, it’s where the cmake export files must be installed. Usually
set(INSTALL_EXPORTS_DIR ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME})
is ok.
Adding properties
Some property is required to define the correct version in the library.
set_target_properties(mylib PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
EXPORT_NAME mylib)
Adding exports files to installation
Now we are ready to install all the files:
install(TARGETS mylib
EXPORT mylib-export
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
install(EXPORT mylib-export
FILE mylibTargets.cmake
NAMESPACE mylib::
DESTINATION ${INSTALL_EXPORTS_DIR})
Results
Once you build your lirbary and run sudo make install
, you’ll end with a complete installation. The manifest file, in build/install_manifest.txt
, will list all the installed files.
For example:
/usr/local/include/mylib/this.h
/usr/local/include/mylib/that.h
/usr/local/include/mylib/version.h
/usr/local/lib/pkgconfig/mylib.pc
/usr/local/lib/cmake/mylib/mylibTargets.cmake
/usr/local/lib/cmake/mylib/mylibTargets-debug.cmake
/usr/local/lib/cmake/mylib/mylibConfig.cmake
/usr/local/lib/cmake/mylib/mylibConfigVersion.cmake
Testing with a client project
You can create a cmake project with the following content: a single CMakeFiles.txt
, a single test.cpp
(or .c) that calls one function from the library. For example:
cmake_minimum_required(VERSION 3.20.0)
project(testMyLob)
find_package(mylib REQUIRED)
add_executable(testMyLib test.c)
target_link_libraries(testMyLib mylib::mylib)
And test.c:
#include <mylib/mylib.h>
#include <mylib/version.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
mylib_init();
printf("Testing mylib version: %08x ABI: %d\n", MYLIB_VERSION, MYLIB_ABI_VERSION);
return 0;
}
It should build without errors.
Troubleshooting
It is important to test your setup with the test project above. I suggest to always include it in the main project as sort of testing suite.
If the project doesn’t compile, there can be mistakes, some are very common.
Cmake fails
Check the paths in the install_manifest.txt
for wrong paths. mylibConfig.cmake
should include mylibTargets.cmake
. It should call mylibTargets-debug.cmake
. Check that names are correct. If some name is not correct, check the relevant instruction, and check that all the cariables are correctly defined.
Include files aren’t found
Check that the paths for the included files are correctly defined in the PUBLIC
or INTERFACE
section of target_include_directories
. PRIVATE
should include paths that are absolute for the current project, so including ${CMAKE_SOURCE_DIR}
. INTERFACE
are inherited by the client project. Ideally, in the INTERFACE
section you should see something like INTERFACE $<INSTALL_INTERFACE:include>
.
Sometimes your project may assume the include files are organized in directories rooted in the main project include, but it installs it in a project subdirectory, and this may break the automatic properties retrival.
For example, your project may have an include
with separated subprojects like include/a/a.h
and include/b/b.h
. So in your header file you’ll probably have #include <a/a.h>
. But if you install your headers in /usr/local/include/mylib/
, you’ll have to declare /usr/local/include/mylib
as include directory, not /usr/local/include
.
If the test project fails, check that the correct include is defined in the INTERFACE
section.