Loading learning content...
Every process running on Windows exists within its own virtual address space—a private, isolated view of memory that appears continuous and exclusively owned, even as the physical reality involves shared RAM, disk backing, and complex kernel structures. Understanding this virtual address space is fundamental to comprehending how Windows manages memory, provides process isolation, enables security features, and achieves the performance characteristics that power billions of devices worldwide.
Windows memory management has evolved dramatically from the limited 16-bit segmented models of Windows 3.x through the 32-bit flat model of Windows NT to today's sophisticated 64-bit architectures supporting terabytes of virtual address space. Each evolution brought new capabilities, new challenges, and new solutions that systems programmers and performance engineers must understand.
By the end of this page, you will understand the complete architecture of Windows virtual address space—from the fundamental user/kernel split to the nuances of address space layout randomization (ASLR), the differences between 32-bit and 64-bit address spaces, and how Windows organizes VADs (Virtual Address Descriptors) to track memory regions. This knowledge forms the foundation for understanding all other Windows memory management concepts.
In Windows, every process receives its own virtual address space (VAS)—a range of virtual addresses that the process can use. This address space is an abstraction provided by the Windows kernel and the processor's Memory Management Unit (MMU), creating the illusion that each process has exclusive access to a large, contiguous block of memory.
The Core Principle:
Virtual addresses used by a process bear no direct relationship to physical RAM addresses. When a program accesses memory at virtual address 0x00007FF6A1234000, the MMU translates this to whatever physical address currently holds that data—or triggers a page fault if the data must be retrieved from disk. This translation happens transparently, millions of times per second, enabling:
| Characteristic | Description | Implication |
|---|---|---|
| Private per process | Each process has its own VAS isolated from others | Memory corruption in one process cannot directly affect another |
| Sparse | Not all addresses in the range are usable simultaneously | Process can allocate memory on-demand without reserving upfront |
| Paged | Backed by RAM and/or page file in page-sized chunks | Memory can be swapped to disk when physical RAM is under pressure |
| Protected | Kernel enforces access permissions on memory regions | Stack, heap, code all have appropriate RWX permissions |
| Divided | Split between user-mode and kernel-mode regions | Kernel memory always mapped but inaccessible from user mode |
The virtual address space is like a ledger of accounts, not a physical warehouse. Creating a 'slot' in the ledger (reserving address space) costs almost nothing—the expense comes when you actually put something in it (committing memory). This distinction between reserved and committed memory is central to Windows memory management.
Every Windows virtual address space is divided into two primary regions: user mode space and kernel mode space. This division is enforced by the processor hardware and represents a fundamental security boundary.
The Classical 32-bit Split:
On 32-bit Windows, the total 4 GB (2^32 bytes) address space is traditionally split:
This 2GB/2GB split was chosen as a balance between user application needs and kernel requirements. However, some applications need more user-mode address space, leading to the /3GB boot option that provides 3 GB user / 1 GB kernel—at the cost of reduced kernel virtual address space.
The 64-bit Revolution:
With 64-bit Windows, the theoretical 16 exabytes (2^64) of address space eliminates practical limitations. However, current implementations don't use the full range:
| Windows Version | User Mode Space | Kernel Mode Space | Total VAS |
|---|---|---|---|
| 32-bit (default) | 2 GB | 2 GB | 4 GB |
| 32-bit (/3GB) | 3 GB | 1 GB | 4 GB |
| 64-bit (pre-8.1) | 8 TB | 8 TB | 16 TB |
| 64-bit (8.1+) | 128 TB | 128 TB | 256 TB |
| 64-bit (Server 2025) | Up to 64 PB* | Up to 64 PB* | 128 PB* |
On x64 processors, only 48 bits of the 64-bit address are currently used (or 57 bits with LA57/5-level paging). The unused upper bits must be sign-extended from bit 47, creating 'canonical' addresses. Addresses where the high bits don't match are invalid, causing a fault. This is why user addresses start with 0x0000 and kernel addresses start with 0xFFFF on 64-bit Windows—they're in different halves of the canonical address space.
Why Map the Kernel Everywhere?
You might wonder: why is kernel memory mapped into every process's address space? The answer involves performance:
System Call Efficiency: When a process makes a system call (transitioning from user to kernel mode), changing the entire page table mapping would be expensive. By keeping kernel mappings present in every process, the transition only requires changing a privilege level, not remapping memory.
Interrupt Handling: Hardware interrupts can occur at any time. The kernel must be immediately accessible to handle them without address space switching.
Shared Data Structures: Some data structures (like the KUSER_SHARED_DATA page) are legitimately shared between user and kernel mode.
Protection Through Privilege, Not Absence:
The kernel pages are mapped in user-mode address spaces but protected—the page table entries mark them as supervisor-only. User-mode code attempting to access kernel addresses triggers an access violation, enforced by the CPU hardware.
1234567891011121314151617181920212223242526272829303132333435363738394041424344
64-bit Windows Virtual Address Space Layout═══════════════════════════════════════════════════════════════════High Memory (Kernel Space - ring 0 only) 0xFFFFFFFF'FFFFFFFF ┌─────────────────────────────────────────────┐ │ HAL and System Reserved │ ├─────────────────────────────────────────────┤ │ Page Tables (PML4) │ ├─────────────────────────────────────────────┤ │ Kernel Non-Paged Pool │ ├─────────────────────────────────────────────┤ │ Kernel Paged Pool │ ├─────────────────────────────────────────────┤ │ System Cache Views │ ├─────────────────────────────────────────────┤ │ PFN Database │ ├─────────────────────────────────────────────┤ │ Kernel Code (ntoskrnl.exe, drivers) │ ├─────────────────────────────────────────────┤0xFFFF8000'00000000 │ Kernel Space Start (128 TB) │═══════════════════════════════════════════════════════════════════ │ │ │ NON-CANONICAL HOLE │ │ (Invalid addresses - cannot be used) │ │ │═══════════════════════════════════════════════════════════════════0x00007FFF'FFFFFFFF │ User Space End (128 TB) │ ├─────────────────────────────────────────────┤ │ Stack(s) │ ├─────────────────────────────────────────────┤ │ Memory-Mapped Files │ │ (DLLs, etc.) │ ├─────────────────────────────────────────────┤ │ │ │ Heap(s) - Growing Upward │ │ │ ├─────────────────────────────────────────────┤ │ Executable Image (.exe) │ ├─────────────────────────────────────────────┤0x00000000'00010000 │ First valid user address │ ├─────────────────────────────────────────────┤0x00000000'00000000 │ NULL pointer region (reserved) │ └─────────────────────────────────────────────┘Low Memory (User Space - ring 3 accessible)While page tables track the mapping of individual pages to physical memory, the Windows Memory Manager uses a higher-level structure called Virtual Address Descriptors (VADs) to track regions of virtual memory. VADs are organized in a self-balancing AVL tree for each process, enabling O(log n) lookup of any virtual address.
What VADs Track:
Each VAD describes a contiguous region of virtual addresses and stores:
VAD Tree Structure:
The VAD tree enables the Memory Manager to quickly answer questions like:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Simplified representation of VAD structure// Actual Windows structure is more complex typedef struct _MMVAD_SHORT { // AVL tree linkage struct _MMVAD_SHORT* LeftChild; struct _MMVAD_SHORT* RightChild; struct _MMVAD_SHORT* Parent; // Address range (in page numbers, not bytes) ULONG_PTR StartingVpn; // Starting Virtual Page Number ULONG_PTR EndingVpn; // Ending Virtual Page Number // Flags encoding protection, type, etc. union { ULONG_PTR Flags; struct { ULONG_PTR VadType : 3; // Private, Mapped, etc. ULONG_PTR Protection : 5; // PAGE_READONLY, etc. ULONG_PTR PrivateMemory : 1; // Private or shared? ULONG_PTR MemCommit : 1; // Committed? // ... additional flags }; };} MMVAD_SHORT, *PMMVAD_SHORT; // For mapped files and image sections, extended VADtypedef struct _MMVAD { MMVAD_SHORT Core; // Contains basic VAD info // Additional fields for mapped/image VADs union { ULONG_PTR LongFlags; struct { ULONG_PTR CopyOnWrite : 1; ULONG_PTR Inherit : 1; // Inherited to child processes? // ... more flags }; }; PCONTROL_AREA ControlArea; // Points to file/section backing PFILE_OBJECT FileObject; // The file backing this region } MMVAD, *PMMVAD;Think of VADs as the Memory Manager's high-level bookkeeping, while page tables are the CPU's low-level implementation. VADs describe what should be at an address (a file mapping, private memory, etc.), while page tables describe where it actually is (in RAM, on disk, not yet allocated). The Memory Manager uses VADs to lazily populate page tables on demand.
VAD Types:
Windows uses several VAD types to handle different memory allocation patterns:
| VAD Type | Description | Example Use |
|---|---|---|
VadNone | Basic private allocation | VirtualAlloc for private data |
VadDevicePhysicalMemory | Physical memory mapping | Device driver mappings |
VadImageMap | Executable image section | .exe and .dll files |
VadAwe | Address Windowing Extensions | Large database buffers |
VadWriteWatch | Write-tracked regions | JIT compilers, GC |
VadLargePages | Large page (2MB/1GB) allocations | High-performance apps |
VadRotatePhysical | Rotating physical page backing | Special allocations |
Address Space Layout Randomization (ASLR) is a critical security feature that randomizes the memory locations of key process structures. Introduced in Windows Vista and enhanced in every subsequent release, ASLR makes exploitation significantly harder by preventing attackers from predicting where code and data will be located.
The Threat Model:
Without ASLR, an attacker who discovers a buffer overflow vulnerability knows exactly where critical structures reside. They can craft an exploit that:
How ASLR Defeats This:
With ASLR, the attacker doesn't know where anything is located:
| Component | 32-bit Entropy | 64-bit Entropy | Notes |
|---|---|---|---|
| EXE Base | 8 bits (256 locations) | 17-19 bits | High-entropy ASLR on 64-bit |
| DLL Base | 8 bits | 19 bits | Per-boot randomization |
| Stack | 14 bits | 17 bits | Per-thread randomization |
| Heap | 5 bits | 17 bits | Per-process randomization |
| Kernel | N/A | 24 bits | KASLR (Kernel ASLR) |
ASLR only works for executables compiled with the /DYNAMICBASE linker flag (and /HIGHENTROPYVA for 64-bit high-entropy ASLR). Legacy applications without these flags load at their preferred base address, negating ASLR benefits. Modern Visual Studio enables these by default, but third-party and legacy software may not be protected.
ASLR Implementation Details:
Boot-time DLL Randomization: When Windows boots, system DLLs are loaded at randomized addresses. All processes share these mappings (for memory efficiency), so the DLL randomization is per-boot, not per-process. This means the same DLL has the same address in all processes until the next reboot.
Per-Process Image Randomization: Each process's main executable is randomized individually if compiled with ASLR support. Unlike DLLs, the EXE base changes every process launch.
Stack and Heap Randomization: Beyond base addresses, Windows adds additional randomization:
Control Flow Guard (CFG) Integration: Windows 10 added Control Flow Guard, which validates indirect call targets. Combined with ASLR, this significantly raises the exploitation difficulty.
12345678910111213141516171819202122232425
# Check if executables have ASLR enabled using dumpbindumpbin /headers "C:\Windows\System32\notepad.exe" | findstr "DLL characteristics" # Output shows:# 8160 DLL characteristics# High Entropy Virtual Addresses <- HIGHENTROPYVA# Dynamic base <- DYNAMICBASE (ASLR)# NX compatible <- DEP# Terminal Server Aware # Using PowerShell to check multiple filesGet-ChildItem "C:\Windows\System32\*.exe" | ForEach-Object { $pe = [System.Reflection.Assembly]::LoadFile($_.FullName) # ... PE header analysis} # Using Process Hacker or windbg for live analysis# In WinDbg attached to a process:!dh -f ntdll # Show DLL characteristicslm # List modules with base addresses # Compare base addresses across process launches:# Launch 1: notepad.exe at 0x00007FF6A1340000# Launch 2: notepad.exe at 0x00007FF67E280000# Addresses differ - ASLR is working!One of the most important concepts in Windows memory management is the distinction between reserved and committed memory. This two-phase allocation model provides flexibility and efficiency by separating address space reservation from actual memory resource consumption.
Reserved Memory:
Reserving memory claims a range of virtual addresses without consuming physical RAM or page file space. It's essentially placing a placeholder in the VAD tree saying "this region is allocated, but empty." Attempting to access reserved (but not committed) memory causes an access violation.
Committed Memory:
Committing memory actually allocates backing store—the system guarantees that physical RAM or page file space exists to hold the data. Only committed memory can be accessed without faulting.
Why the Distinction Matters:
This model enables efficient patterns like:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
#include <windows.h>#include <stdio.h> void demonstrateReserveCommit() { // Phase 1: Reserve 1 MB of address space // No physical memory consumed yet! SIZE_T reserveSize = 1024 * 1024; // 1 MB LPVOID reserved = VirtualAlloc( NULL, // Let system choose address reserveSize, // Size to reserve MEM_RESERVE, // Reserve only, don't commit PAGE_READWRITE // (Ignored until commit) ); if (!reserved) { printf("Reserve failed: %lu\n", GetLastError()); return; } printf("Reserved 1 MB at: %p\n", reserved); // Attempting to access reserved memory causes access violation! // CRASH: ((char*)reserved)[0] = 'x'; // Phase 2: Commit first 64 KB SIZE_T commitSize = 64 * 1024; // 64 KB LPVOID committed = VirtualAlloc( reserved, // Within reserved region commitSize, // Size to commit MEM_COMMIT, // Now commit PAGE_READWRITE // With read/write access ); if (!committed) { printf("Commit failed: %lu\n", GetLastError()); VirtualFree(reserved, 0, MEM_RELEASE); return; } printf("Committed 64 KB starting at: %p\n", committed); // Now we can use the committed memory memset(committed, 'A', commitSize); printf("Wrote to committed memory successfully\n"); // Phase 3: Commit an additional 64 KB further into the reserved region LPVOID moreCommit = VirtualAlloc( (BYTE*)reserved + (256 * 1024), // 256 KB offset commitSize, MEM_COMMIT, PAGE_READWRITE ); printf("Committed another 64 KB at: %p\n", moreCommit); // Query the region to see state MEMORY_BASIC_INFORMATION mbi; VirtualQuery(reserved, &mbi, sizeof(mbi)); printf("\nFirst region state: %s\n", mbi.State == MEM_COMMIT ? "COMMITTED" : mbi.State == MEM_RESERVE ? "RESERVED" : "FREE"); // Clean up VirtualFree(reserved, 0, MEM_RELEASE); // Release entire region printf("Released all memory\n");} // Pattern 2: Reserve+Commit in one call (common shorthand)void simpleAlloc() { // Most allocations do both at once LPVOID mem = VirtualAlloc( NULL, 4096, MEM_RESERVE | MEM_COMMIT, // Both flags together PAGE_READWRITE ); // Ready to use immediately memset(mem, 0, 4096); VirtualFree(mem, 0, MEM_RELEASE);}Even committed memory doesn't immediately consume physical RAM. Windows uses demand paging: physical pages are allocated only when first accessed. Committing just adds to the system commit charge (accounting) and creates the appropriate VAD entry. The actual page tables and physical pages materialize on first touch.
A Windows process's address space contains several distinct regions, each serving a specific purpose. Understanding these regions helps diagnose memory issues, optimize performance, and exploit protections.
1. Null Region (0x00000000 - 0x0000FFFF):
The first 64 KB of address space is permanently reserved and uncommitted. This catches null pointer dereferences—attempting to read from or write to addresses near NULL causes an access violation rather than silently corrupting memory.
2. Executable Image (.exe and .dlls):
The main executable and all loaded DLLs are mapped into the address space. Within each image:
.text section: Code (PAGE_EXECUTE_READ).data section: Initialized globals (PAGE_READWRITE).rdata section: Read-only data, import tables (PAGE_READONLY).bss section: Uninitialized globals (PAGE_READWRITE)3. Heap(s):
Process heaps managed by the heap manager (ntdll!RtlHeap or the Low Fragmentation Heap). Each process has a default heap, and can create additional heaps. Heaps grow upward from their initial location.
4. Thread Stacks:
Each thread receives a stack, typically with 1 MB reserved and 64 KB+ committed. Stacks have guard pages to detect overflow. Stack grows downward (toward lower addresses).
5. Memory-Mapped Files:
File contents mapped into the address space. Includes:
6. PEB and TEB:
The Process Environment Block (PEB) and Thread Environment Blocks (TEBs) contain critical per-process and per-thread information and are mapped at specific locations.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
#include <windows.h>#include <psapi.h>#include <stdio.h> // Walk the process memory mapvoid examineMemoryMap() { SYSTEM_INFO si; GetSystemInfo(&si); printf("Memory Information\n"); printf("══════════════════════════════════════════════════════════\n"); printf("Page Size: %lu bytes\n", si.dwPageSize); printf("Allocation Granularity: %lu bytes\n", si.dwAllocationGranularity); printf("User Address Range: %p - %p\n", si.lpMinimumApplicationAddress, si.lpMaximumApplicationAddress); printf("\n"); // Walk all memory regions LPVOID address = si.lpMinimumApplicationAddress; MEMORY_BASIC_INFORMATION mbi; SIZE_T totalCommitted = 0; SIZE_T totalReserved = 0; SIZE_T totalFree = 0; printf("Address Range Size State Type Protection\n"); printf("──────────────────────────────────────────────────────────────────\n"); while (address < si.lpMaximumApplicationAddress) { if (VirtualQuery(address, &mbi, sizeof(mbi)) == 0) { break; } const char* state = ""; switch (mbi.State) { case MEM_COMMIT: state = "COMMIT "; totalCommitted += mbi.RegionSize; break; case MEM_RESERVE: state = "RESERVE"; totalReserved += mbi.RegionSize; break; case MEM_FREE: state = "FREE "; totalFree += mbi.RegionSize; break; } const char* type = ""; switch (mbi.Type) { case MEM_IMAGE: type = "IMAGE "; break; case MEM_MAPPED: type = "MAPPED "; break; case MEM_PRIVATE: type = "PRIVATE"; break; default: type = " "; break; } char prot[32]; switch (mbi.Protect) { case PAGE_EXECUTE_READ: strcpy(prot, "EXECUTE_READ"); break; case PAGE_EXECUTE_READWRITE: strcpy(prot, "EXECUTE_RW"); break; case PAGE_READWRITE: strcpy(prot, "READWRITE"); break; case PAGE_READONLY: strcpy(prot, "READONLY"); break; case PAGE_WRITECOPY: strcpy(prot, "WRITECOPY"); break; case PAGE_NOACCESS: strcpy(prot, "NOACCESS"); break; case PAGE_GUARD: strcpy(prot, "GUARD"); break; default: sprintf(prot, "0x%x", mbi.Protect); break; } // Print significant regions (skip tiny ones for readability) if (mbi.RegionSize >= 64 * 1024 || mbi.State != MEM_FREE) { printf("%p - %p %8llu KB %s %s %s\n", mbi.BaseAddress, (BYTE*)mbi.BaseAddress + mbi.RegionSize, mbi.RegionSize / 1024, state, type, prot); } address = (BYTE*)mbi.BaseAddress + mbi.RegionSize; } printf("\n════════════════════════════════════════════════════════\n"); printf("Summary:\n"); printf(" Total Committed: %.2f MB\n", totalCommitted / (1024.0 * 1024.0)); printf(" Total Reserved: %.2f MB\n", totalReserved / (1024.0 * 1024.0)); printf(" Total Free: %.2f GB\n", totalFree / (1024.0 * 1024.0 * 1024.0));}We've covered the foundational concepts of Windows virtual address space. Let's consolidate the key takeaways:
What's Next:
With the virtual address space foundation in place, we'll explore the paging file—how Windows extends available memory beyond physical RAM, manages page-out and page-in operations, and configures this critical system resource.
You now understand the architecture of Windows virtual address space—the fundamental abstraction upon which all Windows memory management builds. Every memory operation, from simple malloc() to complex kernel pool allocations, operates within this virtual address space framework.