When packaging software libraries, it is a common requirement to deploy both a static and a shared version. However, CMake library targets are always either one or the other. How do we make it easy for our users to choose which one they want to link to, and why is this difficult to begin with?
In this article we're going to design a CMake build and find_package
script that enables library users to easily choose and switch between the two library types. This also serves as a basic project template for a modern CMake library build. The main thing it's missing is handling dependencies.
TLDR: See this GitHub repo with the full code, complete with GitHub Actions testing.
So why is it tricky to provide both a static and shared version of a library in CMake? The core issue is that a CMake library target models the build and usage requirements for a single library configuration. When you import SomeLib::SomeLib
from a package, the library type is already determined by the time you link another target to it. On the build side, this means that a single library target corresponds to a single physical library on the system.
Static and shared libraries are typically produced from the same set of sources, too, so new CMake users sometimes expect that a single call to add_library
will provide whatever mix of types they want. However, this is fundamentally incompatible with CMake's model of linking, which admits no properties on the link itself. It would also make it harder to make independent decisions about position-independent code. Although most desktop systems (especially Linux) favor PIC for its security benefits (see: ASLR), many embedded systems with slow CPUs and strict power budgets either don't want or can't afford the overhead and prefer to link statically. This often means that static and shared libraries cannot share object files.
There's also no good guidance inside CMake for solving this problem from the find_package
side. Some modules, like FindCUDAToolkit, use separate targets for each type. Others, like FindHDF5 and FindOpenSSL, use variables with no common convention: HDF5 uses HDF5_USE_STATIC_LIBRARIES
while OpenSSL uses OPENSSL_USE_STATIC_LIBS
.
So instead of copying a convention that doesn't exist, we will follow a few guiding principles while trying to establish a new convention:
add_subdirectory
or FetchContent
. The end-user experience should not change when switching between these options and find_package
.-Werror
and you compile with -Wall
, then their compiler might throw a warning your compiler didn't. Such settings belong in toolchain files or presets files (CMake 3.19+).The bar for clean CMake code is significantly higher for a library than for an application because the CMake code itself affects end users. For an application, some ugliness is tolerable because it doesn't propagate through the dependency tree (you don't typically link to executables). If an application does not provide a CMake package or if the package it provides is broken, it is easy enough to call find_program
and have everything you need. On the other hand, a bad CMake build might require complete replacement by a package maintainer. This is a surprisingly common scenario in vcpkg and is the ultimate condemnation of the upstream build. Don't write builds that have to be thrown out like this.
On the build side, a common solution is to create one target for each library type and give them separate names, like so:
set(sources ...)
add_library(SomeLib_static STATIC ${sources})
add_library(SomeLib_shared SHARED ${sources})
Unfortunately, this fails to meet our design criteria.
Most users who invoke the build directly need only one of the two types, so this approach doubles the compilation time for them. Using an object library doesn't help since it would force position independent code on the static library. Although users who directly include the build may use EXCLUDE_FROM_ALL
to build only what is needed, this is a relatively obscure feature and requires extra code in the FetchContent
case.
If your package exports just these targets, it forces the user to make an up-front decision about whether to link statically or dynamically and then propagates that decision transitively. Often, the decision whether to use static or dynamic libraries belongs to the package maintainer. For instance, Linux distributions generally require their packages to not have statically linked dependencies and prefer libraries to dynamically link to system packages. It has to be possible to create and install only one of these libraries, without patching your build or your users' builds.
Robert Schumacher, a lead developer for vcpkg, cautions against this exact practice in both his CppCon 2018 and CppCon 2019 talks. In another talk, he explains that vcpkg is sometimes forced to inject code that redirects the static target to the shared one (or vice versa) when only one was built and installed.
So what should we do? Let's start by examining the ideal user experience for using our library.
cmake_minimum_required(VERSION 3.19)
project(example)
find_package(SomeLib REQUIRED)
add_executable(main main.cpp)
target_link_libraries(main PRIVATE SomeLib::SomeLib)
This looks great, but... there's nothing in there that says whether SomeLib::SomeLib
should be shared or static! How does this solve anything?
Normally, the user sets SomeLib_ROOT
or CMAKE_PREFIX_PATH
to a path that contains exactly the one version of SomeLib
at configure time. We need to keep supporting that pattern in our solution, but we also need to support a distribution that contains both versions.
Our first major insight is this: because the build interface should match the install interface, SomeLib::SomeLib
should respect BUILD_SHARED_LIBS
the same way FetchContent
or add_subdirectory
would. However, overriding this (or any) variable for one find_package
call is a bit complicated. The fully correct version—that preserves the existence and values of BUILD_SHARED_LIBS
no matter whether it is a cache or normal variable—is this:
function(find_somelib)
set(BUILD_SHARED_LIBS YES)
find_package(SomeLib REQUIRED)
endfunction()
find_somelib()
When find_somelib()
is called, it creates a new variable scope that is destroyed when it returns. Thus, the variable environment after the SomeLib
package search succeeds is the same as it was before, so code that cares whether BUILD_SHARED_LIBS
is a normal or cache variable (or defined at all) continues to work correctly. Saving and restoring the value of BUILD_SHARED_LIBS
in the obvious way requires, first, a temporary variable and, second, a check before writing to BUILD_SHARED_LIBS
whether it was defined to begin with.
On the other hand, the function also erases potentially useful variables set by the package. The targets are tied to the directory scope, so linking to SomeLib::SomeLib
still works. If the package only provides targets, this is not an issue. If some variables are needed, one could set variables in the parent scope via set(... PARENT_SCOPE)
, but this is awful.
Rather than forcing users to create bespoke functions to override a standard variable, the package will respect a new variable, SomeLib_SHARED_LIBS
, that overrides BUILD_SHARED_LIBS
. So now we can specify that we want shared libs from SomeLib
at the command line with -DSomeLib_SHARED_LIBS=YES
or we can enforce it in the CMakeLists.txt
by simply setting it.
set(SomeLib_SHARED_LIBS YES)
find_package(SomeLib REQUIRED)
However, BUILD_SHARED_LIBS
is supposed to be reserved for the user and not set by the build. It's no different for SomeLib_SHARED_LIBS
; users should expect this variable to be respected as a configuration point. To enable a user to truly force SomeLib
to be static or shared, we can use find_package
's components mechanism:
find_package(SomeLib REQUIRED shared) # or `static`
It is an error to request both static
and shared
components. If a single build needs both, it may separate its targets into two directories and call find_package
with different components in each one. Since imported targets are not global by default, this works without any intervention on our part.
So now let's make this work! We're going to implement this around a very simple library that returns a random number. Here's the source file:
// src/random.cpp
#include "somelib/random.h"
namespace SomeLib {
// Thanks to XKCD 221 for this useful function!
int getRandomNumber() {
return 42; // chosen by fair dice roll.
// guaranteed to be random.
}
} // namespace SomeLib
Here's the corresponding header:
// include/somelib/random.h
#ifndef SOMELIB_RANDOM_H
#define SOMELIB_RANDOM_H
#include "somelib/export.h"
namespace SomeLib {
SOMELIB_EXPORT int getRandomNumber();
}
#endif //SOMELIB_RANDOM_H
export.h
is a generated export header that CMake will create for us. It provides the SOMELIB_EXPORT
macro which tells the compiler which symbols to expose from the shared version of our library.
Now the start of the build is mostly boilerplate.
cmake_minimum_required(VERSION 3.19)
project(SomeLib VERSION 1.0.0)
if (NOT DEFINED CMAKE_CXX_VISIBILITY_PRESET AND
NOT DEFINED CMAKE_VISIBILITY_INLINES_HIDDEN)
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN YES)
endif ()
Since CMake doesn't warn you if you use a feature that is too new for the minimum version you should always specify the minimum version that you actually test with.
The next two lines ensure that the shared library version doesn't export anything unintentionally. MSVC hides symbols by default, whereas GCC and Clang export everything. Exporting unintended symbols can cause conflicts and ODR violations as dependencies are added down the line, so libraries should always make their exports explicit (or at least use a linker script if retrofitting the code is too much). Still, if the user manually specifies a different setting, then we respect it.
Next, we'll implement the SomeLib_SHARED_LIBS
override for the build interface that was discussed earlier.
if (DEFINED SomeLib_SHARED_LIBS)
set(BUILD_SHARED_LIBS "${SomeLib_SHARED_LIBS}")
endif ()
Now we can create the library. To keep the build and install interfaces consistent, we also create an alias SomeLib::SomeLib
. The version properties make sure that namelinks and solinks are created for the shared library.
add_library(SomeLib src/random.cpp)
add_library(SomeLib::SomeLib ALIAS SomeLib)
set_target_properties(SomeLib PROPERTIES
VERSION ${SomeLib_VERSION}
SOVERSION ${SomeLib_VERSION_MAJOR})
target_include_directories(
SomeLib PUBLIC "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>")
target_compile_features(SomeLib PUBLIC cxx_std_17)
This assumes that we are using semantic versioning for the joint package and library version. Next we'll create the export header we saw earlier and attach it to the target. The GenerateExportHeader
module assumes it's acting on a shared library, so we have to manually add SOMELIB_STATIC_DEFINE
to the static build to avoid linker errors arising from DLL-import directives on Windows.
include(GenerateExportHeader)
generate_export_header(SomeLib EXPORT_FILE_NAME include/somelib/export.h)
target_compile_definitions(
SomeLib PUBLIC "$<$<NOT:$<BOOL:${BUILD_SHARED_LIBS}>>:SOMELIB_STATIC_DEFINE>")
target_include_directories(
SomeLib PUBLIC "$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/include>")
It would be very nice if generate_export_header
set up the definitions and include paths automatically. This is the kind of busy-work that gives CMake a bad rap.
Finally, we'll add some packaging logic, but include it by default only if we're the top-level project. That insulates FetchContent
users from our install rules if they don't want them, but keeps them available in case they do:
string(COMPARE EQUAL "${CMAKE_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}" is_top_level)
option(SomeLib_INCLUDE_PACKAGING "Include packaging rules for SomeLib" "${is_top_level}")
if (SomeLib_INCLUDE_PACKAGING)
add_subdirectory(packaging)
endif ()
Now we'll take a look at what goes into the packaging/CMakeLists.txt
file.
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
if (NOT DEFINED SomeLib_INSTALL_CMAKEDIR)
set(SomeLib_INSTALL_CMAKEDIR "${CMAKE_INSTALL_LIBDIR}/cmake/SomeLib"
CACHE STRING "Path to SomeLib CMake files")
endif ()
The GNUInstallDirs
module defines a bunch of variables that control the default behavior of the install()
commands and picks sane defaults for every supported platform, including Windows. The name is mostly historical and should probably be changed. We'll use CMakePackageConfigHelpers
later to create a required version compatibility script.
Since various package management systems (like vcpkg, Nuget, APT, etc.) have different standards for where to place CMake package config scripts, we create a cache variable, SomeLib_INSTALL_CMAKEDIR
, to allow our users to control where those scripts go. We pick a common, safe default.
Now we'll add the logic to install our libraries and headers:
install(TARGETS SomeLib EXPORT SomeLib_Targets
RUNTIME COMPONENT SomeLib_Runtime
LIBRARY COMPONENT SomeLib_Runtime
NAMELINK_COMPONENT SomeLib_Development
ARCHIVE COMPONENT SomeLib_Development
INCLUDES DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}")
install(DIRECTORY "${SomeLib_SOURCE_DIR}/include/" "${SomeLib_BINARY_DIR}/include/"
TYPE INCLUDE
COMPONENT SomeLib_Development)
When we install SomeLib
, we add it to an export set called SomeLib_Targets
. To support users to wish to package our library in separate runtime and development components, we create prefixed component names (to avoid clashes with other projects). We won't dwell on componentized packages here, but if you've ever noticed that Ubuntu provides separate libfoo
and libfoo-dev
packages, that's what this is for. To learn more, watch Craig Scott's CppCon 2019 talk, "Deep CMake for Library Authors".
Now we'll export our targets to a file specific to the library type:
if (BUILD_SHARED_LIBS)
set(type shared)
else ()
set(type static)
endif ()
install(EXPORT SomeLib_Targets
DESTINATION "${SomeLib_INSTALL_CMAKEDIR}"
NAMESPACE SomeLib::
FILE SomeLib-${type}-targets.cmake
COMPONENT SomeLib_Development)
When the library is built as a shared library, we get SomeLib-shared-targets.cmake
and when it's built as a static library, we get SomeLib-static-targets.cmake
. To turn this into a bona-fide CMake package, we need two files: SomeLibConfig.cmake
and SomeLibConfigVersion.cmake
. The latter is easy to auto-generate since we're using semantic versioning:
write_basic_package_version_file(
SomeLibConfigVersion.cmake
COMPATIBILITY SameMajorVersion)
The purpose of this file is to support the version number argument to find_package
. It prevents an incompatible package from being loaded when a version number is specified. The meat of the CMake package is defined in SomeLibConfig.cmake
, but we'll discuss that in just a moment. The last rule places these two files in the CMake installation directory.
install(FILES
"${CMAKE_CURRENT_SOURCE_DIR}/SomeLibConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/SomeLibConfigVersion.cmake"
DESTINATION "${SomeLib_INSTALL_CMAKEDIR}"
COMPONENT SomeLib_Development)
Now we'll see the package config file SomeLibConfig.cmake
in all its glory.
cmake_minimum_required(VERSION 3.19)
set(SomeLib_known_comps static shared)
set(SomeLib_comp_static NO)
set(SomeLib_comp_shared NO)
foreach (SomeLib_comp IN LISTS ${CMAKE_FIND_PACKAGE_NAME}_FIND_COMPONENTS)
if (SomeLib_comp IN_LIST SomeLib_known_comps)
set(SomeLib_comp_${SomeLib_comp} YES)
else ()
set(${CMAKE_FIND_PACKAGE_NAME}_NOT_FOUND_MESSAGE
"SomeLib does not recognize component `${SomeLib_comp}`.")
set(${CMAKE_FIND_PACKAGE_NAME}_FOUND FALSE)
return()
endif ()
endforeach ()
if (SomeLib_comp_static AND SomeLib_comp_shared)
set(${CMAKE_FIND_PACKAGE_NAME}_NOT_FOUND_MESSAGE
"SomeLib `static` and `shared` components are mutually exclusive.")
set(${CMAKE_FIND_PACKAGE_NAME}_FOUND FALSE)
return()
endif ()
set(SomeLib_static_targets "${CMAKE_CURRENT_LIST_DIR}/SomeLib-static-targets.cmake")
set(SomeLib_shared_targets "${CMAKE_CURRENT_LIST_DIR}/SomeLib-shared-targets.cmake")
macro(SomeLib_load_targets type)
if (NOT EXISTS "${SomeLib_${type}_targets}")
set(${CMAKE_FIND_PACKAGE_NAME}_NOT_FOUND_MESSAGE
"SomeLib `${type}` libraries were requested but not found.")
set(${CMAKE_FIND_PACKAGE_NAME}_FOUND FALSE)
return()
endif ()
include("${SomeLib_${type}_targets}")
endmacro()
if (SomeLib_comp_static)
SomeLib_load_targets(static)
elseif (SomeLib_comp_shared)
SomeLib_load_targets(shared)
elseif (DEFINED SomeLib_SHARED_LIBS AND SomeLib_SHARED_LIBS)
SomeLib_load_targets(shared)
elseif (DEFINED SomeLib_SHARED_LIBS AND NOT SomeLib_SHARED_LIBS)
SomeLib_load_targets(static)
elseif (BUILD_SHARED_LIBS)
if (EXISTS "${SomeLib_shared_targets}")
SomeLib_load_targets(shared)
else ()
SomeLib_load_targets(static)
endif ()
else ()
if (EXISTS "${SomeLib_static_targets}")
SomeLib_load_targets(static)
else ()
SomeLib_load_targets(shared)
endif ()
endif ()
There are a few confusing things going on here. First, CMake's package search is case-insensitive, so we need to look at ${CMAKE_FIND_PACKAGE_NAME}
to know the exact name the user requested and therefore what CMake named the input variables to the package file. If only this were normalized to upper-case, we could write SOMELIB_FIND_COMPONENTS
instead of the ugly mess we have, but alas.
Still, what's actually happening is rather simple. It checks the components to see if the user requested either static
or shared
. If both were, the package fails and sets an informative error message. If just one was, it tries to load the corresponding targets file. If the user supplies an invalid component, it fails, too. Otherwise, it checks SomeLib_SHARED_LIBS
, and BUILD_SHARED_LIBS
in turn and defaults to static if nothing is set, which matches common practice.
The package components and SomeLib_SHARED_LIBS
variable are considered binding if set, so the package will fail to be found if the installation does not contain the requested libraries. However, if only BUILD_SHARED_LIBS
is set (or nothing is set) and only one of the static or shared configuration is installed, we still load the available library to match existing CMake practices. If BUILD_SHARED_LIBS
is OFF
(or not set) and only the shared libraries are available, then the shared libraries will be loaded.
Whew. After all that, you'll be happy to know that actually building this requires nothing special. Here you go (from the source directory):
$ cmake -G Ninja -S . -B build-shared -DBUILD_SHARED_LIBS=YES -DCMAKE_BUILD_TYPE=Release
$ cmake -G Ninja -S . -B build-static -DBUILD_SHARED_LIBS=NO -DCMAKE_BUILD_TYPE=Release
$ cmake --build build-shared
$ cmake --build build-static
$ cmake --install build-shared --prefix _install
$ cmake --install build-static --prefix _install
None of this should be surprising. We build and install both library types in Release mode to a common prefix. On Windows, we need to be careful that the static library .lib
file does not conflict with the shared library's .lib
import library. We can work around this by adding -DCMAKE_RELEASE_POSTFIX=_static
to the configure step for the static library. That way we'll get SomeLib_static.lib
from the static build and the usual SomeLib.dll
plus SomeLib.lib
combination from the shared build.
Now we can write a little program that calls it. Here's the test:
// main.cpp
#include <iostream>
#include <somelib/random.h>
int main() {
std::cout << "My very random number is: " << SomeLib::getRandomNumber() << "\n";
return 0;
}
Here's the CMakeLists.txt
:
cmake_minimum_required(VERSION 3.19)
project(example)
enable_testing()
find_package(SomeLib 1 REQUIRED)
add_executable(main main.cpp)
target_link_libraries(main PRIVATE SomeLib::SomeLib)
add_test(NAME random_is_42 COMMAND main)
set_tests_properties(random_is_42 PROPERTIES
PASS_REGULAR_EXPRESSION "is: 42"
ENVIRONMENT "PATH=$<TARGET_FILE_DIR:SomeLib::SomeLib>")
It also includes a little test to make sure that our very random number was indeed returned. We can build it several ways and verify with ldd
(on Linux, at least) that it was linked correctly.
$ cmake -G Ninja -S . -B build -DCMAKE_PREFIX_PATH=/path/to/_install
-- The C compiler identification is GNU 9.3.0
-- The CXX compiler identification is GNU 9.3.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /path/to/build
$ cmake --build build
[1/2] /usr/bin/c++ -DSOMELIB_STATIC_DEFINE -isystem /path/to/_install/include ↩
-MD -MT CMakeFiles/main.dir/main.cpp.o -MF CMakeFiles/main.dir/main.cpp.o.d ↩
-o CMakeFiles/main.dir/main.cpp.o -c ../main.cpp
[2/2] : && /usr/bin/c++ CMakeFiles/main.dir/main.cpp.o -o main ↩
/path/to/_install/lib/libSomeLib.a && :
$ ./build/main
My very random number is: 42
$ ldd build/main | grep SomeLib
$ cmake -B build -DBUILD_SHARED_LIBS=YES
-- Configuring done
-- Generating done
-- Build files have been written to: /path/to/build
$ cmake --build build
[1/2] /usr/bin/c++ -isystem /path/to/_install/include -MD -MT ↩
CMakeFiles/main.dir/main.cpp.o -MF CMakeFiles/main.dir/main.cpp.o.d -o ↩
CMakeFiles/main.dir/main.cpp.o -c ../main.cpp
[2/2] : && /usr/bin/c++ CMakeFiles/main.dir/main.cpp.o -o main ↩
-Wl,-rpath,/path/to/_install/lib /path/to/_install/lib/libSomeLib.so.1.0.0 ↩
&& :
$ ./build/main
My very random number is: 42
$ ldd build/main | grep SomeLib
libSomeLib.so.1 => /path/to/libSomeLib.so.1 (0x00007f41880ae000)
The associated GitHub repo has a simple GitHub Actions workflow to test the package.
There's a lot awkward about CMake, and it's definitely on display here. Even so, the actual solution itself is simple, even if the implementation has some warts. Most importantly, the complexity is all placed on the library author, not on the library user. A lot of this can be set up and forgotten, and the little pain now is well worth sparing all the downstream users, support staff, StackOverflow volunteers, and so on a far greater amount of pain.
Unless otherwise stated, all code snippets are licensed under CC-BY-SA 4.0.