Building a Dual Shared and Static Library with CMake

Sat 06 March 2021

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.

Design Philosophy

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:

  1. The build interface should match the install interface. It is increasingly common to directly integrate third-party builds with the primary build using add_subdirectory or FetchContent. The end-user experience should not change when switching between these options and find_package.
  2. Only strict build requirements belong in CMakeLists.txt. Anything that isn't absolutely necessary inevitably becomes an imposition on the end user. For a common example, if the end user compiles with -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+).
  3. A single project will not mix both shared and static versions of a library. Certainly for a single target, it is totally illegal to link to both at the same time. This means we don't need to support mixing both types in a single directory.

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.

A Common but Flawed Solution

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.

The Ideal User Experience

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.

The Implementation

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.

Build rules

Now the start of the build is mostly boilerplate.

cmake_minimum_required(VERSION 3.19)
project(SomeLib VERSION 1.0.0)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED YES)
set(CMAKE_CXX_EXTENSIONS NO)

set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN YES)

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. There's nothing C++17-specific here (it's basically a C library), but we set C++17 anyway and disable language extensions to keep the language features consistent across compilers.

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).

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 ()

Packaging

Now we'll take a look at what goes into the packaging/CMakeLists.txt file.

include(GNUInstallDirs)
include(CMakePackageConfigHelpers)

set(SomeLib_INSTALL_CMAKEDIR "${CMAKE_INSTALL_LIBDIR}/cmake/SomeLib"
    CACHE STRING "Path to SomeLib CMake files")

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.

Building the Project

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.

Conclusion

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.


If this article helps you with your work, consider saying thank you by buying me a coffee! Buy me a coffee