DEV Community

Cover image for What Actually Happens Between malloc() and Physical RAM: Virtual Memory, Page Faults, and the MMU
Syed Anzar
Syed Anzar

Posted on

What Actually Happens Between malloc() and Physical RAM: Virtual Memory, Page Faults, and the MMU

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

What the Linux Kernel Does

The kernel does not search physical memory banks for 100 MB of contiguous DRAM. Instead:

  1. It looks at the process's memory descriptor (struct mm_struct).
  2. It finds an unused range of virtual addresses inside the process's 48-bit (or 57-bit) virtual address space.
  3. It creates a new struct vm_area_struct (VMA) recording that this range is valid with read/write permissions.
  4. 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) |
+----------+----------+----------+----------+-------------+
Enter fullscreen mode Exit fullscreen mode

Every running process has its own isolated tree of page tables:

  1. CR3 Register: A CPU control register that holds the physical address of the current process's root page table (PGD - Page Global Directory).
  2. PGD / P4D / PUD / PMD: Intermediate directory levels. Each level contains 512 entries (each 8 bytes, fitting neatly in a 4 KB page).
  3. 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?
  4. 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
Enter fullscreen mode Exit fullscreen mode

Under the hood, the compiler translates this into an assembly instruction:

mov byte ptr [rax], 65
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Stores the offending virtual address into the CR2 control register.
  2. Pushes an architectural error code onto the kernel stack (recording whether the access was a read or write, user mode or kernel mode).
  3. Switches privilege level from User Mode (Ring 3) to Kernel Mode (Ring 0).
  4. 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 ]
Enter fullscreen mode Exit fullscreen mode

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 .rodata section), the kernel also terminates it with SIGSEGV.
  • 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):

  1. It asks the Linux Buddy Allocator for an available 4 KB physical memory page frame (struct page).
  2. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. The kernel duplicates the parent's page tables for the child process.
  2. Both parent and child PTEs point to the same physical frames in RAM.
  3. The kernel clears the Write bit on every single PTE: R/W = 0 (Read-Only) on both processes.
  4. When either process attempts to write to any page, the MMU triggers a Page Fault (#PF).
  5. 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 = 1 for 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) ]
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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) or mmap(..., MAP_POPULATE) to force the kernel to allocate and wire all physical pages at startup.
  2. 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.
  3. 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)