When you call malloc(100 * 1024 * 1024) in C, new byte[100 * 1024 * 1024] in C#, or allocate a massive buffer in Node.js or Go, how much physical RAM did your machine just allocate?
Almost every junior developer answers: 100 megabytes.
The real answer is zero bytes.
Your operating system handed your process a promise written on virtual paper. Not a single transistor in your DRAM sticks holds your data. No physical memory page was assigned to your process.
If you inspect your process immediately in top or /proc/self/status, you will see something counterintuitive:
- VSZ (Virtual Memory Size): jumped by 100 MB.
- RSS (Resident Set Size): changed by exactly 0 KB.
Physical memory is only claimed when your CPU attempts to read or write the very first byte of a page. What happens in that split microsecond between your CPU instruction and your physical RAM chips is a sequence involving hardware traps, multi-level page table traversals, and kernel memory management.
Here is the exact journey from malloc() to physical silicon.
1. The Allocation Illusion: Why malloc Does Not Touch RAM
When your application code requests memory, it never speaks directly to the hardware. It talks to your language's memory allocator (such as ptmalloc in glibc, jemalloc, or Go's runtime allocator).
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
int main() {
// Request 100 MB from the OS
char *buffer = malloc(100 * 1024 * 1024);
printf("Allocated virtual memory. Check /proc/%d/status\n", getpid());
pause(); // Process pauses here without touching the buffer
return 0;
}
For allocations larger than a threshold (default 128 KB in glibc), the allocator bypasses the heap's brk pointer and issues a system call to the Linux kernel:
mmap(NULL, 104857600, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
What the Linux Kernel Does
The kernel does not search physical memory banks for 100 MB of contiguous DRAM. Instead:
- It looks at the process's memory descriptor (
struct mm_struct). - It finds an unused range of virtual addresses inside the process's 48-bit (or 57-bit) virtual address space.
- It creates a new
struct vm_area_struct(VMA) recording that this range is valid with read/write permissions. - It returns the starting virtual address pointer to your program.
At this point, the Page Table Entries (PTEs) for this address range do not even exist or are marked invalid (Present bit = 0).
2. The Hardware Hierarchy: MMU, CR3, and 4-Level Page Tables
To understand what happens next, we need to look at how modern CPUs look up memory.
A 64-bit CPU does not use all 64 bits for addressing. On standard x86-64 hardware with 4-level paging, the CPU uses a 48-bit virtual address (providing 256 TB of address space, split evenly between user space and kernel space).
47 39 38 30 29 21 20 12 11 0
+----------+----------+----------+----------+-------------+
| PGD (9b) | PUD (9b) | PMD (9b) | PTE (9b) | Offset(12b) |
+----------+----------+----------+----------+-------------+
Every running process has its own isolated tree of page tables:
- CR3 Register: A CPU control register that holds the physical address of the current process's root page table (PGD - Page Global Directory).
- PGD / P4D / PUD / PMD: Intermediate directory levels. Each level contains 512 entries (each 8 bytes, fitting neatly in a 4 KB page).
-
PTE (Page Table Entry): The leaf entry. It holds the physical Page Frame Number (PFN) and permission flags:
- P (Present): Bit 0. Is this page currently in physical RAM?
- R/W (Read/Write): Bit 1. Is writing allowed?
- U/S (User/Supervisor): Bit 2. Can user-space code access this?
- NX (No-Execute): Bit 63. Can code execute from this page?
- Offset (12 bits): Points to the exact byte within the 4,096-byte (4 KB) page frame ($2^{12} = 4096$).
When your program accesses an address, the CPU's hardware Memory Management Unit (MMU) checks its ultra-fast hardware cache: the TLB (Translation Lookaside Buffer).
If the translation is cached, the MMU converts the virtual address to a physical RAM address in under a nanosecond.
3. The First Touch: Triggering Interrupt Vector 14
Now, your application executes its first write to the newly allocated memory:
buffer[0] = 'A'; // Writing one single byte
Under the hood, the compiler translates this into an assembly instruction:
mov byte ptr [rax], 65
Here is the exact chain of hardware and kernel events that unfolds:
[ CPU Instruction: MOV [RAX], 65 ]
│
▼
[ MMU checks TLB Cache ] ───(Miss)───► [ MMU Page Table Walk (CR3) ]
│
▼
[ PTE Present Bit == 0 ]
│
▼
💥 HARDWARE EXCEPTION: #PF
(CPU Interrupt Vector 14)
│
▼
[ CR2 = Faulting Address ]
[ Switch to Ring 0 Kernel ]
│
▼
[ do_page_fault() Handler ]
Step 1: The MMU Hits a Wall
The MMU checks the TLB: cache miss. It starts walking the page table from the base address in CR3. When it arrives at the entry for buffer[0], it checks bit 0: Present = 0.
The MMU cannot translate this address into physical RAM.
Step 2: The Hardware Exception
The CPU halts the execution of the mov instruction immediately. It does not crash the program yet. Instead, it triggers CPU Interrupt Vector 14, known as the Page Fault Exception (#PF).
The CPU hardware automatically:
- Stores the offending virtual address into the
CR2control register. - Pushes an architectural error code onto the kernel stack (recording whether the access was a read or write, user mode or kernel mode).
- Switches privilege level from User Mode (Ring 3) to Kernel Mode (Ring 0).
- Jumps to the kernel's interrupt descriptor table (IDT) entry for vector 14:
exc_page_fault().
4. Inside the Linux Kernel: do_page_fault() and Demand Paging
The kernel takes over execution. It must determine whether this access was a legitimate memory request or an illegal access that deserves a crash.
[ Kernel do_page_fault() ]
│
┌───────────────┴───────────────┐
▼ ▼
[ Address in VMA? ] [ Address Invalid? ]
│ │
YES │ ▼
▼ [ SIGSEGV ]
[ Permissions Match? ] (Segmentation Fault)
│
YES │
▼
[ handle_mm_fault() ]
│
▼
[ Buddy Allocator: 4KB Frame ]
│
▼
[ Zero-Fill-On-Demand (Security) ]
│
▼
[ Update PTE: P=1, R/W=1, PFN ]
│
▼
[ IRET: Restart MOV Instruction ]
Step 1: VMA Validation
The kernel reads the faulting address from CR2 and queries the process's virtual memory areas using find_vma() (searching the red-black / maple tree).
-
Case A (Invalid): If the address is outside any mapped VMA (like dereferencing a NULL pointer
0x0), the kernel sends signal 11:SIGSEGV(Segmentation Fault). -
Case B (Permission Violation): If the code tried to write to a read-only area (like a string literal in the
.rodatasection), the kernel also terminates it withSIGSEGV. -
Case C (Valid Demand Page): The address falls inside the 100 MB VMA created earlier by
mmap. The kernel proceeds to allocate physical backing.
Step 2: Physical Frame Allocation (The Buddy Allocator)
The kernel calls handle_mm_fault(). Since this is an anonymous mapping (not backed by a file on disk):
- It asks the Linux Buddy Allocator for an available 4 KB physical memory page frame (
struct page). - Zero-Fill-On-Demand: Before giving the page to the process, the kernel immediately zeroes all 4,096 bytes. Why? If the kernel did not zero the page, your process could read leftover data left behind by other processes, such as decrypted TLS keys, hashed passwords, or private tokens.
Step 3: Wiring the Page Table
The kernel populates the page table hierarchy down to the leaf PTE:
- Sets the physical Page Frame Number (PFN) to the newly allocated frame.
- Sets
Present = 1,Read/Write = 1,User = 1. - Increments the process's Resident Set Size counter (
mm->rss_stat).
Step 4: Instruction Restart
The kernel executes the x86 iretq (Interrupt Return) instruction. The CPU switches back to User Mode (Ring 3) and restarts the exact same mov instruction that faulted.
This time, the MMU walks the page table, finds Present = 1, resolves the physical address, caches it in the TLB, and writes byte 65 ('A') directly to physical DRAM.
The entire cycle took between 1 and 3 microseconds.
5. Minor vs. Major Page Faults
Not all page faults are created equal. Operating systems categorize page faults into two primary types:
| Metric | Minor Page Fault (Soft Fault) | Major Page Fault (Hard Fault) |
|---|---|---|
| Storage Access | Handled purely in CPU/RAM | Requires Block I/O (Disk / SSD) |
| Typical Latency | 1 – 3 microseconds | 1 – 10 milliseconds (1,000x slower) |
| Common Causes | Demand zero paging (malloc), shared libraries already in RAM, Copy-on-Write |
Reading memory swapped out to disk, first access to a cold mmap file |
| Thread State | Remains Running | Thread suspended (TASK_UNINTERRUPTIBLE / D state) |
Empirical Verification in Python
We can observe this mechanism directly using Linux kernel metrics in /proc/self/status and getrusage:
import mmap
import resource
import os
def get_memory_stats():
with open('/proc/self/status') as f:
lines = f.readlines()
vmsize = int([l for l in lines if l.startswith('VmSize:')][0].split()[1])
vmrss = int([l for l in lines if l.startswith('VmRSS:')][0].split()[1])
return vmsize, vmrss
# 1. Allocate 100 MB with anonymous mmap (lazy)
v0, r0 = get_memory_stats()
u0 = resource.getrusage(resource.RUSAGE_SELF).ru_minflt
mem = mmap.mmap(-1, 100 * 1024 * 1024, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS)
v1, r1 = get_memory_stats()
u1 = resource.getrusage(resource.RUSAGE_SELF).ru_minflt
print(f"After allocation: VmSize diff = +{v1 - v0} kB, VmRSS diff = +{r1 - r0} kB, Faults = {u1 - u0}")
# Output: After allocation: VmSize diff = +102400 kB, VmRSS diff = +0 kB, Faults = 0
# 2. Touch exactly 1 byte in each 4096-byte page (25,600 pages total)
for offset in range(0, 100 * 1024 * 1024, 4096):
mem[offset] = 1
v2, r2 = get_memory_stats()
u2 = resource.getrusage(resource.RUSAGE_SELF).ru_minflt
print(f"After touching: VmSize diff = +{v2 - v0} kB, VmRSS diff = +{r2 - r0} kB, Faults = +{u2 - u1}")
# Output: After touching: VmSize diff = +102400 kB, VmRSS diff = +102400 kB, Faults = +25600
Notice the arithmetic: $100 \text{ MB} \div 4 \text{ KB} = 25,600 \text{ pages}$.
Touching one byte per page generated exactly 25,600 minor page faults, and VmRSS increased by exactly 102,400 kB.
6. Copy-on-Write (COW): How fork() Clones Processes Instantly
This same page fault mechanism powers the Unix fork() system call.
If a Redis server or PostgreSQL process holding 32 GB of data calls fork() to spawn a background save worker, does Linux copy all 32 GB of memory?
No. fork() completes in milliseconds because of Copy-on-Write:
- The kernel duplicates the parent's page tables for the child process.
- Both parent and child PTEs point to the same physical frames in RAM.
- The kernel clears the Write bit on every single PTE:
R/W = 0(Read-Only) on both processes. - When either process attempts to write to any page, the MMU triggers a Page Fault (#PF).
- The kernel catches the fault, realizes the VMA allows writing, allocates a fresh 4 KB frame, copies only that single 4 KB page, sets
R/W = 1for the writing process, and resumes execution.
Both processes share read-only memory seamlessly until one of them modifies a page.
7. The Trap: Memory Overcommit and the OOM Killer
Because virtual memory is allocated lazily on demand, Linux defaults to an optimistic strategy called Overcommit (controlled via /proc/sys/vm/overcommit_memory):
- If your system has 16 GB of physical RAM, processes can collectively call
malloc()for 100 GB of memory without failing. - The kernel accepts every
malloc()request because it assumes most applications never touch all the memory they request.
When the Bill Comes Due
What happens if all those processes suddenly start writing to their allocated memory at once?
The CPU fires page faults, but the Buddy Allocator has zero physical frames left to hand out. At that point, malloc() has already returned success seconds or minutes ago. The kernel cannot return NULL from malloc because malloc finished in the past.
The kernel has only one recourse: activate the Out-Of-Memory (OOM) Killer.
[ Out of Physical Memory ]
│
▼
[ Linux OOM Killer Triggered ]
│
▼
[ Calculate oom_badness() for every PID ]
= (% of RAM consumed) + oom_score_adj
│
▼
[ Select victim with highest score ]
│
▼
[ SIGKILL (kill -9) ]
The OOM killer scans running processes, computes an oom_badness score based on physical RAM consumption and /proc/<pid>/oom_score_adj, and sends an uncatchable SIGKILL to terminate the worst offender instantly.
8. Practical Engineering Takeaways
Understanding the virtual memory and page fault lifecycle directly impacts system design:
-
Pre-faulting for Latency-Critical Systems: High-frequency trading engines, real-time audio systems, and game engines cannot tolerate 3-microsecond page fault jitter in hot execution paths. Use
mlockall(MCL_CURRENT | MCL_FUTURE)ormmap(..., MAP_POPULATE)to force the kernel to allocate and wire all physical pages at startup. - Transparent Huge Pages (THP): Traversing a 4-level page table on every TLB miss has measurable CPU overhead. Huge pages (2 MB or 1 GB instead of 4 KB) reduce page table entries and TLB pressure by a factor of 512.
- Monitor RSS, Not Just VSZ: Virtual memory size (VSZ) indicates what your program asked for; Resident Set Size (RSS) indicates what physical RAM it actually occupies. Spikes in major page faults indicate memory pressure and disk thrashing.
Virtual memory is not just an abstraction layer. It is a dynamic contract between the CPU hardware and the Linux kernel that turns lazy promises into physical silicon only when your code demands it.
Top comments (0)