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
- 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.
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
- 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 |
- 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.
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
- 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.
LD_DEBUG=libs ./myapp # trace each resolution decision
ldd -v ./myapp # resolved paths plus version requirements
- 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.
#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);
}
- 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.
#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;
}
gcc -shared -fPIC -o myinterpose.so myinterpose.c -ldl
LD_PRELOAD=./myinterpose.so ./myapp
- 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.
__attribute__((visibility("default"))) int public_api(void) { return 42; }
# mylib.map
MYLIB_1.0 {
global:
mylib_init;
mylib_process;
local:
*;
};
gcc -shared -fPIC -fvisibility=hidden -Wl,--version-script=mylib.map \
mylib.c -o libmylib.so
- 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.
1---2name: dynamic-linking3description: 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.4---56# Dynamic linking78Linux 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.910## Contract1112| Field | Bound contract |13|---|---|14| 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`. |15| 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. |16| 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. |17| 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. |1819## Inputs2021- The failing binary or the library to build: required.22- The intended library location: required for search-path work.23- Whether deployment must be relocatable: required before choosing `$ORIGIN`, RPATH, or RUNPATH.24- Root access: required only when registering a library system-wide with `ldconfig`.2526## Procedure27281. 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`.2930```bash31gcc -fPIC -c src/mylib.c -o mylib.o32gcc -shared -Wl,-soname,libmylib.so.1 mylib.o -o libmylib.so.1.2.333ln -s libmylib.so.1.2.3 libmylib.so.1 # loader name34ln -s libmylib.so.1 libmylib.so # linker name for -lmylib35```36372. Bump versions by ABI change, not by habit. Done when: the bump class matches the change.3839| Bump | When |40|------|------|41| PATCH | Bug fix, ABI unchanged |42| MINOR | Symbols added, backwards compatible: keep the soname, refresh the `.so.1` symlink |43| MAJOR | ABI breaks: new soname, old `.so.1` files stay installed for existing binaries |44453. 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.4647```bash48gcc main.c -L./lib -lmylib \49 -Wl,-rpath,'$ORIGIN/../lib' -Wl,--enable-new-dtags -o myapp50readelf -d myapp | grep -E 'RPATH|RUNPATH'51chrpath -l myapp # show52chrpath -r '/new/path' myapp # rewrite on an existing binary53```54554. 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.5657```bash58LD_DEBUG=libs ./myapp # trace each resolution decision59ldd -v ./myapp # resolved paths plus version requirements60```61625. 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.6364```c65#include <dlfcn.h>6667typedef int (*plugin_fn_t)(const char *input);6869void load_plugin(const char *path) {70 void *handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);71 if (!handle) {72 fprintf(stderr, "dlopen: %s\n", dlerror());73 return;74 }75 dlerror(); // clear any error state before dlsym76 plugin_fn_t fn = (plugin_fn_t)dlsym(handle, "plugin_run");77 const char *err = dlerror();78 if (err) {79 fprintf(stderr, "dlsym: %s\n", err);80 dlclose(handle);81 return;82 }83 fn("hello");84 dlclose(handle);85}86```87886. 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.8990```c91#define _GNU_SOURCE92#include <dlfcn.h>93#include <stdio.h>9495void *malloc(size_t size) {96 static void *(*real_malloc)(size_t) = NULL;97 if (!real_malloc)98 real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");99 void *ptr = real_malloc(size);100 fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);101 return ptr;102}103```104105```bash106gcc -shared -fPIC -o myinterpose.so myinterpose.c -ldl107LD_PRELOAD=./myinterpose.so ./myapp108```1091107. 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.111112```c113__attribute__((visibility("default"))) int public_api(void) { return 42; }114```115116```text117# mylib.map118MYLIB_1.0 {119 global:120 mylib_init;121 mylib_process;122 local:123 *;124};125```126127```bash128gcc -shared -fPIC -fvisibility=hidden -Wl,--version-script=mylib.map \129 mylib.c -o libmylib.so130```1311328. Diagnose the common failures. Done when: each reported error maps to its row and the fix is applied.133134| Error | Cause | Fix |135|-------|-------|-----|136| `cannot open shared object file` | Library outside the search path | Set RUNPATH, extend `LD_LIBRARY_PATH`, or run `ldconfig` |137| `symbol lookup error: undefined symbol` | Missing library or version mismatch | Check `ldd`, fix link order, or add the missing `-l` |138| `relocation R_X86_64_32 against .rodata` | Non-PIC code in a shared object | Compile that object with `-fPIC` |139| `version 'GLIBC_2.xx' not found` | Built on a newer glibc than the runtime | Build on the older host or link statically |140141## Failure and recovery142143| Failure class | Behavior |144|---|---|145| `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. |146| `chrpath` refuses a longer path | `chrpath` cannot grow an existing string. Rebuild with the correct `-Wl,-rpath`, or use `patchelf --set-rpath`. |147| Interposition breaks a setuid binary | The loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH` for setuid executables. This is loader policy, not a bug. |148| Plugin symbols clash across libraries | Reopen the plugin with `RTLD_LOCAL`, or hide symbols per step 7. |149| `ldconfig` not runnable | Root is required. Ship the RUNPATH inside the binary instead, and skip the system-wide registration. |150151## Output152153The 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`.