What Is mm_struct in the Linux Kernel? – Free Linux Device Driver Training

← Previous Lecture

Next Lecture →

Linux Process User Virtual Address Space
mm_struct, User Memory Segments, and the NULL Trap Page Explained
Free
Course
~50 min
Read Time
Kernel 6.x
Updated

🎓 What You Will Learn

  • How the user segment of the Linux process virtual address space is organized
  • What each user-space memory region does: text, data, BSS, heap, stack, environment, arguments
  • The role and structure of mm_struct — the kernel’s representation of a process’s memory map
  • What the NULL trap page is, why it exists, and how the kernel uses it to catch NULL pointer dereferences
  • How to inspect a live process’s memory map using /proc/<pid>/maps
  • How to access mm_struct fields from inside a kernel module
  • How user space VAS layout differs between 32-bit and 64-bit, and how ASLR affects it

Topics Covered:

Linux Process Virtual Address Space
mm_struct
User Segment Layout
NULL Trap Page
ASLR
Text Segment
Heap and Stack
/proc/pid/maps
VMA – Virtual Memory Area
Free Linux Kernel Development Course

⚠ Prerequisites

  • Completion of the previous tutorial: Linux Kernel Virtual Address Space – Kernel Segment Deep Dive
  • Basic C programming knowledge (pointers, stack vs heap)
  • Understanding of the ELF binary format is helpful but not required

Linux Process User Virtual Address Space – mm_struct, Segments & the NULL Trap Page

When a program runs on Linux, the operating system creates a virtual address space for it. This address space is divided into two halves: the kernel segment (which we covered in the previous tutorial) and the user segment. The user segment is what the program itself actually uses — it holds the program’s code, data, heap, stack, and any shared libraries it loads.

Understanding the Linux process virtual address space at this level is not just academic. It directly affects how you write safer kernel modules, how you debug memory corruption bugs, and how you understand the output of tools like strace, pmap, and /proc/<pid>/maps. This is a core topic in any serious free Linux kernel development course.

The User Segment at a Glance

The user segment of the Linux process virtual address space is not a single flat region. It is a collection of individually mapped regions, each with its own start address, end address, and permission flags (read, write, execute). These individual regions are called Virtual Memory Areas (VMAs), and the kernel tracks each one as a vm_area_struct inside the process’s mm_struct.

Linux Process User Virtual Address Space – Full Layout (64-bit)
TASK_SIZE − 1
Environment strings & argv strings
Top of user space
env_start / env_end
Process environment variables
env_start → env_end
arg_start / arg_end
Command-line arguments (argv)
arg_start → arg_end
start_stack
Stack ↓ (grows downward)
start_stack (top of stack)
· · · · · · unmapped gap (ASLR randomized) · · · · · ·
mmap base
Memory-mapped files & shared libraries (mmap region)
ld-linux, libc.so, etc.
· · · · · · unmapped gap · · · · · ·
brk (current top)
Heap ↑ (grows upward)
brk grows up with malloc
start_brk
Heap start
start_brk (initial brk)
end_data
BSS segment (zero-initialized globals)
uninitialized globals
start_data / end_data
Data segment (initialized globals)
start_data → end_data
end_code
Text segment (program code — read+execute only)
start_code → end_code
start_code
ELF load base (ASLR randomized on 64-bit)
Program text starts here
· · · · · · unmapped pages · · · · · ·
0x0000 0000
NULL trap page — Page 0 — NO permissions (r/w/x all forbidden)
Catches NULL deref

The diagram above shows the conceptual layout from top to bottom. In reality, ASLR (Address Space Layout Randomization) randomizes the base of the text segment, the mmap region, and the stack each time a program is launched, so the exact addresses differ per run. But the relative ordering of segments remains the same.

Each Region Explained

Text Segment (Code Segment)

The text segment holds the compiled machine code of the program — the actual instructions the CPU executes. It is mapped read-only and execute-only (on architectures and kernels that support execute-only mappings) so that the program cannot accidentally or maliciously overwrite its own code at runtime.

On 64-bit systems with ASLR enabled, the text segment base address is randomized every time the program starts. You can verify this yourself with a simple experiment:

/* show_addr.c – compile and run twice to see ASLR in action */
#include <stdio.h>

void my_function(void) { }

int main(void)
{
    printf("Text (main)       : %p\n", (void *)main);
    printf("Text (my_function): %p\n", (void *)my_function);
    return 0;
}

/* Compile: gcc -o show_addr show_addr.c  */
/* Run twice and compare the addresses    */
✅ Try it: Compile and run show_addr twice. On a system with ASLR enabled (the default on all modern Linux distributions), the printed addresses will differ between runs. To disable ASLR temporarily for debugging: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. Always restore it afterward: echo 2 | sudo tee /proc/sys/kernel/randomize_va_space.

Data Segment and BSS

Global and static variables that are initialized with a non-zero value live in the data segment. Variables that are declared but not initialized (or initialized to zero) live in BSS (Block Started by Symbol). BSS is not stored in the ELF binary on disk — the kernel simply maps zero-filled pages when the program loads. This is why large zero-initialized arrays do not inflate binary sizes.

/* Demonstrating data vs BSS in C */

int global_initialized = 42;   /* Lives in DATA segment */
int global_uninitialized;       /* Lives in BSS segment  */
static int static_zero = 0;    /* Lives in BSS segment  */

int main(void)
{
    /* Local variables live on the STACK (not in data or BSS) */
    int local_var = 10;

    /* Heap-allocated memory comes from the HEAP segment */
    int *heap_ptr = malloc(sizeof(int) * 100);

    printf("data: %p\n", (void *)&global_initialized);
    printf("bss : %p\n", (void *)&global_uninitialized);
    printf("heap: %p\n", (void *)heap_ptr);
    printf("stk : %p\n", (void *)&local_var);

    free(heap_ptr);
    return 0;
}

Heap Segment

The heap is where dynamically allocated memory lives — memory requested via malloc(), calloc(), realloc(), or the brk()/sbrk() system calls directly. The heap grows upward in the virtual address space as more memory is allocated.

The kernel tracks two key addresses for the heap in mm_struct: start_brk (the initial top of the heap when the program was loaded) and brk (the current top of the heap). When malloc() calls brk() or mmap() to get more memory from the OS, the kernel updates these values.

💡 Note: Modern glibc malloc uses mmap() for large allocations (typically > 128 KB by default) rather than extending the heap with brk(). So you will see large allocations appear in the mmap region of /proc/<pid>/maps, not at the heap address.

mmap Region (Shared Libraries and File Mappings)

The mmap region is where the dynamic linker places shared libraries like libc.so, libpthread.so, and your application’s other dependencies. It is also where anonymous mmap() calls for large allocations land, and where explicitly memory-mapped files appear.

This region typically sits between the heap and the stack. On 64-bit systems with ASLR, the base of the mmap region is randomized at program startup.

# See exactly what is mapped in a live process
# Replace 1234 with any real PID (or use $$ for the shell itself)
cat /proc/$$/maps

Each line in /proc/<pid>/maps corresponds to one VMA. The format is:

start-end  perms  offset  dev  inode  pathname

Example output:
55a3c1234000-55a3c1236000 r--p 00000000 08:01 1234567  /usr/bin/bash
55a3c1236000-55a3c1240000 r-xp 00002000 08:01 1234567  /usr/bin/bash
55a3c1240000-55a3c1244000 r--p 0000c000 08:01 1234567  /usr/bin/bash
55a3c1244000-55a3c1245000 r--p 0000f000 08:01 1234567  /usr/bin/bash
55a3c1245000-55a3c1246000 rw-p 00010000 08:01 1234567  /usr/bin/bash
7f8a2c000000-7f8a2c028000 rw-p 00000000 00:00 0        [heap]
7f8a30000000-7f8a301c0000 r-xp 00000000 08:01 987654   /lib/x86_64-linux-gnu/libc.so.6
7ffd12345000-7ffd12367000 rw-p 00000000 00:00 0        [stack]

Stack Segment

The stack holds local variables, function call frames, return addresses, and function arguments. Unlike the heap, the stack grows downward — from a high address toward lower addresses as function calls are made. When a function returns, the stack shrinks back up.

The kernel sets the initial stack pointer to near the top of the user virtual address space. Just above the stack pointer live the command-line argument strings (argv) and environment variable strings, which the kernel copies there before handing control to the program’s entry point.

⚠ Stack overflow: If a process uses more stack space than the limit allows (typically 8 MB by default, controlled by ulimit -s), the kernel sends SIGSEGV to the process. This is what happens with deep or infinite recursion. The OS does not automatically grow the stack beyond its limit.

The NULL Trap Page — Virtual Page Zero

At the very beginning of the user virtual address space — starting at virtual address 0x00000000 — sits a special page called the NULL trap page. This is virtual page zero, and it has no permissions: it is not readable, not writable, and not executable.

The NULL Trap Page – What Happens on a NULL Pointer Dereference

STEP 1 – Buggy code executes
int *ptr = NULL;  *ptr = 42;  /* NULL dereference */

↓

STEP 2 – CPU issues memory access to virtual address 0x0
The CPU’s MMU looks up VAddr 0x0 in the page tables
↓

STEP 3 – Page fault raised by MMU
Page 0 has no valid PTE (not present or no-access). MMU raises a Page Fault exception.
↓

STEP 4 – Kernel page fault handler runs
Kernel checks: is address 0x0 a valid mapping for this process? NO — it is the NULL trap page.
↓

STEP 5 – SIGSEGV delivered to the process
Kernel sends SIGSEGV (signal 11) to the faulting process → process terminates with “Segmentation fault (core dumped)”

The NULL trap page exists specifically to catch one of the most common programming bugs: the NULL pointer dereference. When a C program writes code like int *p = NULL; *p = 5;, the CPU tries to write to virtual address 0. Because page zero has no permissions set in the page tables, the MMU immediately raises a page fault. The kernel’s page fault handler sees that address zero is not a valid mapping, so it sends SIGSEGV to the process, which terminates.

This is an intentional design choice. If page zero were mapped and writable, a NULL pointer bug might silently corrupt data instead of crashing immediately. The NULL trap page makes these bugs crash-and-burn visible, which is far better for debugging than silent corruption.

🚫 Security note: The null trap page also has important security implications. On older 32-bit kernels, it was sometimes possible for user space to map virtual page zero using the mmap() system call with a fixed address of 0. This allowed certain kernel NULL pointer dereference bugs to be exploited as privilege escalation attacks. Modern kernels restrict this via the /proc/sys/vm/mmap_min_addr sysctl, which sets the minimum address that user space is allowed to map. The default is typically 65536 (0x10000) on most distributions.
# Check the mmap_min_addr on your system
cat /proc/sys/vm/mmap_min_addr
# Typical output: 65536

# This means user space cannot map any address below 64 KB
# The NULL trap page and the pages around it are protected

The mm_struct — Kernel’s View of a Process Memory Map

For every process (and every thread group), the Linux kernel maintains a data structure called mm_struct. This structure is the kernel’s complete description of a process’s Linux process virtual address space. It holds pointers to the list of VMAs, the page tables, and a set of fields that record the boundaries of each named segment.

The mm_struct is defined in include/linux/mm_types.h in the kernel source. Let us look at the fields that describe the user segment boundaries:

Field in mm_struct What It Stores
start_code Virtual address where the program’s text segment begins
end_code Virtual address where the text segment ends
start_data Start of the initialized data (DATA) segment
end_data End of the initialized data segment (also start of BSS)
start_brk Initial start of the heap when the process was loaded
brk Current top of the heap (changes as malloc/free runs)
start_stack Virtual address at the top of the main thread’s stack
arg_start Start of command-line argument strings in memory
arg_end End of command-line argument strings
env_start Start of environment variable strings
env_end End of environment variable strings
mmap Pointer to the linked list of vm_area_struct (VMAs)
mm_count Reference count — how many threads share this mm_struct
total_vm Total number of virtual pages mapped in this process
pgd Pointer to the page global directory (top-level page table)

Accessing mm_struct from a Kernel Module

From inside a kernel module, you can access the mm_struct of the current process through the current pointer. The current macro always points to the task_struct of the thread currently running on the CPU. The task_struct has a field mm that points to the process’s mm_struct.

/* kernel_mm_inspect.c – inspect current process mm_struct */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/sched.h>
#include <linux/mm.h>
#include <linux/mm_types.h>

static int __init mm_inspect_init(void)
{
    struct mm_struct *mm;

    /*
     * 'current' is the task_struct of the process/thread
     * that triggered this module's init (usually insmod itself).
     * For a kernel thread, mm is NULL. Always check before use.
     */
    mm = current->mm;

    if (!mm) {
        pr_info("mm_inspect: called from kernel thread — no user mm\n");
        return 0;
    }

    /* Use mmap_read_lock to safely read mm fields */
    mmap_read_lock(mm);

    pr_info("=== mm_struct for PID %d (%s) ===\n",
            current->pid, current->comm);

    pr_info("Text  : 0x%lx – 0x%lx  (%lu KB)\n",
            mm->start_code, mm->end_code,
            (mm->end_code - mm->start_code) / 1024);

    pr_info("Data  : 0x%lx – 0x%lx  (%lu bytes)\n",
            mm->start_data, mm->end_data,
            mm->end_data - mm->start_data);

    pr_info("Heap  : start_brk=0x%lx  current brk=0x%lx\n",
            mm->start_brk, mm->brk);

    pr_info("Stack : start=0x%lx\n", mm->start_stack);

    pr_info("Args  : 0x%lx – 0x%lx  (%lu bytes)\n",
            mm->arg_start, mm->arg_end,
            mm->arg_end - mm->arg_start);

    pr_info("Env   : 0x%lx – 0x%lx  (%lu bytes)\n",
            mm->env_start, mm->env_end,
            mm->env_end - mm->env_start);

    pr_info("Total VMAs  : %d\n", mm->map_count);
    pr_info("Total pages : %lu  (%lu MB)\n",
            mm->total_vm,
            (mm->total_vm * PAGE_SIZE) / (1024 * 1024));

    mmap_read_unlock(mm);
    return 0;
}

static void __exit mm_inspect_exit(void)
{
    pr_info("mm_inspect: unloaded\n");
}

module_init(mm_inspect_init);
module_exit(mm_inspect_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Inspect current process mm_struct");
⚠ Always hold the mmap lock: Before reading fields from mm_struct that describe the VMA list or segment boundaries, acquire mmap_read_lock(mm) (or mmap_write_lock() if you need to modify). This prevents races with other threads that might be calling mmap(), munmap(), or brk() simultaneously. In older kernels (before 5.8) this was down_read(&mm->mmap_sem).

Walking the VMA List from a Kernel Module

Every distinct memory region in a process is described by a vm_area_struct. The kernel maintains them in a red-black tree for fast lookup by address, and also in a linked list for sequential iteration. From a kernel module, you can iterate over all VMAs of a process using the VMA_ITERATOR macro or the older linked-list approach.

/* Walk all VMAs of the current process */
#include <linux/module.h>
#include <linux/mm.h>
#include <linux/mm_types.h>

static void dump_vmas(struct mm_struct *mm)
{
    struct vm_area_struct *vma;
    unsigned long total_kb = 0;
    int count = 0;

    /* mmap_read_lock must already be held by the caller */
    VMA_ITERATOR(vmi, mm, 0);

    pr_info("%-20s %-20s %-6s  %s\n",
            "Start", "End", "Perms", "Name");
    pr_info("----------------------------------------------------\n");

    for_each_vma(vmi, vma) {
        unsigned long size_kb = (vma->vm_end - vma->vm_start) / 1024;
        char perms[5] = "----";

        if (vma->vm_flags & VM_READ)    perms[0] = 'r';
        if (vma->vm_flags & VM_WRITE)   perms[1] = 'w';
        if (vma->vm_flags & VM_EXEC)    perms[2] = 'x';
        if (vma->vm_flags & VM_SHARED)  perms[3] = 's';
        else                             perms[3] = 'p';

        pr_info("0x%016lx-0x%016lx  %s  %6lu KB  %s\n",
                vma->vm_start, vma->vm_end, perms, size_kb,
                vma->vm_file ? vma->vm_file->f_path.dentry->d_name.name
                             : "[anon]");

        total_kb += size_kb;
        count++;
    }

    pr_info("Total: %d VMAs, %lu KB mapped\n", count, total_kb);
}

static int __init vma_walk_init(void)
{
    struct mm_struct *mm = current->mm;
    if (!mm) return 0;

    mmap_read_lock(mm);
    dump_vmas(mm);
    mmap_read_unlock(mm);
    return 0;
}

static void __exit vma_walk_exit(void) { }

module_init(vma_walk_init);
module_exit(vma_walk_exit);
MODULE_LICENSE("GPL");
💡 Kernel 6.1+ change: The VMA iterator API (VMA_ITERATOR and for_each_vma) was introduced in Linux 6.1 as a replacement for the older linked-list iteration. In kernels before 6.1, use for (vma = mm->mmap; vma; vma = vma->vm_next) instead. Always check the kernel version your module targets.

How ASLR Works Inside the Kernel

Address Space Layout Randomization (ASLR) is implemented in the kernel’s ELF loader and mmap() implementation. When a new process is created and an ELF binary is loaded, the kernel picks random offsets for:

  • The text/data segment base (position-independent executables compiled with -fPIE -pie)
  • The mmap base address (where shared libraries and anonymous mappings start)
  • The stack start address (randomized by a few megabytes up or down)

The randomization is controlled by /proc/sys/kernel/randomize_va_space:

Value Behavior
0 ASLR completely disabled (never do this in production)
1 Stack and mmap randomized; heap base not randomized
2 Full ASLR: stack, mmap, heap, and PIE text all randomized (default)
# Check ASLR setting
cat /proc/sys/kernel/randomize_va_space

# See ASLR in action: run the same binary twice and compare maps
cat /proc/$$/maps | grep "r-xp" | head -3
# Open another shell and repeat — different addresses!

Comparing User VAS: 32-bit vs 64-bit

Property 32-bit ARM (3:1 split) 64-bit x86_64
Total user VAS 3 GB (0x0 – 0xBFFFFFFF) ~128 TB (0x0 – 0x7FFFFFFFFFFF)
Default stack size limit 8 MB 8 MB (same)
TASK_SIZE 0xC0000000 0x800000000000
ASLR entropy (mmap) 8 bits (256 positions) 28 bits (>260 million positions)
PIE text randomization 8 bits 28 bits
Number of VMAs (typical) 20–50 30–100+
mmap_min_addr default 32768 or 65536 65536

Practical: Reading a Process VAS from User Space

You do not always need a kernel module to inspect process memory. The /proc filesystem exposes a great deal of this information directly from user space. Here are the most useful files:

# /proc/self refers to the process reading the file (your shell)

# Full VMA list (same as /proc/<pid>/maps)
cat /proc/self/maps

# Detailed VMA list including VM flags and page counts
# (requires kernel >= 3.3 and root for some fields)
sudo cat /proc/self/smaps | head -40

# Compact summary: physical memory usage per VMA
cat /proc/self/smaps_rollup

# Process memory statistics (RSS, VSZ, etc.)
cat /proc/self/status | grep -E "Vm|Threads"

# Efficient binary format of maps (faster than parsing text)
# Used by tools like pmap internally
sudo cat /proc/self/pagemap | xxd | head  # raw binary, not human-readable directly

# pmap: human-friendly view of process address space
pmap -x $$

Common Mistakes and Troubleshooting

  • Dereferencing user pointers in kernel code: If a user-space pointer is passed to a kernel module through a system call or ioctl, never dereference it directly. Use copy_from_user() and copy_to_user(). Direct dereference causes a kernel Oops or panic.
  • Assuming fixed segment addresses: With ASLR enabled, segment addresses change every run. Never hard-code them. Use /proc/self/maps or appropriate library functions at runtime.
  • NULL pointer in kernel code is different from user space: In user space, a NULL pointer dereference hits the NULL trap page and sends SIGSEGV to your process — recoverable at the OS level. In the kernel, a NULL pointer dereference causes a kernel Oops or panic — much more severe.
  • Forgetting to check mm != NULL: Kernel threads have current->mm == NULL because they have no user-space address space. Always check before using it.
  • Not holding mmap_read_lock: Reading VMAs without holding the lock leads to race conditions that produce incorrect results or kernel crashes in SMP environments.

Best Practices When Working with Process VAS

  • Always use get_task_mm() and mmput() when accessing the mm_struct of another task — this properly manages the reference count.
  • Use access_ok() before calling copy_from_user() on a pointer from user space to validate it belongs to the user’s address space.
  • Use find_vma(mm, addr) to find the VMA containing a given address rather than walking the entire list manually.
  • When writing to user space from the kernel, always check the return value of copy_to_user() — a non-zero return means some bytes were not copied because the user pointer became invalid.
  • Keep kernel module code out of the hot path if it walks VMAs — the mmap lock is a reader-writer lock but contention still hurts performance.

Summary and Key Takeaways

Key Takeaways:

  • The Linux process virtual address space user segment contains: text, data, BSS, heap, mmap region, stack, arguments, and environment — in that order from low to high addresses.
  • The NULL trap page (virtual page 0) has no permissions and exists to catch NULL pointer dereferences, turning silent corruption into immediate crashes.
  • mm_struct is the kernel’s complete description of a process’s memory layout, holding segment boundaries, VMA list, page tables, and reference counts.
  • Always hold mmap_read_lock(mm) when reading VMA-related fields from mm_struct to prevent races.
  • ASLR randomizes text, mmap, and stack bases — never hard-code virtual addresses in user or kernel code.
  • /proc/<pid>/maps and /proc/<pid>/smaps are your best tools for inspecting a live process’s virtual address space without writing kernel code.

FAQ: Linux Process User Virtual Address Space

Q: What is the difference between VSZ and RSS in process memory?

VSZ (Virtual Size) is the total size of the virtual address space mapped for a process — the sum of all VMA sizes. RSS (Resident Set Size) is how much of that virtual space is actually backed by physical RAM right now. VSZ is always larger than RSS because Linux uses demand paging — pages are only loaded into physical RAM when actually accessed.

Q: What happens to the VAS when a process calls fork()?

The child process gets an exact copy of the parent’s virtual address space (same VMA layout, same page table structure). However, Linux uses Copy-on-Write (CoW) — the physical pages are shared between parent and child until one of them writes to a page, at which point the kernel creates a private copy for the writer. This makes fork() very fast regardless of how much memory the parent has allocated.

Q: Can I map virtual address 0 in Linux?

On modern Linux systems, no. The /proc/sys/vm/mmap_min_addr sysctl (typically set to 65536) prevents user space from mapping any address below that threshold. This protects against kernel NULL pointer dereference exploits. With special capabilities (CAP_SYS_RAWIO), the restriction can be bypassed, but this is not recommended for security.

Q: What is a VMA (Virtual Memory Area)?

A VMA is a contiguous range of virtual addresses in a process’s address space that share the same attributes: permissions (read/write/execute), backing (file-backed or anonymous), and mapping flags. The kernel represents each VMA as a vm_area_struct. Each line in /proc/<pid>/maps corresponds to exactly one VMA.

Q: Why does the heap sometimes have many small VMAs instead of one large one?

When malloc() uses mmap() for large allocations (rather than extending the brk heap), each such allocation appears as a separate anonymous VMA. When it is freed with free(), the kernel calls munmap() which removes that VMA. This is why the VMA count for a long-running process with lots of large allocations can be much higher than expected.

Q: What is the difference between task_struct->mm and task_struct->active_mm?

For user-space processes, both mm and active_mm point to the same mm_struct. For kernel threads, mm is NULL (no user space), but active_mm points to the mm of the last user process that ran on the same CPU. This allows the kernel thread to borrow the page table for the kernel mappings without maintaining its own. Never use active_mm in module code — always use mm after checking it is not NULL.

Q: How do I check how many VMAs a process has?

From user space: cat /proc/<pid>/maps | wc -l gives the VMA count. From kernel code: mm->map_count holds the count directly. There is also a kernel-wide limit on VMAs per process controlled by /proc/sys/vm/max_map_count (default 65530). Exceeding it causes mmap() to return ENOMEM.

Outbound References

Learn Linux Kernel Development for Free

EmbeddedPathashala is a completely free embedded systems and Linux kernel development course platform. No ads, no fees, no paywalls — ever.

Browse All Courses

 

Leave a Reply

Your email address will not be published. Required fields are marked *