5.3.4 · D3Build Systems & Toolchain

Worked examples — Dynamic - shared libraries — .so - .dll, dynamic linking, PIC

3,513 words16 min readBack to topic

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.

Figure — Dynamic - shared libraries — .so  -  .dll, dynamic linking, PIC

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 sqrt resolution 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 ✓; dlopen returns 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 malloc definitions found in scope = at least 1 (the interposer); the first in order is chosen. Deterministic given the load order. ✓


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).