Loading learning content...
Read aloud is unavailable in this browser.
Every process running on a Linux system believes it has exclusive access to a vast, contiguous address space spanning terabytes of memory. This illusion—one of the most elegant abstractions in systems programming—is orchestrated by the virtual memory system. But how exactly does Linux organize this virtual address space? What determines where your stack lives, where the heap grows, and where the kernel resides?
Understanding the virtual memory layout is foundational for systems programmers, kernel developers, security researchers, and anyone who needs to debug memory corruption, optimize performance, or understand low-level system behavior. This isn't abstract theory—it's the actual memory map you'll encounter when debugging with GDB, analyzing crash dumps, or writing exploit mitigations.
By completing this page, you will understand the complete virtual address space layout on x86-64 Linux: the 48-bit addressing scheme, canonical address forms, the kernel/user space split at the 128TB boundary, and the precise organization of text, data, BSS, heap, shared libraries, and stack regions. You'll be able to read and interpret /proc/[pid]/maps with expert-level understanding.
Before examining Linux-specific layout, we must understand the hardware constraints that shape it. The x86-64 architecture defines a 64-bit virtual address space, but current implementations don't use all 64 bits.
Why 48 bits, not 64?
Modern x86-64 processors implement only 48 bits of virtual address space, supporting 256 TB of addressable memory. This might seem like an arbitrary limitation, but it reflects practical engineering tradeoffs:
Page table depth: Each additional bit of address space requires larger page tables. 48 bits already requires 4-level page tables; full 64 bits would require even deeper hierarchies.
Physical memory limits: No system today has 16 exabytes of RAM. Engineering for the common case saves transistors and power.
Hardware complexity: Wider address buses increase interconnect complexity and latency.
However, newer processors (Intel Ice Lake, AMD Zen 4) support 57-bit virtual addresses through 5-level paging, expanding the addressable space to 128 PB. Linux supports this via the CONFIG_X86_5LEVEL kernel option.
The x86-64 architecture enforces 'canonical' addresses. For 48-bit addressing, bits 48-63 must all be copies of bit 47 (sign extension). This creates two valid address ranges: 0x0000000000000000 to 0x00007FFFFFFFFFFF (user space) and 0xFFFF800000000000 to 0xFFFFFFFFFFFFFFFF (kernel space). Addresses in the 'non-canonical hole' between these ranges cause a general protection fault if accessed.
| Configuration | Virtual Bits | Page Table Levels | User Space Size | Kernel Space Size |
|---|---|---|---|---|
| Standard (48-bit) | 48 | 4 | 128 TB (0x0 - 0x7FFFFFFFFFFF) | 128 TB (0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF) |
| 5-Level Paging (57-bit) | 57 | 5 | 64 PB | 64 PB |
The canonical address requirement explained:
Consider a 48-bit address like 0x00007FFF12345678. In 64-bit form, bits 48-63 must equal bit 47. Since bit 47 is 0, the full 64-bit representation is 0x00007FFF12345678 (bits 48-63 are all 0).
For a kernel address like 0xFFFF800012345678, bit 47 is 1, so bits 48-63 must all be 1, giving the full 64-bit form 0xFFFF800012345678.
Any address in the non-canonical hole (e.g., 0x0001000000000000) triggers a General Protection Fault (#GP) when dereferenced. This architectural feature provides a natural security boundary between user and kernel space.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
/* * Canonical Address Verification * Demonstrates how to check if an address is canonical on x86-64 */ #include <stdio.h>#include <stdint.h>#include <stdbool.h> /* * Check if a 64-bit address is canonical (valid for 48-bit addressing). * * For 48-bit virtual addressing: * - Bits 0-47 contain the actual address * - Bits 48-63 must be sign extensions of bit 47 * - If bit 47 is 0, bits 48-63 must all be 0 (user space) * - If bit 47 is 1, bits 48-63 must all be 1 (kernel space) */bool is_canonical_address(uint64_t addr) { /* * Shift right by 47 to get the high bits. * For a canonical address, this should yield either: * - 0x00000 (all zeros) for addresses 0x0000000000000000 - 0x00007FFFFFFFFFFF * - 0x1FFFF (all ones) for addresses 0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF */ uint64_t high_bits = addr >> 47; return (high_bits == 0) || (high_bits == 0x1FFFF);} /* * Determine the address space region for a given address. */const char* get_address_region(uint64_t addr) { if (!is_canonical_address(addr)) { return "NON-CANONICAL (Invalid - causes #GP fault)"; } /* Bit 47 determines user vs kernel space */ if (addr & (1ULL << 47)) { return "KERNEL SPACE (0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF)"; } else { return "USER SPACE (0x0000000000000000 - 0x00007FFFFFFFFFFF)"; }} int main() { /* Test addresses demonstrating canonical address rules */ uint64_t test_addresses[] = { 0x0000000000401000, /* Typical code segment */ 0x00007FFF12345678, /* Top of user space (stack region) */ 0x0001000000000000, /* Non-canonical hole (INVALID) */ 0x8000000000000000, /* Non-canonical (INVALID) */ 0xFFFF800000000000, /* Start of kernel space */ 0xFFFFFFFF80000000, /* Kernel text segment */ 0xFFFFFFFFFFFFFFFF, /* Top of kernel space */ }; printf("x86-64 Canonical Address Analysis (48-bit mode)"); printf("================================================ "); for (int i = 0; i < sizeof(test_addresses) / sizeof(test_addresses[0]); i++) { uint64_t addr = test_addresses[i]; printf("Address: 0x%016lX", addr); printf(" Canonical: %s", is_canonical_address(addr) ? "YES" : "NO"); printf(" Region: %s ", get_address_region(addr)); } return 0;}Within the 128 TB user space region, Linux organizes process memory into well-defined regions. Each region serves a specific purpose and has distinct properties regarding permissions, growth behavior, and lifecycle.
The classic process memory layout (low to high addresses):
This layout isn't arbitrary—it reflects decades of Unix design evolution and x86 architecture constraints.
Modern Linux systems enable Address Space Layout Randomization (ASLR) by default. With ASLR, the base addresses of stack, heap, shared libraries, and even the executable itself are randomized at each execution. The conceptual layout remains the same, but absolute addresses vary. You can disable ASLR for debugging with 'echo 0 > /proc/sys/kernel/randomize_va_space' (requires root).
Understanding each region in depth:
Text Segment (Code)
The text segment contains the machine code instructions of the program. Key characteristics:
Data and BSS Segments
These segments hold global and static variables:
int x = 42;)int y;)Heap Region
The heap provides dynamically allocated memory:
brk() or mmap()mmap() instead of extending the heap12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
/* * Demonstrating Linux Process Memory Layout * Compile: gcc -o memory_layout memory_layout_demo.c * Run: ./memory_layout * Analysis: cat /proc/$(pgrep memory_layout)/maps */ #include <stdio.h>#include <stdlib.h>#include <unistd.h> /* Global initialized variable - goes to DATA segment */int global_initialized = 42; /* Global uninitialized variable - goes to BSS segment */int global_uninitialized; /* Constant string - goes to read-only DATA (.rodata) */const char *constant_string = "This string lives in .rodata"; void print_memory_layout() { /* Local variable - goes to STACK */ int stack_variable = 100; /* Dynamic allocation - goes to HEAP */ int *heap_ptr = malloc(1024); /* Large allocation - typically uses mmap instead of heap */ int *mmap_allocation = malloc(1024 * 1024); /* 1 MB */ printf("=== Linux Process Memory Layout Demo === "); printf("TEXT (Code) Segment:"); printf(" main() function: %p", (void*)main); printf(" print_memory_layout(): %p ", (void*)print_memory_layout); printf("DATA Segment (initialized globals):"); printf(" global_initialized: %p ", (void*)&global_initialized); printf("RODATA Segment (constants):"); printf(" constant_string: %p ", (void*)constant_string); printf("BSS Segment (uninitialized globals):"); printf(" global_uninitialized: %p ", (void*)&global_uninitialized); printf("HEAP Segment (small dynamic allocations):"); printf(" heap_ptr (1KB): %p ", (void*)heap_ptr); printf("MMAP Region (large allocations):"); printf(" mmap_allocation (1MB): %p ", (void*)mmap_allocation); printf("STACK Segment:"); printf(" stack_variable: %p ", (void*)&stack_variable); printf("Process ID: %d", getpid()); printf("Examine detailed memory map: cat /proc/%d/maps", getpid()); /* Keep process alive for /proc examination */ printf("Press Enter to exit..."); getchar(); free(heap_ptr); free(mmap_allocation);} int main() { print_memory_layout(); return 0;}The stack and heap are the two primary regions of dynamic memory in a process. Understanding their fundamentally different growth patterns and limits is essential for diagnosing memory issues.
Stack Characteristics:
ulimit -s)Heap Characteristics:
| Property | Stack | Heap |
|---|---|---|
| Growth Direction | Downward (high → low) | Upward (low → high) |
| Allocation Speed | O(1) - just move stack pointer | Variable - depends on allocator |
| Deallocation | Automatic on function return | Explicit (free/delete) |
| Default Size | ~8 MB per thread | Limited by address space |
| Fragmentation | None (LIFO discipline) | Can be severe over time |
| Thread Safety | Thread-private by default | Shared, requires synchronization |
| Cache Locality | Excellent (recent data hot) | Variable (scattered allocations) |
In older 32-bit systems, the stack growing down and heap growing up could theoretically collide. Modern 64-bit systems place huge gaps between them, and the mmap region sits in between. Guard pages prevent silent overflows. However, a thread's stack exhaustion remains a real risk—especially with deep recursion or large stack allocations (e.g., large arrays as local variables).
The mmap region and its role:
Between the stack and heap lies the mmap region, which handles:
.so file loaded into the processmmap() system callThe kernel typically places the mmap region starting just below the stack and growing downward. With 128 TB of user space, there's ample room between stack top (~0x7FFFFFFFFFFF) and heap (typically starting around 0x10000000000 or lower).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
/* * Stack Limit Demonstration * Shows how to query and understand stack limits on Linux * * Compile: gcc -o stack_limits stack_limits.c */ #include <stdio.h>#include <stdlib.h>#include <sys/resource.h>#include <pthread.h> void print_stack_limits() { struct rlimit rl; if (getrlimit(RLIMIT_STACK, &rl) == 0) { printf("Stack Limits:"); printf(" Current (soft) limit: "); if (rl.rlim_cur == RLIM_INFINITY) { printf("unlimited"); } else { printf("%lu KB (%lu MB)", rl.rlim_cur / 1024, rl.rlim_cur / (1024 * 1024)); } printf(" Maximum (hard) limit: "); if (rl.rlim_max == RLIM_INFINITY) { printf("unlimited"); } else { printf("%lu KB (%lu MB)", rl.rlim_max / 1024, rl.rlim_max / (1024 * 1024)); } }} void print_address_space_limits() { struct rlimit rl; if (getrlimit(RLIMIT_AS, &rl) == 0) { printf("Address Space Limits:"); printf(" Current limit: "); if (rl.rlim_cur == RLIM_INFINITY) { printf("unlimited"); } else { printf("%lu GB", rl.rlim_cur / (1024UL * 1024 * 1024)); } }} void demonstrate_stack_frame() { int local_var = 42; char local_array[1024]; /* 1 KB on stack */ printf("Stack Frame Analysis:"); printf(" Address of local_var: %p", (void*)&local_var); printf(" Address of local_array: %p", (void*)local_array); printf(" Stack frame size ~%ld bytes", (char*)&local_var - local_array);} /* * Recursive function to demonstrate stack growth * WARNING: This can crash if you remove the depth limit! */void recursive_depth_test(int depth, void *initial_frame) { int frame_marker; if (depth == 100 || depth == 1000 || depth == 10000) { size_t stack_used = (char*)initial_frame - (char*)&frame_marker; printf(" Depth %5d: Stack used = %zu KB", depth, stack_used / 1024); } if (depth < 10000) { recursive_depth_test(depth + 1, initial_frame); }} void test_recursive_depth() { int frame_marker; printf("Recursive Stack Growth:"); recursive_depth_test(1, &frame_marker);} int main() { printf("=== Linux Stack Analysis === "); print_stack_limits(); print_address_space_limits(); demonstrate_stack_frame(); test_recursive_depth(); printf("Tip: Modify stack limit with 'ulimit -s <size_in_kb>'"); return 0;}The /proc/[pid]/maps file provides a complete snapshot of a process's virtual memory layout. This pseudo-file is indispensable for debugging, security analysis, and understanding how the kernel organizes process memory.
Format of each line:
address perms offset dev inode pathname
00400000-00452000 r-xp 00000000 fd:00 12345678 /bin/cat
Field breakdown:
| Field | Description |
|---|---|
address | Start-end virtual addresses (hexadecimal) |
perms | Permissions: r=read, w=write, x=execute, p=private/s=shared |
offset | Offset into the mapped file (for file-backed mappings) |
dev | Device (major:minor) of the mapped file |
inode | Inode number on that device |
pathname | File path, or special indicators like [heap], [stack], [vdso] |
The pathname field has special values: [heap] = process heap, [stack] = main thread stack, [vdso] = virtual dynamic shared object (kernel-provided fast syscalls), [vvar] = kernel-provided variables, [vsyscall] = legacy fast syscall page. Anonymous mappings have no pathname—these are often memory allocated via mmap(NULL, ...).
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
# Example /proc/[pid]/maps output for a simple program# Read with: cat /proc/self/maps (or use /proc/<pid>/maps for another process) # -----------------------------------------------------------# EXECUTABLE (TEXT, DATA, BSS)# -----------------------------------------------------------00400000-00403000 r--p 00000000 fd:01 12582912 /home/user/program00403000-00405000 r-xp 00003000 fd:01 12582912 /home/user/program00405000-00406000 r--p 00005000 fd:01 12582912 /home/user/program00406000-00407000 r--p 00005000 fd:01 12582912 /home/user/program00407000-00408000 rw-p 00006000 fd:01 12582912 /home/user/program # Key observations:# - r--p (read-only): .rodata, symbol tables# - r-xp (read+execute): .text (code)# - rw-p (read+write): .data, .bss (global variables) # -----------------------------------------------------------# HEAP (grows upward)# -----------------------------------------------------------01a4f000-01a70000 rw-p 00000000 00:00 0 [heap] # - Always rw-p (read+write, private)# - No backing file (device 00:00, inode 0)# - [heap] pseudo-path identifies it # -----------------------------------------------------------# SHARED LIBRARIES (mmap region)# -----------------------------------------------------------7f3a2c000000-7f3a2c021000 rw-p 00000000 00:00 0 7f3a2c021000-7f3a30000000 ---p 00000000 00:00 0 7f3a31200000-7f3a31400000 rw-p 00000000 00:00 0 # libc.so.6 - The C library7f3a31582000-7f3a315a7000 r--p 00000000 fd:01 1835025 /lib/x86_64-linux-gnu/libc.so.67f3a315a7000-7f3a3173c000 r-xp 00025000 fd:01 1835025 /lib/x86_64-linux-gnu/libc.so.67f3a3173c000-7f3a31794000 r--p 001ba000 fd:01 1835025 /lib/x86_64-linux-gnu/libc.so.67f3a31794000-7f3a31798000 r--p 00211000 fd:01 1835025 /lib/x86_64-linux-gnu/libc.so.67f3a31798000-7f3a3179a000 rw-p 00215000 fd:01 1835025 /lib/x86_64-linux-gnu/libc.so.6 # The dynamic linker (ld-linux.so)7f3a317b7000-7f3a317b9000 r--p 00000000 fd:01 1835013 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.27f3a317b9000-7f3a317e3000 r-xp 00002000 fd:01 1835013 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.27f3a317e3000-7f3a317ed000 r--p 0002c000 fd:01 1835013 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.27f3a317ee000-7f3a317f0000 r--p 00036000 fd:01 1835013 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.27f3a317f0000-7f3a317f2000 rw-p 00038000 fd:01 1835013 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 # -----------------------------------------------------------# KERNEL-PROVIDED MAPPINGS# -----------------------------------------------------------7f3a317f2000-7f3a317f5000 rw-p 00000000 00:00 0 7fff2bc97000-7fff2bcb8000 rw-p 00000000 00:00 0 [stack]7fff2bd0c000-7fff2bd10000 r--p 00000000 00:00 0 [vvar]7fff2bd10000-7fff2bd12000 r-xp 00000000 00:00 0 [vdso] # [stack] - The main thread's stack# [vvar] - Kernel variables (time, etc.) shared read-only# [vdso] - Virtual DSO for fast system calls without kernel entry # -----------------------------------------------------------# VSYSCALL (legacy, x86-64 specific)# -----------------------------------------------------------ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall] # Note: This is in the KERNEL address range!# It's a special compatibility page for legacy vsyscall users.# Modern code uses vDSO instead.Permission patterns and their meaning:
| Pattern | Typical Use | Security Implications |
|---|---|---|
r-xp | Code sections (.text) | Normal: code is executable but not writable |
r--p | Read-only data (.rodata) | Constants, string literals |
rw-p | Data sections (.data, .bss), heap, stack | Writable but not executable (W^X) |
---p | Guard pages | Inaccessible; triggers fault if accessed |
r-xs | Shared executable code | Multiple processes share same physical pages |
rwxp | JIT code regions | Security concern: Self-modifying code |
The W^X (Write XOR Execute) principle:
Modern systems enforce that memory is either writable OR executable, but not both simultaneously. This makes exploitation harder—attackers cannot write shellcode and execute it in the same memory region. Regions marked rwxp (like JIT compilers use) are exceptions that require extra care.
Address Space Layout Randomization (ASLR) fundamentally changes how we think about memory layout. Instead of predictable addresses, modern Linux randomizes the base addresses of key memory regions at each execution.
What ASLR randomizes:
ASLR entropy (randomness) on x86-64 Linux:
| Region | Entropy Bits | Possible Locations |
|---|---|---|
| Stack | 22 bits | ~4 million locations |
| mmap | 28 bits | ~268 million locations |
| Executable (PIE) | 28 bits | ~268 million locations |
| Heap | 13 bits | ~8,192 locations |
| vDSO | 16 bits | ~65,536 locations |
ASLR makes exploitation harder, not impossible. Information leaks (reading a pointer value) can defeat ASLR. 32-bit systems have insufficient entropy—the entire address space can be brute-forced in minutes. Heap ASLR is weaker than stack/mmap ASLR. Non-PIE executables load at fixed addresses, reducing ASLR effectiveness.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
#!/bin/bash# ASLR Demonstration Script# Shows how addresses change across executions echo "=== ASLR Demonstration ==="echo "" # Check current ASLR settingecho "Current ASLR setting:"cat /proc/sys/kernel/randomize_va_spaceecho " 0 = ASLR disabled"echo " 1 = Stack/libraries randomized"echo " 2 = Stack/libraries/heap randomized (full ASLR)"echo "" # Create a simple test programcat > /tmp/aslr_test.c << 'EOF'#include <stdio.h>#include <stdlib.h> int global_var = 42; int main() { int stack_var = 1; int *heap_var = malloc(sizeof(int)); printf("Executable: %p", (void*)main); printf("Stack: %p", (void*)&stack_var); printf("Heap: %p", (void*)heap_var); printf("Global: %p", (void*)&global_var); printf("libc: %p", (void*)printf); free(heap_var); return 0;}EOF # Compile as PIE (default on modern systems)gcc -pie -o /tmp/aslr_test /tmp/aslr_test.c 2>/dev/null echo "Running same program 5 times (observe address randomization):"echo ""for i in {1..5}; do echo "--- Run $i ---" /tmp/aslr_test echo ""done echo "Notice how addresses change between runs!"echo "The relative positions remain similar, but base addresses differ."echo "" # Demonstrate disabling ASLR for a single processecho "Running with ASLR disabled (setarch -R):"echo "--- Run with ASLR off ---"setarch $(uname -m) -R /tmp/aslr_test echo ""echo "--- Another run with ASLR off ---"setarch $(uname -m) -R /tmp/aslr_test echo ""echo "Addresses are now identical between runs." # Cleanuprm -f /tmp/aslr_test /tmp/aslr_test.cPosition Independent Executables (PIE):
For ASLR to protect the main executable, it must be compiled as a Position Independent Executable (PIE). PIE uses relative addressing instead of absolute addresses, allowing the kernel to load the executable at any address.
# Check if a binary is PIE
file /bin/ls
# Output includes 'pie' if position-independent
# Check with readelf
readelf -h /bin/ls | grep Type
# DYN = PIE, EXEC = non-PIE
# Compile as PIE (usually default)
gcc -pie -fPIE -o program program.c
# Compile as non-PIE (for debugging or special cases)
gcc -no-pie -o program program.c
Impact on memory layout:
With PIE enabled, even the executable's code, data, and BSS segments are randomized. Without PIE, these remain at fixed addresses (typically starting at 0x400000), providing attackers a stable foothold for return-oriented programming (ROP) attacks.
We've explored the complete virtual memory layout of Linux processes on x86-64, from hardware constraints to security mechanisms. Let's consolidate the essential concepts:
You now understand the complete virtual memory layout of Linux processes on x86-64. This knowledge is fundamental for systems programming, debugging, and security work. Next, we'll examine how x86-64 page tables translate these virtual addresses to physical memory—the mechanism that makes this entire abstraction possible.
What's next:
In the next page, we'll dive into the x86-64 page table hierarchy—the 4-level structure that translates virtual addresses to physical frames. We'll examine page table entries, their permission bits, and how the kernel manages this translation infrastructure.