Skip to main content

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 .reloc section (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:

  1. 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).
  2. 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.
  3. 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?YesYes (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/jmp always 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 like call/jmp and 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.out format): "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/early glibc (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. .text itself 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

TypeBase addressNotes
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 timep_vaddr values are 0-based/relative in the file
ET_DYN used as PIE (modern default main executable)None — same as a .soLets ASLR randomize the executable's own address too, closing the gap ET_EXEC left

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_*_RELATIVE dynamic 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 every R_*_RELATIVE entry 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_DIR64 base relocations.
  • Lives in .data.rel.ro, patched once at load, then mprotect'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_SLOTR_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 because R_*_RELATIVE entries so overwhelmingly dominate real relocation tables (one measurement: ~208,000 R_X86_64_RELATIVE entries 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 .text remains shareable regardless, since it's fully PIC and contains no load-address-dependent bytes at all.

4. Summary Comparison Table

WindowsLinux/ELF
Code addressing modelAbsolute (non-PIC), historicallyPIC (GOT/PLT) since ELF/SVR4
"Preferred base address" conceptImageBase, 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 addressingSame mechanism as DLLs (ImageBase + .reloc)Historically fixed & non-negotiable (ET_EXEC); modern PIE = fully flexible, same as .so
Cost of address mismatchCPU (patch), pagefile commit, lost cross-process sharingSame three costs, but only for the narrower "absolute pointer" data category (vtables, GOT) — .text unaffected
ASLR granularityPer boot, machine-wide (preserves sharing)Per process/exec() (sharing largely lost by default for the relocated-data category)
Historical rebase toolrebase.exeprelink (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)
ResolvedCompile time, static-type-driven overload resolutionRuntime, always
What's resolvedAccess 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 missNoYes — message forwarding
Runtime implementation swapNoYes — method swizzling
Works with no static typeNoYes — id + selector lookup
  1. C++ non-virtual call, inlined — effectively free
  2. C++ non-virtual call, not inlined — one direct call/return
  3. Lambda called by concrete/auto-deduced type — compiler knows the exact type; often inlined; behaves like 1–2
  4. C++ virtual call ≈ objc_msgSend, cache hit — measured essentially tied (~2.6 ns / ~9 cycles for cached objc_msgSend): both are one indirect jump through a runtime-resolved address
  5. std::function — slower than a plain virtual call; type erasure adds an extra indirection (heap/SBO-allocated wrapper) atop its own internal indirect call
  6. objc_msgSend, cache miss — walks class hierarchy, populates cache
  7. NSInvocation/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)

TierC++ analogueObjective-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, and dllimport/dllexport attributes 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), .swiftmodule metadata recording linkage mode, auto-linking directives updated, a naming convention for the .lib/.lib ambiguity (libswiftCore.lib static archive vs. swiftCore.lib import 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.