How the C++ Linker Works: Undefined References, Library Order, and Shared Libraries

Key takeaways

The linker resolves symbols across object files and libraries. This guide explains symbol resolution, why static library order matters with GNU ld, the real causes of undefined reference and multiple definition errors, shared library lookup with rpath and $ORIGIN, and what LTO changes.

What the linker is actually for

The compiler works on one translation unit at a time: a .cpp file after the preprocessor has pasted in every header. When main.cpp calls add(10, 20), the compiler only needs a declaration to generate the call; it emits an instruction with a placeholder address and records “this object file needs a symbol called _Z3addii”. It has no idea where that function lives, and it does not care.

The linker is the program that answers those questions. It takes all the object files and libraries, builds a table of which symbols each one defines and which it needs, matches them up, and then patches every placeholder with a real address (relocation). Almost every confusing link error comes from one of those two steps failing: a needed symbol has no definition, or a symbol has more than one.

That separation is also why C++ builds scale: changing util.cpp recompiles one object file and relinks, instead of recompiling the whole program. For the stages before this one, see the compilation process.

// util.h
#pragma once
int add(int a, int b);

// util.cpp
#include "util.h"
int add(int a, int b) {
    return a + b;
}

// main.cpp
#include <iostream>
#include "util.h"
int main() {
    std::cout << add(10, 20) << '\n';
}
# Compile only: produces object files, no linking
g++ -c main.cpp -o main.o
g++ -c util.cpp -o util.o

# Link: g++ invokes the system linker (ld, lld, or mold) with the C++ runtime added
g++ main.o util.o -o myapp

# One command does both, but the steps are the same underneath
g++ main.cpp util.cpp -o myapp

Always link C++ programs through g++/clang++ rather than calling ld directly or linking with gcc. The driver adds libstdc++ (or libc++), the startup files and the right runtime libraries. Linking C++ objects with plain gcc is the classic way to get hundreds of undefined references to std:: symbols and operator new.

Looking at symbols with nm

Before debugging a link error, look at what the object files actually contain. nm lists symbols; -C demangles C++ names.

$ nm a.o
0000000000000000 T _Z6a_funcv
                 U _Z6b_funcv

$ nm -C main.o
                 U a_func()
0000000000000000 T main

T means “defined in the text (code) section”, U means “used here but undefined, someone else must provide it”. Lowercase letters (t, d, b) are local symbols that are invisible to other object files, which is what static functions and anonymous-namespace members become. The mangled form _Z6a_funcv encodes the parameter types; that is how overloads can coexist, and it is explained in name mangling.

Other useful tools on Linux: ldd ./myapp shows which shared libraries the loader will resolve, readelf -d ./myapp shows NEEDED entries and the embedded RUNPATH, and objdump -T libfoo.so lists exported dynamic symbols. On macOS the equivalents are otool -L and nm -gU; on Windows with MSVC, dumpbin /symbols and dumpbin /exports.

Static libraries and why order matters

A static library (.a on Unix, .lib on Windows) is just an archive of object files with an index:

g++ -c lib.cpp -o lib.o
ar rcs libmylib.a lib.o
g++ main.o -L. -lmylib -o myapp

-L. adds a search directory; -lmylib means “look for libmylib.so or libmylib.a” in those directories (the shared version is preferred when both exist, unless you pass -static or -l:libmylib.a).

The important detail is how GNU ld treats archives. It walks the command line left to right, once. When it reaches an archive, it only extracts the members that define a symbol that is currently undefined. Anything that becomes undefined later does not cause it to go back. I reproduced this with two tiny libraries where libA calls into libB:

$ g++ main.o -L. -lB -lA -o app
ld: ./libA.a(a.o):a.cpp:(.text+0x9): undefined reference to `b_func()'

$ g++ -L. -lA -lB main.o -o app
ld: main.o:main.cpp:(.text+0xe): undefined reference to `a_func()'

$ g++ main.o -L. -lA -lB -o app && ./app
42

In the first command, when ld sees libB.a, nothing needs b_func yet, so it skips it. In the second, both archives are scanned before main.o creates any undefined symbols. The rule is: objects first, then libraries, with each library before the libraries it depends on. For genuinely circular dependencies you can wrap the group in -Wl,--start-group -lA -lB -Wl,--end-group, which rescans until nothing new is resolved, at some link-time cost.

This behavior is specific to traditional Unix linkers. MSVC’s link.exe, LLVM’s lld and Apple’s ld64 resolve symbols from all libraries regardless of position, so code that links fine on macOS or Windows can fail on a Linux CI box. CMake’s target_link_libraries records dependencies between targets and emits them in a valid order, which is one of the best practical reasons to use it instead of hand-written link lines.

Static linking trade-offs:

  • The executable carries its own copy of the code, so there is nothing to find at runtime.
  • Only the archive members that are actually referenced get pulled in, not the whole library.
  • Every program that uses the library has its own copy, and a bug fix in the library means relinking every program.
  • Static initializers in an archive member that nothing references are silently dropped. If a plugin registers itself through a global constructor and nothing calls into that object file, the registration never runs. -Wl,--whole-archive (or an explicit reference) forces it in.

Shared libraries

g++ -fPIC -c lib.cpp -o lib.o
g++ -shared lib.o -o libmylib.so
g++ main.o -L. -lmylib -Wl,-rpath,'$ORIGIN' -o myapp
./myapp

-fPIC generates position-independent code so the same library pages can be mapped at any address in any process. On x86-64 you cannot build a shared library from non-PIC objects; you get relocation R_X86_64_32 against '.rodata' can not be used when making a shared object; recompile with -fPIC. The same error shows up when you try to link a static library that was built without -fPIC into a .so.

Linking against a .so does not copy its code. The linker only checks that the symbols exist and records a NEEDED libmylib.so entry. At program start, the dynamic loader (ld-linux.so) has to find the file again, and it does not use your -L paths. It searches, roughly in order: the executable’s RPATH/RUNPATH, LD_LIBRARY_PATH, the ldconfig cache, and the default directories such as /lib and /usr/lib. A missing library shows up as:

./myapp: error while loading shared libraries: libmylib.so: cannot open shared object file: No such file or directory

Your options: install the library to a system directory and run ldconfig; embed a search path with -Wl,-rpath; or set LD_LIBRARY_PATH, which is fine for testing but a poor deployment mechanism because it affects every child process.

The rpath value matters. -Wl,-rpath,. is a common tutorial suggestion and it is wrong for deployment: a relative path is resolved against the current working directory of the process, so ./myapp works but cd /tmp && /opt/app/myapp does not. $ORIGIN is expanded by the loader to the directory containing the executable, so -Wl,-rpath,'$ORIGIN/../lib' gives you a relocatable install layout. The single quotes stop the shell from treating $ORIGIN as a variable; in CMake use BUILD_RPATH/INSTALL_RPATH properties instead of passing flags by hand.

On Windows the model is different. The loader searches the executable’s directory first, which is why applications ship DLLs next to the .exe. With MSVC, functions are not exported from a DLL unless marked __declspec(dllexport) (or listed in a .def file), and consumers link against an import library (.lib) rather than the DLL itself. MinGW’s g++ -shared exports everything by default and can link directly against a .dll, which hides this difference until you port the build to MSVC and get a wall of LNK2019 errors. See LNK2019 unresolved external symbol for that side.

Shared library trade-offs:

  • One copy on disk and one set of read-only pages in memory shared by all processes using it.
  • Security fixes can be deployed by replacing the .so, as long as the ABI stays compatible.
  • Calls into the library go through the PLT/GOT indirection, and the library’s own internal calls may also be indirect unless symbols are hidden. This is rarely a bottleneck, but it is one reason -fvisibility=hidden plus explicit exports is recommended for libraries.
  • ABI compatibility becomes your problem: changing a class layout, a virtual function order or an inline function in a header breaks existing binaries without any build error. That is what SONAME versioning (libfoo.so.1 vs libfoo.so.2) exists for.

The errors you will actually see

undefined reference

ld: missing.o:missing.cpp:(.text+0x18): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status

Something declared add(int, int) and called it, and no input defined it. The causes, roughly in the order I check them:

  1. The defining file or library is not on the link line. Common with build systems: the .cpp exists but is not listed in the target’s sources.
  2. Library order, as shown above.
  3. The signature does not match. Declared add(int, int), defined add(long, long) or add(const int&, int). The mangled names differ, so to the linker they are unrelated. The demangled name in the error message is the one that was requested; compare it with nm -C on the object you expect to define it.
  4. C and C++ mixing. A function compiled as C has the unmangled name c_add; a C++ caller without extern "C" looks for _Z5c_addii and fails with undefined reference to 'c_add(int, int)'. The fix is extern "C" on the declaration, typically wrapped in #ifdef __cplusplus in the C header. See extern and linkage.
  5. The definition has internal linkage. A function marked static or placed in an anonymous namespace in another .cpp is invisible outside it, even though it has the right name.
  6. Template defined in a .cpp file. Templates are instantiated where they are used, so if the body is only in tmpl.cpp, the using file gets undefined reference to 'int twice<int>(int)'. Move the definition to the header, or explicitly instantiate the needed types in the .cpp.
  7. Declared but never defined special members. A virtual function with no definition produces undefined reference to 'vtable for Foo'; a static data member declared in the class but never defined (pre-C++17, not inline) produces an undefined reference to Foo::count.

multiple definition

ld: dup2.o:dup2.cpp:(.bss+0x0): multiple definition of `counter';
    dup1.o:dup1.cpp:(.bss+0x0): first defined here

This is the One Definition Rule enforced by the linker. The usual cause is a definition in a header: int counter = 0; or a non-inline function body in a .h that two .cpp files include. Header guards do not help here, because guards only prevent double inclusion within one translation unit; each .cpp still gets its own copy. The fixes are extern int counter; in the header with one definition in a .cpp, or inline (C++17 inline variables work for this too).

The version of this I have hit most often was not the clean error above but its silent sibling. Functions defined in a class body, templates and inline functions are allowed to appear in many translation units, and the linker keeps one copy without comparing them. If two .cpp files each define a class named Config in the global namespace with different members, or the same header is compiled with different #define settings, the program links cleanly and one of the two versions wins arbitrarily. The symptom is a crash or corrupted data that only happens in one build configuration. Putting file-local helper types in an anonymous namespace makes this class of bug impossible, and it is one of the few C++ habits I apply mechanically.

Runtime symbol errors

./myapp: symbol lookup error: ./myapp: undefined symbol: _Z3addii

This one comes from the loader, not the linker: the program was linked against one version of libmylib.so and is running against another that no longer exports the symbol. It is common after upgrading a library without rebuilding, or when an old copy earlier in the search path shadows the new one. ldd ./myapp tells you which file was actually picked.

Normally each translation unit is optimized in isolation, so the compiler cannot inline add into main because it only sees the declaration. With -flto, object files contain the compiler’s intermediate representation instead of final machine code, and the optimizer runs again across the whole program at link time.

g++ -O2 -flto -c main.cpp -o main.o
g++ -O2 -flto -c util.cpp -o util.o
g++ -O2 -flto main.o util.o -o myapp

Things to know before turning it on:

  • Pass -flto and your optimization flags at link time too, not only when compiling. The link step is where the cross-module optimization and final code generation actually happen, so a link line without them does not reliably give you the build you think you asked for.
  • Static archives containing LTO objects need the plugin-aware wrappers (gcc-ar, gcc-ranlib, or llvm-ar); plain ar can produce archives whose symbols the linker cannot see, which shows up as undefined references that nm claims are there.
  • Link time and memory use grow, often considerably on large projects. Clang’s ThinLTO (-flto=thin) and GCC’s parallel -flto=auto exist to keep this manageable.
  • LTO is also a correctness amplifier. Code with undefined behavior or ODR violations that happened to work because of TU boundaries can start misbehaving once the optimizer sees across them. GCC’s -Wodr warning, which only works with LTO, catches some of those violations, which is a good reason to try LTO in CI even if you do not ship with it.

Build system sketches

A Makefile only needs the link rule to list libraries after objects:

CXX      = g++
CXXFLAGS = -std=c++17 -Wall -g
OBJS     = main.o util.o

myapp: $(OBJS)
	$(CXX) $(OBJS) -L./lib -lmylib -o $@

In CMake, express dependencies between targets and let it compute order and flags, including -fPIC for shared libraries:

add_library(mylib STATIC lib.cpp)
add_executable(myapp main.cpp util.cpp)
target_link_libraries(myapp PRIVATE mylib)

If mylib itself depends on another library, link it to mylib with target_link_libraries(mylib PUBLIC otherlib) rather than adding it to myapp. CMake then propagates it and orders it correctly. More on the Make side in the Makefile guide.

Static vs dynamic at a glance

Static (.a / .lib)Dynamic (.so / .dll / .dylib)
Code locationCopied into the executableLoaded by the runtime loader
Runtime lookupNonerpath, LD_LIBRARY_PATH, system paths (exe directory on Windows)
Updating the libraryRelink every programReplace the file if the ABI is compatible
Memory across processesEach process has its own copyRead-only pages shared
Typical failureUndefined references from link order, dropped static initializers”cannot open shared object file”, “undefined symbol” at runtime