Worked examples — Dynamic - shared libraries — .so - .dll, dynamic linking, PIC
This page is the hands-on lab for the parent topic. We already know what a shared library is; here we push a real program through every scenario the topic can throw at you — different load addresses, lazy vs eager binding, missing files, Windows rebasing, and the corner cases (zero symbols, a symbol that lives in the program itself).
Two acronyms appear on every line below, so we pin them down first. PIC = Position-Independent Code: machine code that uses only relative addressing (distances from the current instruction), so it runs correctly no matter what address it is loaded at. GOT = Global Offset Table: a small per-process data table of real pointers that PIC code reads through instead of hardcoding addresses. Both are defined properly in the Terms box.
The scenario matrix
Dynamic linking has a small number of "axes" that can vary. Every worked example below nails down one or more cells of this matrix so you never meet an untried case.
| Axis | Cases we must cover | Example that hits it |
|---|---|---|
| Load address | library at address vs (must give same result) | Ex 1, Ex 2 |
| Symbol reference direction | internal (same .so) vs external (other .so) |
Ex 3 |
| Binding time | lazy (first call) vs eager (LD_BIND_NOW) |
Ex 4 |
| Degenerate input | a .so that exports zero symbols; a symbol resolved to the main executable |
Ex 5, Ex 6 |
| Failure / edge | file present at link time, missing at run time | Ex 7 |
| Cross-platform | Windows .dll rebasing when preferred base is taken |
Ex 8 |
| Limiting value | processes → how much RAM saved as (word problem) | Ex 9 |
| Exam twist | why two different virtual addresses read the same physical byte | Ex 10 |
Prerequisites you may want open: Virtual Memory & mmap, The Linker — symbol resolution & relocation, ELF and PE file formats, ASLR & Security.
Ex 1 — Same code, two load addresses (Cell: load address )
Forecast: the slot address is base + GOT offset + index·8; the value read is itself.
Step 1 — Locate the GOT. Why this step? The code never hardcodes the GOT's absolute address — it holds a PC-relative offset (a distance from the current instruction) and adds it to where the instruction currently sits. Here we express the net result as base + for clarity.
Step 2 — Index the correct slot.
Each slot is 8 bytes (a 64-bit pointer). The printf slot is index 3:
Why this step? The GOT is a flat array of pointers; you address the -th pointer by adding bytes.
Step 3 — Read the value. The loader already patched that slot, so reading it yields: Why this step? This is the whole point of the GOT — the code page has no absolute address, only the data page (GOT) does.
Verify: ✓. The value read is exactly what the loader wrote, . Units are all byte-addresses; consistent.
Ex 2 — The same library in a different process (Cell: load address )
Forecast: the code bytes are identical; only the GOT contents differ.
Step 1 — Recompute the slot address. Why this step? Notice the offset is identical to Ex 1. The instruction encodes only this offset, not the absolute base — so the same machine bytes work at both bases. That is PIC.
Step 2 — Read the (different) value. Why this step? The GOT is a per-process data page; the loader wrote a different real address here. The code is unchanged; only the data it points through changed.
Verify: offset in both processes is ; instructions identical → provable page sharing. Slot addresses differ only by the base difference . ✓
Figure s01 draws this side by side: stack Process A on top of Process B. Notice the two teal code boxes carry the same orange text "read GOT+0x3018" — that identical instruction is what lets a single physical page serve both. The two plum GOT boxes hold different printf targets, because the GOT is a per-process data page. Trace the orange "indirect" arrow: the code reads its answer out of the GOT rather than baking it in.

Ex 3 — Internal vs external symbol (Cell: reference direction)
Forecast: internal → plain PC-relative call; external → PLT/GOT.
Step 1 — Internal call: call foo_helper.
Both caller and callee are in the same mapped segment, so their distance never changes regardless of load base. The compiler emits a PC-relative call (a jump measured as a distance from the current instruction address, the PC):
Why this step? If two things move together by the same amount, their difference is invariant — no table needed.
Step 2 — External call: call printf@plt.
libc is a separate mapping; the distance to printf is unknown at compile time and differs per process. So the call goes through the PLT stub → GOT slot:
Why this step? Cross-library distance is not fixed, so it must be resolved into a per-process data slot.
Verify: Internal offset is a compile-time constant (invariant under a uniform shift). External must be run-time because varies per process. Two mechanisms, correctly matched. ✓
Ex 4 — Lazy vs eager binding (Cell: binding time)
Forecast: lazy = 1 (only printf, only on first call); eager = 2 (both referenced symbols, at startup).
Step 1 — Lazy: resolve on first use only.
First printf() call → PLT stub asks ld-linux for the address, writes it to the GOT. Calls 2–5 read the now-filled GOT slot directly. sqrt is referenced but never called, so its slot is never resolved:
Why this step? Lazy means "pay only for what you actually call." A reference that is never executed costs nothing.
Step 2 — Eager: resolve every referenced symbol at startup.
LD_BIND_NOW=1 fills the GOT slots for all symbols the program references before main runs — that is the 2 functions our program names, not all 200 the library exports:
Why this step? Eager binding trades startup time for no per-call surprise (useful for real-time / security-hardened builds where you don't want lazy writes to the GOT). But it only resolves relocations the program has — symbols the program never mentions are irrelevant, exported or not.
Mistake to avoid: "the library exports 200, so eager = 200." Wrong — the loader only processes the relocation entries in your program/library, i.e. the symbols you reference. Exporting 200 functions doesn't create 200 lookups for a caller that names 2.
Verify: lazy , eager . Eager just moves the
sqrtresolution earlier; it does not touch the 198 exported-but-unreferenced functions. ✓
Ex 5 — Degenerate input: a .so with zero exported symbols (Cell: degenerate)
Forecast: zero exported-symbol lookups; the load still succeeds (mapping + init is separate from symbol resolution).
Step 1 — Count exported-symbol resolutions. There are 0 exported symbols, so the number of lookups a caller can trigger is: Why this step? Resolution is per-referenced-symbol. No symbols exported → nothing for another module to resolve against this library.
Step 2 — Does load succeed?
Yes. mmap-ing segments, running the library's own initializers, and returning a handle are independent of whether it exports anything. A zero-export .so is legal (think: a plugin whose only job is a constructor side-effect).
Why this step? It separates two ideas students conflate: mapping the file vs resolving symbols.
Verify: exported-symbol resolutions ✓;
dlopenreturns a non-NULL handle (load succeeds). The degenerate case is well-defined, not an error.
Ex 6 — Symbol resolved to the main executable (Cell: degenerate direction)
Forecast: the main executable's custom malloc, because it wins the global lookup order.
Step 1 — Apply search order.
ELF default symbol scope searches: main executable first, then libraries in load order. The first definition of malloc found wins:
Why this step? Symbols resolve against a global namespace, not "whatever .so I'm in." A library can be redirected to a definition above it.
Step 2 — Consequence.
Even libc's internal allocations may route to the interposer — the mechanism behind tools like LD_PRELOAD and memory-debuggers.
Why this step? Shows the GOT direction can point out of and even above the library.
Verify: count of
mallocdefinitions found in scope = at least 1 (the interposer); the first in order is chosen. Deterministic given the load order. ✓
Ex 7 — Failure edge: link OK, run fails (Cell: failure)
Forecast: link-time search passed; run-time search fails → "error while loading shared libraries."
Step 1 — Link-time search.
The static linker only needed to (a) confirm the symbol exists and (b) record libfoo.so as a DT_NEEDED name (the dependency-name entry). Result: pass (nothing copied, file existed then).
Why this step? Link time proves existence at that moment, not future availability.
Step 2 — Run-time search.
ld-linux searches RPATH (directories baked into the executable), LD_LIBRARY_PATH, the ldconfig cache, /lib, and /usr/lib for a file named libfoo.so. It is gone → fatal:
error while loading shared libraries: libfoo.so: cannot open shared object file
Why this step? Run time needs the physical file; two searches, two failure points.
Verify: link searches passed = yes; run searches passed = no (file count found at run time = 0). The failure is a loader error, not a linker error — exactly the two-search distinction. ✓
Ex 8 — Windows .dll rebasing (Cell: cross-platform)
Forecast: all 250 addresses shift by ; each sample entry gains that same delta.
Step 1 — Compute the delta. Why this step? Rebasing is a uniform translation: because the entire image moved by , every baked-in absolute address inside it moved by exactly the same distance. One delta fixes all of them.
Step 2 — Apply to each reloc entry.
The loader walks all 250 entries in the .reloc table and adds to each:
Why this step? Windows historically stored absolute addresses and patched them (rebasing), where ELF avoids them via PIC. Same problem — "code loaded somewhere unexpected" — two solutions.
Step 3 — Patch the two sample entries. Why this step? This shows the fixup is literally "old value + delta" applied per entry — no magic, just addition.
Verify: ✓; entries become and ✓; number of patched addresses . A rebased page can no longer be shared with a process that used (its bytes now differ), illustrating why PIC (no patching) is preferred. ✓
Ex 9 — Word problem: RAM saved as grows (Cell: limiting value)
Forecast: static uses MB; dynamic uses MB total; each extra process saves ~2 MB.
Step 1 — Static cost. Each static binary embeds its own 2 MB copy: Why this step? No sharing → cost is linear in .
Step 2 — Dynamic cost. The OS maps one physical copy into all processes: Why this step? Shared read-only pages are counted once, whatever is.
Step 3 — Saving and limit. As , each additional process costs ~0 MB of code (just its private data/GOT), so the marginal saving per process MB: Why this step? Dynamic linking's benefit grows without bound in ; this is the quantitative version of "static wastes the page cache."
Verify: static MB, dynamic MB, saved MB. Marginal cost of process under dynamic linking code MB, so the per-process limiting saving MB. ✓
Ex 10 — Exam twist: two virtual addresses, one physical byte (Cell: exam twist)
Forecast: yes, same byte; PIC guarantees the byte doesn't depend on or .
Step 1 — Map virtual → physical. Both virtual addresses have in-page offset and both page-table entries point to frame : Why this step? Virtual memory lets different virtual addresses map to the same physical frame.
Step 2 — Why PIC is required. If the byte at that spot were a hardcoded absolute address, it would have to be different in A and B ( vs ) → the two page contents would differ → they could not share frame . PIC guarantees the byte is an offset, identical for both → sharing is legal. Why this step? This closes the loop with the parent's derivation: "share the code bytes" forces "no per-process value in code" forces PIC.
Verify: → identical byte read. Sharing possible ⟺ byte independent of load address ⟺ PIC. ✓
Recall Self-test before you close the tab
Which two of the ten examples prove the code page bytes are identical across processes? ::: Ex 2 (same offset at both bases) and Ex 10 (same physical frame).
In Ex 4, why is lazy=1 but eager=2 (not 200)? ::: Lazy resolves only symbols actually called (printf once); eager resolves all symbols the program references (printf + sqrt = 2), never the 198 exported-but-unreferenced functions.
In Ex 7, at which search does it fail? ::: The run-time search by ld-linux; the link-time search already passed.
In Ex 9, what is the marginal code-RAM cost of one more process under dynamic linking? ::: ~0 MB (the code page is already resident and shared).