C++ Module 15 – Build systems, testing and tooling
Static and shared libraries in C++
Build one C++ library as a static archive and as a shared library with CMake, compare what each puts in the program, and see what the loader needs.
What you will learn
- Build a static and a shared library from one CMakeLists.txt
- Limit which symbols a shared library exports
- Fix library-not-loaded errors after a program is installed or moved
- Recognise library changes that break programs built against an older version
Before you start
What you’ll learn · Before you start
What you will learn
- Build a static and a shared library from one CMakeLists.txt
- Limit which symbols a shared library exports
- Fix library-not-loaded errors after a program is installed or moved
- Recognise library changes that break programs built against an older version
Before you start
On this page
The three statistics functions of this lesson’s mathx library can reach a program in two ways: copied into it when
it is linked, which made the program 14,928 bytes larger, or loaded from a file of their own every time it starts.
Those are the two kinds of library C++ has. A static library (libmathx.a on Linux and macOS, mathx.lib on
Windows) is an archive of object files: when a program is linked, the linker: The last stage of a build: it joins the object files of a program and the libraries it uses into one executable, matching every symbol a file uses with exactly one definition. More in “How C++ compiles: preprocess, compile and link” copies the code
it needs into the executable, and the library file is not needed after that. A shared library (libmathx.so on
Linux, libmathx.dylib on macOS, mathx.dll on Windows) stays a separate file: the program records only its name,
and the dynamic loader: The part of the operating system that loads the shared libraries a program records, every time the program starts: dyld on macOS, ld.so on Linux. finds and loads it every time the program starts.
Static linking gives one self-contained program. Shared linking lets many programs use one copy and lets you fix the library without relinking them, at the price of shipping it, letting the loader find it and keeping it compatible.
Static or shared: copied in once, or loaded at every start
Every row of this table follows from when the library’s code joins the program:
| Static library | Shared library | |
|---|---|---|
| File | libname.a (Linux, macOS), name.lib (Windows) |
libname.so (Linux), libname.dylib (macOS), name.dll plus an import name.lib (Windows) |
| Joins the program | Once, when it is linked: the linker copies the object files it needs | Every time the program starts: the dynamic loader loads the file |
| What you ship | The program only | The program and the library, somewhere the loader looks |
| A bug fix in the library | Relink every program | Replace one file, if the new version stays compatible |
| What users see | Only what the program calls | Every symbol the library exports |
| Typical use | Command-line tools, contest code, small libraries inside one project | System libraries, plugins, libraries that many programs share or that update on their own schedule |
The diagram follows both routes for the report program of the next section: the linker copying code out of the
archive once, and the loader finding the shared file again at every start.
A static library joins the program when it is linked; a shared library each time it starts
Text description of the diagram
The top panel shows a static library. libmathx.a, an archive of object files, goes to the linker, which copies the code the program needs into report. The finished program holds its own copy of the mathx code and needs nothing else at run time.
The bottom panel shows a shared library built from the same source code. The program report records only a reference, @rpath/libmathx.dylib. Every time the program starts, the dynamic loader (dyld on macOS, ld.so on Linux) searches the program's run-path folders for that file and loads libmathx.dylib, one copy of which can serve every program that uses it.
Key idea
A static library’s code is copied into each program when it is linked, so nothing else ships. A shared library is found and loaded at every start, so it must be shipped, found by the loader and kept compatible.
Checkpoint
Check: When library code joins the program
One CMakeLists.txt builds both, and the shared one exports more
Static and shared libraries in C++ are built from the same source code, and CMake switches between them with one
setting. The mathx library computes a mean, a median and a standard deviation, and report prints them for a week of
monsoon rainfall. add_library names no type here, so the BUILD_SHARED_LIBS variable decides: OFF, the default,
gives a static library, and ON a shared one.
cmake_minimum_required(VERSION 3.28...4.4)
project(mathx VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_SCAN_FOR_MODULES OFF)
# With no STATIC or SHARED here, BUILD_SHARED_LIBS decides: OFF (the default) builds libmathx.a, ON libmathx.dylib.
add_library(mathx src/stats.cpp)
target_include_directories(mathx PUBLIC include)
target_compile_features(mathx PUBLIC cxx_std_23)
add_executable(report app/main.cpp)
target_link_libraries(report PRIVATE mathx) C++ header · mathx/include/mathx/stats.hpp
#pragma once
#include <span>
namespace mathx {
// The arithmetic mean of the values (0 for none).
double mean(std::span<const double> values);
// The middle value, or the mean of the two middle values (0 for none).
double median(std::span<const double> values);
// The population standard deviation (0 for none).
double stddev(std::span<const double> values);
} // namespace mathx C++ (stats.cpp) · mathx/src/stats.cpp
#include "mathx/stats.hpp"
#include <algorithm>
#include <cmath>
#include <numeric>
#include <vector>
namespace mathx {
namespace {
// Used only inside this file: not part of the library's interface.
double sum_of_squares(std::span<const double> values, double centre) {
double total = 0.0;
for (double v : values) total += (v - centre) * (v - centre);
return total;
}
} // namespace
double mean(std::span<const double> values) {
if (values.empty()) return 0.0;
return std::accumulate(values.begin(), values.end(), 0.0) / static_cast<double>(values.size());
}
double median(std::span<const double> values) {
if (values.empty()) return 0.0;
std::vector<double> sorted(values.begin(), values.end());
std::ranges::sort(sorted);
const auto n = sorted.size();
return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
}
double stddev(std::span<const double> values) {
if (values.empty()) return 0.0;
return std::sqrt(sum_of_squares(values, mean(values)) / static_cast<double>(values.size()));
}
} // namespace mathx C++ (main.cpp) · mathx/app/main.cpp
#include <array>
#include <print>
#include "mathx/stats.hpp"
int main() {
// Rainfall in millimetres on seven days of a monsoon week.
constexpr std::array<double, 7> rain{12.0, 48.5, 3.0, 0.0, 61.0, 27.5, 9.0};
std::println("mean {:6.2f} mm", mathx::mean(rain));
std::println("median {:6.2f} mm", mathx::median(rain));
std::println("stddev {:6.2f} mm", mathx::stddev(rain));
} from session import run
# One CMakeLists.txt, two build folders: BUILD_SHARED_LIBS chooses the kind of library.
run("""
cd mathx
cmake -S . -B build-static -G Ninja -DBUILD_SHARED_LIBS=OFF
cmake -S . -B build-shared -G Ninja -DBUILD_SHARED_LIBS=ON
""", keep=r"^-- Build files")
run("""
cmake --build build-static
cmake --build build-shared
./build-static/report
./build-shared/report
wc -c build-static/libmathx.a build-static/report build-shared/libmathx.dylib build-shared/report
nm -gU build-static/report | c++filt | grep mathx
nm -gU build-shared/libmathx.dylib | c++filt
otool -L build-shared/report
""") Output
$ cd mathx $ cmake -S . -B build-static -G Ninja -DBUILD_SHARED_LIBS=OFF -- Build files have been written to: build-static [8 lines not shown] $ cmake -S . -B build-shared -G Ninja -DBUILD_SHARED_LIBS=ON -- Build files have been written to: build-shared [8 lines not shown] $ cmake --build build-static [1/4] Building CXX object CMakeFiles/mathx.dir/src/stats.cpp.o [2/4] Linking CXX static library libmathx.a [3/4] Building CXX object CMakeFiles/report.dir/app/main.cpp.o [4/4] Linking CXX executable report $ cmake --build build-shared [1/4] Building CXX object CMakeFiles/mathx.dir/src/stats.cpp.o [2/4] Linking CXX shared library libmathx.dylib [3/4] Building CXX object CMakeFiles/report.dir/app/main.cpp.o [4/4] Linking CXX executable report $ ./build-static/report mean 23.00 mm median 12.00 mm stddev 21.91 mm $ ./build-shared/report mean 23.00 mm median 12.00 mm stddev 21.91 mm $ wc -c build-static/libmathx.a build-static/report build-shared/libmathx.dylib build-shared/report 44448 build-static/libmathx.a 332440 build-static/report 50384 build-shared/libmathx.dylib 317512 build-shared/report 744784 total $ nm -gU build-static/report | c++filt | grep mathx 000000010001bea4 T mathx::mean(std::__1::span<double const, 18446744073709551615ul>) 000000010001c064 T mathx::median(std::__1::span<double const, 18446744073709551615ul>) 000000010001c2f0 T mathx::stddev(std::__1::span<double const, 18446744073709551615ul>) $ nm -gU build-shared/libmathx.dylib | c++filt 00000000000010c8 T mathx::mean(std::__1::span<double const, 18446744073709551615ul>) 0000000000001288 T mathx::median(std::__1::span<double const, 18446744073709551615ul>) 0000000000001514 T mathx::stddev(std::__1::span<double const, 18446744073709551615ul>) 0000000000001ee4 T std::__1::allocation_result<double*, unsigned long> std::__1::allocator_traits<std::__1::allocator<double>>::allocate_at_least[abi:nqe220106]<std::__1::allocator<double>>(std::__1::allocator<double>&, unsigned long) 0000000000002a90 T void std::__1::allocator_traits<std::__1::allocator<double>>::destroy[abi:nqe220106]<double, 0>(std::__1::allocator<double>&, double*) 0000000000001d14 T unsigned long std::__1::allocator_traits<std::__1::allocator<double>>::max_size[abi:nqe220106]<std::__1::allocator<double>, 0>(std::__1::allocator<double> const&) 000000000000278c T void std::__1::allocator_traits<std::__1::allocator<double>>::construct[abi:nqe220106]<double, double const&, 0>(std::__1::allocator<double>&, double*, double const&) 000000000000364a S std::__1::ranges::__cpo::end 000000000000364b S std::__1::ranges::__cpo::next 0000000000003648 S std::__1::ranges::__cpo::sort 0000000000003649 S std::__1::ranges::__cpo::begin 000000000000364c S std::__1::ranges::__cpo::advance 0000000000001818 T void std::__1::vector<double, std::__1::allocator<double>>::__init_with_size[abi:nqe220106]<std::__1::__wrap_iter<double const*>, std::__1::__wrap_iter<double const*>>(std::__1::__wrap_iter<double const*>, std::__1::__wrap_iter<double const*>, unsigned long) 0000000000001a58 T void std::__1::vector<double, std::__1::allocator<double>>::__construct_at_end<std::__1::__wrap_iter<double const*>, std::__1::__wrap_iter<double const*>>(std::__1::__wrap_iter<double const*>, std::__1::__wrap_iter<double const*>, unsigned long) 0000000000003374 T double* std::__1::vector<double, std::__1::allocator<double>>::__add_alignment_assumption[abi:nqe220106]<double*, 0>(double*) $ otool -L build-shared/report build-shared/report: @rpath/libmathx.dylib (compatibility version 0.0.0, current version 0.0.0) /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 2200.27.0) /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 both_session.py
Step through the two builds side by side, with the sizes and the symbols the session printed:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two folders. The session configured one
CMakeLists.txttwice:build-staticwithBUILD_SHARED_LIBS=OFFandbuild-sharedwithON. Step 2 of each build linked a different kind of library. - Two library files. The static build wrote the archive
libmathx.a, 44,448 bytes. The shared build wrotelibmathx.dylib, 50,384 bytes. - Copied in. The static
reportholds the threemathxfunctions itself:nmlistsmean,medianandstddevat addresses of the program, markedTfor code. It is 332,440 bytes. - Recorded, not copied. The shared
reportis 317,512 bytes, 14,928 fewer, and holds none of them.otool -Lshows what it records instead,@rpath/libmathx.dylib, a name the loader resolves when the program starts. - The same three lines. Both programs printed a mean of 23.00 mm, a median of 12.00 mm and a standard deviation of 21.91 mm. Each one is over 300 KB because it holds its own copy of the formatting code behind
std::println. - What the library offers. The shared library exported 15 symbols, not 3: the functions of its header and 12 pieces of the standard library that its code instantiated, such as
std::vector's allocation.
Both programs printed the same three lines. The static report carries the mathx functions inside it: nm lists
them at addresses of the program itself, marked T for code. The shared report is 14,928 bytes smaller and
contains none of them; otool -L shows that it needs @rpath/libmathx.dylib instead, a name the loader resolves when
the program starts. Both programs weigh over 300 KB because each one holds its own copy of the formatting code behind
std::println.
The shared library exported 15 symbol: A name in an object file's table, such as a function's mangled name. nm marks a symbol the file defines in its code with T, and one it uses but leaves to another file with U. More in “How C++ compiles: preprocess, compile and link”, not 3. The other 12 are pieces of the standard
library, from std::vector’s allocation to std::ranges::sort, that the library’s code instantiated and that became
part of what any program can link against. Static and shared libraries in C++ differ most in this kind of detail,
which a static library never exposes to anyone.
Checkpoint
Check: One CMakeLists.txt, both kinds of library
What decides whether a shared library keeps working
Shared libraries bring a few names that appear in build files and in error messages:
- symbol visibility: Whether a symbol of a shared library is exported, so that other programs can link against it. The flag -fvisibility=hidden makes every symbol hidden unless the code marks it otherwise. decides which functions a shared library exports;
-fvisibility=hiddenhides all of them by default. - Export macros such as
MATHX_EXPORTmark the declarations that form the library’s interface. - The install name and the rpath: A list of folders recorded in a program or a library, where the dynamic loader looks for the shared libraries it needs, such as the folders that @rpath in an install name may stand for. are what the program records:
@rpath/libmathx.dylib, and a list of folders where@rpathmay point. @loader_pathand$ORIGINstart an rpath at the program’s own folder, so an installed folder can move.DYLD_LIBRARY_PATHandLD_LIBRARY_PATHadd folders to the loader’s search through the environment.- ABI (application binary interface): What compiled code relies on: the sizes and layouts of types, the symbols and the calling conventions. A new version of a library keeps its ABI when programs built against the old version still work with it. compatibility means a new version keeps the sizes, layouts and symbols that programs were compiled against.
- SOVERSION puts the ABI version into the file name, such as
libmathx.so.1, so two versions can be installed.
Each name decides something at one moment in a library’s life: when it is built, when a program starts, or when a new version replaces an old one.
When each name decides whether a shared library keeps working
Text description of the diagram
The diagram is a column of three groups joined by arrows, one for each moment in the life of a shared library.
- When the library is built: symbol visibility decides which functions are exported, and export macros mark the declarations that form the interface.
- At every start of a program: the install name and the rpath are what the program records; @loader_path and $ORIGIN start an rpath at the program's own folder; DYLD_LIBRARY_PATH and LD_LIBRARY_PATH add folders through the environment.
- When a new version is installed: ABI compatibility means the sizes, layouts and symbols that programs were compiled against are kept, and SOVERSION puts the ABI version into the file name.
Check yourself
Nine questions about the two kinds of library and the names above, starting with the idea you are least sure of. Each answer says at once why it is right or what it leaves out.
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Exporting only the interface: hidden visibility and export macros, measured Pro: opens with a Pro, Premium or Ultimate pass
A shared library that exports everything makes its internal code part of what other programs may depend on. Hiding everything by default and marking the public functions turns the export list into a deliberate interface.
In this part:
- Hidden by default, exported on purpose
- A missing export macro, reproduced
- What each platform exports unless told otherwise
4 runnable examples (C++ header, Python, Text) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
When the program cannot find its library: rpath failures reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
A shared library has to be found again every time its program starts, and each system searches differently. The failures below appear as soon as a program leaves the folder it was built in.
In this part:
- Where each loader looks
- Built and working, installed and broken
- Why DYLD_LIBRARY_PATH seems to stop working
- Reading a loader failure in three steps
1 diagram · 3 runnable examples (CMAKE, Python) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
ABI breaks: an old program against a changed library, reproduced Pro: opens with a Pro, Premium or Ultimate pass
Programs keep running against whatever version of a shared library is installed, using the sizes and layouts they were compiled with. When a new version changes those, nothing reports an error and the results are simply wrong.
In this part:
- One new member, two wrong numbers
- Changes that break the ABI, and changes that do not
- Version numbers that keep old programs working
- Exercise: make a library update safe
1 diagram · 4 runnable examples (C++, C++ header (summary.hpp), Python, Text) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Mid to senior
Your macOS program runs from the build folder, but the zip you send to testers fails at start with "Library not loaded: @rpath/libgeo.dylib" and "Reason: no LC_RPATH's found". Explain what happened, and fix the build for macOS and Linux without asking testers to set any variable.
-
Mid to senior
A bug-fix release of your shared library adds one field to a public struct. Programs built against the previous release, which nobody recompiled, now print wrong values but do not crash. Explain the mechanism, and how you would ship the change safely.
Key takeaways
- A static library is copied into the program at link time; a shared library is found and loaded every time the program starts.
- One CMakeLists.txt builds both:
add_librarywithout a type followsBUILD_SHARED_LIBS. - The static
reportcarried the library’s code and was 14,928 bytes larger; the shared one recorded@rpath/libmathx.dylibinstead. - A shared library exports more than its header suggests unless its visibility is controlled: 15 symbols here, 3 of them the library’s own functions.
Terms in this lesson
- ABI (application binary interface)
- What compiled code relies on: the sizes and layouts of types, the symbols and the calling conventions. A new version of a library keeps its ABI when programs built against the old version still work with it.
- dynamic loader
- The part of the operating system that loads the shared libraries a program records, every time the program starts: dyld on macOS, ld.so on Linux.
- rpath
- A list of folders recorded in a program or a library, where the dynamic loader looks for the shared libraries it needs, such as the folders that @rpath in an install name may stand for.
- shared library
- A library that stays a file of its own, such as libmathx.so, libmathx.dylib or mathx.dll. A program records its name, and the dynamic loader finds and loads it every time the program starts.
- static library
- An archive of object files, such as libmathx.a or mathx.lib. The linker copies the code a program needs out of it, so the program does not need the library file once it is linked.
- symbol visibility
- Whether a symbol of a shared library is exported, so that other programs can link against it. The flag -fvisibility=hidden makes every symbol hidden unless the code marks it otherwise.
References
- add_library (Kitware)
- GenerateExportHeader (Kitware)
- INSTALL_RPATH (target property) (Kitware)
- SOVERSION (target property) (Kitware)
- Options for Code Generation Conventions (-fvisibility) (GNU Project)
- Run-Path Dependent Libraries (Apple)
- System Integrity Protection Guide: Runtime Protections (Apple)
- System Integrity Protection Guide: File System Protections (Apple)
- ld.so(8): dynamic linker/loader (Debian manual pages) (Debian)
- Dynamic-link library search order (Microsoft)
- WINDOWS_EXPORT_ALL_SYMBOLS (target property) (Kitware)
- ldd(1): print shared object dependencies (Debian manual pages) (Debian)
- Exporting from a DLL (Microsoft)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) (Arm)
- System V Application Binary Interface, AMD64 Architecture Processor Supplement (x86-64 psABI project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress