C / MULTI-FILE PROGRAMS AND BUILDS
Shared libraries and how dynamic loading works
Build a .so with -fPIC and -shared, understand how ld.so finds and binds it at run time, and load libraries yourself with dlopen and dlsym.
What you will learn
- Build a shared object with gcc -fPIC -c then gcc -shared -o libfoo.so
- Make a library findable at run time with -Wl,-rpath,'$ORIGIN' or ldconfig
- Load and call code at run time with dlopen, dlsym and dlerror
- Choose between RTLD_LAZY and RTLD_NOW and say when a bad symbol shows up
Understanding Shared libraries and how dynamic loading works
A static archive is a bag of .o files: the linker copies the members you actually use into your executable and afterwards the archive is irrelevant. A shared object is different, because the linker only checks that the symbols exist and records the library's soname in a DT_NEEDED entry, so the machine code stays inside libfoo.so and is mapped into every process that needs it. That is why the library must still be present and findable on every run, and why it must be compiled with -fPIC: the same pages appear at different addresses in different processes, so the code cannot hold hard-coded absolute addresses and instead reaches its globals through a per-process Global Offset Table.
Starting a dynamically linked program is a two-stage affair. The kernel sees a PT_INTERP entry in the ELF header, loads ld-linux.so first and hands control to it; the loader then walks the DT_NEEDED list, searching DT_RPATH/DT_RUNPATH, then LD_LIBRARY_PATH, then the ldconfig cache in /etc/ld.so.cache, then the default directories. Each library it finds is mapped and its data relocations applied, but calls are normally left lazy: the first call to a library function jumps through the PLT into the loader, which resolves the symbol and patches the GOT so every later call goes straight to the target. Setting LD_BIND_NOW=1 forces that work up front, which turns a missing symbol from a crash halfway through the run into a failure before main starts.
dlopen is the same machinery driven by hand instead of at startup. dlopen returns a handle (or NULL, with the reason available from dlerror), dlsym looks up one symbol in that library and its dependencies, and dlclose drops a reference count. RTLD_LAZY defers binding while RTLD_NOW resolves everything immediately so an unresolvable symbol is reported at load time, and RTLD_GLOBAL additionally publishes the library's symbols into the global scope for libraries loaded later. Because dlsym returns a void * and a legitimate symbol may itself hold the value NULL, the reliable error check is dlerror() to clear, then dlsym, then dlerror again.
/* build: gcc -Wall -o dyn dyn.c -ldl note: no -lm anywhere */
<stdio.h>
<dlfcn.h>
int main(void)
{
double (*cube_root)(double);
const char *err;
void *h;
h = dlopen("libm.so.6", RTLD_LAZY);
if (h == NULL) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
dlerror(); /* drop any stale message */
*(void **)&cube_root = dlsym(h, "cbrt"); /* POSIX cast workaround */
err = dlerror();
if (err != NULL) {
fprintf(stderr, "dlsym: %s\n", err);
dlclose(h);
return 1;
}
printf("cbrt(27) = %.1f\n", cube_root(27.0));
printf("cbrt(1000) = %.1f\n", cube_root(1000.0));
dlclose(h);
return 0;
}
A shared library is bound at run time rather than copied at link time: the executable stores only a library name and the symbols it needs, and the dynamic loader supplies the addresses.
Worked examples
Building a .so and linking against it
Shows that library initialization code runs before main and that the executable only remembers the library by name.
/* counter.c
gcc -Wall -fPIC -c counter.c
gcc -shared -o libcounter.so counter.o */
<stdio.h>
static int calls = 0;
__attribute__((constructor))
static void on_load(void) { printf("libcounter loaded\n"); }
__attribute__((destructor))
static void on_unload(void)
{
printf("libcounter unloaded after %d calls\n", calls);
}
int bump(void) { return ++calls; }
/* main.c
gcc -Wall -o app main.c -L. -lcounter -Wl,-rpath,'$ORIGIN' */
<stdio.h>
int bump(void); /* normally this declaration lives in counter.h */
int main(void)
{
printf("bump -> %d\n", bump());
printf("bump -> %d\n", bump());
return 0;
}
Example explained
Line 1-fPIC on counter.c routes accesses to calls through the GOT, so the code is correct at whatever address the library is mapped.
Line 2The constructor is called by ld.so while it initializes libcounter.so, which is why its line prints before anything in main.
Line 3-Wl,-rpath,'$ORIGIN' stores "look next to the executable" in the binary, so ./app finds libcounter.so with no LD_LIBRARY_PATH.
Line 4The destructor runs at exit, after main returns, so it sees the final value of the library-private counter.
Looking up a symbol with no handle
Demonstrates that the dynamic symbol table belongs to the process, and that NULL alone cannot distinguish failure from success.
_GNU_SOURCE
<stdio.h>
<dlfcn.h>
int main(void)
{
void *p = dlsym(RTLD_DEFAULT, "puts");
void *q = dlsym(RTLD_DEFAULT, "no_such_function");
printf("puts resolved: %s\n", p != NULL ? "yes" : "no");
printf("no_such_function resolved: %s\n", q != NULL ? "yes" : "no");
return 0;
}
Example explained
Line 1_GNU_SOURCE must be defined before dlfcn.h because RTLD_DEFAULT is a glibc extension, not POSIX.
Line 2RTLD_DEFAULT searches the global scope, meaning the executable plus every library loaded at startup, so puts is found without any dlopen.
Line 3No handle is involved at all, which shows symbol resolution is a property of the running process rather than of one dlopen call.
Line 4The second lookup returns NULL, but only dlerror() can prove that means "not found" rather than "found, and its value is NULL".
dlopen is reference counted
Shows that opening the same library twice yields one mapping and one handle, so dlclose calls must be paired.
<stdio.h>
<dlfcn.h>
int main(void)
{
void *first = dlopen("libm.so.6", RTLD_LAZY);
void *second = dlopen("libm.so.6", RTLD_LAZY);
if (first == NULL || second == NULL) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
printf("same handle: %s\n", first == second ? "yes" : "no");
dlclose(first);
printf("cbrt still reachable: %s\n",
dlsym(second, "cbrt") != NULL ? "yes" : "no");
dlclose(second);
return 0;
}
Example explained
Line 1The second dlopen finds libm already loaded, so it increments a reference count and hands back the identical handle instead of mapping a second copy.
Line 2dlclose(first) only takes the count from two to one, which is why the library stays mapped and dlsym on the same handle still works.
Line 3Unmapping happens when the count reaches zero, so a plugin host that dlopens twice and dlcloses once leaks the mapping for the life of the process.
Important notes
Library file names are platform specific: "libm.so.6" is a glibc name, musl folds libm into libc, and macOS uses .dylib. On glibc 2.34 and newer the dl functions live in libc, so -ldl is harmless but no longer required.
LD_LIBRARY_PATH is a debugging aid, not a deployment strategy: it applies to every child process and is ignored for setuid binaries, so prefer RUNPATH or a proper install plus ldconfig.
Common mistakes
Compiling library objects without -fPIC: the .o links fine into a normal program, but gcc -shared fails with "relocation R_X86_64_PC32 against symbol ... can not be used when making a shared object; recompile with -fPIC".
Assuming -L. makes the library available forever: the build succeeds, then running the binary from another directory dies with "error while loading shared libraries: libcounter.so: cannot open shared object file", because -L is a link-time path only.
Passing the -l style name to dlopen, as in dlopen("m", RTLD_LAZY): dlopen takes a real file name such as "libm.so.6", so the call returns NULL and the program reports a load failure for a library that is already on the system.
Declaring the wrong prototype for a dlsym result, for instance int (*f)(int) for cbrt: the symbol resolves, the call compiles, and the program prints garbage or crashes because nothing checks that the type matches.
Try it yourself
Change, predict, then run
Write a program that dlopens libm.so.6 with RTLD_NOW, then resolves both "sqrt" and the misspelled "sqrtt", printing the exact dlerror message for the failed lookup while still calling sqrt(2.0) and printing its value.
Open the C workspaceCheck your understanding
A program links cleanly with gcc -o app main.c -L./libs -lfoo, but running ./app fails immediately with "libfoo.so: cannot open shared object file: No such file or directory". What does this tell you?
- -L only guided the linker at build time; the run-time search path is separate, so ld.so has no idea where libfoo.so lives
- libfoo.so was built without -fPIC, so its relocations cannot be applied when it is mapped
- main.c was compiled without the foo header, so the call went to an undeclared function
- The program needs to call dlopen before it can use any function from libfoo.so
Show answer
-L and -l only tell the link-time linker where to find the library and which symbols exist; the executable keeps just the soname, so at startup ld.so consults RUNPATH, LD_LIBRARY_PATH and the ldconfig cache instead, and finds nothing. Missing -fPIC is the tempting answer but it fails while creating the .so with a relocation error, so the program would never have linked in the first place.