mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4807 字
13 分钟
虚拟内存与 VMA
2024-12-30

一、引言:从物理页帧到虚拟世界#

第 6 章中,我们深入剖析了 Linux 如何管理物理内存——伙伴系统分配连续页帧、Slub 分配器处理小对象、kswapd 在内存紧张时回收页面。但这一切对用户进程而言是完全不可见的。当你的程序通过 malloc() 申请内存时,它拿到的是一个虚拟地址,而非物理地址。当程序访问一个从未写入的变量时,CPU 产生的缺页异常会穿越多层抽象,最终由内核在幕后完成物理页帧的分配与映射。

虚拟内存是现代操作系统最精妙的发明之一。它为每个进程营造了一个”独占整台机器内存”的幻觉,同时实现了内存保护(进程间互不干扰)、按需分配(用多少给多少)、内存超额承诺(承诺的总量可以超过物理内存)和内存共享(不同进程映射同一物理页)。这些能力的基础,正是本章要深入剖析的核心数据结构——mm_structvm_area_struct

本章将从进程地址空间的全景布局出发,逐层拆解虚拟内存管理的核心机制:VMA 的组织与查找、多级页表的实现、缺页异常的完整处理流程、写时复制的精妙设计,以及 mmap 系统调用的深度解析。

flowchart TB subgraph 进程地址空间 A[用户栈<br/>VM_GROWSDOWN] --> B[mmap 区域<br/>VMAs] B --> C[堆<br/>VM_GROWSUP] C --> D[BSS 段] D --> E[数据段] E --> F[代码段<br/>VM_EXEC] end subgraph 虚拟内存核心机制 G[mm_struct<br/>地址空间描述符] H[vm_area_struct<br/>虚拟内存区域] I[多级页表<br/>PGD→P4D→PUD→PMD→PTE] J[缺页异常处理<br/>do_page_fault] K[写时复制 COW<br/>do_wp_page] L[mmap 系统调用<br/>文件/匿名映射] end G --> H H --> I I --> J J --> K L --> H style A fill:#e74c3c,color:#fff style G fill:#3498db,color:#fff style J fill:#e67e22,color:#fff style K fill:#9b59b6,color:#fff

二、进程地址空间全景#

1.1 虚拟地址空间布局#

在 64 位 Linux 系统上,每个进程拥有一个庞大的虚拟地址空间。x86-64 架构下,虚拟地址为 48 位(或 57 位,启用 5 级页表时),用户空间占低 128 TB,内核空间占高 128 TB。典型的进程地址空间布局如下:

高地址 ┌──────────────────────────────┐ 0xFFFFFFFFFFFFFFFF
│ 内核空间 │ (所有进程共享)
│ (128 TB,用户态不可访问) │
├──────────────────────────────┤ 0x7FFFFFFFFFFF
│ ┌───┐ │
│ │栈↓│ VM_GROWSDOWN │ ← 栈向低地址增长
│ └───┘ │
│ │
│ ┌──────────────┐ │
│ │ mmap 区域 │ │ ← 共享库、mmap 映射
│ └──────────────┘ │
│ │
│ ┌───┐ │
│ │堆↑│ VM_GROWSUP │ ← 堆向高地址增长
│ └───┘ │
│ │
│ ┌──────────────┐ │
│ │ BSS 段 │ │ ← 未初始化全局变量
│ ├──────────────┤ │
│ │ 数据段 │ │ ← 已初始化全局变量
│ ├──────────────┤ │
│ │ 代码段 │ VM_EXEC │ ← 可执行指令
│ └──────────────┘ │
低地址 ├──────────────────────────────┤ 0x000000000000
│ 不可访问(空指针捕获区) │
└──────────────────────────────┘
Note

栈的增长方向因架构而异。x86-64 上栈向低地址增长(VM_GROWSDOWN),而某些架构(如 PA-RISC)栈向高地址增长(VM_GROWSUP)。这种差异通过 VMA 的 flags 统一抽象,内核代码无需关心具体方向。

1.2 地址空间的”空洞”#

关键认知:虚拟地址空间并非连续映射。在代码段与堆之间、堆与 mmap 区域之间、mmap 区域与栈之间,存在大量未映射的”空洞”。访问这些空洞会触发缺页异常,内核发现没有对应的 VMA 后,将向进程发送 SIGSEGV 信号。

这正是空指针崩溃的原理——地址 0 附近(通常 0 到 4KB 之间)没有被任何 VMA 覆盖,解引用空指针时 CPU 触发缺页异常,内核在 VMA 树中找不到对应区域,于是发送 SIGSEGV 终止进程。

三、mm_struct:进程地址空间的”户口本”#

2.1 核心数据结构#

mm_struct 是进程整个虚拟地址空间的描述符,每个进程(或更准确地说,每个地址空间)对应一个 mm_struct 实例。它记录了地址空间的所有关键信息:

include/linux/mm_types.h
struct mm_struct {
struct {
struct vm_area_struct *mmap; // VMA 链表头(按地址排序)
struct rb_root mm_rb; // VMA 红黑树根
u64 vmacache_seqnum; // VMA 缓存序列号
unsigned long mmap_base; // mmap 区域起始地址
unsigned long mmap_legacy_base; // 旧式 mmap 基地址
unsigned long task_size; // 用户空间大小
unsigned long highest_vm_end; // 最高 VMA 结束地址
pgd_t *pgd; // 页全局目录(页表根)
atomic_t mm_users; // 用户计数(共享地址空间的线程数)
atomic_t mm_count; // 引用计数(mm_struct 本身)
int map_count; // VMA 数量
spinlock_t page_table_lock; // 页表自旋锁
struct rw_semaphore mmap_lock; // VMA 读写信号量
unsigned long start_code, end_code; // 代码段范围
unsigned long start_data, end_data; // 数据段范围
unsigned long start_brk, brk; // 堆范围
unsigned long start_stack; // 栈起始地址
unsigned long arg_start, arg_end; // 命令行参数
unsigned long env_start, env_end; // 环境变量
// ...
};
};

2.2 mm_struct 与 task_struct 的关系#

每个 task_struct 通过 mmactive_mm 两个指针与 mm_struct 关联:

struct task_struct {
struct mm_struct *mm; // 用户进程的地址空间
struct mm_struct *active_mm; // 内核线程使用的"借来"的地址空间
// ...
};
  • 用户进程/线程mm 指向自己的 mm_structactive_mm 通常与 mm 相同
  • 内核线程mmNULL(内核线程没有用户地址空间),active_mm 指向前一个进程的 mm_struct(惰性 TLB 切换优化)
Tip

内核线程使用 active_mm 是一项重要的性能优化——内核线程切换时无需切换页表(CR3 寄存器),因为内核空间在所有进程页表中映射相同。这避免了 TLB 全局刷新的开销,称为”惰性 TLB”(Lazy TLB)技术。

2.3 引用计数:mm_users vs mm_count#

mm_struct 有两个引用计数,容易混淆:

字段含义何时递增何时递减
mm_users共享此地址空间的用户(线程)数clone(CLONE_VM) 创建线程线程退出
mm_countmm_struct 本身的引用mm_init()/proc 读取等mm_users 归零时 mmput() 释放

mm_users 降为 0 时,内核释放地址空间中的所有 VMA 和页表,但 mm_struct 本身可能还被 /proc 等内核子系统引用。只有 mm_count 也降为 0 时,mm_struct 结构体本身才被回收。

四、vm_area_struct:虚拟内存区域#

3.1 VMA 的本质#

vm_area_struct(简称 VMA)描述了进程地址空间中一段连续的、具有相同属性的虚拟内存区域。一个进程的地址空间由若干个 VMA 组成,每个 VMA 代表一段用途相同的内存区间——代码段、数据段、堆、栈、mmap 映射区等。

include/linux/mm_types.h
struct vm_area_struct {
unsigned long vm_start; // 区域起始虚拟地址(含)
unsigned long vm_end; // 区域结束虚拟地址(不含)
// 区间为 [vm_start, vm_end)
struct mm_struct *vm_mm; // 所属的 mm_struct
pgprot_t vm_page_prot; // 默认页保护属性
unsigned long vm_flags; // 区域标志位
struct rb_node vm_rb; // 红黑树节点
struct {
struct rb_node rb; // 红黑树节点(新版本)
unsigned long rb_subtree_last; // 子树中最大的 vm_end
} shared;
struct list_head vm_list; // 链表节点(按地址排序)
const struct vm_operations_struct *vm_ops; // VMA 操作函数表
unsigned long vm_pgoff; // 文件映射偏移(以页为单位)
struct file *vm_file; // 关联的文件(匿名映射为 NULL)
// ...
};

3.2 VMA 标志位#

vm_flags 定义了 VMA 的行为属性,这些标志决定了内核如何处理该区域的缺页异常、共享和保护:

标志含义
VM_READ0x00000001可读
VM_WRITE0x00000002可写
VM_EXEC0x00000004可执行
VM_SHARED0x00000008共享映射(否则为私有)
VM_MAYREAD0x00000010可能可读(mprotect 可改)
VM_MAYWRITE0x00000020可能可写
VM_MAYEXEC0x00000040可能可执行
VM_GROWSDOWN0x00000100向低地址增长(栈)
VM_GROWSUP0x00000200向高地址增长(PA-RISC 栈)
VM_PFNMAP0x00000400页帧号映射(非 struct page)
VM_DENYWRITE0x00000800拒绝写打开
VM_LOCKED0x00002000mlock 锁定(不可换出)
VM_IO0x00004000I/O 设备映射
VM_STACK0x04000000栈区域
Note

VM_READ/VM_WRITE/VM_EXEC 与页表项的权限位是不同层次的概念。VMA 标志描述的是”这个区域应该有什么权限”,而页表项的 R/W/X 位描述的是”这个页当前的硬件权限”。例如,写时复制(COW)场景下,VMA 有 VM_WRITE 标志,但对应的页表项 R/W 位为 0(只读),写入时触发缺页异常,内核再处理 COW。

3.3 VMA 的组合语义#

VMA 标志的组合决定了映射的本质:

组合含义典型场景
VM_READ | VM_EXEC只读可执行代码段
VM_READ | VM_WRITE可读写数据段、堆、BSS
VM_READ | VM_WRITE | VM_GROWSDOWN可向下增长用户栈
VM_READ | VM_WRITE | VM_SHARED共享可写共享内存、MAP_SHARED
VM_READ | VM_EXEC | VM_MAYWRITE可读可执行,可能可写代码段(允许调试器设断点)

3.4 vm_operations_struct:VMA 的行为接口#

vm_operations_struct 为 VMA 提供了一组操作回调,允许不同类型的映射定义自己的行为:

include/linux/mm.h
struct vm_operations_struct {
void (*open)(struct vm_area_struct *); // VMA 被创建/复制时调用
void (*close)(struct vm_area_struct *); // VMA 被销毁时调用
vm_fault_t (*fault)(struct vm_fault *); // 缺页异常处理(核心!)
vm_fault_t (*page_mkwrite)(struct vm_fault *); // 只读页变可写时调用
void (*map_pages)(struct vm_fault *); // 批量映射周围页(预读优化)
int (*access)(struct vm_area_struct *, unsigned long, void*, int, int); // ptrace 访问
};

对于文件映射vm_ops->fault 通常指向 filemap_fault(),它从页缓存中查找或读取文件页;对于匿名映射vm_ops 通常为 NULL,缺页异常由内核的匿名页处理逻辑直接处理。

五、VMA 的组织与查找#

4.1 双重数据结构:链表 + 红黑树#

Linux 为 VMA 维护了两种数据结构,兼顾不同操作的性能需求:

  • 链表mm->mmap):VMA 按 vm_start 地址升序链接。适合顺序遍历,如 /proc/pid/maps 的输出
  • 红黑树mm->mm_rb):VMA 按 vm_start 作为键组织在红黑树中。适合查找操作,时间复杂度 O(log n)
// mm/mmap.c 中的查找实现
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
struct vm_area_struct *vma;
// 先查 VMA 缓存(快速路径)
vma = vmacache_find(mm, addr);
if (likely(vma))
return vma;
// 缓存未命中,查红黑树
vma = rb_entry(mm->mm_rb.rb_node, struct vm_area_struct, vm_rb);
while (vma) {
if (addr < vma->vm_start) {
vma = rb_entry(vma->vm_rb.rb_left, struct vm_area_struct, vm_rb);
} else if (addr >= vma->vm_end) {
vma = rb_entry(vma->vm_rb.rb_right, struct vm_area_struct, vm_rb);
} else {
break; // addr 在 [vm_start, vm_end) 内
}
}
if (vma)
vmacache_update(addr, vma); // 更新缓存
return vma;
}

4.2 VMA 缓存(vmacache)#

每次 find_vma() 都遍历红黑树开销较大,Linux 引入了每线程 VMA 缓存(vmacache),缓存最近查找的 4 个 VMA:

include/linux/mm_types.h
struct vmacache {
u64 seqnum; // 序列号,用于失效检测
struct vm_area_struct *vmas[VMACACHE_SIZE]; // 4 个缓存槽位
};

mm_struct 的 VMA 发生变化(插入、删除、合并)时,vmacache_seqnum 递增,所有线程的 vmacache 自动失效。这个设计利用了局部性原理——进程的内存访问通常集中在少数几个 VMA 中。

4.3 VMA 的合并#

当新的映射与相邻 VMA 的属性完全一致时,内核会尝试合并它们,而非创建新的 VMA。合并条件包括:

  1. 相邻且地址连续
  2. vm_flags 相同
  3. 同一文件且偏移连续(文件映射)
  4. 同一 vm_ops
  5. 同一 anon_vma(匿名映射)

合并操作在 vma_merge() 中实现,它显著减少了 VMA 的数量,降低了红黑树和链表的开销。

六、多级页表:从虚拟地址到物理地址#

5.1 为什么需要多级页表?#

最朴素的页表设计是单级页表:用一个数组直接索引所有虚拟页号。对于 48 位地址空间、4KB 页大小,需要 2^36 个页表项——每个 8 字节,总计 512 GB!这显然不可行。

多级页表通过按需分配解决了这个问题:只有实际使用的虚拟地址范围才需要分配页表项,未使用的部分根本不分配页表页。对于典型的进程,实际映射的虚拟内存远小于 128 TB,多级页表只需几 MB 甚至几 KB。

5.2 四级页表(x86-64 默认)#

x86-64 默认使用四级页表,将 48 位虚拟地址分为 5 部分:

48位虚拟地址
┌──────────┬──────────┬──────────┬──────────┬──────────────┐
│ PGD索引 │ PUD索引 │ PMD索引 │ PTE索引 │ 页内偏移 │
│ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │
└──────────┴──────────┴──────────┴──────────┴──────────────┘
47 39 38 30 29 21 20 12 11 0

地址翻译过程:

// 伪代码:四级页表地址翻译
phys_addr_t translate(virt_addr_t addr, pgd_t *pgd)
{
pgd_entry = pgd[PGD_INDEX(addr)]; // 第1级:页全局目录
if (!pgd_entry.present) -> 缺页异常
pud_entry = pud_of(pgd_entry)[PUD_INDEX(addr)]; // 第2级:页上级目录
if (!pud_entry.present) -> 缺页异常
pmd_entry = pmd_of(pud_entry)[PMD_INDEX(addr)]; // 第3级:页中间目录
if (!pmd_entry.present) -> 缺页异常
pte_entry = pte_of(pmd_entry)[PTE_INDEX(addr)]; // 第4级:页表项
if (!pte_entry.present) -> 缺页异常
return pte_entry.pfn << 12 | (addr & 0xFFF); // 物理地址
}

每一级页表包含 512 个条目(2^9 = 512),每个条目 8 字节,恰好占一个 4KB 页。

5.3 五级页表(Linux 4.11+)#

随着物理内存容量的增长,Linux 4.11 引入了五级页表(P4D 层),支持 57 位虚拟地址:

57位虚拟地址
┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┐
│ PGD索引 │ P4D索引 │ PUD索引 │ PMD索引 │ PTE索引 │ 页内偏移 │
│ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘
56 48 47 39 38 30 29 21 20 12 11 0

五级页表将虚拟地址空间从 256 TB 扩展到 128 PB,满足了大型数据库和内存密集型应用的需求。Linux 内核通过 CONFIG_PGTABLE_LEVELS 配置页表级数,代码中始终保留 P4D 层的遍历逻辑——在四级页表配置下,P4D 层被编译为空操作(直接返回 PGD 条目),不增加运行时开销。

5.4 大页(Huge Pages)与透明大页#

标准 4KB 页在处理大内存时会产生大量页表项,增加 TLB 失效。Linux 支持两种大页机制:

  • 透明大页(THP, Transparent Huge Pages):2MB 大页,内核自动将连续的 4KB 页合并为 2MB 大页,对应用透明。PMD 条目直接指向 2MB 物理页帧,跳过 PTE 层
  • Hugetlbfs:1GB 大页,需要应用显式使用,通过 mmap(MAP_HUGETLB) 或 hugetlbfs 文件系统分配

大页将 TLB 覆盖范围从 4KB 提升到 2MB 或 1GB,显著减少 TLB miss,对数据库、虚拟化等大内存工作负载至关重要。

七、缺页异常处理:虚拟内存的核心引擎#

缺页异常是虚拟内存系统的核心驱动力。当 CPU 访问的虚拟地址没有有效的页表映射时,触发缺页异常,内核介入完成映射的建立。正是这种”先承诺,后兑现”的机制,实现了按需分配和写时复制。

6.1 缺页异常的触发条件#

缺页异常在以下情况触发:

  1. 页表项不存在(Present 位为 0):虚拟地址从未映射,或页面已被换出
  2. 写权限违规:对只读页执行写操作(COW 场景)
  3. 访问权限违规:用户态访问内核页,或对不可执行页执行指令
  4. 栈自动扩展:访问栈 VMA 以下的保护页

6.2 缺页异常处理完整流程#

flowchart TD A["CPU 访问虚拟地址"] --> B{"页表项存在?"} B -->|是| C{"权限检查通过?"} B -->|否| D["触发缺页异常<br/>do_page_fault()"] C -->|是| E["正常访问"] C -->|否| D D --> F["find_vma() 查找 VMA"] F --> G{"找到 VMA?"} G -->|否| H["SIGSEGV<br/>段错误"] G -->|是| I{"VMA 权限检查"} I -->|不通过| H I -->|通过| J["handle_mm_fault()"] J --> K["遍历页表<br/>PGD→P4D→PUD→PMD"] K --> L{"PMD 是大页?"} L -->|是| M["handle_pmd_fault()"] L -->|否| N["handle_pte_fault()"] N --> O{"PTE 存在?"} O -->|否| P{"匿名页?"} P -->|是| Q["do_anonymous_page()<br/>分配匿名页"] P -->|否| R["do_fault()<br/>文件映射缺页"] O -->|是| S{"写只读页?"} S -->|是| T["do_wp_page()<br/>写时复制"] S -->|否| U["异常情况<br/>SIGSEGV"] Q --> V["建立页表映射<br/>返回用户态"] R --> V T --> V M --> V style D fill:#e74c3c,color:#fff style H fill:#c0392b,color:#fff style Q fill:#27ae60,color:#fff style T fill:#9b59b6,color:#fff style R fill:#3498db,color:#fff

6.3 do_page_fault():入口函数#

x86 架构下,CPU 缺页异常由 do_page_fault() 处理:

// arch/x86/mm/fault.c(简化)
static noinline void __do_page_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
struct vm_area_struct *vma;
struct task_struct *tsk = current;
struct mm_struct *mm = tsk->mm;
// 1. 检查是否在中断上下文中
if (unlikely(fault_in_kernel_space(address))) {
// 内核空间缺页,特殊处理
vmalloc_fault(address);
return;
}
// 2. 获取 mmap_lock 读锁
if (mmap_read_trylock(mm)) {
// 3. 查找 address 所在的 VMA
vma = find_vma(mm, address);
if (unlikely(!vma))
goto bad_area; // 没有对应 VMA → SIGSEGV
// 4. 检查 address 是否在 VMA 范围内
if (likely(vma->vm_start <= address))
goto good_area;
// 5. 可能是栈扩展
if (unlikely(expand_stack(vma, address)))
goto bad_area;
good_area:
// 6. 检查访问权限
if (unlikely(access_error(error_code, vma)))
goto bad_area;
// 7. 调用核心处理函数
handle_mm_fault(vma, address, flags);
}
}

6.4 handle_mm_fault():分发中心#

handle_mm_fault() 负责遍历页表,将缺页异常分发到具体的处理函数:

// mm/memory.c(简化)
vm_fault_t handle_mm_fault(struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
pgd_t *pgd;
p4d_t *p4d;
pud_t *pud;
pmd_t *pmd;
// 遍历页表各级目录,按需分配中间页表页
pgd = pgd_offset(mm, address);
p4d = p4d_alloc(mm, pgd, address);
pud = pud_alloc(mm, p4d, address);
pmd = pmd_alloc(mm, pud, address);
// 到达 PMD 层,判断是大页还是普通页
return handle_pmd_fault(vma, address, pmd, flags);
}

6.5 handle_pte_fault():分类处理#

handle_pte_fault() 根据 PTE 的状态将缺页异常分为几大类:

// mm/memory.c(简化)
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
if (!vmf->pte) {
// PTE 不存在:需要分配新页
if (!vmf->vma->vm_file) {
// 匿名映射缺页
return do_anonymous_page(vmf);
} else {
// 文件映射缺页
return do_fault(vmf);
}
}
if (!pte_present(*vmf->pte)) {
// PTE 存在但页不在内存中(已换出)
return do_swap_page(vmf);
}
if (vmf->flags & FAULT_FLAG_WRITE) {
// 写只读页:写时复制
return do_wp_page(vmf);
}
// 其他异常
return VM_FAULT_SIGSEGV;
}

6.6 匿名页缺页:do_anonymous_page()#

当进程首次访问 malloc() 分配的内存(通过 brkmmap 扩展的堆区域)时,触发匿名页缺页:

// mm/memory.c(简化)
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
if (vmf->flags & FAULT_FLAG_WRITE) {
// 写访问:分配新物理页
struct page *page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
entry = pte_mkwrite(pte_mkdirty(mk_pte(page, vma->vm_page_prot)));
} else {
// 只读访问:映射到零页(zero page)
// 零页是一个全局共享的只读页,内容全零
entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
vma->vm_page_prot));
}
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
return VM_FAULT_NOPAGE;
}
Note

零页优化是 Linux 的一个巧妙设计:对于只读的匿名页访问,内核不分配物理页帧,而是将 PTE 指向一个全局共享的零页。这节省了大量物理内存——程序中未初始化的全局变量(BSS 段)在首次读取时都映射到零页,只有在写入时才分配真正的物理页。

6.7 文件映射缺页:do_fault()#

文件映射缺页分为三种情况:

  1. 读缺页do_read_fault):从文件的页缓存中查找或读取页面
  2. 写私有映射缺页do_cow_fault):读取文件页 + 写时复制
  3. 写共享映射缺页do_shared_fault):读取文件页 + 标记脏页
// mm/memory.c(简化)
static vm_fault_t do_fault(struct vm_fault *vmf)
{
if (!vmf->pte) {
// PTE 不存在
if (vmf->flags & FAULT_FLAG_WRITE) {
if (!(vmf->vma->vm_flags & VM_SHARED))
return do_cow_fault(vmf); // 私有写 → COW
else
return do_shared_fault(vmf); // 共享写 → 直接写
} else {
return do_read_fault(vmf); // 只读 → 读文件页
}
}
// ...
}

do_read_fault() 的核心逻辑是调用 vm_ops->fault(),对于 ext4 等文件系统,最终调用 filemap_fault(),它先在页缓存中查找,未命中则从磁盘读取。

八、写时复制(COW):fork 的性能魔法#

7.1 COW 的核心思想#

第 3 章中,提到 fork() 使用写时复制来避免复制整个地址空间。COW 的核心思想是:共享直到修改

fork() 时,子进程与父进程共享所有物理页帧,但将所有可写页的 PTE 标记为只读。当任一方尝试写入时,CPU 触发缺页异常,内核复制该页,为写入方分配新的物理页帧。

7.2 do_wp_page():COW 的实现#

// mm/memory.c(简化)
static vm_fault_t do_wp_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct page *old_page = vmf->page;
// 1. 检查是否可以复用当前页
if (page_count(old_page) == 1 && !PageSwapCache(old_page)) {
// 只有一个映射者,直接修改权限即可,无需复制
pte_unmap_unlock(vmf->pte, vmf->ptl);
return VM_FAULT_WRITE;
}
// 2. 分配新页并复制内容
struct page *new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
copy_user_highpage(new_page, old_page, vmf->address, vma);
// 3. 建立新页的映射
entry = pte_mkwrite(pte_mkdirty(mk_pte(new_page, vma->vm_page_prot)));
ptep_clear_flush(vma, vmf->address, vmf->pte);
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
// 4. 释放旧页的引用
put_page(old_page);
return VM_FAULT_WRITE;
}

COW 的精妙之处在于延迟分配:如果子进程 fork() 后立即 exec() 加载新程序,那么父进程的页帧从未被复制——COW 的开销为零。即使子进程不 exec(),也只有实际被写入的页才会被复制,大部分页面可能永远不会被修改。

7.3 COW 与页表的关系#

fork() 前:
父进程 PTE: [物理页P, R/W] → 物理页P
fork() 后(COW 设置):
父进程 PTE: [物理页P, R/O] → 物理页P ← 共享
子进程 PTE: [物理页P, R/O] → 物理页P ← 共享
(物理页P 的引用计数 = 2)
父进程写入时(触发 do_wp_page):
父进程 PTE: [物理页P', R/W] → 物理页P'(新分配的副本)
子进程 PTE: [物理页P, R/O] → 物理页P
(物理页P 的引用计数 = 1,P' 的引用计数 = 1)

九、mmap 系统调用深度解析#

8.1 mmap 的本质#

mmap() 是 Linux 虚拟内存系统最重要的系统调用之一,它将文件或设备映射到进程的虚拟地址空间,或者创建匿名映射。映射后,进程可以像访问内存一样访问文件内容,无需 read()/write() 系统调用。

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

8.2 四种映射类型#

mmapflags 参数决定了映射的本质属性,形成四种组合:

映射类型flags特征典型用途
私有文件映射MAP_PRIVATE | MAP_FILE修改不写回文件,COW 语义加载可执行文件代码段/数据段
共享文件映射MAP_SHARED | MAP_FILE修改写回文件,进程间共享共享内存文件、内存映射 I/O
私有匿名映射MAP_PRIVATE | MAP_ANONYMOUS无文件后端,修改不共享malloc() 大块内存、堆扩展
共享匿名映射MAP_SHARED | MAP_ANONYMOUS无文件后端,fork 后共享父子进程共享内存

8.3 mmap 的内核实现#

mmap 系统调用在内核中的调用链为:

sys_mmap() → sys_mmap_pgoff() → vm_mmap_pgoff() → do_mmap()

do_mmap() 是核心实现,它完成以下工作:

  1. 参数校验:检查 offset 是否页对齐、length 是否合法、protflags 是否兼容
  2. 查找可用地址空间:调用 get_unmapped_area() 在 VMA 树中找到足够大的空闲区间
  3. 计算 VMA 标志:根据 protflags 计算 vm_flags
  4. 创建 VMA:调用 mmap_region() 分配并初始化 vm_area_struct
  5. 文件映射初始化:调用文件系统的 f_op->mmap(),建立文件与 VMA 的关联
// mm/mmap.c(简化)
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, unsigned long pgoff)
{
// 1. 计算 vm_flags
vm_flags = calc_vm_prot_bits(prot) | calc_vm_flag_bits(flags);
vm_flags |= mm->def_flags;
// 2. 查找空闲地址空间
addr = get_unmapped_area(file, addr, len, pgoff, flags);
// 3. 创建 VMA 并映射
addr = mmap_region(file, addr, len, vm_flags, pgoff);
return addr;
}

8.4 munmap:解除映射#

munmap() 解除指定地址范围的映射,核心逻辑是 do_munmap()

  1. 查找受影响的 VMA
  2. 如果 VMA 被部分解除映射,需要分裂 VMA(split_vma()
  3. 释放 VMA 覆盖的物理页帧
  4. 刷新对应的 TLB 条目
  5. 从红黑树和链表中移除 VMA
// 使用示例
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 使用 ptr ...
munmap(ptr, 4096); // 解除映射

8.5 文件映射 vs 匿名映射的缺页差异#

特性文件映射缺页匿名映射缺页
处理函数do_fault()filemap_fault()do_anonymous_page()
数据来源页缓存 → 磁盘零页或新分配的零初始化页
vm_ops文件系统提供的 vm_ops通常为 NULL
vm_file指向映射的 struct fileNULL
换出后从文件重新读取从 swap 区域读回

十、栈的自动扩展#

9.1 VM_GROWSDOWN 与栈扩展#

栈 VMA 带有 VM_GROWSDOWN 标志,表示它向低地址增长。当进程访问栈 VMA 以下的地址时,内核不会发送 SIGSEGV,而是尝试扩展栈 VMA

栈扩展的逻辑在 expand_stack() 中实现:

// mm/mmap.c(简化)
int expand_stack(struct vm_area_struct *vma, unsigned long address)
{
// 检查扩展限制
if (address < vma->vm_start - stack_guard_gap)
return -ENOMEM; // 超过栈保护间隙
// 检查栈大小限制
if (vma->vm_end - address > rlimit(RLIMIT_STACK))
return -ENOMEM; // 超过 RLIMIT_STACK
// 扩展 VMA
vma->vm_start = address;
return 0;
}

9.2 栈保护间隙#

Linux 引入了栈保护间隙(stack guard gap),默认为 256 MB(或 1 MB,取决于配置)。它确保栈 VMA 不会紧挨着其他 VMA 增长,防止栈溢出覆盖其他内存区域。当栈扩展到距离相邻 VMA 不足保护间隙时,扩展失败,进程收到 SIGSEGV

9.3 栈扩展 vs 缺页异常#

栈扩展发生在 do_page_fault()VMA 查找阶段——如果 find_vma() 找到的 VMA 在 address 之上,且该 VMA 带有 VM_GROWSDOWN 标志,则尝试扩展 VMA 的 vm_startaddress。扩展成功后,再进入正常的缺页异常处理流程,为新的栈页建立映射。

十一、madvise:内存使用建议#

madvise() 系统调用允许进程向内核提供关于其内存使用模式的建议,帮助内核优化内存管理决策:

#include <sys/mman.h>
int madvise(void *addr, size_t length, int advice);

常用的 advice 值:

advice含义内核行为
MADV_NORMAL默认行为无特殊处理
MADV_RANDOM随机访问禁用预读
MADV_SEQUENTIAL顺序访问积极预读,使用后尽快释放
MADV_WILLNEED即将使用触发预读,将页面读入内存
MADV_DONTNEED不再需要立即释放页面(文件映射可重新读取)
MADV_FREE可释放标记为可回收,内核在内存紧张时释放
MADV_MERGEABLE可合并允许 KSM 合并相同页
MADV_HUGEPAGE使用大页启用透明大页
Tip

MADV_DONTNEEDMADV_FREE 的关键区别:MADV_DONTNEED 立即释放页面,后续访问触发缺页异常重新分配(匿名映射得到零页);MADV_FREE 是”惰性释放”,页面被标记为可回收但内容暂时保留,后续访问可能直接使用(不触发缺页),直到内核在内存紧张时回收。

十二、动手实践#

实践一:查看进程地址空间映射#

# 查看当前进程的完整地址空间映射
cat /proc/self/maps
# 输出示例:
# 55a1c2a000-55a1c2b000 r--p 00000000 08:01 12345 /usr/bin/cat
# 55a1c2b000-55a1c2f000 r-xp 00001000 08:01 12345 /usr/bin/cat
# 55a1c2f000-55a1c31000 r--p 00005000 08:01 12345 /usr/bin/cat
# 55a1c31000-55a1c32000 r--p 00007000 08:01 12345 /usr/bin/cat
# 55a1c32000-55a1c33000 rw-p 00008000 08:01 12345 /usr/bin/cat
# 7f0a3c00000-7f0a3c22000 r--p 00000000 08:01 67890 /usr/lib/libc.so.6
# ...
# 格式:地址范围 权限 偏移 设备号 inode 路径
# 权限:r=读 w=写 x=执行 p=私有 s=共享

实践二:使用 pmap 查看详细内存映射#

# 查看进程的详细内存映射(需要 PID)
pmap -x $$
# 输出包含:地址、Kbytes、RSS(实际物理内存)、Dirty(脏页)
# 最后一行显示总计

实践三:编写 mmap 程序#

mmap_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
// 1. 匿名私有映射
void *anon = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (anon == MAP_FAILED) { perror("mmap anon"); exit(1); }
strcpy(anon, "Hello, anonymous mmap!");
printf("匿名映射: %s\n", (char *)anon);
// 2. 文件私有映射
int fd = open("/etc/hostname", O_RDONLY);
if (fd >= 0) {
void *file_map = mmap(NULL, 4096, PROT_READ,
MAP_PRIVATE, fd, 0);
if (file_map != MAP_FAILED) {
printf("文件映射: %.20s\n", (char *)file_map);
munmap(file_map, 4096);
}
close(fd);
}
// 3. 查看映射
char cmd[64];
snprintf(cmd, sizeof(cmd), "cat /proc/%d/maps", getpid());
system(cmd);
munmap(anon, 4096);
return 0;
}
# 编译运行
gcc -o mmap_demo mmap_demo.c && ./mmap_demo

实践四:使用 mincore 检查页面驻留#

mincore_demo.c
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
long page_size = sysconf(_SC_PAGESIZE);
int num_pages = 10;
size_t len = num_pages * page_size;
// 分配匿名映射
char *region = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (region == MAP_FAILED) { perror("mmap"); exit(1); }
// 检查哪些页在物理内存中
unsigned char *vec = malloc(num_pages);
if (mincore(region, len, vec) == 0) {
printf("访问前 - 驻留情况:\n");
for (int i = 0; i < num_pages; i++)
printf(" 页 %d: %s\n", i, vec[i] & 1 ? "在内存中" : "不在内存中");
}
// 触发缺页:写入第 0、3、7 页
region[0] = 'A';
region[3 * page_size] = 'B';
region[7 * page_size] = 'C';
if (mincore(region, len, vec) == 0) {
printf("访问后 - 驻留情况:\n");
for (int i = 0; i < num_pages; i++)
printf(" 页 %d: %s\n", i, vec[i] & 1 ? "在内存中" : "不在内存中");
}
munmap(region, len);
free(vec);
return 0;
}

实践五:观察写时复制#

cow_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>
int main(void)
{
// 分配共享匿名映射(用于计数)
int *counter = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
// 分配私有匿名映射(用于观察 COW)
char *private_data = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
strcpy(private_data, "父进程的数据");
*counter = 0;
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("子进程: 修改前 private_data = %s\n", private_data);
printf("子进程: private_data 地址 = %p\n", private_data);
// 修改私有数据(触发 COW)
strcpy(private_data, "子进程的数据");
(*counter)++; // 共享计数器递增
printf("子进程: 修改后 private_data = %s\n", private_data);
exit(0);
} else {
wait(NULL);
printf("父进程: private_data = %s (未被子进程修改影响)\n", private_data);
printf("父进程: counter = %d (被子进程递增)\n", *counter);
}
munmap(counter, 4096);
munmap(private_data, 4096);
return 0;
}
# 编译运行
gcc -o cow_demo cow_demo.c && ./cow_demo
# 同时在另一个终端观察页表变化
watch -n 0.5 "cat /proc/$(pgrep cow_demo)/smaps | grep -A1 private"

参考资料#

内核源码#

文件路径内容
include/linux/mm_types.hmm_structvm_area_struct 定义
include/linux/mm.h内存管理核心声明与内联函数
mm/mmap.cdo_mmap()do_munmap()find_vma()vma_merge()
mm/memory.chandle_mm_fault()handle_pte_fault()do_anonymous_page()do_wp_page()
mm/mmap.cexpand_stack()、栈扩展逻辑
arch/x86/mm/fault.cdo_page_fault() x86 架构实现
mm/filemap.cfilemap_fault() 文件映射缺页处理
include/linux/pgtable.h多级页表操作抽象

经典书籍#

  • 《深入理解 Linux 内核》(Daniel P. Bovet 等)— 第九章”内存管理”对虚拟内存有详尽描述
  • 《Linux 内核设计与实现》(Robert Love)— 第 12 章”内存管理”覆盖 VMA 与缺页异常
  • 《理解 Linux 进程》(陈硕)— 从实践角度解析进程地址空间
  • 《操作系统导论》(OSTEP)(Remzi H. Arpaci-Dusseau)— 虚拟内存概念的绝佳入门

在线资源#

姊妹系列#

  • 「从零开始的操作系统」第 4 章:虚拟内存基础 — 从硬件角度理解分页机制与页表设计

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

虚拟内存与 VMA
https://blog.souloss.com/posts/linux-internals/virtual-memory-and-vma/
作者
Souloss
发布于
2024-12-30
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时