Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 6 of 10

A project with forty .cpp files does not want forty .o files on every link line. A library is a bundle of object files, and there are two kinds with genuinely different behaviour — not just different file extensions.

// geometry.hpp
#pragma once

double circle_area(double r);
double square_area(double s);
// circle.cpp
#include "geometry.hpp"

double circle_area(double r) { return 3.14159265358979 * r * r; }
// square.cpp
#include "geometry.hpp"

double square_area(double s) { return s * s; }
// main.cpp
#include <iostream>
#include "geometry.hpp"

int main() { std::cout << circle_area(2.0) << "\n"; }

Static libraries: an archive of object files

A .a file is little more than a zip of .o files with an index:

g++ -std=c++20 -c circle.cpp -o circle.o
g++ -std=c++20 -c square.cpp -o square.o
ar rcs libgeometry.a circle.o square.o
echo "archive contents:"
ar t libgeometry.a
echo "archive size: $(stat -c%s libgeometry.a) bytes"
archive contents:
circle.o
square.o
archive size: 2946 bytes

ar rcs means replace, create, write-an-index. The naming convention is not optional: a library named libgeometry.a is referred to as -lgeometry, and -L. tells the linker to look in the current directory.

g++ -std=c++20 -c main.cpp -o main.o
g++ main.o -L. -lgeometry -o prog && ./prog
12.5664

The linker only takes what it needs

This is the defining property of a static library:

echo "circle_area in the binary: $(nm -C prog | grep -c 'T circle_area')"
echo "square_area in the binary: $(nm -C prog | grep -c 'T square_area')"
circle_area in the binary: 1
square_area in the binary: 0

square.o was never extracted. The linker walks the archive looking for symbols it is currently missing, takes the object files that provide them, and ignores the rest. A 200 MB static library costs you only the parts you actually call.

And this is the consequence people trip over:

echo "=== library after the object that needs it:"
g++ main.o -L. -lgeometry -o prog && echo "linked ok"
echo "=== library before it:"
g++ -L. -lgeometry main.o -o prog2 2>&1 | grep -v warning
=== library after the object that needs it:
linked ok
=== library before it:
/usr/bin/ld: main.o: in function `main':
main.cpp:(.text+0x15): undefined reference to `circle_area(double)'
collect2: error: ld returned 1 exit status

Same files, same flags, different order, and one fails.

The linker processes its arguments left to right, once. When it reaches -lgeometry in the second command, nothing has asked for circle_area yet — main.o has not been read — so it takes nothing from the archive and moves on. By the time main.o creates the need, the archive is behind it and never revisited.

The rule: list libraries after the objects that use them, and if libraries depend on each other, in dependency order — a library that uses another goes first. For genuine cycles there is -Wl,--start-group ... -Wl,--end-group, which tells the linker to keep rescanning, at some build-time cost.

This is the second-commonest cause of "undefined reference to a function I can clearly see in the library", after the mangling mismatches of lesson 17.5.

Shared libraries: loaded at run time

A .so is a real program image, linked at load time rather than copied in:

g++ -std=c++20 -fPIC -c circle.cpp -o circle_pic.o
g++ -std=c++20 -fPIC -c square.cpp -o square_pic.o
g++ -shared circle_pic.o square_pic.o -o libgeometry.so
echo "static  libgeometry.a:  $(stat -c%s libgeometry.a) bytes"
echo "shared  libgeometry.so: $(stat -c%s libgeometry.so) bytes"
static  libgeometry.a:  2946 bytes
shared  libgeometry.so: 15272 bytes

-fPIC — position-independent code — is required, because a shared library does not know what address it will be loaded at. Forget it and the linker will tell you to add it.

Linking looks identical. Running does not:

g++ main.o -L. -lgeometry -o prog_shared && echo "linked ok"
./prog_shared
linked ok
./prog_shared: error while loading shared libraries: libgeometry.so: cannot open shared object file: No such file or directory

-L is a build-time search path. It says nothing about run time. The linker found the library, recorded "I need libgeometry.so", and the loader has no idea where to look.

The ldd command lists what a binary needs and whether it can be found:

ldd prog_shared | grep geometry
	libgeometry.so => not found

Three ways to fix the runtime path

-rpath, baked into the binary. $ORIGIN means "the directory this binary is in", which is what you want for a self-contained application:

g++ main.o -L. -lgeometry -Wl,-rpath,'$ORIGIN' -o prog_rpath && ./prog_rpath
ldd prog_rpath | grep geometry
12.5664
	libgeometry.so => ./libgeometry.so (0x...)

LD_LIBRARY_PATH, an environment variable, useful for development and a poor deployment strategy — it affects every program you run from that shell.

Install to a standard location (/usr/local/lib plus ldconfig), which is what a system package does.

For anything you ship yourself, -rpath with $ORIGIN is the right default. Note the single quotes: $ORIGIN must reach the linker literally, not be expanded by your shell.

The trade-off, honestly

The received wisdom is that static linking makes large binaries. At this scale it does not:

echo "statically linked:  $(stat -c%s prog) bytes"
echo "dynamically linked: $(stat -c%s prog_shared) bytes"
statically linked:  16472 bytes
dynamically linked: 16440 bytes

Essentially identical — one small function either way. The size argument only becomes real with large libraries, and even then it is about total disk and memory when many programs share one copy, not about any single binary.

The difference that always matters is this one:

mkdir -p away && mv libgeometry.a libgeometry.so away/
echo "=== static binary, library deleted:"
./prog && echo "  still runs"
echo "=== dynamic binary, library deleted:"
./prog_rpath 2>&1 | head -1
mv away/libgeometry.a away/libgeometry.so . && rmdir away
=== static binary, library deleted:
12.5664
  still runs
=== dynamic binary, library deleted:
./prog_rpath: error while loading shared libraries: libgeometry.so: cannot open shared object file: No such file or directory

A static binary carries its dependencies. A dynamic one does not.

| | Static (.a) | Shared (.so) | |---|---|---| | Code lives in | the executable | a separate file | | Missing at run time | cannot happen | fails to start | | Fixing a library bug | relink every program | replace one file | | Many programs, one library | a copy each | one copy in memory | | Link-order sensitive | yes | no | | Startup cost | none | symbol resolution | | Cross-library inlining | possible with LTO | no |

Choose static for applications you ship as a unit, and for anything where "it works on my machine" must mean "it works". Choose shared for a library many programs use, for plugins, and where a security fix must not require rebuilding every consumer.

Shared libraries are not link-order sensitive because the linker records the whole dependency rather than extracting pieces — there is nothing to miss on a single pass.

What a shared library exports

By default, a shared library exports every non-static symbol, which is usually far more than you meant:

echo "exported functions: $(nm -DC libgeometry.so | grep -c ' T ')"
nm -DC libgeometry.so | grep -E 'circle_area|square_area' | awk '{$1=""; print substr($0,2)}'
exported functions: 2
T circle_area(double)
T square_area(double)

nm -D lists dynamic symbols. For a real library you want to export only your public interface — both to keep your ABI small and to let the linker optimise the rest. That is -fvisibility=hidden plus an explicit attribute on what you publish:

// geometry_api.hpp
#pragma once

#define GEOMETRY_API __attribute__((visibility("default")))

GEOMETRY_API double circle_area(double r);
double square_area(double s);           // deliberately not exported
// circle_api.cpp
#include "geometry_api.hpp"

double circle_area(double r) { return 3.14159265358979 * r * r; }
// square_api.cpp
#include "geometry_api.hpp"

double square_area(double s) { return s * s; }
g++ -std=c++20 -fPIC -fvisibility=hidden -c circle_api.cpp -o circle_api.o
g++ -std=c++20 -fPIC -fvisibility=hidden -c square_api.cpp -o square_api.o
g++ -shared circle_api.o square_api.o -o libgeometry_api.so
for sym in circle_area square_area; do
  printf '%-12s exported: %s\n' "$sym" "$(nm -DC libgeometry_api.so | grep -c "T $sym")"
  printf '%-12s present:  %s\n' "$sym" "$(nm -C  libgeometry_api.so | grep -c " t \\?$sym")"
done
circle_area  exported: 1
circle_area  present:  0
square_area  exported: 0
square_area  present:  1

square_area is still in the file and still callable from inside the library; it is simply no longer part of the public interface. On Windows this is __declspec(dllexport), which is why real cross-platform libraries define a macro like GEOMETRY_API rather than writing either one directly.

Try it

Build the same three-function library as a .a and a .so, and measure: which object files each link pulls in, what ldd reports for each binary, and what happens to each when the library file moves. Then create a two-library dependency — libapp.a using libcore.a — and find the link order that works.

Checkpoint

  • A static library (.a) is an archive of object files with an index, built by ar rcs.
  • The linker extracts only the object files providing symbols it currently needs.
  • Measured: square.o never entered the binary, because nothing called square_area.
  • Link order matters for static libraries: list them after the objects that use them.
  • A dependent library goes before the library it depends on; cycles need -Wl,--start-group.
  • An undefined symbol from library X usually means X is too early on the command line.
  • A shared library (.so) needs -fPIC and is resolved by the loader at run time.
  • -L is a build-time search path only — it says nothing about where the loader will look.
  • ldd lists a binary's shared dependencies and whether each resolves.
  • Fix the runtime path with -Wl,-rpath,'$ORIGIN', quoted so the shell does not expand it.
  • Measured: static and shared binaries here differ by 32 bytes — the size folklore needs a large library to be true.
  • The difference that always matters: a static binary runs with the library deleted; a shared one does not start.
  • Shared libraries are not link-order sensitive, because nothing is being extracted.
  • A .so exports every non-static symbol by default; -fvisibility=hidden plus __attribute__((visibility("default"))) publishes only your interface.

Questions about this lesson

Log in to ask a question.