dlsym(), dlclose() & dladdr() Advanced dlopen API

 

TLPI · Chapter 42 · Part 2

dlsym(), dlclose() & dladdr()
Advanced dlopen API

Deep-dive into symbol lookup with dlsym(), function-pointer casting rules, pseudohandles RTLD_DEFAULT & RTLD_NEXT, reference-counted unloading with dlclose(), and introspection via dladdr() — with a complete working program.

dlsym() Function Pointers RTLD_DEFAULT RTLD_NEXT dlclose() dladdr() C99 Casting Rules

1. dlsym() — Looking Up a Symbol at Runtime

dlsym() is the core lookup function of the dlopen API. Once you have a library handle from dlopen(), you call dlsym() to resolve a named symbol — whether a variable or a function — to its in-process virtual address.

#include <dlfcn.h>

void *dlsym(void *handle, char *symbol); Returns: address of symbol on success, NULL if not found (or on error)

The handle is either:

  • A library handle returned by dlopen(), or
  • One of the special pseudohandles: RTLD_DEFAULT or RTLD_NEXT (see Section 3).

The symbol argument is the C name of the symbol (e.g., “malloc”, “my_func”, “global_counter”).

1.1 The NULL Return Ambiguity

A tricky corner case: dlsym() returns NULL both when a symbol is not found and when a symbol’s actual value is NULL (e.g., a global pointer variable initialised to NULL).

⚠️ Never check dlsym()‘s return value alone when a symbol could legitimately be NULL. You must use dlerror() to disambiguate.

The correct pattern is to clear any prior error first, call dlsym(), then inspect dlerror():

void *sym;
const char *err;

dlerror();                          /* 1. Clear any previous error */
sym = dlsym(handle, "my_symbol");
err = dlerror();                    /* 2. Check for error AFTER the call */
if (err != NULL) {
    fprintf(stderr, "dlsym error: %s\n", err);
    exit(EXIT_FAILURE);
}

/* sym == NULL now means the symbol's value genuinely IS NULL */
if (sym == NULL)
    printf("Symbol value is NULL\n");
dlerror() → clear
dlsym(handle, name)
dlerror() == NULL?
Use sym safely

1.2 Accessing a Variable Symbol

When the symbol names a global variable in the shared library, cast the void * return to the correct pointer type, then dereference:

#include <dlfcn.h>
#include <stdio.h>

/* --- In the shared library (libdemo.so) --- */
// int myvar = 42;

/* --- In the calling program --- */
int *ip;

dlerror();
ip = (int *) dlsym(handle, "myvar");
if (dlerror() == NULL && ip != NULL)
    printf("myvar = %d\n", *ip);      /* prints: myvar = 42 */
Note: Casting to a data pointer (int *, char *, etc.) is well-defined under C99 — it is only function pointers that require special treatment.

1.3 Calling a Function Symbol — The C99 Cast Problem

This is the most nuanced part of dlsym() usage. The C99 standard forbids direct conversion between void * (a data pointer) and a function pointer. Attempting the intuitive assignment:

int (*funcp)(int);

/* ❌ WRONG — C99 forbids direct void* → function-pointer conversion */
funcp = dlsym(handle, "my_function");

produces a compiler warning (“assignment from incompatible pointer type”). Similarly, the apparently equivalent:

/* ❌ Also WRONG — gcc -pedantic warns: */
/* "ANSI C forbids use of cast expressions as lvalues" */
(void *) funcp = dlsym(handle, "my_function");

The correct, portable workaround — sanctioned by SUSv3 TC1 — is:

int (*funcp)(int);   /* function pointer: takes int, returns int */

/* ✅ CORRECT — cast address of funcp to void**, then assign */
*(void **) (&funcp) = dlsym(handle, "my_function");
Why does *(void **)(&funcp) work?
We are not converting a function pointer to/from void *. Instead, we take the address of funcp (which is int (**)(int)), cast that address to void ** (a pointer to a data pointer), and assign the void * return of dlsym() into that storage location. The assignment target is a data pointer (void *), so C99 permits it.

After obtaining the function pointer, invoke it with standard C function-pointer syntax:

int result;

/* Invoke the dynamically loaded function */
result = (*funcp)(42);

/* Most compilers also accept: */
result = funcp(42);
Platform note: On many UNIX implementations a plain cast such as
funcp = (int (*)(int)) dlsym(handle, sym);
compiles cleanly, but C99-conformant compilers must still warn. SUSv4 did not introduce a cleaner API, so *(void **) remains the standard idiom.

2. Pseudohandles: RTLD_DEFAULT and RTLD_NEXT

Instead of a real library handle from dlopen(), dlsym() accepts two special pseudohandle constants. These extend the symbol search scope beyond a single library.

Both constants require #define _GNU_SOURCE before including <dlfcn.h>. They are not mandated by SUSv3 (reserved for future use) and may not be available on all UNIX systems.
Pseudohandle Search Scope Typical Use Case
RTLD_DEFAULT Main program first, then all loaded shared libraries in load order (including RTLD_GLOBAL dlopen’d libs). Mirrors the dynamic linker’s default resolution. Find a symbol anywhere in the process — useful for optional/feature-detection checks.
RTLD_NEXT Shared libraries loaded after the calling library (i.e., next in the load-order chain after the current object). Implementing interposers / wrappers that delegate to the real symbol after custom logic.

2.1 RTLD_DEFAULT — Global Symbol Lookup

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

int main(void)
{
    void *sym;

    /* Search everywhere in the process for "printf" */
    dlerror();
    sym = dlsym(RTLD_DEFAULT, "printf");
    if (dlerror() == NULL && sym != NULL)
        printf("Found printf at: %p\n", sym);

    return 0;
}

Compile and run:

$ gcc -o rtld_default rtld_default.c -ldl
$ ./rtld_default
Found printf at: 0x7f8a3c2b1234

2.2 RTLD_NEXT — Wrapping / Interposing a Function

The classic use of RTLD_NEXT is to wrap a standard library function. Your wrapper has the same name; it does its extra work, then calls the real implementation via RTLD_NEXT.

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdlib.h>
#include <stdio.h>

/* Custom malloc: log every allocation, then call the real malloc */
void *malloc(size_t size)
{
    static void *(*real_malloc)(size_t) = NULL;

    if (real_malloc == NULL) {
        /* One-time initialisation: find the real malloc */
        dlerror();
        *(void **) (&real_malloc) = dlsym(RTLD_NEXT, "malloc");
        if (dlerror() != NULL) {
            fprintf(stderr, "Cannot locate real malloc!\n");
            return NULL;
        }
    }

    fprintf(stderr, "[TRACE] malloc(%zu)\n", size);
    return real_malloc(size);   /* delegate to real implementation */
}
/* Build as a preload library */
$ gcc -shared -fPIC -o libmymalloc.so mymalloc.c -ldl

/* Preload it so every malloc goes through our wrapper */
$ LD_PRELOAD=./libmymalloc.so ls /tmp
[TRACE] malloc(552)
[TRACE] malloc(120)
...
Key insight: RTLD_NEXT only makes sense when called from within a shared library (or an LD_PRELOAD interposer). From the main executable it would search all libraries without a meaningful “next” anchor.

3. Complete Example: dynload Program

Listing 42-1 from TLPI demonstrates a minimal but complete use of the dlopen API. The program accepts a shared library path and a function name on the command line, dynamically loads the library, resolves the function, calls it, then unloads the library cleanly.

/* shlibs/dynload.c */
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[])
{
    void        *libHandle;       /* Handle for the shared library     */
    void        (*funcp)(void);   /* Pointer: function taking no args  */
    const char  *err;

    if (argc != 3 || strcmp(argv[1], "--help") == 0) {
        fprintf(stderr, "Usage: %s lib-path func-name\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    /* ── Step 1: Load the shared library ── */
    libHandle = dlopen(argv[1], RTLD_LAZY);
    if (libHandle == NULL) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        exit(EXIT_FAILURE);
    }

    /* ── Step 2: Look up the named symbol ── */
    dlerror();                                    /* clear prior errors */
    *(void **) (&funcp) = dlsym(libHandle, argv[2]);
    err = dlerror();
    if (err != NULL) {
        fprintf(stderr, "dlsym: %s\n", err);
        exit(EXIT_FAILURE);
    }

    /* ── Step 3: Call or report ── */
    if (funcp == NULL)
        printf("%s is NULL\n", argv[2]);
    else
        (*funcp)();                               /* call the function  */

    /* ── Step 4: Unload ── */
    dlclose(libHandle);
    exit(EXIT_SUCCESS);
}

Build and run:

$ gcc -o dynload dynload.c -ldl

/* Using a relative library path (slash present → relative pathname) */
$ ./dynload ./libdemo.so.1 x1
Called mod1-x1

/* Using LD_LIBRARY_PATH to set search path, library name only */
$ LD_LIBRARY_PATH=. ./dynload libdemo.so.1 x2
Called mod1-x2
Step Call What Happens
1 dlopen(argv[1], RTLD_LAZY) Library mapped into process; symbol table loaded; dependency libs opened. Returns opaque handle.
2 dlerror(); dlsym(handle, argv[2]) Symbol table searched; virtual address of argv[2] returned (or NULL).
3 (*funcp)() The resolved function executed in the calling process.
4 dlclose(handle) Reference count decremented; library unmapped when count hits zero.

4. dlclose() — Unloading a Shared Library

#include <dlfcn.h>

int dlclose(void *handle); Returns: 0 on success, -1 on error

dlclose() decrements an internal reference counter maintained by the dynamic linker for the library identified by handle. The library is only actually unmapped from memory when:

  • Its reference count drops to zero, and
  • No other currently loaded library depends on it (dependency-tree check, applied recursively).
Event Reference Count Change Library State
First dlopen() 0 → 1 Loaded and mapped
Second dlopen() (same lib) 1 → 2 Stays loaded; same handle
First dlclose() 2 → 1 Still loaded
Second dlclose() 1 → 0 Unmapped (if no dependents)
Process exit (implicit) All libraries unloaded
atexit() in shared libraries (glibc ≥ 2.2.3): A function inside a shared library can call atexit() (or on_exit()) to register a cleanup handler. This handler is automatically invoked when the library is unloaded via dlclose(), before the library is unmapped — giving libraries a chance to release resources.
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    void *h1 = dlopen("./libfoo.so", RTLD_LAZY);  /* refcount = 1 */
    void *h2 = dlopen("./libfoo.so", RTLD_LAZY);  /* refcount = 2 */

    /* h1 and h2 are the same handle value (same library) */

    dlclose(h1);   /* refcount = 1 — library still loaded */
    dlclose(h2);   /* refcount = 0 — library now unmapped  */

    return 0;
}
⚠️ Use-after-close: After dlclose() reduces the reference count to zero, any function pointers or data pointers obtained via dlsym() become dangling. Accessing them is undefined behaviour.

5. dladdr() — Introspecting a Symbol Address

dladdr() is the reverse lookup function: given a virtual address (typically one obtained from dlsym() or a function pointer), it returns metadata about the symbol and the shared library that contains it.

#define _GNU_SOURCE
#include <dlfcn.h>

int dladdr(const void *addr, Dl_info *info); Returns: non-zero if addr found in a shared library, 0 otherwise
Note: NOT available on all UNIX implementations; not standardised by SUSv3

The caller provides a Dl_info structure (allocated on the stack or heap); dladdr() fills it in:


typedef struct {
const char *dli_fname; /* Pathname of shared library containing addr */
void *dli_fbase; /* Runtime base address of that library */
const char *dli_sname; /* Name of nearest symbol with address ≤ addr */
void *dli_saddr; /* Exact address of the symbol in dli_sname */
} Dl_info;
Field Type Meaning Notes
dli_fname const char * Full pathname of the shared library that contains addr Empty string for the main executable
dli_fbase void * Address at which that library is loaded (ASLR base) Useful for offset calculations
dli_sname const char * Name of the nearest exported symbol with address ≤ addr May be NULL if no exported symbol found
dli_saddr void * Actual address of the symbol named in dli_sname Equals addr when addr is an exact symbol address

If addr points to the exact start of a symbol, dli_saddr == addr. If addr points inside a function body (e.g., a return address from a stack frame), dli_sname gives the enclosing function’s name and dli_saddr its start — useful for stack-trace generation.

5.1 dladdr() — Working Example

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <math.h>

int main(void)
{
    Dl_info info;
    void (*fp)(void);

    /* Look up sin() from the math library */
    dlerror();
    *(void **) (&fp) = dlsym(RTLD_DEFAULT, "sin");
    if (dlerror() != NULL) {
        fprintf(stderr, "sin not found\n");
        return 1;
    }

    /* Reverse-lookup: which library and symbol does this address belong to? */
    if (dladdr(fp, &info)) {
        printf("Library  : %s\n", info.dli_fname);
        printf("Load base: %p\n", info.dli_fbase);
        printf("Symbol   : %s\n", info.dli_sname);
        printf("Sym addr : %p\n", info.dli_saddr);
    } else {
        printf("Address not found in any shared library\n");
    }

    return 0;
}
$ gcc -o dladdr_demo dladdr_demo.c -ldl -lm
$ ./dladdr_demo
Library  : /lib/x86_64-linux-gnu/libm.so.6
Load base: 0x7f3a1c000000
Symbol   : sin
Sym addr : 0x7f3a1c045600
Practical applications of dladdr(): generating human-readable stack traces in crash handlers, security auditing (verifying that a function pointer still points inside its expected library), and profiling/tracing tools.

6. Common Mistakes and Best Practices

Mistake Consequence Fix
Direct funcp = dlsym(…) assignment C99 compiler warning; may silently truncate on platforms where sizeof(void *)≠sizeof(fptr) Use *(void **)(&funcp) = dlsym(…)
Forgetting dlerror() before dlsym() Stale error string from a previous call may be mistaken for a new error Always call dlerror() immediately before dlsym()
Using pointers after dlclose() Undefined behaviour / segfault — library is unmapped NULL all cached pointers before or after dlclose()
Assuming one dlclose() unloads immediately Library stays loaded until all reference counts are zero Understand reference counting; call dlclose() once per dlopen()
Using RTLD_NEXT from the main executable Search starts after the main program — may miss the intended symbol Use RTLD_NEXT only from within a shared library / LD_PRELOAD interposer
Omitting -ldl at link time Linker error: undefined references to dlopen, dlsym, etc. Always link with -ldl when using the dlopen API

7. Interview Questions & Answers

Q1. Why can’t you directly assign dlsym()‘s return value to a function pointer, and what is the correct idiom?
The C99 standard prohibits implicit or explicit conversion between a data pointer (void *) and a function pointer. dlsym() returns void *, so a direct assignment such as funcp = dlsym(handle, “f”) violates the standard and elicits a compiler warning. The portable idiom is *(void **)(&funcp) = dlsym(handle, “f”): we take the address of the function-pointer variable, cast that to void **, and dereference to write the void * value into the storage. This never involves a function-pointer ↔ data-pointer conversion directly.
Q2. dlsym() returns NULL. How do you determine whether the symbol was not found or whether its value is legitimately NULL?
Call dlerror() immediately before dlsym() to clear any prior error, then call dlerror() again after. If the second call returns a non-NULL string, a lookup error occurred. If it returns NULL, the symbol was found and its value is genuinely NULL (e.g., a global pointer initialised to NULL in the library).
Q3. What is the difference between RTLD_DEFAULT and RTLD_NEXT, and when would you use each?
RTLD_DEFAULT searches the entire process symbol namespace in the dynamic linker’s default order (main program → all loaded libs). Use it to find any symbol without knowing which library provides it. RTLD_NEXT searches only the libraries loaded after the calling object. It is used in function interposers (typically via LD_PRELOAD) to find the “real” implementation of a symbol that the interposer wraps — for example, a custom malloc() calling the real one.
Q4. Does calling dlclose() always immediately unload the library?
No. The dynamic linker maintains a reference count per library. dlopen() increments it; dlclose() decrements it. The library is unmapped only when the count reaches zero and no other loaded library depends on it (the check is applied recursively through the dependency tree). Process termination causes an implicit dlclose() of all remaining handles.
Q5. What information does dladdr() provide, and name two practical use cases?
dladdr() performs a reverse symbol lookup: given a virtual address, it fills a Dl_info structure with the pathname of the containing shared library (dli_fname), that library’s load base address (dli_fbase), the name of the nearest exported symbol at or before the given address (dli_sname), and that symbol’s exact address (dli_saddr). Practical uses: (1) stack trace generation — convert a raw return address from a signal handler or exception frame into a human-readable function name and library; (2) security validation — verify that a function pointer still resolves inside its expected library (detecting GOT/PLT hijacking).
Q6. A shared library registers a cleanup function with atexit(). When is that function called if the library is loaded with dlopen() and later unloaded with dlclose()?
From glibc 2.2.3 onward, the registered atexit() handler is called automatically when the library is unloaded by dlclose() — specifically, just before the library’s memory is unmapped. This allows libraries to release file descriptors, memory, or other resources without relying on the caller to provide a teardown API. If dlclose() only decrements the reference count without unloading (count > 0), the handler is not yet called.

Leave a Reply

Your email address will not be published. Required fields are marked *