虚拟内存管理:进程地址空间、VMA、缺页异常、匿名页、文件页、写时复制、内存映射文件

虚拟内存管理,说白了就是给每个进程一张「假地图」。进程以为自己拥有 4GB 的连续内存,实际上物理内存可能只有 256MB。这张地图怎么画、怎么用、出错了怎么修——就是本章要聊的核心。

我早年调试一个视频监控设备时,遇到过进程莫名其妙被 OOM killer 干掉的情况。查了三天,最后发现是 VMA 合并出了问题,导致大量匿名页无法回收。嗯,从那以后我对虚拟内存的每个细节都不敢马虎了。

进程地址空间布局

每个进程在 32 位系统下拥有 4GB 虚拟地址空间。Linux 将其分为用户空间和内核空间,比例通常是 3:1 或 2:2。你想想看,这 3GB 的用户空间里,代码段、数据段、堆、栈、内存映射区各占一块,布局是固定的。

区域 起始地址(典型) 用途
代码段(.text) 0x08048000 可执行指令,只读
数据段(.data/.bss) 0x08049000 全局变量、静态变量
堆(heap) brk 指针之上 动态分配(malloc)
内存映射区 靠近栈底 共享库、mmap 文件
栈(stack) 0xBFFFFFFF 向下 局部变量、函数调用

我个人习惯在调试时先看 /proc/<pid>/maps,这个文件把整个地址空间摊开给你看。每一行就是一个 VMA,清清楚楚。

VMA:虚拟内存区域的基石

VMA(struct vm_area_struct)是内核管理进程地址空间的基本单位。每个 VMA 描述一段连续的虚拟地址区间,包含起始地址、结束地址、权限标志、关联的文件等信息。

// 内核源码:include/linux/mm_types.h
struct vm_area_struct {
    unsigned long vm_start;      // 起始地址
    unsigned long vm_end;        // 结束地址
    struct mm_struct *vm_mm;     // 所属进程
    unsigned long vm_flags;      // 权限标志(读/写/执行/共享)
    struct file *vm_file;        // 关联的文件(文件页时有效)
    unsigned long vm_pgoff;      // 文件内的偏移
    struct list_head anon_vma_chain; // 匿名页链
};

VMA 之间不能重叠,内核用红黑树 + 双向链表来管理它们。查找一个地址属于哪个 VMA,时间复杂度是 O(log n)。

关键点:VMA 的 vm_flags 决定了页面的行为。VM_SHARED 表示共享映射,VM_WRITE 表示可写,VM_EXEC 表示可执行。缺页异常处理时,内核会根据这些标志决定如何分配物理页。

缺页异常:虚拟内存的发动机

进程访问一个虚拟地址时,MMU 会查页表。如果页表项无效(present bit 为 0),CPU 触发缺页异常,内核接管处理。缺页异常分三种情况:

  1. 有效但未分配物理页——比如堆区首次访问,内核分配物理页并建立映射。
  2. 有效但页面被换出——页面在交换分区,需要换入。
  3. 无效访问——地址不在任何 VMA 内,触发 SIGSEGV。

我在项目中遇到过一种坑:用户态程序访问了已释放的 mmap 区域,内核返回 SIGSEGV,但程序没有注册信号处理函数,直接崩溃。后来我建议团队在所有 mmap 使用后加 mprotect 保护,提前暴露问题。

匿名页 vs 文件页

物理页面按来源分为两类:

类型 来源 回写方式 典型场景
匿名页 堆、栈、写时复制产生的私有页 交换分区(swap) malloc、栈溢出
文件页 映射到文件的内容 回写磁盘(脏页) mmap 文件、共享库

匿名页没有磁盘上的「家」,只能靠 swap 换出。文件页有对应的磁盘文件,脏页可以写回。内核在内存紧张时,优先回收文件页的干净页,因为不需要写回,直接丢弃就行。

调试技巧:用 /proc/meminfo 查看 AnonPages 和 FilePages 的比例。如果 AnonPages 持续增长且 swap 使用率上升,说明程序有内存泄漏或分配不合理。

写时复制(COW)

写时复制是 Linux 最巧妙的设计之一。fork() 创建子进程时,父子进程共享所有物理页,页表项标记为只读。当一方尝试写入时,触发缺页异常,内核复制物理页,然后分别映射。

为什么会这样?因为 fork 后立即 exec 的场景太常见了,如果提前复制所有页,浪费时间和内存。COW 把复制推迟到真正写入的那一刻。

// 缺页异常处理中的 COW 路径(简化)
static vm_fault_t do_wp_page(struct vm_fault *vmf) {
    struct page *old_page = vmf->page;
    struct page *new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, ...);
    
    // 复制旧页内容到新页
    copy_user_highpage(new_page, old_page, vmf->address, vmf->vma);
    
    // 建立新映射,标记为可写
    page_add_new_anon_rmap(new_page, vmf->vma, vmf->address);
    // 释放旧页引用
    put_page(old_page);
    return VM_FAULT_WRITE;
}

我记得有一次调试一个多进程服务,发现内存占用是预期的两倍。查了半天,原来是子进程 fork 后没有立即 exec,而是修改了大量全局变量,触发了 COW,每个子进程都复制了一份完整的堆数据。解决方案是在 fork 前先分配好共享内存池。

注意:COW 虽然节省了 fork 时的开销,但频繁的写时复制会导致大量缺页异常和页面分配。对于高性能场景,考虑使用 vfork 或 posix_spawn 替代 fork。

内存映射文件

mmap 系统调用把文件的一部分直接映射到进程地址空间。映射后,读写这段内存就像读写文件一样,内核负责在后台同步脏页。

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

映射类型有两种:

  • 私有映射(MAP_PRIVATE):写操作触发 COW,不影响原文件。常用于加载可执行文件和共享库。
  • 共享映射(MAP_SHARED):写操作直接修改物理页,内核定期写回文件。多进程间通过共享映射实现 IPC。

我建议在需要频繁读写大文件时,优先考虑 mmap 而不是 read/write。mmap 省去了用户态和内核态之间的数据拷贝,性能提升明显。但要注意,mmap 映射的区域会占用虚拟地址空间,32 位系统下地址空间有限,别映射太多。

知识体系总览

下面这张图把本章的核心概念串起来了。你想想看,从进程地址空间到物理页,中间经过了 VMA、页表、缺页异常三层抽象。

虚拟内存管理核心流程 进程地址空间 代码/数据/堆/栈/映射区 VMA 红黑树 vm_start / vm_end / vm_flags 页表(MMU) PGD → PUD → PMD → PTE 缺页异常处理 do_page_fault() 物理页面 匿名页 / 文件页 关键路径 • 缺页异常是虚拟内存的入口 • VMA 决定访问权限和类型 • 页表完成虚拟→物理转换 页面分类 匿名页:堆、栈、COW 私有页 文件页:mmap 文件、共享库 脏页:需要写回磁盘 干净页:可直接丢弃

从这张图可以清楚看到:进程访问虚拟地址 → 查 VMA 确认合法性 → 查页表找物理页 → 缺页则触发异常处理 → 分配物理页并建立映射。每一步都有对应的内核函数和数据结构。

实战建议:调试内存问题时,先看 /proc/<pid>/maps 确认 VMA 布局,再看 /proc/<pid>/smaps 看每个 VMA 的详细统计(RSS、PSS、脏页数)。这两个文件能帮你快速定位问题区域。

虚拟内存管理是 Linux 内核最精巧的部分之一。理解 VMA、缺页异常、COW 和页面分类,你就能看懂大部分内存相关的问题。下一层我们深入物理内存管理,看看伙伴系统和 slab 分配器怎么工作。