虚拟内存管理:进程地址空间、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 触发缺页异常,内核接管处理。缺页异常分三种情况:
- 有效但未分配物理页——比如堆区首次访问,内核分配物理页并建立映射。
- 有效但页面被换出——页面在交换分区,需要换入。
- 无效访问——地址不在任何 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 确认合法性 → 查页表找物理页 → 缺页则触发异常处理 → 分配物理页并建立映射。每一步都有对应的内核函数和数据结构。
实战建议:调试内存问题时,先看 /proc/<pid>/maps 确认 VMA 布局,再看 /proc/<pid>/smaps 看每个 VMA 的详细统计(RSS、PSS、脏页数)。这两个文件能帮你快速定位问题区域。
虚拟内存管理是 Linux 内核最精巧的部分之一。理解 VMA、缺页异常、COW 和页面分类,你就能看懂大部分内存相关的问题。下一层我们深入物理内存管理,看看伙伴系统和 slab 分配器怎么工作。