5.3.4 · D2Build Systems & Toolchain

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

2,412 words11 min readBack to topic

Step 1 — What a program actually is: bytes at addresses

WHAT. A running program is just a long strip of numbered boxes. Each box holds one byte (a small number, 0–255). The number of a box is its address. When we say "the CPU runs the instruction at address 0x1000", we mean: go to box number 0x1000, read the bytes there, do what they say.

WHY start here. Every confusion about shared libraries dissolves once you see that code is just bytes sitting at addresses. The whole drama is about which address a given byte gets.

PICTURE. Look at the strip of boxes. The green boxes are code (instructions the CPU executes). The yellow box is data (a number the code uses). The label under each box is its address.

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

Step 2 — The library is loaded at a different address in each program

WHAT. The same file libfoo.so gets mapped into program A starting at address 0x7f00_0000, but into program B starting at 0x7e40_0000. Same bytes, different starting number.

WHY. The operating system chooses the load address at run time (partly for security — see ASLR & Security). The library's author cannot know it in advance. So any instruction that names an absolute address is a landmine: correct in A, wrong in B.

PICTURE. Two copies of the same instruction strip. The instruction "jump to print_helper" is highlighted. In A the helper truly lives at 0x7f00_0040; in B it lives at 0x7e40_0040. If we baked 0x7f00_0040 into the bytes, B would jump into garbage (red arrow).

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

Step 3 — Relative addressing: naming a place without naming its address

WHAT. Instead of "jump to 0x7f00_0040", the CPU can say "jump forward 0x40 bytes from where I am now". The "where I am now" is the program counter (PC) — the address of the currently-running instruction. The 0x40 is called an offset: a distance, not a location.

WHY this tool and not absolute addresses. We need a way to point at a nearby instruction that stays correct no matter where the strip is loaded. A distance between two instructions inside the same file never changes — if you slide the whole strip left or right, the gap between box #100 and box #164 is still 64 boxes. That invariance is exactly the property we need. This is why relative (PC-relative) addressing is the right tool: it encodes what stays constant (the gap) and forgets what changes (the absolute position).

PICTURE. The same strip in A and in B. The distance between the jump instruction and print_helper is drawn as a fixed span of 0x40. Slide the whole thing; the span is unchanged. So the bytes — "jump forward 0x40" — are identical in both programs.

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

This handles internal jumps perfectly. But we still have a problem it can't solve.


Step 4 — The hard case: reaching a symbol in another library

WHAT. A symbol is a name for a function or global variable — e.g. printf. When libfoo.so calls printf, printf lives in a different file, libc.so, loaded at some address neither library knew in advance.

WHY relative addressing fails here. The offset trick needed a fixed distance. But the distance from libfoo's call to libc's printf depends on where the OS dropped each library — and it's different in every program. There is no constant we can bake into the code bytes for this. We are stuck… unless we split the problem.

PICTURE. Two separate strips (libfoo and libc), floating at unpredictable positions. The dashed arrow from the call site to printf has a different length in program A than in program B. No single number can describe it.

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

Step 5 — The GOT: one small writable table of real addresses

WHAT. We add a tiny table of pointers — boxes that each hold the real address of one external symbol. This is the Global Offset Table (GOT). The code never hardcodes printf's address; it instead reads it out of a GOT box.

WHY this is legal when hardcoding was not. Notice the GOT is data, not code. Each program gets its own private copy of the GOT (a writable data page). So the code page can stay byte-identical and shared, while the changing address lives in the per-program data page where the loader is free to write the right value. We separated "the part that must be constant" (code) from "the part that must differ" (the address).

PICTURE. The shared code page (green, one physical copy) reaches sideways into program A's GOT and program B's GOT (yellow, one per program). Same code, two different GOTs holding two different printf addresses.

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

Step 6 — The PLT: don't pay for symbols you never call (lazy binding)

WHAT. Filling in every GOT slot at startup is wasteful — most programs call only a handful of a library's functions. So function calls first hop through a tiny stub in the Procedure Linkage Table (PLT). The first call to foo() runs the stub, which asks the loader "where is foo?", writes the answer into the GOT, then jumps. Every later call finds the GOT already filled and jumps straight through.

WHY lazy and not eager. Same reasoning as not photocopying a whole book to read one page: resolve a symbol only when you actually reach it. This trades a tiny one-time cost per used function for zero cost on unused ones — a big win at startup.

PICTURE. A flow for two calls to foo. First call: code → PLT stub → loader resolves → writes GOT → jumps to foo. Second call: code → PLT stub → sees GOT already filled → jumps directly. The slow path is drawn coral, the fast path mint.

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

Step 7 — Edge & degenerate cases (never leave the reader hanging)

WHAT / WHY / PICTURE for each corner:

  • A purely self-contained library (no external symbols). Then Steps 4–6 never fire: every jump is PC-relative (Step 3) and there is nothing to put in the GOT. PIC still works — it just costs nothing extra. Picture: the GOT box is empty; all arrows stay inside the green code page.
  • A global variable, not a function. Data can't be resolved "lazily on first call" because there is no call to intercept. So its GOT slot is filled eagerly at load time. Picture: the data slot is filled during load (before main), the function slot is filled on first call.
  • Static linking instead (.a / .lib). The linker copies the code in and can compute every distance at link time, so absolute addresses are safe and no GOT/PLT is needed. That's the whole trade-off with static libraries: self-contained but not shareable across processes.
  • Windows .dll with a taken preferred base. No PIC by default; the loader instead rebases — walks the code and patches absolute addresses. Correct, but it dirties the code page, so it can't be shared as cheaply. Same problem, different default answer.
Figure — Dynamic - shared libraries — .so  -  .dll, dynamic linking, PIC

The one-picture summary

Everything above collapses into one diagram: the constant code page in the middle, shared by every program; each program's private, writable GOT/PLT on the side, holding the per-program real addresses. The arrow from code to GOT is a fixed PC-relative offset (never changes); the contents of the GOT boxes are filled per program by the loader (always change). That split — constant code, variable data — is Position-Independent Code.

Figure — Dynamic - shared libraries — .so  -  .dll, dynamic linking, PIC
Recall Feynman retelling — the whole walkthrough in plain words

Imagine a cookbook (libfoo.so) that 200 cooks share. Some recipes say "see the cake recipe three pages further on" — that direction still works no matter which shelf the book sits on, because "three pages further" is a distance, not a shelf number. That's Step 3.

But other recipes say "use the sauce from the other cookbook", and that book is on a different shelf in every kitchen. You can't write a shelf number in the shared book, or it'd be wrong somewhere. So each kitchen keeps its own little index card (the GOT) that says "the other cookbook is on your shelf number 42". The shared book just says "look it up on your index card" — same words for everyone (Step 5).

And you don't fill in the whole index card up front. The first time a recipe actually needs the sauce, a helper runs to find it, writes the shelf number on the card, and from then on you just read the card (Step 6, lazy binding).

The punchline (the one-picture summary): the book stays identical so all 200 cooks can share one physical copy; only the tiny per-kitchen index card changes. Keeping the book constant by pushing every changing number onto the index card — that is Position-Independent Code.


Quick self-test

Why can't an external symbol's address be baked into the code page?
Because each process loads libraries at different virtual addresses, so that address differs per process; baking it in would make the code bytes non-identical and therefore unshareable.
What stays constant in the code, and what changes per process?
The PC-relative offset to the GOT and the slot offset stay constant (in the code page); the contents of the GOT slots (real addresses) change per process (in the writable data page).
Why is data resolved eagerly but functions lazily?
A function call can be intercepted by a PLT stub on first use, so it's resolved then; a global variable has no call to intercept, so its GOT slot must be filled at load time.
A library with no external symbols — does it still need a GOT?
No; all its jumps are internal and PC-relative, so PIC costs it nothing extra.