QUESTION
Is the address of printf already fixed at link time?
When a dynamic executable is linked, the actual runtime addresses of external symbols are not known. The linker leaves relocation records and the GOT/PLT behind; after ld-linux maps the shared objects, it fills in addresses adjusted by their load biases.
With lazy binding, the first call through a PLT entry enters the resolver and updates the GOT slot. BIND_NOW or full RELRO completes relocations at startup and shortens the period during which the GOT is writable.
STRUCTURE
Structure diagram
Executable
- call printf@PLT
- .rela.plt: printf
- DT_NEEDED: libc.so.6
GOT / link_map
- printf GOT slot
- load bias
- symbol version
ld-linux
- lookup scope
- relocation write
- mprotect RELRO
libc.so.6
- printf symbol
- resolved runtime VA
- DSO PT_LOAD
The PLT is a call waypoint, while GOT slots hold runtime addresses. With full RELRO, the GOT enters a read-only region after relocation.
CALL PATH
Call path
Symbol lookup and relocations are mostly userspace code in the dynamic linker. The kernel supplies file mappings and protection changes, but does not interpret symbol semantics.
This diagram is not for memorizing function names. Follow which return value, file descriptor, memory mapping, or wait queue is passed to the next stage.
SOURCE COORDINATES
Linux 6.18.37 LTS source locations
Go beyond the glibc function to the files where the syscall implementation meets kernel objects. Each link points to the original file at the same tag.
| File | Function / structure | What to inspect |
|---|---|---|
| fs/binfmt_elf.c | load_elf_interp() | Load the PT_INTERP file as a separate ELF image |
| mm/mmap.c | do_mmap(), vm_mmap_pgoff() | shared object PT_LOAD segment mapping |
| mm/mprotect.c | do_mprotect_pkey() | Change RELRO page protections after relocation |
COMPLETE PROGRAM
Complete runnable example
The code below is not pseudocode with explanatory lines omitted. It is a minimal example that can be built and run as a file.
cc -std=c17 -Wall -Wextra -O2 -Wl,-z,relro,-z,now dynlink.c -ldl -o dynlink01#define _GNU_SOURCE
02#include <dlfcn.h>
03#include <stdio.h>
04#include <stdlib.h>
05
06typedef double (*cos_fn)(double);
07
08int main(void)
09{
10 void *handle = dlopen("libm.so.6", RTLD_NOW | RTLD_LOCAL);
11 if (handle == NULL) {
12 fprintf(stderr, "dlopen: %s\n", dlerror());
13 return 1;
14 }
15
16 dlerror();
17 cos_fn fn = (cos_fn)dlsym(handle, "cos");
18 const char *error = dlerror();
19 if (error != NULL) {
20 fprintf(stderr, "dlsym: %s\n", error);
21 dlclose(handle);
22 return 1;
23 }
24
25 printf("cos(0.0) = %.1f\n", fn(0.0));
26 return dlclose(handle) != 0;
27}
CODE NOTES
Code notes
#define _GNU_SOURCEExposes the dlfcn interface and related extensions from the header. The feature-test macro must be defined before every header.
RTLD_NOW | RTLD_LOCALCompletes undefined-symbol relocations at dlopen time and does not place symbols from this handle in the global lookup scope of objects loaded later.
dlerror();Clears any stale error state before dlsym. A NULL return from dlsym is not necessarily a failure because the symbol value itself may be NULL; use dlerror to decide.
cos_fn fn =In a POSIX environment, a dlsym return value can be used as a function pointer. ISO C alone does not generally guarantee conversion between object pointers and function pointers.
dlclose(handle)Drops the reference count. Do not assume the mapping disappears immediately: another dependency or a NODELETE policy may retain it.
DETAILS
Detailed behavior
Search order changes the deployed result
DT_RPATH and DT_RUNPATH have different inheritance rules, followed by LD_LIBRARY_PATH, ld.so.cache, and the default directories. Secure-execution mode for setuid/setgid programs ignores several environment variables.
Record the combination of executable and actually loaded DSOs using readelf -d, ld.so --list, and /proc/PID/maps together.
Interposition constrains optimization
Because a default-visibility symbol can be interposed by another object, the compiler and linker cannot always assume a local direct call. -Bsymbolic, protected/hidden visibility, and -fno-semantic-interposition change both semantics and the available optimization scope.
LD_PRELOAD hooks work for the same reason: global lookup scope and relocation order.
RELRO closes the write window
Leaving a relocation target in the GOT writable forever makes it easier for an arbitrary-write vulnerability to redirect control flow. Full RELRO with immediate binding moves work to startup but makes the pages read-only after relocation.
Do not rely on checksec output alone; confirm both GNU_RELRO in readelf -l and the BIND_NOW dynamic tag.
OBJECTS
Objects and lifetimes
| Object | Creation and release | Values to inspect |
|---|---|---|
link_map | Created when ld.so loads an object and removed when the object is unloaded | load bias, dynamic section, dependency |
GOT slot | Receives an address during relocation and may become read-only after RELRO | Symbol version and resolved address |
dlopen handle | Represents a dlopen reference and is decremented by dlclose | RTLD_LOCAL/GLOBAL, NODELETE |
FAILURE PATH
Failure conditions and common misconceptions
| Observed symptom | Likely causes | How to verify |
|---|---|---|
| shared object not found | RUNPATH/cache/architecture mismatch | LD_DEBUG=libs, readelf -d |
| undefined symbol | Version or lookup-scope mismatch | readelf -Ws, objdump -T |
| A crash after dlclose | A DSO function pointer, TLS value, or object remains in use | Check the handle lifetime and reference owner |
LAB
Verify it yourself
- Enable LD_DEBUG=libs and LD_DEBUG=bindings separately to distinguish library lookup from the moment symbols are bound.
- Compare startup syscalls and first-call latency between builds using -Wl,-z,lazy and -Wl,-z,now.
- Create a puts wrapper with LD_PRELOAD and observe interposition under combinations of default visibility and -Bsymbolic.
./dynlinkLD_DEBUG=libs,reloc ./dynlink 2>&1 | lessPRIMARY REFERENCES