Before you start#
Review ELF and the ABI, executable loading, and libc and threads. This chapter uses glibc 2.40 commit 3d1aed874918c466a4477af1da35983ab036690e as a historical source example. Loader paths, instruction relocations, interpreter names and ABI details differ by architecture and libc; host exercise results do not establish execution of that pinned source.
Learning goals#
Separate kernel executable loading from userspace dynamic linking, follow a conditional dlopen path through mapping and lookup, and explain scope, symbol versions, relocations, TLS and initialization. Maintain a library handle until every thread and callback has stopped using code and data owned by that library. Diagnose a missing library, symbol or interpreter as different failure stages.
Source walkthrough#
Read the text version
- Executable: Requests symbols and dependencies. → Loader scope: Ordered candidates and symbol versions. (lookup)
- Shared object: May request other libraries. → Loader scope: Ordered candidates and symbol versions. (lookup)
- Executable: Requests symbols and dependencies. → Mapped object: Segments, relocations and initialization state. (load dependency)
- Shared object: May request other libraries. → Mapped object: Segments, relocations and initialization state. (retain dependency)
A dynamically linked ELF executable commonly contains a PT_INTERP program header naming an interpreter. The kernel's ELF loader checks the executable and interpreter and establishes initial mappings and a stack under architecture-specific rules. It transfers control to the interpreter, rather than completing all userspace symbol resolution itself. The interpreter, commonly glibc's ld.so on a glibc system, bootstraps sufficiently to relocate and use its own state. It inspects the auxiliary vector, program headers, environment and dependency metadata, maps needed shared objects, performs required relocations, and arranges initialization before program entry. A static executable and a different libc need not follow this exact route. The glibc 2.40 dynamic linker entry and Linux v6.12 ELF loader are distinct source boundaries.
A shared object is an ELF file that can contribute code, data, dependencies and exported symbols to a process. The loader represents loaded objects with link_map structures and maintains lookup scopes and namespaces. A scope is an ordered set of candidate objects, not just a list of files in a directory. A lookup can depend on the calling object, namespace, visibility, symbol binding, requested version and load flags. An executable and two libraries can define the same symbol, so file presence alone does not determine which definition wins. Namespace and interposition behavior are configuration and API dependent.
The following exact excerpt from glibc 2.40 elf/dl-open.c is within dl_open_worker_begin; that file's notice is LGPL-2.1-or-later. Comments and surrounding branches are omitted, with no omitted lines inside this block:
struct link_map *new;
args->map = new = _dl_map_object (call_map, file, lt_loaded, 0,
mode | __RTLD_CALLMAP, args->nsid);
new is the loader's object representation, and args->map preserves it for subsequent work. call_map, the requested filename, the load type, mode and namespace identify the context for this mapping request. _dl_map_object may find an existing object or map a new one; the following source has a specific RTLD_NOLOAD null case. The worker increments l_direct_opencount after successful mapping and examines whether the object was already open. That count is not a license for application threads to use a stale function pointer after closing their last valid handle. The normal path also maps dependencies and proceeds through relocation and initialization; the worker's flags and error paths decide what actually happens.
The next exact excerpt from elf/dl-lookup.c, also LGPL-2.1-or-later, is inside _dl_lookup_symbol_x; the preceding comments and following code are omitted:
return _dl_lookup_symbol_x (undef_name, undef_map, ref,
(flags & DL_LOOKUP_GSCOPE_LOCK)
? undef_map->l_scope : symbol_scope,
version, type_class, flags, skip_map);
The surrounding branch handles failure to add a dependency on a dynamically loaded definition, for example if that candidate was removed concurrently. It retries the named lookup rather than blindly returning a possibly invalid object. The arguments retain the unresolved name, referencing map, symbol reference, relevant scope, version requirement and lookup flags; the conditional scope selection depends on locking state. The code illustrates that lifetime and lookup are connected: a definition must remain usable by its referencing object. It does not mean all lookups race, all explicit lookups add this dependency, or applications can safely keep arbitrary pointers after dlclose.
Search, versions and relocation#
At initial load, dependencies are named in dynamic metadata such as DT_NEEDED; an entry containing a slash is treated differently from a bare library name. For a bare name, glibc's search uses applicable RPATH or RUNPATH information, environment configuration, loader cache and default directories according to detailed rules. DT_RUNPATH applies to direct dependencies rather than automatically supplying the same search path for every descendant; older DT_RPATH has different transitive behavior when applicable. $ORIGIN expansion can make a private executable find a sibling library, but quoting matters when generating the ELF metadata so a shell does not expand it prematurely. See glibc's loading implementation and the installed loader documentation for the actual host.
LD_LIBRARY_PATH and LD_PRELOAD can influence ordinary executions, but they are not a general deployment or security solution. In secure-execution mode the loader ignores or restricts sensitive environment variables under documented conditions. Configuration, loader version and privilege transition affect the rules; do not test them with host set-user-ID executables. For a safe experiment, set a variable on the command line of a self-authored child only. System-wide /etc/ld.so.conf, cache changes, and privileged loader settings are outside this lab.
A dynamic symbol has a name and attributes including binding and visibility. GNU symbol versioning adds version definitions and requirements: a consumer may request a particular version of a named symbol rather than accepting any spelling match. A version script when linking a shared object can define a version node for its exported function. readelf --version-info exposes version metadata; nm -D or readelf -Ws exposes the dynamic symbol table. These are file observations, not evidence that a runtime call reached a particular definition. A missing required version can fail startup or loading even if a library with the expected filename exists. A dlsym name lookup and dlvsym version-specific lookup have distinct contracts; check dlerror rather than assuming a null value alone always identifies every failure. dlvsym and dlsym semantics
Relocations tell the loader how references in mapped objects need adjustment. Their encodings and machine instructions are architecture-specific. A global offset table can hold resolved addresses used by position-independent code, while procedure linkage table entries can route calls through binding machinery where the architecture and build use them. RTLD_NOW requests immediate resolution of relevant undefined symbols; RTLD_LAZY can defer function binding, subject to flags and environment, and does not defer every relocation. A lazy resolution failure may occur later at a call site, when recovery options differ from an initial dlopen failure. Do not assume all calls go through a PLT or all systems choose lazy binding.
An indirect function, or IFUNC, selects an implementation through a resolver under the platform's dynamic linking rules. Resolver execution occurs in a constrained loader context and should not be described as an ordinary arbitrary application callback. TLS provides per-thread instances of module data; loading a DSO with TLS may require loader bookkeeping across threads. In glibc's dl_open_worker, a recursive loader TLS lock protects global and module-specific TLS state while dl_open_worker_begin is run under an exception handler. The lock is released before the exception is raised again, according to the inspected source. Initialization functions run for appropriate newly loaded objects, with dependency ordering and recursive loads affecting the details. A constructor can fail indirectly or start work; applications still own the consequences of that work.
A concrete scope and lifetime timeline#
Suppose a program opens libalpha.so, which exports answer with version DEMO_1, and separately opens libbeta.so, which also exports answer under another version. The program asks dlvsym on alpha's handle for answer@DEMO_1; the result is a function pointer whose validity depends on alpha remaining loaded. Asking alpha for DEMO_9 fails if that version is absent, even though the unversioned name appears in the symbol table. The two libraries' names alone do not describe a global search order: the requested handle, scope and namespace matter. The example is a model, not a prediction about an arbitrary application's interposition.
At time T0 alpha is not loaded. At T1 dlopen succeeds and the application owns a handle. At T2 it resolves the pointer and starts a worker that may call it. At T3 the main thread wants to close the handle. A safe application first prevents new calls, signals the worker, waits for in-flight calls and callbacks to finish, and joins the worker. Only at T4 may it release the last handle under its own ownership protocol. At T5 the loader may unload the object if its dependencies, flags and implementation permit; a successful dlclose does not guarantee immediate unmapping. At T6 an old function pointer or pointer to the DSO's static data must not be used even if the address still appears mapped. A destructor or another reference can complicate the exact order, so keep handles and callback ownership explicit. POSIX/Linux dlclose contract
- T0 not loaded
libalpha.so is absent from the process.
- T1 dlopen
The application holds a handle; the loader's direct-open count increases.
- T2 resolve and start worker
The resolved pointer is valid only while the object stays loaded.
- T3 stop callers
Prevent new calls, signal and join in-flight work before the last close.
- T4 last handle closed
The application's ownership protocol ends here, not the loader's.
- T5 unload may wait
A successful dlclose does not promise immediate unmapping.
- T6 stale pointer
Using a function pointer or static data after this point is invalid even if the address is still mapped.
In glibc 2.40 elf/dl-close.c, LGPL-2.1-or-later per its notice, _dl_close_worker drops one direct-open reference and returns early while references or nesting remain (surrounding code omitted):
void
_dl_close_worker (struct link_map *map, bool force)
{
/* One less direct use. */
--map->l_direct_opencount;
/* If _dl_close is called recursively (some destructor call dlclose),
just record that the parent _dl_close will need to do garbage collection
again and return. */
static enum { not_pending, pending, rerun } dl_close_state;
if (map->l_direct_opencount > 0 || map->l_type != lt_loaded
|| dl_close_state != not_pending)
{
if (map->l_direct_opencount == 0 && map->l_type == lt_loaded)
dl_close_state = rerun;
/* There are still references to this object. Do nothing more. */
if (__glibc_unlikely (GLRO(dl_debug_mask) & DL_DEBUG_FILES))
_dl_debug_printf ("\nclosing file=%s; direct_opencount=%u\n",
map->l_name, map->l_direct_opencount);
return;
}
The early return means a nonzero direct-open count leaves the object loaded. The excerpt does not show the later garbage-collection and unmapping steps, destructors, or whether another thread still runs code from the object; those are separate protocols.
A common error is holding the application mutex while joining a worker whose cleanup needs that same mutex. Another is calling a library-provided callback after releasing the library handle. The loader's internal locks protect loader state, not application-level thread termination or a foreign callback queue. A reference to a library and a lock on a data structure solve different problems. RTLD_NODELETE and other implementation-specific flags can alter unload behavior, but they are not a substitute for a clear application lifetime contract.
Object identity and reproducibility#
Two paths that look alike need not identify the same mapped object: a search directory, symbolic link, namespace or container mount view can change the file selected. Record the actual resolved file and, when available, its build ID as well as its package and loader version. Conversely, two references can resolve to an already loaded object, so counting source-level dlopen calls alone does not prove that the loader created two independent mappings. The source's direct-open counter is one part of its internal accounting, alongside dependencies and policy flags. It is not a public substitute for an application's own handle ownership.
If a constructor recursively loads another object, the loader must account for recursive activity and its own locks. Application locks introduce a separate potential cycle: a constructor that waits on a worker holding a lock needed by the initiating thread may deadlock even when the loader's own bookkeeping is correct. Keep constructors small, avoid assuming other application threads have reached a particular state, and document how failure is reported. A source-only diagram should label this as a possible design hazard rather than an observed deadlock in glibc.
Normal and failure paths#
For a runtime open, _dl_open checks mode and enters loader coordination. dl_open_worker_begin maps the requested object and, if necessary, dependencies, establishes scopes and relocates under relevant conditions. dl_open_worker uses the TLS lock and exception machinery, then runs initializers when the worker has advanced to that stage. The source is more complicated than a fixed single chain: an existing object, RTLD_NOLOAD, nested constructor load, namespace selection, failed allocation, version mismatch or relocation error changes the path. In the top-level error handling, the loader catches an exception and handles partially initialized state; application code should treat a null handle and dlerror as failure, not assume a partially returned object is usable.
A missing executable and a missing interpreter can both yield an apparent ENOENT from execve, but readelf -l can reveal the interpreter named by an existing ELF file. A missing DT_NEEDED dependency is typically reported by the interpreter after it has started. A missing requested symbol or version can fail later still, depending on binding mode. Capture the exact executable, library search environment, loader diagnostics and relevant build IDs without collecting secrets. Do not modify the system loader cache to force the experiment to pass.
- Missing interpreter
- Named by PT_INTERP. It can appear as an apparent ENOENT from execve; readelf -l shows the interpreter path the file requests.
- Missing shared object
- A DT_NEEDED name is not found after the program has started; check the search rules and readelf -d output.
- Missing symbol or version
- Found at open or at a later lazy call, depending on binding; dlvsym with dlerror reports a missing version at lookup time.
Practice#
Use a private directory and a self-authored DSO. This GNU-toolchain lab requires cc, readelf, nm and a libc providing dlvsym; it may not work on musl or non-GNU linkers. It writes only temporary files and does not change the host loader configuration:
work=$(mktemp -d)
cat > "$work/demo.c" <<'C'
int answer(void) { return 42; }
C
cat > "$work/versions.map" <<'MAP'
DEMO_1 { global: answer; local: *; };
MAP
cat > "$work/main.c" <<'C'
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
int main(int argc, char **argv) {
if (argc != 2) return 2;
void *h = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; }
dlerror();
int (*f)(void) = (int (*)(void))dlvsym(h, "answer", "DEMO_1");
const char *err = dlerror();
if (err) { fprintf(stderr, "%s\n", err); dlclose(h); return 1; }
printf("answer=%d\n", f());
dlerror();
(void)dlvsym(h, "answer", "DEMO_9");
printf("missing version detected: %s\n", dlerror() ? "yes" : "no");
if (dlclose(h)) return 1;
return 0;
}
C
cc -fPIC -shared "$work/demo.c" -Wl,--version-script="$work/versions.map" -o "$work/libdemo.so"
cc -Wall -Wextra "$work/main.c" -ldl -o "$work/main"
readelf --version-info "$work/libdemo.so"
nm -D "$work/libdemo.so" | grep answer
"$work/main" "$work/libdemo.so"
"$work/main" "$work/missing.so" || printf '%s\n' 'missing library rejected'
rm -rf -- "$work"
Expected observations are a version node named DEMO_1, an exported answer symbol, answer=42, and a detected missing DEMO_9; the missing file reports a loader error. Exact diagnostic text varies. If tools or dlvsym are unavailable, preserve the failure and use the source-only timeline instead of installing packages or altering host settings. The function-pointer cast reflects the POSIX dlsym usage pattern on supported systems; ISO C by itself does not guarantee interchangeability of object and function pointers. The single-thread program closes the handle only after its last call. It says nothing about secure execution or all loader versions.
For a source-only deliverable, draw T0–T6 above and annotate the requested version, handle, resolved pointer, worker and final close. Explain why the DEMO_9 lookup fails and why T3 must wait. Add a second table distinguishing missing interpreter, missing DSO and missing symbol. Mark this as reasoning from the pinned sources, not a captured loader trace.
Knowledge check#
- What does the kernel do with
PT_INTERP, and which component resolves ordinary dynamic symbols? - Why can a matching symbol name still fail a version-specific lookup?
- What do
l_direct_opencountand an application-helddlopenhandle fail to prove about another thread's stale pointer? - Why is a return from
writeor a successful file lookup unrelated to whether a loader constructor or deferred binding succeeds? - How does
RTLD_NOWchange the timing of relevant failures, and what remains architecture or configuration dependent?
Answers#
- The kernel ELF loader processes the executable and interpreter according to the architecture and transfers control to the interpreter. The userspace dynamic linker maps dependencies and performs dynamic linking; a static executable and other formats differ.
- A consumer can require a specific version node. A name with a different or absent version does not satisfy that request even if a same-named export exists in the file.
- They represent loader and application references, not proof that all threads have stopped calling the object. The application must coordinate callbacks, in-flight code and data pointers before dropping the last handle; an old pointer must not be used after its ownership guarantee ends.
- These are different stages and subsystems. A file can be found or bytes can be written while mapping, relocation, version checks, constructors or a later lazy call fail. Record the actual stage and error rather than inferring one from another.
RTLD_NOWrequests immediate resolution for relevant undefined references at open rather than deferring eligible function bindings. Specific relocation types, IFUNC, namespace rules and loader behavior remain dependent on architecture, flags and implementation.
Checkpoint#
Submit the versioned DSO observations or the supplied state timeline and missing-stage table. Ready means explaining the kernel/loader split, why DEMO_9 fails, and how a worker's pointer lifetime constrains dlclose. Retry with one DSO and one worker if scopes or ownership remain unclear; a source-only result is not a runtime qualification.