Virtual Memoryvirtual addressaddress spacepage tableuser spacekernel space

The Virtual Address Space

Process A’s address 0x1000 and process B’s address 0x1000 are two different bytes because each process has its own page table; the address space they see is 48 bits wide, split between a user half and a kernel half that is mapped into every process but unreachable from user mode.

ConceptualLinux
▶ InteractiveInterview question
Progress

The problem

Two processes both read address 0x1000 and get different values, and neither can construct an address that reaches the other’s data or the kernel’s. What does an address actually mean to the CPU, and what does one process’s address space contain?

Same address, different memory

A virtual address is not a location; it is a key. The CPU splits it into a virtual page number (the high bits) and an offset within the page (the low 12 bits for 4 kB pages), looks the page number up in the current process’s page table, and gets a physical frame number; frame number plus the unchanged offset is the address that goes to the memory controller. Process A’s table maps virtual page 1 (address 0x1000) to frame 81; process B’s maps its page 1 to frame 12. The same key, two dictionaries, two values. Neither table contains the other’s frame, so neither process can produce an address that reaches it — not by arithmetic, not by guessing, not by a bug.

Which table is current is decided by one register that the kernel writes at every context switch (Context Switching): CR3 on x86-64, TTBR0_EL1 on ARM64. From the CPU’s point of view "which process is running" *is* "which table is loaded"; there is no other notion of process identity in the memory system.

VA 0x1000 in two processes
VPN 1, offset 01 → 81VPN 1, offset 01 → 12Process A: read 0x1000Process B: read 0x1000A’s page table (CR3 = A)B’s page table (CR3 = B)Frame 81 → 0x51000Frame 12 → 0x0C000
UserLLMAgentToolDataDecisionHumanGuardrail

What one address space contains

Linux

The kernel does not track a process’s memory one page at a time; it keeps a list of regions (virtual memory areas, VMAs, on Linux) each with a start, an end, permissions and a backing: the executable’s code, its data, the heap, each shared library’s code and data, thread stacks, anonymous mmap regions, memory-mapped files, and the main stack. Page-table entries are created from these regions lazily, on first touch. /proc/<pid>/maps prints the list, and reading it is the fastest way to understand a process’s memory.

Note the addresses. The binary is at 0x55… and the libraries and stack at 0x7f… — randomised per run by ASLR within those ranges — and the heap starts right after the binary’s data. Between the regions are gaps: unmapped addresses whose page-table entries do not exist, where any access is a segfault. On a 64-bit process the gaps are the overwhelming majority of the space.

Linux: /proc/self/maps of a small program (abridged; addresses change every run)
$ cat /proc/self/maps
55c1e8a00000-55c1e8a02000 r--p 00000000 08:01 1310  /usr/bin/cat        # ELF headers, read-only
55c1e8a02000-55c1e8a06000 r-xp 00002000 08:01 1310  /usr/bin/cat        # code: read + execute
55c1e8a08000-55c1e8a09000 rw-p 00007000 08:01 1310  /usr/bin/cat        # data: read + write
55c1ea1c0000-55c1ea1e1000 rw-p 00000000 00:00 0     [heap]              # brk heap
7f3a4c000000-7f3a4c022000 r--p 00000000 08:01 4021  /usr/lib/libc.so.6  # libc, shared with every process
7f3a4c022000-7f3a4c1a7000 r-xp 00022000 08:01 4021  /usr/lib/libc.so.6
7f3a4c1fc000-7f3a4c200000 rw-p 001fb000 08:01 4021  /usr/lib/libc.so.6  # libc data: private copy on write
7f3a4c2c0000-7f3a4c2c2000 rw-p 00000000 00:00 0                         # anonymous mmap
7ffd7a5e0000-7ffd7a601000 rw-p 00000000 00:00 0     [stack]
7ffd7a7f0000-7ffd7a7f2000 r-xp 00000000 00:00 0     [vdso]              # kernel-provided code page
# columns: range, perms (r/w/x, p=private), file offset, device, inode, backing

The kernel half

Conceptual

Every process’s page table also maps the kernel: its code, its data, its view of all physical memory. Those entries are marked supervisor-only, so a user-mode access to them faults, but they are there — which is why a system call or interrupt can switch into kernel mode without switching page tables, and why the kernel can read your buffer in write() with an ordinary load. Conventionally the address space is split in half: user addresses have their top bits clear and kernel addresses have them set, so on x86-64 Linux user space is 0x0000_0000_0000_00000x0000_7fff_ffff_ffff and kernel space is 0xffff_8000_0000_0000 upwards. Windows uses the same halves; 32-bit systems used a 3 GB / 1 GB or 2 GB / 2 GB split.

The Meltdown vulnerability (2018) showed that speculative execution could leak kernel data through those mapped-but-protected entries. The mitigation, kernel page-table isolation (KPTI on Linux, KVA Shadow on Windows), gives each process two tables — one with the full kernel for kernel mode, one with only the entry trampolines for user mode — and switches between them on every syscall and interrupt. That is the page-table switch per syscall mentioned in System Calls, and it is why PCID support suddenly mattered for syscall-heavy workloads.

48 bits, not 64

Conceptual

A 64-bit register can hold 2⁶⁴ addresses, but no CPU implements that many: page tables for a 64-bit space would be six levels deep and the extra bits buy nothing. x86-64 and ARM64 implement 48-bit virtual addresses — 256 TB, split into 128 TB of user space and 128 TB of kernel space — with four levels of page table (Page Tables). The unused 16 bits must be a copy of bit 47 (a canonical address); an address like 0x0001_2345_6789_abcd is non-canonical and faults with a general protection error rather than a page fault, which is how JavaScript engines and other runtimes get away with pointer tagging: stuffing type bits into the top of a pointer and masking them off before use.

Intel’s 5-level paging (Ice Lake and later; Linux enables it on demand) extends this to 57 bits and 128 PB, for machines with many terabytes of RAM and for databases that want to map everything. Physical addresses are separately limited — typically 46–52 bits — and the two limits are independent: a process on a 16 GB laptop has 128 TB of virtual space, nearly all of it holes.

  • x86-64 / ARM64: 48-bit virtual addresses, 4-level tables, 128 TB user + 128 TB kernel.
  • Canonical form: bits 63…48 equal bit 47; violating it is a #GP, not a page fault.
  • 5-level paging: 57 bits, 128 PB, opt-in per process on Linux via an mmap hint above 47 bits.
  • Physical address width (46–52 bits) is a separate limit; ARM64 Linux commonly configures 39, 42 or 48 bits of VA.
  • Windows: same 128 TB user half since Windows 8.1 x64 (earlier: 8 TB).

Key points

  • A virtual address is a key into the current process’s page table; VPN → frame, offset unchanged. Same key in two processes, two frames.
  • Which table is current is a single register written at context switch; that register is the CPU’s notion of "which process".
  • A process’s address space is a sparse list of regions (code, data, heap, libraries, stacks, mmaps) with permissions; /proc/<pid>/maps shows it.
  • The kernel is mapped into every address space, supervisor-only, so traps do not need a table switch — until Meltdown forced KPTI.
  • 48-bit addresses: 128 TB user, 128 TB kernel; non-canonical addresses fault immediately, which pointer tagging relies on.
  • Most of any 64-bit address space is unmapped holes; VSZ measures the mapped regions, not RAM.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why start every process’s code at the same address?

Because the linker can then resolve addresses once, at build time, and the OS relocates the whole program by choosing frames. ASLR later moved to position-independent code so that the base can be randomised too.

Why map the kernel into every process?

So that a syscall or interrupt lands in kernel code without a page-table switch, and the kernel can read and write user buffers with ordinary loads. The protection bit keeps user code out; Meltdown showed that speculation could partly bypass it.

Why 48 bits and not the full 64?

Page tables grow a level per 9 bits; 48 bits already covers 256 TB, and hardware engineers do not implement address bits nobody can use. The canonical-address rule keeps the door open for extension, which 5-level paging then used.

Why can two processes share libc but not their heaps?

Both are table entries. libc’s code is mapped read-only from the same file, so the same frames are safe to share. A heap is private anonymous memory; its frames appear in one table only.

Address translation

Two processes, one physical memory
Both processes use the address 0x1000 — and touch different RAM. The page table is the map; the process only ever sees the left side.
Process A · virtual pages
Process B · virtual pages
Physical memory · 16 frames × 4 kB
f0
kernel
f1
A
f2
B
f3
A
f4
free
f5
B
f6
free
f7
A
f8
free
f9
A+B
f10
B
f11
free
f12
A
f13
free
f14
B
f15
kernel
Virtual address
0x1000
PTE
frame 7 · RW-
Physical address
0x7000
Region
data
Isolation: process A has no PTE that reaches the other process's frames, so there is no address it can form that touches them.
Conceptual

How it fails

What the failure looks like from inside real software.

  • A "corrupted pointer" that is really a tagged pointer used without masking: the address is non-canonical and the crash is a general protection fault at a bizarre address.
  • Reading /proc/<pid>/maps shows thousands of small anonymous regions: an allocator or JIT that mmaps per allocation; the VMA count limit (vm.max_map_count, 65,530 by default) will eventually make mmap fail with ENOMEM even with RAM free — a known Elasticsearch prerequisite.
  • A 32-bit process (or a 32-bit build of a tool) runs out of address space at 2–4 GB despite a 64 GB machine: mmap fails, malloc returns NULL.
  • After KPTI a syscall-heavy service slowed by 10–30% on hardware without PCID; the "memory" fix was a CPU generation.
  • Comparing or hashing addresses across runs, or persisting them, breaks under ASLR — the map differs every run.