Dynamic Library Loading in C with dlopen and dlsym

In POSIX-compliant C programming, the dynamic linking interface defined in <dlfcn.h>—primarily dlopen and dlsym—enables an application to load shared object files (.so) into memory and bind functions or variables at runtime. Rather than resolving all external dependencies at compile or startup time, this mechanism allows a running process to inspect, load, and execute compiled code on demand, forming the backbone for extensible architectures such as plugin systems, lazy-loading modules, and runtime driver interfaces.

The Role of dlopen

The dlopen function makes the dynamic library's executable code and data available to the calling process. Its function prototype is:

void *dlopen(const char *filename, int flags);

When called with a path to a shared library, the dynamic linker (such as ld.so on Linux) maps the target object into the calling process's virtual address space. If the library depends on other shared objects, dlopen recursively loads those dependencies as well.

The flags argument determines symbol resolution and visibility behaviors:

  • RTLD_LAZY: Resolves function references only when code contained within the library is executed.
  • RTLD_NOW: Resolves all undefined symbols before dlopen returns, failing if any unresolved symbols exist.
  • RTLD_GLOBAL: Makes the symbols loaded in this library available for symbol resolution of subsequently loaded libraries.
  • RTLD_LOCAL: Keeps symbols private to the library, preventing other modules from resolving symbols against it unless explicitly referenced.

Upon mapping the shared object, dlopen executes any initialization routines defined in the library, such as functions marked with __attribute__((constructor)) or the legacy _init routine. It returns an opaque void* handle that acts as an identifier for the loaded object in subsequent operations.

Resolving Symbols with dlsym

Once a library is loaded, the process needs an entry point into its functions or variables. The dlsym function locates the memory address of an exported symbol using the handle returned by dlopen:

void *dlsym(void *handle, const char *symbol);

dlsym parses the dynamic symbol table (.dynsym) of the targeted library to find the offset of the specified symbol name. Once found, it calculates the absolute runtime memory address by adding the library’s base load address to the symbol offset.

Because dlsym returns a generic pointer (void*), the calling program must cast this address into the appropriate function pointer or data pointer type to execute it. In strict ISO C, casting between a data pointer (void*) and a function pointer is technically undefined, but POSIX mandates that void* can represent function pointers to ensure compatibility with dlsym.

Error Handling with dlerror

Because runtime resolution introduces failure points such as missing files, unresolved symbols, or syntax mismatches, POSIX provides dlerror:

char *dlerror(void);

Calling dlerror returns a human-readable null-terminated string describing the most recent dynamic linking error that occurred in dlopen, dlsym, or dlclose. It also resets the internal error status, meaning consecutive calls return NULL unless another error occurs.

Releasing Resources with dlclose

When a library is no longer needed, it is detached using dlclose:

int dlclose(void *handle);

The dynamic linker maintains an internal reference count for every dynamically loaded object. Each successful call to dlopen increments this count, and each dlclose decrements it. When the reference count drops to zero, the linker executes any cleanup routines (such as destructor functions marked with __attribute__((destructor))) and unmaps the library from the process's address space via munmap, freeing memory.

Complete Runtime Dynamic Flow

The runtime dynamic linking process executes in the following sequence:

  1. Load: The application calls dlopen("libmath_plugin.so", RTLD_NOW). The operating system locates the file, maps its ELF segments into memory, resolves symbols, executes constructors, and returns a handle.
  2. Inspect: The program calls dlerror() to verify that the load succeeded without errors.
  3. Resolve: The application queries dlsym(handle, "compute_value"). The linker traverses the library's dynamic symbol table, retrieves the relative offset of compute_value, adds the base load address, and returns the pointer.
  4. Execute: The returned pointer is cast to a function pointer signature (e.g., double (*)(double)) and executed directly by the CPU.
  5. Unload: The application calls dlclose(handle), decreasing the reference count and unmapping the memory once the library is no longer referenced.