Visual walkthrough — Dynamic - shared libraries — .so - .dll, dynamic linking, PIC
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.

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

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.

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.

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.

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.

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

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.

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.