一、引言:从物理页帧到虚拟世界
在第 6 章中,我们深入剖析了 Linux 如何管理物理内存——伙伴系统分配连续页帧、Slub 分配器处理小对象、kswapd 在内存紧张时回收页面。但这一切对用户进程而言是完全不可见的。当你的程序通过 malloc() 申请内存时,它拿到的是一个虚拟地址,而非物理地址。当程序访问一个从未写入的变量时,CPU 产生的缺页异常会穿越多层抽象,最终由内核在幕后完成物理页帧的分配与映射。
虚拟内存是现代操作系统最精妙的发明之一。它为每个进程营造了一个”独占整台机器内存”的幻觉,同时实现了内存保护(进程间互不干扰)、按需分配(用多少给多少)、内存超额承诺(承诺的总量可以超过物理内存)和内存共享(不同进程映射同一物理页)。这些能力的基础,正是本章要深入剖析的核心数据结构——mm_struct 与 vm_area_struct。
本章将从进程地址空间的全景布局出发,逐层拆解虚拟内存管理的核心机制:VMA 的组织与查找、多级页表的实现、缺页异常的完整处理流程、写时复制的精妙设计,以及 mmap 系统调用的深度解析。
二、进程地址空间全景
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 │ 不可访问(空指针捕获区) │ └──────────────────────────────┘栈的增长方向因架构而异。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 实例。它记录了地址空间的所有关键信息:
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 通过 mm 和 active_mm 两个指针与 mm_struct 关联:
struct task_struct { struct mm_struct *mm; // 用户进程的地址空间 struct mm_struct *active_mm; // 内核线程使用的"借来"的地址空间 // ...};- 用户进程/线程:
mm指向自己的mm_struct,active_mm通常与mm相同 - 内核线程:
mm为NULL(内核线程没有用户地址空间),active_mm指向前一个进程的mm_struct(惰性 TLB 切换优化)
内核线程使用 active_mm 是一项重要的性能优化——内核线程切换时无需切换页表(CR3 寄存器),因为内核空间在所有进程页表中映射相同。这避免了 TLB 全局刷新的开销,称为”惰性 TLB”(Lazy TLB)技术。
2.3 引用计数:mm_users vs mm_count
mm_struct 有两个引用计数,容易混淆:
| 字段 | 含义 | 何时递增 | 何时递减 |
|---|---|---|---|
mm_users | 共享此地址空间的用户(线程)数 | clone(CLONE_VM) 创建线程 | 线程退出 |
mm_count | 对 mm_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 映射区等。
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_READ | 0x00000001 | 可读 |
VM_WRITE | 0x00000002 | 可写 |
VM_EXEC | 0x00000004 | 可执行 |
VM_SHARED | 0x00000008 | 共享映射(否则为私有) |
VM_MAYREAD | 0x00000010 | 可能可读(mprotect 可改) |
VM_MAYWRITE | 0x00000020 | 可能可写 |
VM_MAYEXEC | 0x00000040 | 可能可执行 |
VM_GROWSDOWN | 0x00000100 | 向低地址增长(栈) |
VM_GROWSUP | 0x00000200 | 向高地址增长(PA-RISC 栈) |
VM_PFNMAP | 0x00000400 | 页帧号映射(非 struct page) |
VM_DENYWRITE | 0x00000800 | 拒绝写打开 |
VM_LOCKED | 0x00002000 | mlock 锁定(不可换出) |
VM_IO | 0x00004000 | I/O 设备映射 |
VM_STACK | 0x04000000 | 栈区域 |
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 提供了一组操作回调,允许不同类型的映射定义自己的行为:
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:
struct vmacache { u64 seqnum; // 序列号,用于失效检测 struct vm_area_struct *vmas[VMACACHE_SIZE]; // 4 个缓存槽位};当 mm_struct 的 VMA 发生变化(插入、删除、合并)时,vmacache_seqnum 递增,所有线程的 vmacache 自动失效。这个设计利用了局部性原理——进程的内存访问通常集中在少数几个 VMA 中。
4.3 VMA 的合并
当新的映射与相邻 VMA 的属性完全一致时,内核会尝试合并它们,而非创建新的 VMA。合并条件包括:
- 相邻且地址连续
vm_flags相同- 同一文件且偏移连续(文件映射)
- 同一
vm_ops - 同一
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 缺页异常的触发条件
缺页异常在以下情况触发:
- 页表项不存在(Present 位为 0):虚拟地址从未映射,或页面已被换出
- 写权限违规:对只读页执行写操作(COW 场景)
- 访问权限违规:用户态访问内核页,或对不可执行页执行指令
- 栈自动扩展:访问栈 VMA 以下的保护页
6.2 缺页异常处理完整流程
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() 分配的内存(通过 brk 或 mmap 扩展的堆区域)时,触发匿名页缺页:
// 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;}零页优化是 Linux 的一个巧妙设计:对于只读的匿名页访问,内核不分配物理页帧,而是将 PTE 指向一个全局共享的零页。这节省了大量物理内存——程序中未初始化的全局变量(BSS 段)在首次读取时都映射到零页,只有在写入时才分配真正的物理页。
6.7 文件映射缺页:do_fault()
文件映射缺页分为三种情况:
- 读缺页(
do_read_fault):从文件的页缓存中查找或读取页面 - 写私有映射缺页(
do_cow_fault):读取文件页 + 写时复制 - 写共享映射缺页(
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 四种映射类型
mmap 的 flags 参数决定了映射的本质属性,形成四种组合:
| 映射类型 | 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() 是核心实现,它完成以下工作:
- 参数校验:检查
offset是否页对齐、length是否合法、prot与flags是否兼容 - 查找可用地址空间:调用
get_unmapped_area()在 VMA 树中找到足够大的空闲区间 - 计算 VMA 标志:根据
prot和flags计算vm_flags - 创建 VMA:调用
mmap_region()分配并初始化vm_area_struct - 文件映射初始化:调用文件系统的
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():
- 查找受影响的 VMA
- 如果 VMA 被部分解除映射,需要分裂 VMA(
split_vma()) - 释放 VMA 覆盖的物理页帧
- 刷新对应的 TLB 条目
- 从红黑树和链表中移除 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 file | NULL |
| 换出后 | 从文件重新读取 | 从 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_start 到 address。扩展成功后,再进入正常的缺页异常处理流程,为新的栈页建立映射。
十一、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 | 使用大页 | 启用透明大页 |
MADV_DONTNEED 与 MADV_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 程序
#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 检查页面驻留
#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;}实践五:观察写时复制
#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.h | mm_struct、vm_area_struct 定义 |
include/linux/mm.h | 内存管理核心声明与内联函数 |
mm/mmap.c | do_mmap()、do_munmap()、find_vma()、vma_merge() |
mm/memory.c | handle_mm_fault()、handle_pte_fault()、do_anonymous_page()、do_wp_page() |
mm/mmap.c | expand_stack()、栈扩展逻辑 |
arch/x86/mm/fault.c | do_page_fault() x86 架构实现 |
mm/filemap.c | filemap_fault() 文件映射缺页处理 |
include/linux/pgtable.h | 多级页表操作抽象 |
经典书籍
- 《深入理解 Linux 内核》(Daniel P. Bovet 等)— 第九章”内存管理”对虚拟内存有详尽描述
- 《Linux 内核设计与实现》(Robert Love)— 第 12 章”内存管理”覆盖 VMA 与缺页异常
- 《理解 Linux 进程》(陈硕)— 从实践角度解析进程地址空间
- 《操作系统导论》(OSTEP)(Remzi H. Arpaci-Dusseau)— 虚拟内存概念的绝佳入门
在线资源
- Linux 内核文档 — 内存管理
- Bootlin Elixir — mm/mmap.c 源码
- LWN.net: Five-level page tables — 五级页表引入的详细讨论
- LWN.net: Copy-on-write — COW 实现的深入分析
姊妹系列
- 「从零开始的操作系统」第 4 章:虚拟内存基础 — 从硬件角度理解分页机制与页表设计
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






