Windows & Linux Memory Management, Base Addresses, and Dispatch Mechanisms
Reference notes covering PE/ELF binary loading, base-address/relocation history on Windows and Linux, and related dispatch/vtable mechanics.
1. Foundational Concept: Virtual Memory (MMU/TLB)
Everything in this doc assumes hardware-assisted virtual memory (an MMU implementing per-process page tables) exists. This is what lets two processes see the same physical page at different (or the same) virtual addresses — the basis of all code-sharing schemes below.
- The TLB is just a hardware cache for page-table translations — it speeds up repeated lookups but is not itself what provides sharing capability. Sharing comes from the page tables (MMU) existing at all.
- Mainstream Unix has assumed an MMU from very early on (PDP-11 Unix had primitive segmentation; VAX-Unix/3BSD, ~1979, had full demand-paged VM) — this is not something bolted on later.
- Genuinely MMU-less systems are a modern, parallel embedded track (uClinux, 2001+), not a predecessor stage mainstream Unix passed through. Without an MMU, the "same code, different virtual address" trick has no foundation — see §7.
2. Windows: PE/COFF, Base Addresses, and the IAT
2.1 History
The PE/COFF format was introduced with Windows NT itself (NT 3.1, shipped July 1993) — roughly a decade before 64-bit Windows (WOW64 arrived 2003). It replaced the 16-bit NE format. The import-table/IAT mechanism has existed in this format since day one; it is not a WOW64/32-on-64 invention. WOW64 runs the same, unmodified 32-bit PE-loading mechanism inside a 32-bit process — it adds no new IAT/relocation concept.
2.2 Why Windows code isn't position-independent
Windows DLLs (especially classic 32-bit ones) are not PIC. When code references a global variable or jump target, the compiler can bake the actual runtime address directly into the instruction (x86-32's mov has no general PC-relative addressing mode — only call/jmp do). This is a deliberate performance choice: direct absolute addressing is cheaper per-access than the indirection PIC requires.
2.3 Preferred base address (ImageBase)
Every PE image (EXE or DLL) has an ImageBase field. Since code contains absolute addresses, it's only correct if the image loads at the address the compiler assumed.
- If the DLL loads at its preferred address: zero patching needed. Code pages map read-only, directly from disk, and are physically shared across every process using that DLL.
- If it doesn't (collision): the loader rebases it — walks the
.relocsection (base relocation table) and patches every absolute reference. Costs: (a) CPU time to walk/patch potentially thousands of entries, (b) those patched pages become private, pagefile-backed copies (can no longer be discarded/reread from the file), (c) cross-process sharing is lost — and historically, if two different processes both relocate the same DLL, Windows didn't even reunite them; each got its own private pagefile-backed copy.
Why EXEs rarely hit this in practice (but have the identical mechanism): each process typically has exactly one EXE in an otherwise-empty new address space — nothing to collide with. DLLs coexist, potentially dozens per process, all needing distinct addresses — this is why the "preferred base address" conversation historically centers on DLLs.
rebase.exe: Microsoft's tool to hand-assign non-overlapping preferred addresses to OS-shipped DLLs, so the common case (everyone gets their preferred address) held in practice.
2.4 The Import Address Table (IAT) — a separate mechanism from base-address relocation
Calling into a DLL does not depend on the EXE's or DLL's own base address matching anything:
- Compiler emits
call [slot]— an indirect call through a location in the caller's own data section (addressed relative to wherever the caller itself loaded — no cross-module addressing needed for the call instruction). - At load time, the loader walks the IAT, resolves each imported symbol's real runtime address (via the DLL's export table), and writes it into that slot.
- This happens every time, regardless of whether either module hit its preferred base. The header declares the function signature (compile-time, type-checking); the import library supplies the linker with which DLL/symbol satisfies the call (link-time); the IAT is populated at load time.
Alternative, no-import-lib path: LoadLibrary + GetProcAddress — explicit/runtime linking. No IAT entry is pre-populated; the caller resolves the symbol itself, whenever it chooses, against a DLL loaded whenever it chooses.
Historical refinements (not new mechanisms): bound imports (linker precomputes expected addresses assuming preferred-base loading, for a load-time shortcut); delay-loading (/DELAYLOAD, defers DLL load/IAT population until first use, transparently via a compiler-generated thunk).
2.5 .o/.obj vs .a vs .lib (two kinds)
.o/.obj | .a (POSIX) | .lib (Windows) | |
|---|---|---|---|
| Contains real code? | Yes | Yes (archive of .os + symbol index) | Static-archive .lib: yes, same idea as .a. Import-library .lib: no code at all — just symbol names/ordinals + enough metadata to build an IAT thunk |
Windows overloads .lib for two structurally different artifacts. No POSIX equivalent exists for the import library category — on ELF you link directly against the .so itself (via its SONAME); Windows keeps interface (import lib) and implementation (DLL) as two separate files by design.
2.6 x86-64 and RIP-relative addressing — why 64-bit needs far fewer relocations
call/jmpalways encoded as relative displacements, even on 32-bit x86 — shifting a whole module by a constant delta preserves these automatically; never needed relocation.- x86-32's biggest source of relocations: ordinary global-variable access (
mov eax, [global]) had no choice but absolute addressing — no PC-relative mode existed for general memory operands. - x86-64 added RIP-relative addressing (
mov rax, [rip + disp32]) as a general mode — ordinary global/data references now behave likecall/jmpand survive whole-module rebasing with zero patching (within ±2GB, true for virtually all same-module references). This is the architectural reason 64-bit PE/ELF binaries need vastly fewer relocations than 32-bit ones.
2.7 What still needs absolute addresses even on x86-64
Anything that is passive data sitting in memory, not an instruction executing near it — RIP-relative addressing only helps instructions, not values read out of a table later:
- Vtables (arrays of function pointers)
- Explicit function-pointer variables/globals
- Traditional (non-relative-style) switch jump tables
- RTTI / exception-handling metadata structures
- The IAT itself — deliberately meant to hold absolute addresses, filled by the loader (not "a problem," the mechanism)
These live in .rdata/.data, not .text. When a module lands at its preferred base, these sections are read exactly as compiled (no runtime modification). When it doesn't, the loader patches the relevant IMAGE_REL_BASED_DIR64 entries — those specific pages become private/dirtied, same cost category as before, just scoped to a narrower set of pages than on 32-bit.
2.8 ASLR era (Vista, 2007+)
Deliberately relocates every image to a randomized address, for security (predictable addresses aid exploits) — reusing the same relocation mechanism as always, not a new one. Key nuance: Windows ASLR picks its random address once per boot, machine-wide, so every process loading the same DLL in that boot session gets the same address and can still share the relocated pages with each other (just not across a reboot). This is why manually setting preferred base addresses is now largely moot — ASLR overrides it anyway (/DYNAMICBASE).
3. Linux/ELF: PIC, GOT/PLT, and the Same Problem Solved Differently
3.1 History
- Earliest Unix: fully static linking, no shared-library problem to solve.
- SunOS 4.x (1987) and Linux's own early
libc4(a.outformat): "primitive" shared libraries using fixed, pre-assigned load addresses + load-time relocation on conflict — structurally the same approach Windows still uses today. - SVR4 introduced ELF, with PIC + GOT/PLT built into the format from the start, solving the collision problem structurally.
- Linux adopted ELF with
libc5/earlyglibc(mid-1990s) — this is when Linux shared libraries stopped needing fixed preferred addresses.
3.2 Position-Independent Code (PIC): GOT and PLT
- GOT (Global Offset Table): small, per-process, writable data table. Global/external references compile to
load register from GOT[N], never a bare absolute address. - PLT (Procedure Linkage Table): small per-function stub for calls into other shared objects; supports lazy binding (resolve on first call, cache in GOT).
- Only the GOT (small, private) needs per-process filling.
.textitself is never patched, stays byte-identical across every process, and is always shareable regardless of load address — this is the structural advantage over Windows' non-PIC model: PIC pays a small, constant per-access indirection cost, always, but never suffers a catastrophic "lost sharing" case the way Windows relocation does.
3.3 ET_EXEC vs ET_DYN vs PIE
| Type | Base address | Notes |
|---|---|---|
ET_EXEC (classic, non-PIE executable) | Fixed, mandatory, no fallback (0x08048000 x86-32, 0x400000 x86-64) | No relocation support for its own code at all — never needed one (one EXE per fresh address space) |
ET_DYN (shared library, .so) | None — position-independent, address chosen freely at load time | p_vaddr values are 0-based/relative in the file |
ET_DYN used as PIE (modern default main executable) | None — same as a .so | Lets ASLR randomize the executable's own address too, closing the gap ET_EXEC left |
3.4 prelink — Linux's rebase.exe equivalent
Even though PIC works at any address, filling the GOT/resolving symbols still costs something. prelink assigned each shared library a fixed, non-overlapping virtual-address slot system-wide and pre-computed relocations, storing results in the .so file — if the library actually loads at that slot (unchanged since prelinking), the dynamic linker can skip almost all relocation work, avoiding CPU cost and "unshareable pages." Like Windows base-address tuning, prelink is now largely obsolete/disabled by default, for the identical reason: it conflicts with ASLR (which deliberately randomizes to defeat exactly this kind of fixed-address predictability).
3.5 Handling absolute-address data (vtables, etc.) in PIC/PIE binaries
- Such data compiles with
R_*_RELATIVEdynamic relocations (e.g.R_X86_64_RELATIVE) instead of a baked-in address. - The loader computes one number per module — the load bias (
actual runtime base − link-time assumed base of 0) — and for everyR_*_RELATIVEentry does*offset = bias + addend. No symbol lookup needed (target is in the same module) — this is the direct ELF analogue of Windows'IMAGE_REL_BASED_DIR64base relocations. - Lives in
.data.rel.ro, patched once at load, thenmprotect'd read-only — this hardening is called RELRO:- Partial RELRO: just this data section.
- Full RELRO: also eagerly resolves the GOT itself at startup (
R_X86_64_JUMP_SLOT→R_X86_64_GLOB_DAT) and locks it read-only, closing GOT-overwrite exploits.
- Cross-module vtable references (target defined in a different
.so) can't use the cheap "just add my bias" trick — they fall back to genuine symbol-resolving relocations (heavier, but rarer). RELR/SHT_RELR: a compact encoding (bitmap of offsets needing the uniform bias-add) exists becauseR_*_RELATIVEentries so overwhelmingly dominate real relocation tables (one measurement: ~208,000R_X86_64_RELATIVEentries vs. ~238 symbol-based ones in a single binary) — reported to save 5–20% of PIE binary size.
3.6 Windows ASLR vs. Linux ASLR — a real difference
- Windows: one random base per boot, machine-wide — deliberately preserves cross-process sharing for that session.
- Linux (default): randomizes independently per
exec()— each process launch gets its own random bias. Consequence:.data.rel.ro/GOT-style pages essentially never end up shared across processes by default on Linux, even for concurrent instances of the identical program — whereas.textremains shareable regardless, since it's fully PIC and contains no load-address-dependent bytes at all.
4. Summary Comparison Table
| Windows | Linux/ELF | |
|---|---|---|
| Code addressing model | Absolute (non-PIC), historically | PIC (GOT/PLT) since ELF/SVR4 |
| "Preferred base address" concept | ImageBase, mandatory field, real fallback (rebase) | Optional optimization via prelink for .so; mandatory/fixed (no fallback) for classic ET_EXEC; absent entirely for PIE/.so |
| Main-executable addressing | Same mechanism as DLLs (ImageBase + .reloc) | Historically fixed & non-negotiable (ET_EXEC); modern PIE = fully flexible, same as .so |
| Cost of address mismatch | CPU (patch), pagefile commit, lost cross-process sharing | Same three costs, but only for the narrower "absolute pointer" data category (vtables, GOT) — .text unaffected |
| ASLR granularity | Per boot, machine-wide (preserves sharing) | Per process/exec() (sharing largely lost by default for the relocated-data category) |
| Historical rebase tool | rebase.exe | prelink (now largely obsolete, same reason: conflicts with ASLR) |
| Relocation encoding | .reloc / IMAGE_REL_BASED_DIR64 | .rela.dyn / R_*_RELATIVE; compact SHT_RELR variant |
5. C++ Vtables and Dispatch Mechanism Comparisons
5.1 What a vtable is, and why it needs absolute addresses
A vtable is a compiled-in array of function pointers — data, not instructions. It's populated by the compiler/linker as a constant, and (per §2.7/§3.5) needs either a real baked-in address (position-dependent code) or a load-time relocation entry (PIC) — RIP-relative/PC-relative addressing modes don't help here because those only assist an instruction referencing something relative to its own position; a value merely sitting in an array has no "current instruction" context to be relative to.
5.2 operator-> vs. [obj method] (Objective-C message send) — for contrast with vtables
operator-> (C++) | Message send (Objective-C) | |
|---|---|---|
| Resolved | Compile time, static-type-driven overload resolution | Runtime, always |
| What's resolved | Access syntax (chains to a concrete function call, itself resolved statically or via vtable) | The call itself — dispatch and invocation are one operation |
| Runtime redirect on miss | No | Yes — message forwarding |
| Runtime implementation swap | No | Yes — method swizzling |
| Works with no static type | No | Yes — id + selector lookup |
5.3 Performance ranking (fastest → slowest), per Mike Ash's 2016 benchmark and related sources
- C++ non-virtual call, inlined — effectively free
- C++ non-virtual call, not inlined — one direct call/return
- Lambda called by concrete/auto-deduced type — compiler knows the exact type; often inlined; behaves like 1–2
- C++ virtual call ≈
objc_msgSend, cache hit — measured essentially tied (~2.6 ns / ~9 cycles for cachedobjc_msgSend): both are one indirect jump through a runtime-resolved address std::function— slower than a plain virtual call; type erasure adds an extra indirection (heap/SBO-allocated wrapper) atop its own internal indirect callobjc_msgSend, cache miss — walks class hierarchy, populates cacheNSInvocation/performSelector:/dynamic forwarding — constructs the call at runtime; an order of magnitude+ slower
Key structural point: objc_msgSend's cache-hit path is engineered to cost about the same as a C++ vtable indirect call (~9 cycles) — "runtime-resolved" doesn't automatically mean "slow." The real cost of Objective-C's dynamism shows up in the corners (cold cache, full dynamic forwarding), not the steady state. __attribute__((objc_direct)) lets a method skip objc_msgSend entirely when the developer promises no swizzling/override, recovering near-non-virtual performance.
5.4 Objective-C's objc_msgSend mechanism (for completeness)
Per-class (not per-call-site) hash table, keyed by selector: load isa → hash-lookup in the class's method cache → indirect jump on hit; on miss, walk the class hierarchy and populate the cache. Because any class's implementation can change at runtime (categories, method_setImplementation, swizzling, forwarding), the compiler can never prove what code a send will execute — forecloses inlining/CSE even for calls that are, in practice, always hitting the same implementation.
5.5 Runtime vs. library layering (Objective-C, for the C++-vtable analogy)
| Tier | C++ analogue | Objective-C |
|---|---|---|
| Runtime support (dispatch, exceptions, RTTI) | libc++abi/libsupc++ | libobjc2 (GNUstep) / Apple libobjc |
| Standard/foundation library (data types) | libstdc++/libc++ | Foundation / GNUstep Base |
NSObject-the-class is a library convention (a custom root class needs only implement -retain/-release/-autorelease/-dealloc), closer to std::string's status than to objc_msgSend's. The NSObject protocol (in the runtime header <objc/NSObject.h>) is a thinner, genuinely compiler-aware seam, since ARC's synthesized code type-checks against it.
6. Swift's Relative Pointers (Metadata-Layout Response to the Same Problem)
Swift's ABI-stable metadata (type descriptors, protocol conformance records, witness tables) uses relative pointers — signed 32-bit offsets from the pointer field's own location to its target — specifically to avoid load-time relocation cost for this data, letting it be treated like code: position-independent, shareable, mappable straight from the file.
- Constraint created: the field and its target must be within ~2GB of each other — a local, pairwise distance rule (same shape as RIP-relative's ±2GB reach), not a global binary-size or address-space limit. Since these records are always emitted within the same compiled image, and no real program's metadata section approaches 2GB, this is a non-issue in practice.
- Indirect relative pointers: for cases needing to reference something in a different module where 2GB proximity can't be guaranteed — a relative offset instead points to a local pointer-sized slot, which itself holds a real absolute pointer (filled via ordinary load-time relocation). One extra hop, unbounded reach — directly parallel to PLT/GOT indirection for out-of-range references.
- No relationship to process memory limits: heap objects, arrays, and ordinary runtime pointers remain full 64-bit absolute addresses; virtual address space on 64-bit OSes is measured in terabytes, governed entirely by the OS/hardware, unrelated to this metadata-layout technique.
6.1 Swift-on-Windows static linking (why this was a real engineering problem, not a toolchain gap)
- Clang/LLVM's ability to emit PE binaries,
.libs, anddllimport/dllexportattributes was never the blocker — this generic infrastructure predates Swift's Windows port entirely. - IRGen (Swift's AST/SIL → LLVM IR lowering stage, Swift's analogue to Clang's CodeGen) had hard-coded the assumption that the Swift runtime is always dynamically linked — emitting IAT-thunk-style indirect calls (
call [__imp_symbol]) unconditionally, rather than choosing a plain direct call for symbols that would end up statically archived. - This is a genuine ABI difference from ELF/Mach-O: on those platforms, an external call compiles to essentially the same instruction regardless of static/dynamic resolution (deferred to the linker); on Windows PE, the choice of instruction shape (direct call vs. IAT-indirect call) is baked in at compile time, and nothing at link time can convert one into the other after the fact.
- Fix required: IRGen distinguishing three cases (defined locally / imported from a DLL / statically linked from another archive),
.swiftmodulemetadata recording linkage mode, auto-linking directives updated, a naming convention for the.lib/.libambiguity (libswiftCore.libstatic archive vs.swiftCore.libimport library), and the runtime's "registrar" bootstrap code needing a linkage-aware variant. - Swift Package Manager's general static-library product type already existed pre-Windows-work — the gap was specifically the Windows-targeted runtime and IRGen's PE-specific codegen decisions, not Swift's general concept of static linking.
7. MMU-less Systems (Brief, for Completeness)
Standard ELF PIC assumes a library's code and its private data sit at a fixed, predictable relative distance — true under an MMU (virtual memory normalizes the layout) but not guaranteed without one. uClinux (Linux for MMU-less microcontroller-class CPUs) uses FDPIC (Function Descriptor PIC):
- A dedicated register holds "my data segment base," set per call.
- A bare function pointer becomes a function descriptor: a pair (code entry address, correct data-segment base to load) rather than a single address — because the offset between shared code and private data can differ per process without page-table remapping to normalize it.
- This still lets read-only code be physically shared across processes; only the descriptor mechanism differs from ordinary MMU-based PIC.