Federico Fuga

Engineering, Tech, Informatics & science

Why and how you should modularize your cmake library

20 Jun 2023 14:50 CEST

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.

  1. First of all, you need to have all your files correctly installed in the right positions, using the install() statement.
  2. 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.
  3. Additionally, files for version check and debug building can be generated and installed in the same directory
  4. 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.