dynamic-linking · git:20260905.fd53e99 · 2026-09-05 · sha256 8c8effaa0f54f07f
dynamic-linking git:20260905.fd53e99A
Immutable. This exact content is served forever at /api/v1/blob/8c8effaa0f54f07f.
---
name: dynamic-linking
description: 'Use when debugging shared library load failures, setting RPATH or RUNPATH, applying soname versioning, writing dlopen plugins, or intercepting with LD_PRELOAD. Not for static archives: use binutils.'
---
# Dynamic linking
Linux loads shared libraries at startup or on demand through `ld.so`. This skill builds versioned shared objects, places them where the loader finds them, and diagnoses the failures in between.
## Contract
| Field | Bound contract |
|---|---|
| Trigger | A build or run fails with `cannot open shared object file` or `symbol lookup error`, the task sets RPATH or RUNPATH, versions a library with a soname, writes a `dlopen` plugin, or interposes a function with `LD_PRELOAD`. |
| Authority | Reversible local: writes only built `.so` files, symlinks, source, and the loader cache entries the procedure names (`ldconfig` needs root); rollback is version control, removing created symlinks, and re-running `ldconfig`. No remote mutation. |
| Side effect | Local writes to build outputs, symlinks, and `/etc/ld.so.cache` when `ldconfig` runs. Environment variables and loader flags stay inside the session. |
| Done | The binary runs against the intended library, proven by `ldd` resolving every dependency to the intended path and by a clean `LD_DEBUG=libs` trace or the plugin loading end to end. |
## Inputs
- The failing binary or the library to build: required.
- The intended library location: required for search-path work.
- Whether deployment must be relocatable: required before choosing `$ORIGIN`, RPATH, or RUNPATH.
- Root access: required only when registering a library system-wide with `ldconfig`.
## Procedure
1. Build the shared library with a soname. The soname is what executables record and what `ldconfig` maintains. Done when: `readelf -d libmylib.so.1.2.3` prints the intended `SONAME`.
```bash
gcc -fPIC -c src/mylib.c -o mylib.o
gcc -shared -Wl,-soname,libmylib.so.1 mylib.o -o libmylib.so.1.2.3
ln -s libmylib.so.1.2.3 libmylib.so.1 # loader name
ln -s libmylib.so.1 libmylib.so # linker name for -lmylib
```
2. Bump versions by ABI change, not by habit. Done when: the bump class matches the change.
| Bump | When |
|------|------|
| PATCH | Bug fix, ABI unchanged |
| MINOR | Symbols added, backwards compatible: keep the soname, refresh the `.so.1` symlink |
| MAJOR | ABI breaks: new soname, old `.so.1` files stay installed for existing binaries |
3. Embed the runtime search path. RPATH is searched before `LD_LIBRARY_PATH`; RUNPATH after it. Prefer RUNPATH for deployed binaries because environment variables then keep control. `-Wl,--enable-new-dtags` selects RUNPATH and is the modern linker default. `$ORIGIN` expands to the directory holding the binary, which makes an install tree relocatable. Done when: `readelf -d myapp` shows the intended tag and value.
```bash
gcc main.c -L./lib -lmylib \
-Wl,-rpath,'$ORIGIN/../lib' -Wl,--enable-new-dtags -o myapp
readelf -d myapp | grep -E 'RPATH|RUNPATH'
chrpath -l myapp # show
chrpath -r '/new/path' myapp # rewrite on an existing binary
```
4. Know the search order to predict a failure. `ld.so` searches, in order: `DT_RPATH` when no `DT_RUNPATH` exists, then `LD_LIBRARY_PATH` (ignored for setuid binaries), then `DT_RUNPATH`, then the `/etc/ld.so.cache` built by `ldconfig`, then `/lib` and `/usr/lib`. Done when: the failing library is placed at a search step that the deployment controls.
```bash
LD_DEBUG=libs ./myapp # trace each resolution decision
ldd -v ./myapp # resolved paths plus version requirements
```
5. Load a plugin with `dlopen` and `dlsym`. Clear `dlerror()` before each call; `dlsym` reports success through a null return from it, not from the pointer. Link with `-ldl` on glibc before 2.34; glibc 2.34 and later fold `dlfcn` into libc. Done when: the plugin loads, its entry point runs, and `dlclose` releases it.
```c
#include <dlfcn.h>
typedef int (*plugin_fn_t)(const char *input);
void load_plugin(const char *path) {
void *handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (!handle) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return;
}
dlerror(); // clear any error state before dlsym
plugin_fn_t fn = (plugin_fn_t)dlsym(handle, "plugin_run");
const char *err = dlerror();
if (err) {
fprintf(stderr, "dlsym: %s\n", err);
dlclose(handle);
return;
}
fn("hello");
dlclose(handle);
}
```
6. Interpose a function with `LD_PRELOAD`. The preloaded library is searched first, so its symbols win. `RTLD_NEXT` finds the next definition in the chain. Done when: running with `LD_PRELOAD=...` shows the interception and the real call still works.
```c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
void *malloc(size_t size) {
static void *(*real_malloc)(size_t) = NULL;
if (!real_malloc)
real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
void *ptr = real_malloc(size);
fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
return ptr;
}
```
```bash
gcc -shared -fPIC -o myinterpose.so myinterpose.c -ldl
LD_PRELOAD=./myinterpose.so ./myapp
```
7. Export only the intended symbols. Build with `-fvisibility=hidden` and mark the public API `visibility("default")`, or restrict exports with a version script. Done when: `nm -D --defined-only libmylib.so` lists the public API and nothing else.
```c
__attribute__((visibility("default"))) int public_api(void) { return 42; }
```
```text
# mylib.map
MYLIB_1.0 {
global:
mylib_init;
mylib_process;
local:
*;
};
```
```bash
gcc -shared -fPIC -fvisibility=hidden -Wl,--version-script=mylib.map \
mylib.c -o libmylib.so
```
8. Diagnose the common failures. Done when: each reported error maps to its row and the fix is applied.
| Error | Cause | Fix |
|-------|-------|-----|
| `cannot open shared object file` | Library outside the search path | Set RUNPATH, extend `LD_LIBRARY_PATH`, or run `ldconfig` |
| `symbol lookup error: undefined symbol` | Missing library or version mismatch | Check `ldd`, fix link order, or add the missing `-l` |
| `relocation R_X86_64_32 against .rodata` | Non-PIC code in a shared object | Compile that object with `-fPIC` |
| `version 'GLIBC_2.xx' not found` | Built on a newer glibc than the runtime | Build on the older host or link statically |
## Failure and recovery
| Failure class | Behavior |
|---|---|
| `ldd` shows `not found` after a correct RUNPATH | The dependency of a dependency needs its own RUNPATH. Trace with `LD_DEBUG=libs` and fix the library that records the path. |
| `chrpath` refuses a longer path | `chrpath` cannot grow an existing string. Rebuild with the correct `-Wl,-rpath`, or use `patchelf --set-rpath`. |
| Interposition breaks a setuid binary | The loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH` for setuid executables. This is loader policy, not a bug. |
| Plugin symbols clash across libraries | Reopen the plugin with `RTLD_LOCAL`, or hide symbols per step 7. |
| `ldconfig` not runnable | Root is required. Ship the RUNPATH inside the binary instead, and skip the system-wide registration. |
## Output
The running binary or loaded plugin, plus the resolving evidence: `ldd` output, the `SONAME` and RUNPATH lines from `readelf -d`, or the interposition trace. Deep details on search paths, `$ORIGIN`, and version scripts are in `references/ld-rpath-soname.md`.