Using Shared Libraries

Adding support for user-configurable library types and an initial introduction to target installation.

Greg Filak
Updated

We've designed a modular build system using INTERFACE, ALIAS, and IMPORTED targets. We have a clear dependency graph, and properties flow automatically from producers to consumers.

But so far, we've only been building static libraries. In this lesson, we'll build our first shared library.

User-Configurable Library Types

Right now, our greeter/CMakeLists.txt defines our library like this:

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

# add_library called without specifying the type
add_library(GreeterLib src/Greeter.cpp)

target_include_directories(GreeterLib PUBLIC
  "${CMAKE_CURRENT_SOURCE_DIR}/include"
)

As we learned, when the type is omitted, add_library() defaults to creating a STATIC library. But what if we wanted to use a shared library instead? The most direct solution would be to simply add the SHARED keyword to our add_library() command:

add_library(GreeterLib SHARED src/Greeter.cpp)

Alternatively, we can leave our library type unspecified, and instead allow users to specify whether they want static or shared libraries by setting the BUILD_SHARED_LIBS cache variable.

How BUILD_SHARED_LIBS Works

BUILD_SHARED_LIBS is a special variable recognized by CMake. When an add_library() command is called without an explicit STATIC, SHARED, or INTERFACE keyword, it checks the value of BUILD_SHARED_LIBS:

  • If BUILD_SHARED_LIBS is ON, it creates a SHARED library.
  • If BUILD_SHARED_LIBS is OFF (or not defined), it creates a STATIC library.

To expose this to the user, the convention is to add a friendly option() to your root CMakeLists.txt.

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

# Provide a user-facing option to control the library type
option(BUILD_SHARED_LIBS "Build libraries as shared" OFF) 

project(Greeter VERSION "1.0")

add_subdirectory(greeter)
add_subdirectory(app)

We haven't changed the greeter/CMakeLists.txt file at all. The add_library(GreeterLib ...) call will now automatically respect this global setting. This small change means consumers can now control the library type directly from the command line without ever touching our build scripts.

Exporting and Importing Symbols

On some environments, most notably Windows, we need to do some extra work when we want to use shared libraries.

  • When we want a symbol to be visible to consumers of a shared library, we need to explicitly export it
  • When a symbol we're using is defined in a shared library, we need to explicitly import it

With MSVC, for example, we export a symbol by annotating it with __declspec(dllexport). This means that, in our library code, we'd want something like this:

// This symbol should be visible to consumers
__declspec(dllexport) void some_function() {
  // ...
}

And, when code elsewhere wants to use a symbol defined in that library, it would annotate the declaration with __declspec(dllimport). This means that, in our executable target, our declaration of that symbol might look something like this:

// This symbol will come from a shared library
__declspec(dllimport) void some_function();

int main() {
  some_function();
  return 0;
}

This can be annoying so, to make things easier, we tend to implement this annotation logic in a single place - our library's header file. The code in this header file naturally gets added to every translation unit it is needed via #include directives.

The symbols in the translation unit used to compile the library will need dllexport attributes, whilst the translation unit of any library consumer will need dllimport attributes. We can use the preprocessor to switch between them. For example:

Greeter/include/Greeter.h

#pragma once
#include <string>

#ifdef _WIN32
  #ifdef GREETER_EXPORTS
    #define GREETER_API __declspec(dllexport)
  #else
    #define GREETER_API __declspec(dllimport)
  #endif
#else
  #define GREETER_API
#endif

// Any symbol that is part of our public API
// can now use the `GREETER_API` macro:
GREETER_API std::string get_greeting();

// More examples
GREETER_API void another_function();
GREETER_API bool yet_another(int x);
extern GREETER_API bool some_variable;

// Not exported
void some_internal_function();

This header file now annotates get_greeting() based on GREETER_API, which has one of three possible values:

  • If we are targeting Windows and GREETER_EXPORTS is defined, GREETER_API will be replaced with __declspec(dllexport), thereby flagging our symbol for export.
  • If we are targeting Windows and GREETER_EXPORTS is not defined, our symbol is flagged as being imported, as GREETER_API will be replaced with __declspec(dllimport).
  • If we're not compiling for Windows, GREETER_API will have no value, meaning the preprocessor will remove it and our forward declaration will simply be std::string get_greeting().

Now, to make everything work, we just need to define GREETER_EXPORTS, but only for our library target:

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_library(GreeterLib src/Greeter.cpp)

target_compile_definitions(GreeterLib PRIVATE GREETER_EXPORTS)

target_include_directories(GreeterLib PUBLIC
  "${CMAKE_CURRENT_SOURCE_DIR}/include"
)

This means that, if we're targetting Windows, the Greeter.h contents in our library's translation unit will have GREETER_API set to __declspec(dllexport), whilst our executable's translation unit will use __declspec(dllimport).

Using GenerateExportHeader

To help with managing the visibility of symbols in shared libraries, CMake comes bundled with a useful module called GenerateExportHeader, which provides a generate_export_headers() command.

This command automatically creates a header file containing the preprocessor logic we just walked through. Below, we use it to generate a greeter_export.h file containing our GREETER_API macro.

This header file will be placed in our library's build directory, so we'll also add CMAKE_CURRENT_BINARY_DIR to our search paths to ensure it can be found:

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_library(GreeterLib src/Greeter.cpp)

include(GenerateExportHeader)

generate_export_header(GreeterLib
  EXPORT_MACRO_NAME GREETER_API
  EXPORT_FILE_NAME greeter_export.h
)

target_include_directories(GreeterLib PUBLIC
  "${CMAKE_CURRENT_SOURCE_DIR}/include"
  
  # For greeter_export.h (generated) 
  "${CMAKE_CURRENT_BINARY_DIR}" 
)

# No longer needed - generate_export_header() sets the
# required definition on the target we provided as its
# first argument
target_compile_definitions(GreeterLib PRIVATE GREETER_EXPORTS)

We can now #include this generated header file to replace our previous logic:

Greeter/include/Greeter.h

#pragma once
#include <string>
#include "greeter_export.h"

#ifdef _WIN32
  #ifdef GREETER_EXPORTS
    #define GREETER_API __declspec(dllexport)
  #else
    #define GREETER_API __declspec(dllimport)
  #endif
#else
  #define GREETER_API
#endif

GREETER_API std::string get_greeting();

This generate_export_header() approach is more robust than our basic manual implementation, as it contains logic to handle import/export requirements beyond just Windows.

It also defines further macros that we can optionally use if we want to explicitly declare some symbols as being internal (that is, not exported), or to flag some symbols as deprecated.

Official documentation for the GenerateExportHeader module is available here.

Building a Shared Library

Let's walk through the entire process to see the effect of this change.

Configure with BUILD_SHARED_LIBS ON

From our build directory, we'll re-configure the project, this time setting our new option to ON:

cmake -DBUILD_SHARED_LIBS=ON ..

Build the Project

Now, we run the build command as usual.

cmake --build .

If you inspect your build/greeter directory, you'll see that instead of a static library (.a or .lib), CMake has produced a shared library (.so on Linux, .dll on Windows).

The Runtime Linking Problem

Now, let's try to run our GreeterApp (or GreeterApp.exe) application from the build/app directory:

./app/GreeterApp

As you may have guessed, our GreeterApp now depends on a shared library that it probably can't find. As such, our program will likely error, or just appear to do nothing at all.

./app/GreeterApp: error while loading shared libraries: libGreeterLib.so: cannot open shared object file: No such file or directory

The operating system's loader saw that GreeterApp needs libGreeterLib.so, but it didn't know where to find it.

The loader only checks a few specific places for any shared library that our executable needs. The /build/greeter directory where our shared library is placed after compilation is not one of them.

This is a classic deployment problem. Our build directory is a messy, intermediate workspace. It's not a clean, reliable environment for running our application. To solve this, we need a proper installation step.

Preparing for Distribution with install()

The install() command is CMake's mechanism for taking the outputs from a messy build directory and arranging them into a clean, distributable layout. This "install" directory is what you would package into a .zip file or an installer to give to other people.

Installation, packaging and distribution is an important topic that we'll cover in a dedicated chapter later in the course. But for now, let's walk through a quick example, as the installation process sets some useful context of how CMake can also prepare our project for distribution to users.

The install() command tells CMake what to do when we run the --install build step. We'll cover how to run that soon, but lets first set up what should happen when we do.

Installing the Executable

In app/CMakeLists.txt, we'll add an install rule for our executable:

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_executable(GreeterApp src/main.cpp)
target_link_libraries(GreeterApp GreeterLib)

# Install the final executable to a 'bin' directory
install(TARGETS GreeterApp DESTINATION "${CMAKE_SOURCE_DIR}/bin")

Our arguments are the following:

  1. The TARGETS keyword, informing CMake that the installation rule we are setting applies to targets, and that the next argument(s) will specify those targets.
  2. The targets that these rules apply to. We're applying this rule only to the GreeterApp target, so only a single argument follows TARGETS in this case. We could add more if needed.
  3. The DESTINATION keyword informs CMake that the next argument will specify where we want our files installed to.
  4. The "${CMAKE_SOURCE_DIR}/bin" argument will cause our files to be installed in a directory called bin (binary), which is a common convention. The use of the CMAKE_SOURCE_DIR prefix specifies that this bin directory will always be in the root of our project.

Eventually, users should be able to specifiy where on their system the program should be installed, but we'll keep things simple for now. We'll learn much more about installation later in the course.

Installing the Library

Let's set the same install() rules for our library:

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_library(GreeterLib src/Greeter.cpp)

include(GenerateExportHeader)

generate_export_header(GreeterLib
  EXPORT_MACRO_NAME GREETER_API
  EXPORT_FILE_NAME greeter_export.h
)

target_include_directories(GreeterLib PUBLIC
  "${CMAKE_CURRENT_SOURCE_DIR}/include"
  "${CMAKE_CURRENT_BINARY_DIR}"
)

install(TARGETS GreeterLib 
  DESTINATION "${CMAKE_SOURCE_DIR}/bin"
)

Combined, these rules will place our executable file and shared library in the same location - within /bin in our project root.

Setting the Runtime Library Search Path (rpath)

Placing our shared library in the same location as our executable is enough to let Windows find it. When an .exe file asks for a .dll file, Windows will automatically search the same directory.

However, on Unix-like systems (including macOS and Linux), we need one more step. We'll set the INSTALL_RPATH property on our GreeterApp target. This property gets embedded into the executable, and gives Unix-like systems some additional hints about where that executable's dependencies might be.

The special "$ORIGIN" path will be resolved to the directory the executable is in. This means the platform will look in that same location for any libraries we need, replicating Windows' behavior:

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_executable(GreeterApp src/main.cpp)
target_link_libraries(GreeterApp GreeterLib)

install(TARGETS GreeterApp DESTINATION "${CMAKE_SOURCE_DIR}/bin")

set_target_properties(GreeterApp PROPERTIES 
  INSTALL_RPATH "$ORIGIN" 
)

The End-to-End Installation Workflow

Now let's see our new installation rules in action.

Configuring and Building

First, we configure and build our project as usual. Let's build the shared library version again.

cmake -DBUILD_SHARED_LIBS=ON ..
cmake --build .

Installing

Now, we execute the --install step. As with the previous step, we'll run this from our ./build directory, and use the . command to tell CMake the output of our build is in this same directory:

cmake --install .

This command runs the install() rules we defined. It finds the built artifacts in the current directory and its subdirectories, and copies them to the locations we requested in our DESTINATION arguments.

Inspecting the Result

If you now look in the bin directory, you should both our library and our executable. And, if we run the executable from that location, everything should work.

The following command assumes we're currently in the build directory, so .. navigates up to our project root, and /bin navigates to the bin directory in that location. Remember to add .exe on Windows:

../bin/GreeterApp
Hello from the modular greeter library!

This was only a brief introduction to a very large topic, and our basic implementation isn't flexible or robust. For example:

  • Users cannot control the installation directory. We've hardcoded the destination to the /bin directory in the project root. The cmake --install command even has a --prefix option to let users set their location, and this flexibility is required to package and ship our project, but our CMakeLists.txt files doesn't support it.
  • If we revert back to a static library (such as by setting -DBUILD_SHARED_LIBS=OFF) then the library will still be copied over to our /bin directory, even though it's no longer needed.
  • Installation isn't just for end-users wanting to run our executable. For example, it is also how we would prepare our libraries to be used by other developers. The implementation for that use case looks totally different to what we set up here.

We'll revisit installation and packaging later in the course, addressing all these limitations and much more.

Summary

This lesson covered some of the most important practical aspects of authoring and using libraries in a modern CMake project.

  • Configurable Library Types: Use the BUILD_SHARED_LIBS option to allow users to easily switch between STATIC and SHARED builds of your libraries.
  • Installation for Distribution: Use the install() command to gather your project's artifacts into a clean, distributable directory structure. This separates it from the temporary build tree and prepares it for packaging and shipping (which we'll cover later).
Next Lesson
Lesson 24 of 61

Build Configurations (Debug, Release, etc.)

Learn how CMake manages different build configurations like Debug and Release, the difference between generator types, and how to apply settings conditionally using modern techniques.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate