5.3.4 · D4Build Systems & Toolchain

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

3,674 words17 min readBack to topic

Level 1 — Recognition

Exercise L1.1

Which file extension names a shared (dynamic) library on Linux, and which names a static library?

Recall Solution
  • Shared / dynamic on Linux: ==.so== (shared object).
  • Static on Linux: ==.a== (archive).
  • For the record: Windows uses .dll (dynamic) and .lib (static, or an import stub). The mental picture: .so = one file loaded at run time and shared; .a = a bag of .o files copied into the executable at link time — see Static libraries — .a .lib.

Exercise L1.2

Two tables make Position-Independent Code (PIC) reach external symbols. Name the one that holds real addresses (a data page) and the one that holds the lazy-resolution stubs.

Recall Solution
  • Data table of real addresses: GOT (Global Offset Table). Writable, so the loader can patch it per process.
  • Stub table for lazy function resolution: PLT (Procedure Linkage Table). Rhyme to remember: GOT stores Gone-and-fetched addresses; PLT is the Polite doorman that fetches them the first time.

Exercise L1.3

What GCC flag produces Position-Independent Code (PIC), and what flag tells GCC to emit a shared object instead of an executable?

Recall Solution
  • PIC: ==-fPIC==.
  • Emit shared object: ==-shared==. Typical build: gcc -fPIC -c foo.c -o foo.o then gcc -shared foo.o -o libfoo.so.

Level 2 — Application

Exercise L2.1

RAM accounting. libc.so has 2 MB of read-only code pages and 48 KB of writable data pages (per process). 200 processes all use it. Compare the physical RAM used by dynamic linking versus static linking (assume static bakes the full 2 MB of code into each binary and each still needs its own 48 KB of data).

Recall Solution

First, the unit convention we use throughout this problem: (binary/base-2 kibi units). So the code segment is Now walk the two cases in words, then in numbers.

Dynamic: there is exactly one physical copy of the 2048 KB code, shared by all 200 processes. Each process still keeps its own private 48 KB data page (its GOT lives here — it must differ per process). So we add one code copy plus 200 data copies:

Static: nothing is shared — every process carries its own full code and its own data:

Savings: The single shared code copy is the whole point — see Virtual Memory & mmap for how one physical page maps into many processes. Exercise L3.1 (figure s01) draws exactly this "one shared code page, many private GOTs" picture.

Exercise L2.2

Lazy-binding cost. A library exports 500 functions. A program only ever calls 12 of them. Under lazy binding, how many function symbols does ld-linux.so resolve at run time, and what fraction of the library's exports is that?

Recall Solution

Lazy binding resolves a symbol only on its first call. Uncalled functions are never resolved.

  • Resolved: 12.
  • Fraction: . If instead you forced eager binding (e.g. LD_BIND_NOW=1), all 500 would resolve at startup — — most of it wasted work. That is exactly why lazy is the default.

Exercise L2.3

PIC address computation. A PIC instruction needs the runtime address of global S. The code page is mapped so that the current instruction sits at virtual address 0x555555001000, the PC-relative offset from that instruction to S's GOT slot is 0x2FE0, and at that GOT slot the loader has written 0x7FFFF7A50120 (the real address of S). Which value does the code use as addr(S)? (Recall: PC = program counter, the address of the instruction currently running; "PC-relative offset" is a distance measured from that instruction.)

Recall Solution

Before plugging in numbers, understand the two-step "why" — this is the heart of PIC and figure s02 below draws it.

Step 1 — offset → slot. The code bytes may not contain S's absolute address (that would break sharing, see L3.1). What they can safely contain is a constant distance from the current instruction (the PC) to S's GOT slot — a distance that is the same in every process because the code and its GOT are mapped as a fixed unit. So the code computes This only locates the mailbox; it is not yet the address of S.

Step 2 — slot → real address (dereference). The GOT slot is a pointer. The loader has already written S's true per-process address into that slot. So the code performs one memory load — a dereference — reading the value stored there:

Why two steps and not one? Because the location of the mailbox is fixed (offset, safe in code) but the letter inside it is per-process (absolute address, kept in writable data). Splitting them is exactly what lets the code bytes stay identical across processes while the address still varies. No absolute address is baked into the code — only the relative offset 0x2FE0 is.

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

Level 3 — Analysis

Exercise L3.1

Why must the code page contain no per-process absolute address? Give the causal chain that ends at "therefore PIC."

Recall Solution
  1. Goal: N processes share one physical copy of the library's instruction bytes (that's the RAM win).
  2. A shared physical page can be mapped at different virtual addresses in different processes, but its bytes are byte-for-byte identical for all sharers.
  3. The real location of an external symbol (e.g. printf) differs per process (different mappings, ASLR).
  4. If those differing addresses lived in the code bytes, the bytes couldn't be identical → sharing breaks.
  5. Therefore per-process addresses must live in a per-process data page (the GOT), and the code reaches them indirectly, via PC-relative offsets. That chain is PIC. It is not an arbitrary flag — it is forced by "share the code bytes." Cross-check with ASLR & Security for why (3) is even more true today.

The figure below (s01) draws this argument. A single blue box in the middle is the shared, read-only code page — its bytes are identical for both processes. Below it sit two white boxes, Process A and Process B, each holding its own yellow GOT slot for printf: A stores 0x7f.., B stores 0x7e.. — different absolute addresses. The green arrows from the code page to each process carry the same PC-relative offset, yet land on different addresses because each process reads its own GOT. The red caption underlines the moral: the differing absolute address lives in the GOT (data), never in the code.

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

Exercise L3.2

Why does gcc app.o -lfoo succeed at build time but the program later dies with error while loading shared libraries: libfoo.so?

Recall Solution

There are two different searches at two different times:

  • Link time (-lfoo): the linker only checks that each needed symbol exists inside libfoo.so and records the dependency name. That recorded name is stored in the ELF file as a DT_NEEDED entry — literally a list, inside the binary, of "library filenames I will need at run time" (DT_NEEDED is just the ELF tag for one such "needed library" record; see ELF and PE file formats). The linker does not copy the code and does not care where the file will physically be at run time. See The Linker — symbol resolution & relocation.
  • Run time: ld-linux.so reads those DT_NEEDED names and must physically find each file by scanning /lib, /usr/lib, LD_LIBRARY_PATH, the binary's RPATH/RUNPATH, and the ldconfig cache. If you moved the .so, or built it in a non-standard directory and never ran ldconfig / set the search path, the symbol existed at link time but the file can't be found at load time. Link success ≠ run availability.

Exercise L3.3

Under lazy binding, the first call to foo() is slower than the second. Trace both calls through the PLT/GOT and explain the difference.

Recall Solution

First call: call foo@plt jumps to foo's PLT stub. The GOT slot still points back into the PLT (to the resolver trampoline). So control goes to the dynamic linker's resolver, which looks up foo's real address, writes it into the GOT slot, and then jumps to foo. Cost = one full symbol resolution. Second call: call foo@plt again hits the stub, whose first instruction is "jump to whatever the GOT slot holds." The slot now holds foo's real address, so it jumps straight there. Cost = one indirect jump (a few nanoseconds). The PLT stub is written so it always jumps through the GOT; lazy resolution is just the trick of having the slot initially point at the resolver, then rewrite itself.


Level 4 — Synthesis

Exercise L4.1

Design a plugin system. You want ./app to load renderers it never heard of at compile time (a png_renderer.so, an svg_renderer.so shipped later). Which mechanism do you use, which two API calls, and what is the required contract each plugin must expose?

Recall Solution
  • Mechanism: run-time loading via dlopen() — you cannot link against a file that didn't exist when app was built, so ordinary -l linking is impossible. See dlopen and Plugin Architectures.
  • Two calls: dlopen("png_renderer.so", RTLD_NOW) loads and links the plugin; dlsym(handle, "create_renderer") fetches the address of a known, agreed-upon symbol. (Clean up with dlclose.)
  • Contract: every plugin must export a fixed, agreed symbol name with a fixed signature — e.g. a factory function Renderer* create_renderer(void). app and every plugin share only this ABI; the plugin's internal function names are irrelevant. Optionally a plugin_abi_version symbol so app can reject incompatible plugins. Why this works: dlsym is just a run-time version of the symbol resolution the linker normally does at build time — the GOT/PLT machinery already knows how to fetch addresses at run time, and dlopen exposes that to your code.

Exercise L4.2

Cross-platform port. Your ELF library "just works" once compiled -fPIC. Porting to a Windows .dll, two things break: no symbols are visible to callers, and there's no GOT. What must you add/change, and what is the Windows equivalent of the GOT?

Recall Solution
  • Explicit exports/imports. ELF exports every non-static symbol by default; the PE/DLL model exports nothing unless you mark it. Add __declspec(dllexport) when building the DLL and __declspec(dllimport) when using it (usually via one macro flipped by a build define). See ELF and PE file formats.
  • GOT equivalent: the IAT (Import Address Table), patched by the Windows loader — same job as the GOT (indirection slots filled with real addresses at load time).
  • Base-address strategy: DLLs historically preferred a fixed preferred base and rebased (relocated) only on collision; ELF leans on PIC (Position-Independent Code) always. ASLR now forces relocation on both, so the practical gap shrinks — same problem (unknown load address), different default fix.

Level 5 — Mastery

Exercise L5.1

Edge case — a genuinely absolute pointer inside a PIC library. A PIC shared library defines static const char *msg = "hello";. The string bytes live in the read-only code image, but a pointer to them must appear in the library's data. Since the load address is unknown at link time, how is this pointer made correct per process without violating "no per-process value in the code page"?

Recall Solution

The pointer is stored in a writable data page and marked with a relative relocation (R_*_RELATIVE). At load time the dynamic linker computes load_base + link_time_offset and writes the real address into that data slot — exactly the same "constant code, per-process data" split as the GOT. Key point: the code page still contains only the relative offset used to reach that data slot; nothing per-process is baked into the shared instruction bytes. The per-process absolute pointer lives on a private data page. This is why a PIC .so can still have "pointers to its own strings" and remain shareable.

Exercise L5.2

Degenerate case — a library with no external symbols at all. Suppose a .so is self-contained: it calls nothing outside itself and touches no global variables from other objects. Does it still need -fPIC? Does its GOT do anything?

Recall Solution
  • Still needs -fPIC: yes, if you want it mappable at an arbitrary base. Here is the crucial insight: the reason for PIC is that the load address is unknown at compile time, not that you call outsiders. Internal function calls can use PC-relative jumps (cheap, no GOT needed). But any reference to the library's own global data whose absolute address depends on the load base still needs either PC-relative addressing or a relative relocation — otherwise those bytes would differ per process and break sharing (same argument as L3.1). Compiling -fPIC guarantees the whole image is position-independent, internal references included.
  • Does the GOT do anything? Its external entries shrink toward zero, so in that sense it carries no useful symbol addresses. But the loader still allocates a small GOT that holds housekeeping slots the dynamic linker itself uses (for example a pointer back to the linker's own bookkeeping). So "no external symbols" makes the GOT nearly empty of user data, but it does not delete the GOT — the concept and a minimal table still exist. Bottom line: PIC is about the load address being unknown, not about having external calls. Even a fully self-contained library, loaded at an ASLR-randomized base, must be position-independent. See ASLR & Security for why the base is randomized at all.

Exercise L5.3

Tradeoff mastery — when is static linking actually the right call? Give two concrete situations where you'd deliberately choose static linking despite the RAM/update advantages of shared libraries, and justify each.

Recall Solution
  1. Single self-contained deploy / "copy one file and run" — e.g. a Go/Rust CLI or a container scratch image. There's one process, no other program shares the library, and "no .so to find at run time" removes an entire class of error while loading shared libraries failures. The RAM-sharing benefit is null when only one process exists.
  2. Reproducibility / air-gapped or safety-critical systems — you pin the exact library bytes into the binary so a later system update to libssl.so can't silently change behavior. Here the "update without recompiling" advantage of dynamic linking is a liability, not a feature. Both cases share a theme: the shared-library wins (RAM sharing, hot-swap updates) become irrelevant or unwanted, and static's downsides (fat binary, relink to patch) are acceptable prices. See Static libraries — .a .lib for the mechanics.

Recall Self-test recap (cover the right side)

Static .a copies code in when? ::: At link time, into the executable. Dynamic .so code lands when? ::: At run time, mapped by ld-linux.so. What does PIC stand for and why does a library need it? ::: Position-Independent Code; so identical code bytes can be mapped at different virtual addresses across processes. GOT holds what, and is it writable? ::: Real per-process addresses of external symbols; yes, so the loader can patch it. First vs second call to foo() under lazy binding? ::: First resolves via the PLT resolver and writes the GOT slot; second jumps straight through the now-filled GOT slot. The two-step PIC address computation? ::: PC + offset locates the GOT slot; dereferencing that slot yields the real address of the symbol. Windows equivalent of the GOT? ::: The IAT (Import Address Table). To load a plugin unknown at compile time you use? ::: dlopen + dlsym (a run-time function pointer). What is a DT_NEEDED entry? ::: An ELF record inside a binary naming a shared library it will need at run time; the dynamic linker reads these to know what to locate.