Runtime Dynamic Linking in C/C++ — dlopen, dlsym, and the Art of Loading Libraries at Runtime
What happens when you can't decide at compile time which shared library your program needs?
If you've ever worked on a plugin system, a hardware abstraction layer, or a multi-variant embedded platform, you've hit this wall. The standard approach — linking against a .so at build time — assumes you know exactly which library you need before the program runs. But what if the answer depends on which hardware is detected, which plugins the user installed, or which variant shipped?
This is where POSIX runtime dynamic linking (dlopen/dlsym) comes in. It lets your program discover, load, and call into shared libraries at runtime — no link-time dependency, no recompilation.
In this post, we'll build everything from scratch on a Linux machine. By the end, you'll understand not just the API, but why it exists — by first seeing where normal linking breaks down.
Prerequisites
You'll need a Linux machine (or WSL2) with gcc and basic command-line tools. Every example is self-contained — create a working directory and follow along:
mkdir -p ~/dynamic-linking-lab && cd ~/dynamic-linking-lab
Part 1 — How Linking Normally Works (Implicit Linking)
Before we talk about loading libraries at runtime, let's make sure we understand how things work when you do know the library at compile time. This is called implicit linking (or load-time linking) — the dynamic linker resolves everything before main() runs.
Step 1: Write a Simple Shared Library
Let's create a small math utility library.
mathutils.h — the public interface:
// mathutils.h #ifndef MATHUTILS_H #define MATHUTILS_H int add(int a, int b); int multiply(int a, int b); #endif
mathutils.c — the implementation:
// mathutils.c #include "mathutils.h" int add(int a, int b) { return a + b; } int multiply(int a, int b) { return a * b; }
Step 2: Compile It into a Shared Library
gcc -shared -fPIC -o libmathutils.so mathutils.c
Two flags to understand here:
-sharedtellsgccto produce a shared object (.so) rather than an executable.-fPICstands for Position Independent Code. Since the library can be loaded at any address in a process's virtual address space (especially with ASLR), the generated code can't use hardcoded absolute addresses.-fPICmakes the compiler emit code that uses relative addressing and the GOT (Global Offset Table) instead.
Step 3: Write a Program That Uses It
main.c:
// main.c #include <stdio.h> #include "mathutils.h" int main(void) { printf("3 + 4 = %d\n", add(3, 4)); printf("3 * 4 = %d\n", multiply(3, 4)); return 0; }
Step 4: Compile and Link
gcc -o app main.c -L. -lmathutils
-L.tells the linker to search the current directory for libraries.-lmathutilstells the linker to look forlibmathutils.so(it prependsliband appends.soautomatically).
Step 5: Run It
LD_LIBRARY_PATH=. ./app
Output:
3 + 4 = 7 3 * 4 = 12
We need LD_LIBRARY_PATH=. because the runtime linker doesn't search the current directory by default. Without it, you'd see:
./app: error while loading shared libraries: libmathutils.so: cannot open shared object file: No such file or directory
What Just Happened Under the Hood?
When you ran ./app, the kernel didn't jump straight to your main(). Here's the actual sequence:
- The kernel loads your executable and sees that it needs a dynamic linker (recorded in the
.interpsection of the ELF binary). - The dynamic linker (
ld-linux-x86-64.so.2) takes over. - It reads the
NEEDEDentries in your binary's dynamic section — these are the libraries your program was linked against. - It finds and maps each library into the process's address space.
- It resolves all symbol references — patching up the GOT/PLT so that calls to
add()andmultiply()jump to the right addresses. - Only then does it call
main().
You can see the NEEDED entries yourself:
readelf -d app | grep NEEDED
Output:
0x0000000000000001 (NEEDED) Shared library: [libmathutils.so] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
And you can watch the entire loading process in real time:
LD_DEBUG=libs LD_LIBRARY_PATH=. ./app 2>&1 | head -30
This prints every library the dynamic linker searches for, where it finds them, and how it maps them.
The Key Takeaway
With implicit linking, everything is decided at build time. The compiler verifies function signatures against the header. The linker records which .so files are needed. The dynamic linker loads them before main(). It's safe, type-checked, and automatic.
But it comes with a rigid assumption: you know exactly which libraries you need before the program is compiled. In Part 2, we'll see where this assumption breaks down — and why you'd need a different approach.
Part 2 — Where Implicit Linking Breaks Down
Let's build something slightly more realistic. Imagine you're writing an audio processing application that supports effects plugins — reverb, echo, and so on. Each plugin lives in its own .so file and implements a common interface.
Setting Up the Scenario
First, let's define the contract that all plugins must follow.
plugin_api.h — the shared interface:
// plugin_api.h #ifndef PLUGIN_API_H #define PLUGIN_API_H // Every plugin must export a function matching this signature typedef void (*process_fn)(float *audio, int len); #endif
Now, two plugins that implement this interface.
reverb.c:
// reverb.c #include <stdio.h> #include "plugin_api.h" void process(float *audio, int len) { printf("[reverb] Processing %d samples\n", len); for (int i = 0; i < len; i++) audio[i] *= 0.8f; }
echo.c:
// echo.c #include <stdio.h> #include "plugin_api.h" void process(float *audio, int len) { printf("[echo] Processing %d samples\n", len); for (int i = 1; i < len; i++) audio[i] += audio[i - 1] * 0.5f; }
Compile them into shared libraries:
gcc -shared -fPIC -o libreverb.so reverb.c gcc -shared -fPIC -o libecho.so echo.c
Now, let's try to use these with implicit linking and watch it break.
Problem 1: Symbol Collision
Both plugins export a function called process. That's by design — they implement the same interface. But if you try to implicitly link against both:
app_implicit.c:
// app_implicit.c #include <stdio.h> // How do you even declare this? // Both libraries export "process" with the same signature void process(float *audio, int len); int main(void) { float audio[8] = {1.0f, 0.5f, 0.3f, 0.7f, 0.2f, 0.9f, 0.4f, 0.6f}; process(audio, 8); return 0; }
gcc -o app_implicit app_implicit.c -L. -lreverb -lecho LD_LIBRARY_PATH=. ./app_implicit
Output:
[reverb] Processing 8 samples
Only reverb ran. The linker found the process symbol in libreverb.so first (because -lreverb came before -lecho) and never looked further. The echo plugin was silently ignored. You can't use both — the linker has no way to distinguish between two symbols with the same name.
Verify this with nm:
nm -D libreverb.so | grep process nm -D libecho.so | grep process
Both show process as an exported text symbol. The linker picks the first one it finds and stops.
Problem 2: Unknown Plugins at Compile Time
Now imagine a third-party developer creates a compressor plugin six months after you ship your application. They write libcompressor.so and the user drops it onto their system.
With implicit linking, your binary has no NEEDED entry for libcompressor.so. Check what's actually recorded:
readelf -d app_implicit | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libreverb.so] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
Notice something interesting — libecho.so isn't listed either, even though we linked against it with -lecho. This is because modern GCC passes --as-needed to the linker by default. The linker only records a library as NEEDED if the binary actually uses a symbol from it. Since process was already resolved from libreverb.so, the linker determined that libecho.so contributed nothing and dropped it silently.
This reinforces Problem 1 — not only was echo's process ignored at runtime, the linker didn't even bother recording the library as a dependency. And for any future plugin like libcompressor.so, there's no way to get it into this list without recompiling. To add support for a new plugin, you'd have to rebuild your application. That defeats the entire purpose of a plugin system.
Problem 3: Fatal Failure on Missing Libraries
Any library that IS recorded as NEEDED must be present at runtime — otherwise the program won't start at all. From the readelf output above, we know libreverb.so is a hard dependency. Let's remove it:
mv libreverb.so libreverb.so.bak LD_LIBRARY_PATH=. ./app_implicit
./app_implicit: error while loading shared libraries: libreverb.so: cannot open shared object file: No such file or directory
The program didn't even reach main(). The dynamic linker resolves all NEEDED entries before your code runs — if any one is missing, the entire process aborts. In a plugin system, this means a single missing library kills the whole application, even if the rest of the program could function without it.
Restore the file before continuing:
mv libreverb.so.bak libreverb.so
The Fundamental Issue
All three problems stem from the same root cause: implicit linking requires complete knowledge at compile time. You must know:
- Which libraries will exist (Problem 2)
- That they'll always be present at runtime (Problem 3)
- That their symbol names won't collide (Problem 1)
For a plugin system, none of these assumptions hold. You need a way to:
- Discover libraries at runtime
- Load them on demand
- Handle failures gracefully without crashing
- Isolate each library's symbols from the others
This is exactly what dlopen and dlsym provide. In Part 3, we'll rewrite this example using runtime dynamic linking and solve all three problems.
Part 3 — Runtime Dynamic Linking with dlopen and dlsym
The POSIX <dlfcn.h> header provides four functions for runtime dynamic linking:
#include <dlfcn.h> void *dlopen(const char *filename, int flags); // Load a .so into memory void *dlsym(void *handle, const char *symbol); // Look up a symbol by name int dlclose(void *handle); // Unload a .so char *dlerror(void); // Get the last error message
Let's understand each one by building a working plugin loader.
The Contract
We'll reuse the same plugin_api.h and the same libreverb.so / libecho.so from Part 2. The plugins don't need to change at all — the only thing that changes is how the host application loads them.
Quick reminder of the contract:
// plugin_api.h #ifndef PLUGIN_API_H #define PLUGIN_API_H typedef void (*process_fn)(float *audio, int len); #endif
This typedef defines a function pointer type. It tells the compiler: "any function that takes a float * and an int and returns void can be stored in a variable of type process_fn." This is the contract — both sides (the plugin and the host) agree on this signature.
Note that the typedef itself is not a symbol. It doesn't appear in any .so file's symbol table. It exists only at compile time, in the compiler's head, and is gone after compilation. The actual symbol that dlsym will search for is the function name "process" — the typedef just tells us what shape that function has.
dlopen — Loading a Library
dlopen takes two arguments: the path to the .so file and a set of flags.
void *handle = dlopen("./plugins/libreverb.so", RTLD_NOW);
Under the hood, this does several things:
- Finds and opens the file at the given path.
- Maps its segments into memory — the
.text(code) section as read+execute, the.datasection as read+write. - Performs relocations — since the library is compiled with
-fPIC, the actual runtime addresses aren't known until load time. The dynamic linker patches up internal references. - Loads any dependencies — if the
.soitself depends on other shared libraries, those are loaded recursively. - Runs constructors — any functions marked with
__attribute__((constructor))execute. - Returns an opaque handle — a
void *that you'll pass todlsymanddlclose.
If loading fails, it returns NULL and you can call dlerror() to get a human-readable error message.
The flags control two aspects:
When to resolve symbols:
RTLD_LAZY— defer resolution of function symbols until they're actually called. Faster load time, but a missing symbol causes a runtime crash later.RTLD_NOW— resolve all symbols immediately. Slower load time, but you discover missing symbols right away. Use this when you want fail-fast behavior.
How to scope symbols:
RTLD_LOCAL(default) — symbols from this library are not visible to other subsequently loaded libraries.RTLD_GLOBAL— symbols become globally visible, so otherdlopen'd libraries can resolve against them.
These are OR'd together: dlopen(path, RTLD_NOW | RTLD_GLOBAL).
dlsym — Looking Up a Symbol
Once a library is loaded, dlsym searches its dynamic symbol table (.dynsym) for a symbol by name and returns the address as void *:
void *raw = dlsym(handle, "process");
This is where the function pointer typedef comes in. dlsym returns void * because it's a generic lookup — it has no idea whether you're looking up a function, a global variable, or a struct. It just resolves a name to an address. You can't call a void * — the compiler doesn't know how many arguments to pass, what types they are, or what the return type is. You need to cast it:
process_fn process = (process_fn)dlsym(handle, "process"); process(audio, 8); // now the compiler knows the signature
Error checking has a subtle nuance. dlsym returns NULL on failure, but in theory NULL could be a valid symbol address. The correct pattern is:
dlerror(); // clear any previous error process_fn process = (process_fn)dlsym(handle, "process"); char *err = dlerror(); // check for new error if (err != NULL) { fprintf(stderr, "dlsym failed: %s\n", err); return -1; }
dlclose and dlerror
dlclose(handle) decrements the reference count on the library. When it hits zero, destructors run and the library is unmapped from memory.
dlerror() returns a human-readable string describing the last error from dlopen, dlsym, or dlclose. Returns NULL if no error occurred. Calling it also clears the error state.
Putting It All Together — A Plugin Loader
Now let's build the plugin system that solves all three problems from Part 2.
First, create a plugins directory and move the libraries there:
mkdir -p plugins cp libreverb.so plugins/ cp libecho.so plugins/
app_dynamic.c:
// app_dynamic.c #include <stdio.h> #include <string.h> #include <dirent.h> #include <dlfcn.h> #include "plugin_api.h" int main(void) { float audio[8] = {1.0f, 0.5f, 0.3f, 0.7f, 0.2f, 0.9f, 0.4f, 0.6f}; DIR *dir = opendir("./plugins"); if (!dir) { perror("Could not open plugins directory"); return 1; } struct dirent *entry; while ((entry = readdir(dir)) != NULL) { // Skip files that aren't .so if (!strstr(entry->d_name, ".so")) continue; // Build the full path char path[512]; snprintf(path, sizeof(path), "./plugins/%s", entry->d_name); // dlopen — load the library void *handle = dlopen(path, RTLD_NOW); if (!handle) { fprintf(stderr, "Skipping %s: %s\n", entry->d_name, dlerror()); continue; // app keeps running! } // dlsym — look up the "process" symbol dlerror(); // clear previous error process_fn process = (process_fn)dlsym(handle, "process"); char *err = dlerror(); if (err) { fprintf(stderr, "No process() in %s: %s\n", entry->d_name, err); dlclose(handle); continue; } // Call it printf("Running plugin: %s\n", entry->d_name); process(audio, 8); // Cleanup dlclose(handle); } closedir(dir); return 0; }
Compile and run — notice we don't link against any plugin library:
gcc -o app_dynamic app_dynamic.c -ldl ./app_dynamic
Output:
Running plugin: libreverb.so [reverb] Processing 8 samples Running plugin: libecho.so [echo] Processing 8 samples
Both plugins ran. No symbol collision. No recompilation needed. Let's verify this by checking the binary's dependencies:
readelf -d app_dynamic | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
Note: On older glibc versions (before 2.34), you may also see
libdl.so.2as a separate dependency. On modern systems (Ubuntu 22.04+),libdlhas been merged intolibc, so-ldlis accepted by the compiler but doesn't create a separateNEEDEDentry.
No mention of libreverb.so or libecho.so. The binary has zero knowledge of any plugin — it discovers them at runtime by scanning the directory.
Verifying All Three Problems Are Solved
Problem 1 — Symbol collision: solved. Both libraries export a function called process, and both ran. Because each is loaded with its own dlopen call and dlsym resolves within a specific handle, there's no conflict. Each handle is a separate namespace.
Problem 2 — Unknown plugins: solved. Let's create a brand new plugin and drop it in without recompiling:
compressor.c:
// compressor.c #include <stdio.h> #include "plugin_api.h" void process(float *audio, int len) { printf("[compressor] Processing %d samples\n", len); for (int i = 0; i < len; i++) { if (audio[i] > 0.7f) audio[i] = 0.7f; if (audio[i] < -0.7f) audio[i] = -0.7f; } }
gcc -shared -fPIC -o plugins/libcompressor.so compressor.c ./app_dynamic
Running plugin: libreverb.so [reverb] Processing 8 samples Running plugin: libcompressor.so [compressor] Processing 8 samples Running plugin: libecho.so [echo] Processing 8 samples
The new plugin was picked up automatically — zero changes to the host application.
Problem 3 — Fatal failure: solved. Let's remove a plugin:
rm plugins/libecho.so ./app_dynamic
Running plugin: libreverb.so [reverb] Processing 8 samples Running plugin: libcompressor.so [compressor] Processing 8 samples
The application kept running. It simply didn't load the missing plugin. No crash, no error before main(). The continue in our error handling path makes missing or broken plugins a non-event.
Restore the plugin for subsequent sections:
gcc -shared -fPIC -o plugins/libecho.so echo.c
What We Traded Away
Runtime dynamic linking isn't free. We gained flexibility, but we gave up compile-time type safety. With implicit linking, the compiler checks every function call against the header — wrong argument count, wrong types, wrong return type — all caught at compile time. With dlsym, everything is a void * that you cast on faith. If the plugin author writes:
// Oops — wrong signature, takes an extra argument void process(float *audio, int len, int channels) { ... }
The compiler won't catch it. The process_fn cast will succeed, and you'll get undefined behavior at runtime — silent corruption, a crash, or worse. The typedef and the shared header are your only safety net, and they're enforced by convention, not by the compiler.
In Part 4, we'll look at a pattern that reduces this risk — the function pointer struct — and explore how extern "C" fits into the picture when C++ enters the mix.
Part 4 — The Function Pointer Struct Pattern
In Part 3, our plugin exported a single function. Real plugin systems export many — init, shutdown, process, get_name, get_version, and so on. With the approach from Part 3, you'd need a separate dlsym call for each one:
init_fn init = (init_fn)dlsym(handle, "init"); shutdown_fn shutdown = (shutdown_fn)dlsym(handle, "shutdown"); process_fn process = (process_fn)dlsym(handle, "process"); name_fn get_name = (name_fn)dlsym(handle, "get_name"); // ... and so on for every function
Each dlsym call is a point of failure — the function might not exist, the name might be misspelled, the cast might be wrong. Five functions means five places to get it wrong. Twenty functions means twenty.
There's a better pattern: export one function that returns a struct full of function pointers. One dlsym call, one point of failure, and the struct gives you compile-time type checking from that point forward.
The Improved Contract
plugin_api_v2.h:
// plugin_api_v2.h #ifndef PLUGIN_API_V2_H #define PLUGIN_API_V2_H typedef struct { const char *name; int (*init)(void); void (*process)(float *audio, int len); void (*shutdown)(void); } PluginInterface; // Every plugin exports exactly this one function typedef PluginInterface *(*get_plugin_fn)(void); #endif
The struct is the contract. It says: "a plugin has a name, an init function, a process function, and a shutdown function." The only symbol that dlsym needs to find is get_plugin — everything else is accessed through the struct.
Writing Plugins Against This Contract
reverb_v2.c:
// reverb_v2.c #include <stdio.h> #include "plugin_api_v2.h" static int reverb_init(void) { printf("[reverb] Initialized\n"); return 0; } static void reverb_process(float *audio, int len) { printf("[reverb] Processing %d samples\n", len); for (int i = 0; i < len; i++) audio[i] *= 0.8f; } static void reverb_shutdown(void) { printf("[reverb] Shutdown\n"); } static PluginInterface iface = { .name = "Reverb", .init = reverb_init, .process = reverb_process, .shutdown = reverb_shutdown }; // The single exported entry point PluginInterface *get_plugin(void) { return &iface; }
echo_v2.c:
// echo_v2.c #include <stdio.h> #include "plugin_api_v2.h" static int echo_init(void) { printf("[echo] Initialized\n"); return 0; } static void echo_process(float *audio, int len) { printf("[echo] Processing %d samples\n", len); for (int i = 1; i < len; i++) audio[i] += audio[i - 1] * 0.5f; } static void echo_shutdown(void) { printf("[echo] Shutdown\n"); } static PluginInterface iface = { .name = "Echo", .init = echo_init, .process = echo_process, .shutdown = echo_shutdown }; PluginInterface *get_plugin(void) { return &iface; }
Notice that the internal functions (reverb_init, echo_process, etc.) are all static. They don't appear in the symbol table — they're invisible outside their own .so. Only get_plugin is exported. Verify this:
gcc -shared -fPIC -o plugins/libreverb_v2.so reverb_v2.c gcc -shared -fPIC -o plugins/libecho_v2.so echo_v2.c nm -D plugins/libreverb_v2.so | grep -E "init|process|shutdown|get_plugin"
00000000000011c9 T get_plugin
Only get_plugin is visible. The rest are internal implementation details.
The Host Application
app_v2.c:
// app_v2.c #include <stdio.h> #include <string.h> #include <dirent.h> #include <dlfcn.h> #include "plugin_api_v2.h" int main(void) { float audio[8] = {1.0f, 0.5f, 0.3f, 0.7f, 0.2f, 0.9f, 0.4f, 0.6f}; DIR *dir = opendir("./plugins"); if (!dir) { perror("Could not open plugins directory"); return 1; } struct dirent *entry; while ((entry = readdir(dir)) != NULL) { if (!strstr(entry->d_name, "_v2.so")) continue; char path[512]; snprintf(path, sizeof(path), "./plugins/%s", entry->d_name); void *handle = dlopen(path, RTLD_NOW); if (!handle) { fprintf(stderr, "Skipping %s: %s\n", entry->d_name, dlerror()); continue; } // ONE dlsym call — that's it dlerror(); get_plugin_fn get_plugin = (get_plugin_fn)dlsym(handle, "get_plugin"); char *err = dlerror(); if (err) { fprintf(stderr, "No get_plugin() in %s: %s\n", entry->d_name, err); dlclose(handle); continue; } // Everything from here is type-checked by the compiler PluginInterface *plugin = get_plugin(); printf("Loaded plugin: %s\n", plugin->name); plugin->init(); plugin->process(audio, 8); plugin->shutdown(); printf("\n"); dlclose(handle); } closedir(dir); return 0; }
Build and run:
gcc -o app_v2 app_v2.c -ldl ./app_v2
Loaded plugin: Reverb [reverb] Initialized [reverb] Processing 8 samples [reverb] Shutdown Loaded plugin: Echo [echo] Initialized [echo] Processing 8 samples [echo] Shutdown
Why This Pattern Is Better
After the single dlsym call succeeds, every subsequent call through the struct — plugin->init(), plugin->process(), plugin->shutdown() — is type-checked by the compiler. If you accidentally write plugin->process(audio) (missing the second argument), the compiler catches it. With individual dlsym calls, each cast is a manual trust exercise.
This isn't a pattern we invented. It's how real systems work:
- Linux kernel —
struct file_operationsholds function pointers foropen,read,write,ioctl. Each filesystem driver fills in its own implementations. - Android HAL —
hw_module_tandhw_device_tare structs full of function pointers. The HAL loader does onedlsymto get the module entry point. - GStreamer — plugins register element factories with vtables of function pointers.
Part 5 — extern "C" and the C++ Name Mangling Problem
Everything we've built so far has been pure C. In the real world, your host application or your plugins might be written in C++. This introduces a subtle but critical problem: name mangling.
The Problem
C++ compilers encode function signatures into symbol names to support function overloading. A function like:
int tuner_init(void);
Doesn't get stored as tuner_init in the symbol table. The C++ compiler mangles it into something like _Z10tuner_initv — encoding the name length, argument types, and return type.
Let's see this in action. Write a plugin in C++:
eq_plugin.cpp:
// eq_plugin.cpp #include <cstdio> #include "plugin_api_v2.h" static int eq_init(void) { printf("[equalizer] Initialized\n"); return 0; } static void eq_process(float *audio, int len) { printf("[equalizer] Processing %d samples\n", len); } static void eq_shutdown(void) { printf("[equalizer] Shutdown\n"); } static PluginInterface iface = { "Equalizer", eq_init, eq_process, eq_shutdown }; // No extern "C" — compiled as C++ PluginInterface *get_plugin(void) { return &iface; }
Compile with g++ (C++ compiler) and check the symbol table:
g++ -shared -fPIC -o plugins/libeq_v2.so eq_plugin.cpp nm -D plugins/libeq_v2.so | grep get_plugin
0000000000001199 T _Z10get_pluginv
The symbol is _Z10get_pluginv, not get_plugin. When our host does dlsym(handle, "get_plugin"), it's looking for the literal string "get_plugin" — which doesn't exist in this .so. The lookup silently fails.
Run it and see:
./app_v2
The equalizer plugin either won't appear or you'll see the "No get_plugin()" error message. dlsym couldn't find the symbol because the name doesn't match.
The Fix: extern "C"
extern "C" tells the C++ compiler: "don't mangle this function's name — use C linkage." Fix the plugin:
eq_plugin_fixed.cpp:
// eq_plugin_fixed.cpp #include <cstdio> #include "plugin_api_v2.h" static int eq_init(void) { printf("[equalizer] Initialized\n"); return 0; } static void eq_process(float *audio, int len) { printf("[equalizer] Processing %d samples\n", len); } static void eq_shutdown(void) { printf("[equalizer] Shutdown\n"); } static PluginInterface iface = { "Equalizer", eq_init, eq_process, eq_shutdown }; // With extern "C" — name won't be mangled extern "C" PluginInterface *get_plugin(void) { return &iface; }
Compile and check the symbol table:
g++ -shared -fPIC -o plugins/libeq_v2.so eq_plugin_fixed.cpp nm -D plugins/libeq_v2.so | grep get_plugin
0000000000001199 T get_plugin
Now dlsym(handle, "get_plugin") will find it. Run the host again:
./app_v2
The equalizer plugin should now load and run correctly.
Notice What Didn't Need extern "C"
Only get_plugin needed extern "C" — the function that dlsym searches for. The internal functions (eq_init, eq_process, eq_shutdown) are static and are accessed through the struct's function pointers, not through dlsym. They can be fully mangled C++ names — it doesn't matter because nobody is looking them up by string name.
This is another advantage of the function pointer struct pattern: it minimizes the surface area exposed to name mangling. One extern "C" on one function, regardless of how many functions the plugin implements.
Making the Header Work for Both C and C++
If both C and C++ code include the same header, you need a guard so the C compiler doesn't see extern "C" (which is a C++ keyword):
// plugin_api_v2.h — the portable version #ifndef PLUGIN_API_V2_H #define PLUGIN_API_V2_H #ifdef __cplusplus extern "C" { #endif typedef struct { const char *name; int (*init)(void); void (*process)(float *audio, int len); void (*shutdown)(void); } PluginInterface; typedef PluginInterface *(*get_plugin_fn)(void); #ifdef __cplusplus } #endif #endif
When compiled as C, __cplusplus is not defined, so the extern "C" block is stripped out — C doesn't mangle names anyway. When compiled as C++, the block is active and prevents mangling for any function declarations inside it.
The typedefs and struct inside the block are unaffected by extern "C" — they're type definitions, not exported symbols. They don't appear in .dynsym. The extern "C" only matters for function declarations that become actual symbols. But wrapping the entire header is standard practice — it's simpler than figuring out which individual declarations need it, and it's completely harmless for the types that don't.
Quick Reference: When Do You Need extern "C"?
| Scenario | Need extern "C"? |
|---|---|
.so compiled as C, host uses dlsym | No — C doesn't mangle |
.so compiled as C++, host uses dlsym | Yes — on exported functions |
| C header included in C++ code via implicit linking | Yes — in the header |
typedef / struct definitions | No — they're not symbols |
static functions inside a plugin | No — accessed via struct, not dlsym |
Part 6 — Where You'll See This in the Real World
The plugin example we built is deliberately simple — but the exact same pattern powers critical systems across the software industry. Here are some real-world uses of dlopen/dlsym worth knowing about.
Automotive — Hardware Variant Abstraction
In automotive software, a single infotainment or instrument cluster platform often ships across multiple vehicle variants. Each variant might have different hardware — a different tuner chip, a different display controller, a different GPS module. The application code needs to be identical across all variants, with only the hardware-specific library changing.
A common pattern is to select the .so at runtime based on a hardware identifier:
// Determine which tuner hardware is present int tuner_type = read_hw_config(); const char *lib_path = NULL; if (tuner_type >= TUNER_TYPE_A) lib_path = "/vendor/lib64/libtuner_a.so"; else if (tuner_type >= TUNER_TYPE_B) lib_path = "/vendor/lib64/libtuner_b.so"; void *handle = dlopen(lib_path, RTLD_LAZY); tuner_init_fn init = (tuner_init_fn)dlsym(handle, "tuner_init"); init();
The binary is the same across all variants — only the .so on the target's filesystem changes. This avoids maintaining separate builds for each vehicle model and lets the build system or image assembly pipeline drop in the right library without touching the application.
On QNX-based platforms (common in automotive), this works identically — QNX's dlopen is POSIX-compliant, with the dynamic linker being ldqnx.so.2 instead of Linux's ld-linux-x86-64.so.2.
Android HAL (Hardware Abstraction Layer)
Android's HAL is perhaps the most widely deployed dlopen/dlsym system in existence. When the camera service, audio service, or sensor service starts, it doesn't link directly against a vendor-specific library. Instead, it calls hw_get_module(), which internally does:
// Simplified version of what hw_get_module does snprintf(path, sizeof(path), "/vendor/lib64/hw/camera.%s.so", board_platform); handle = dlopen(path, RTLD_NOW); hmi = (struct hw_module_t *)dlsym(handle, HAL_MODULE_INFO_SYM_AS_STR);
It constructs a path based on the board name, dlopens the vendor's .so, and dlsyms a single entry point that returns a struct of function pointers (hw_module_t → hw_device_t). Every vendor — Qualcomm, Samsung, MediaTek — ships their own .so implementing the same struct interface. The Android framework code is identical across all of them.
This is the function pointer struct pattern from Part 4, at scale. The struct is the contract, the .so is the vendor's implementation, and dlopen/dlsym is the glue.
Nginx — Dynamic Modules
Nginx supports loadable modules that can be compiled separately and added to a running configuration:
load_module modules/ngx_http_geoip_module.so;
On startup, Nginx reads this directive, calls dlopen on the specified path, and dlsyms the module's registration structure. This lets you add functionality — geoIP lookup, image filtering, Lua scripting — without recompiling Nginx itself. The module implements a well-defined interface (ngx_module_t), and the core doesn't need to know about it at build time.
Database Engines — Extension Loading
PostgreSQL loads extensions via dlopen. When you run:
CREATE EXTENSION pg_trgm;
PostgreSQL finds the corresponding .so file (e.g., pg_trgm.so), dlopens it, and dlsyms the initialization function. This is how the entire PostgreSQL extension ecosystem works — PostGIS for geospatial data, pgvector for embeddings, TimescaleDB for time-series — all loaded at runtime into the same server process.
Web Servers and Language Runtimes — Apache, PHP, Python
Apache's entire module system (mod_rewrite, mod_ssl, mod_proxy) uses dlopen. PHP loads extensions like php-mysql, php-curl, and php-gd the same way. Python's C extension modules (.so files in your site-packages) are loaded via dlopen when you import them.
LD_PRELOAD — Function Interposition
A powerful technique related to dlopen is LD_PRELOAD, which injects a .so before any others load. Combined with dlsym(RTLD_NEXT, ...), you can intercept and wrap any function:
// malloc_tracker.c — track allocations without modifying the application #define _GNU_SOURCE #include <dlfcn.h> #include <stdio.h> #include <stdlib.h> void *malloc(size_t size) { // Find the real malloc static void *(*real_malloc)(size_t) = NULL; if (!real_malloc) real_malloc = dlsym(RTLD_NEXT, "malloc"); void *ptr = real_malloc(size); fprintf(stderr, "malloc(%zu) = %p\n", size, ptr); return ptr; }
gcc -shared -fPIC -o malloc_tracker.so malloc_tracker.c -ldl LD_PRELOAD=./malloc_tracker.so ls
Every malloc call made by ls now gets logged. RTLD_NEXT tells dlsym to find the next occurrence of malloc after the current library — i.e., the real one in libc. This technique is used by tools like Valgrind, AddressSanitizer, and jemalloc for production memory allocation tracking.
The Common Thread
Every one of these systems follows the same fundamental pattern:
- Define a contract — a header with function pointer types or a struct interface.
- Build implementations as separate
.sofiles — each satisfying the contract. - Discover and load at runtime — via
dlopen, driven by configuration, directory scanning, or hardware detection. - Resolve and call through a common interface — via
dlsym, with one entry point returning a struct of function pointers.
The specific domain changes — audio plugins, hardware drivers, database extensions, web server modules — but the architecture is always the same. Once you understand dlopen/dlsym and the contract pattern, you'll recognize it everywhere.
Wrapping Up
Let's recap the journey:
Part 1 showed how implicit linking works — the compiler checks types, the linker records dependencies, and the dynamic linker loads everything before main(). Safe and automatic, but rigid.
Part 2 showed where it breaks — symbol collisions, unknown plugins, and fatal crashes from missing libraries. The core issue: implicit linking requires complete knowledge at compile time.
Part 3 introduced dlopen/dlsym — runtime library discovery, loading, and symbol resolution. All three problems solved, at the cost of compile-time type safety.
Part 4 introduced the function pointer struct pattern — one dlsym call, one exported symbol, and type-checked calls through the struct. This is how production plugin systems work.
Part 5 covered extern "C" — why C++ name mangling breaks dlsym, how to fix it, and why the struct pattern minimizes the problem.
Part 6 showed where this pattern lives in the real world — automotive variant abstraction, Android HAL, Nginx modules, PostgreSQL extensions, and LD_PRELOAD function interposition. The same architecture, applied across wildly different domains.
Every file in this post is self-contained and buildable. Recreate them in ~/dynamic-linking-lab and run every command yourself — the best way to internalize this is to watch the symbols, the linker output, and the runtime behavior with your own eyes.
Further Reading
man dlopen,man dlsym— the POSIX specificationman ld.so— how the Linux dynamic linker worksreadelf -d,nm -D,LD_DEBUG=all— your diagnostic toolkit for shared library issues- The ELF specification — for understanding
.dynsym,.dynstr, GOT, and PLT at the binary level