Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.8 虚拟内存与页表

本章回答

一个进程眼里的地址,到 DRAM 的哪一根线上,中间走几步?为什么 mmap 1 GiB 立刻返回,而写第一页要等半天?页表的四级各 9 位是怎么分的,大页为什么能省 TLB?缺页异常进来之后,内核凭什么知道该分配新页、读文件、复制一页还是换入?/proc/PID/maps 里那一行行到底是什么?

读完你会

画出 mm_struct → VMA → 页表 → struct page 的对象链;手算任意虚拟地址的四级索引;说清缺页异常的五条分支与各自的触发条件;用 /proc/PID/smaps 区分 RSS/PSS/共享/私有;写一个模块走一遍内核页表并读出 pfn。

3.7 讲的是物理页从哪来。这一章讲虚拟地址怎么落到物理页上:每个进程有独立的地址空间,同一段虚拟地址在不同进程里指向不同的物理页,靠的就是页表。

虚拟内存是操作系统里「一箭三雕」的设计:它是缓存(把内存当磁盘的缓存,按需换入)、是管理工具(每个进程看到连续、独立的地址空间)、是保护工具(页表项里的权限位由 CPU 强制执行)。CSAPP 第 9 章把这三重身份讲得最清楚,本章补上 Linux 6.8 的具体实现。

路线:先看对象(mm_struct、VMA),再看翻译(四级页表、TLB),然后看 mmap 怎么建 VMA、缺页怎么填页表,最后是内核自己的地址空间与观测实验。

3.8.1 地址空间:mm_struct 与 VMA

/* include/linux/mm_types.h —— 6.8 节选,已简化 */
struct mm_struct {
	struct {
		struct maple_tree mm_mt;	/* 6.1+:VMA 的 maple tree(旧版是 mmap 红黑树) */
		unsigned long mmap_base;
		unsigned long task_size;	/* 用户空间上限(x86-64 为 0x7fff...) */
		pgd_t *pgd;			/* 页表根,切进程时写入 CR3 */
		atomic_t mm_users;		/* 使用该地址空间的线程数 */
		atomic_t mm_count;		/* 结构本身的引用(含 active_mm) */
		int map_count;			/* VMA 个数 */
		struct rw_semaphore mmap_lock;	/* 保护 VMA 与页表结构 */
		unsigned long start_code, end_code, start_data, end_data;
		unsigned long start_brk, brk, start_stack;
		unsigned long arg_start, arg_end, env_start, env_end;
		...
	} __randomize_layout;
};

mm_users 与 mm_count 的区别是面试常问:mm_users 是「有多少个 task 在用这个地址空间」(线程共享,fork 后各自一份新的 mm 但 mm_users=1;clone(CLONE_VM) 则 mm_users++);mm_count 是「有多少个引用指向这个结构」,包含内核线程借用的 active_mm。mm_users 归零时 exit_mmap() 拆页表;mm_count 归零时 mmdrop() 才真正释放 mm_struct。

VMA(virtual memory area)描述「一段连续虚拟地址上的同一类映射」:

/* include/linux/mm_types.h —— 6.8 节选,已简化 */
struct vm_area_struct {
	unsigned long vm_start;
	unsigned long vm_end;
	struct vm_area_struct *vm_next, *vm_prev;
	struct rb_node vm_rb;			/* 旧版用于 mm->mmap 红黑树 */
	struct ma_state vm_mas;			/* 6.1+ maple tree 状态 */
	struct mm_struct *vm_mm;
	pgprot_t vm_page_prot;			/* 页表项的权限模板 */
	unsigned long vm_flags;			/* VM_READ/WRITE/EXEC/SHARED/... */
	struct file *vm_file;			/* 文件映射,匿名映射为 NULL */
	unsigned long vm_pgoff;			/* 文件内偏移(页为单位) */
	const struct vm_operations_struct *vm_ops;
	void *vm_private_data;
	struct anon_vma *anon_vma;		/* 匿名页的反向映射 */
	...
};

一个 64 位进程的地址空间大致长这样:

0x0000000000400000  ┌──────────────┐
                    │ .text        │ r-xp  代码
                    │ .rodata      │ r--p  只读常量
                    │ .data/.bss   │ rw-p  全局变量
                    ├──────────────┤
                    │ heap         │ rw-p  brk 向上增长
                    │   ↓          │
                    │              │
                    │   ↑          │
                    │ mmap 区      │ rw-p  共享库、匿名映射、大块 malloc
                    ├──────────────┤
                    │ stack        │ rw-p  向下增长
0x00007ffffffff000  ├──────────────┤
                    │ vDSO / vvar  │ r-xp / r--p  内核注入的快速系统调用页
                    └──────────────┘
0xffffffffffffffff  内核空间(用户态不可访问)

这张图的每一段在 /proc/PID/maps 里都有一行。注意栈是向下增长的,而 VMA 的 vm_start/vm_end 只描述当前范围,增长时由 expand_stack() 扩展 VMA。

定义 3.8.1 · VMA 与 vm_flags

VMA 是「一段连续的、属性相同的虚拟地址区间」。vm_flags 是 VM_* 位掩码,决定这段地址的语义:

标志 含义
VM_READ/VM_WRITE/VM_EXEC 权限(与 PROT_* 对应)
VM_SHARED 共享映射(MAP_SHARED),写回文件
VM_MAYREAD/VM_MAYWRITE/VM_MAYEXEC mprotect 允许的上限
VM_GROWSDOWN/VM_GROWSUP 栈式增长方向
VM_LOCKED mlock,不换出
VM_DONTEXPAND/VM_DONTDUMP 禁止 mremap 扩展 / 不进 core dump
VM_IO/VM_PFNMAP 设备映射,页表项不由内核管理
VM_ACCOUNT 计入 overcommit 统计
VM_HUGETLB/VM_NOHUGEPAGE 大页控制

vm_page_prot 是「页表项权限模板」,vm_flags 变化时(mprotect)要同步更新两者。

3.8.2 四级页表:9 位一段的翻译

虚拟地址到物理地址的四级翻译 CR3 指向 PML4,逐级取 9 位索引,最后加 12 位页内偏移。 x86-64 四级页表:48 位虚拟地址如何变成物理地址虚拟地址 47…0PMLE49bPML49bPDPT9bPD9bPT9bOFFSET12bCR3 → PML4 基址PML4E取 9 位索引 → PDPTPDPTE1 GiB 大页可分叉PDE2 MiB 大页可分叉PTE4 KiB 页基址物理页框PFN + 权限位TLB缓存最近翻译缺页异常页不在内存 / 权限不符page fault 处理分配页 / COW / 换入命中 TLB 则跳过未命中 → 硬件/内核处理完重试指令每级 9 位、页内 12 位:单级页表 512 项;48 位地址空间 = 256 TiB;大页减少级数与 TLB 压力
读图:从右往左读位域:低 12 位是页内偏移,上面四级各 9 位。每级页表 512 项;TLB 命中时整条路径被短路。PDE/PDPTE 的大页位可以让翻译提前结束——这是 2 MiB/1 GiB 大页降低 TLB miss 的原理。

读图:48 位虚拟地址被切成 5 段——4 个 9 位索引加 12 位页内偏移。每级页表 512 项(2^9),每项 8 字节,正好 4 KiB 一页。CR3 指向 PML4(内核里叫 pgd),逐级查到 PTE,PTE 里的物理页帧号加偏移就是物理地址。

级别内核类型索引位表名覆盖范围
1pgd_tVA[47:39]PML4512 GiB
2pud_tVA[38:30]PDPT1 GiB
3pmd_tVA[29:21]PD2 MiB
4pte_tVA[20:12]PT4 KiB
——VA[11:0]页内偏移4 KiB

4 级页表下没有独立的 p4d 索引:P4D_SHIFT == PGDIR_SHIFT == 39,p4d_offset() 直接返回 pgd。上表按真实索引列;p4d 只在 5 级页表(pgd[56:48]、p4d[47:39])里才是独立一级。

对应到代码:

/* arch/x86/mm/pgtable.c 与 include/linux/pgtable.h 的组合用法 */
pgd_t *pgd = pgd_offset(mm, addr);       /* mm->pgd + pgd_index(addr) */
p4d_t *p4d = p4d_offset(pgd, addr);      /* 4 级页表时 p4d 被折叠成 pgd */
pud_t *pud = pud_offset(p4d, addr);
pmd_t *pmd = pmd_offset(pud, addr);
pte_t *pte = pte_offset_map(pmd, addr);  /* 需要 pte_unmap() 配对 */

p4d 这一级在 4 级页表下是「折叠」的:p4d_offset() 直接返回 pgd,p4d_none() 恒为假。5 级页表(CONFIG_X86_5LEVEL)才真正多一级,见 3.8.6。

大页让翻译提前结束:pmd 的 _PAGE_PSE 位置 1 时,这一项直接给出 2 MiB 物理块的基址,不再查 PTE;pud 的 _PAGE_PSE 给出 1 GiB。大页的价值不在「少一次内存访问」(一次页表 walk 本来就只有 4 次),而在 TLB 覆盖范围:一个 TLB 项覆盖 2 MiB 而不是 4 KiB,同样的 TLB 容量能覆盖 512 倍地址空间。

TLB 是页表的缓存,命中时整条 walk 被短路。内核改页表后必须让 TLB 失效:

  • 单页:invlpg 指令(native_flush_tlb_one_user());
  • 整个地址空间:写 CR3(__flush_tlb_mm()),带 PCID 时用 invpcid;
  • 多核:flush_tlb_mm_range() 发 IPI 让其他核也刷(TLB shootdown),代价很高,所以内核尽量批量刷。
定义 3.8.2 · 页表项标志

x86-64 的 PTE 低 12 位是标志,高位是物理页帧号:

位 标志 含义
0 _PAGE_PRESENT 存在(1 才能翻译)
1 _PAGE_RW 可写
2 _PAGE_USER 用户态可访问(否则仅 ring 0)
3 _PAGE_PWT 写穿透(write-through)
4 _PAGE_PCD 禁用缓存(MMIO 用)
5 _PAGE_ACCESSED 被访问过(CPU 置位,供 LRU 参考)
6 _PAGE_DIRTY 被写过(决定回写)
7 _PAGE_PSE/_PAGE_PAT 大页 / 页属性表
8 _PAGE_GLOBAL 全局页(切 CR3 不失效)
9–11 _PAGE_SOFTW1..3 内核自用(soft-dirty、uffd-wp 等)
63 _PAGE_NX 不可执行(NXE 开启时)

内核只用前 12 位做标志,9–11 位是「软件位」,Intel 不解释,Linux 用来做 soft-dirty(/proc/PID/clear_refs)、userfaultfd 的 write-protect、_PAGE_DEVMAP 等。读页表调试代码时要分清哪些位是硬件语义、哪些是内核约定。

48 位虚拟地址按 9/9/9/9/12 拆成 PGD、PUD、PMD、PTE 与页内偏移;开启 JavaScript 可逐步查看。

3.8.3 mmap:建 VMA,不建页表

mmap() 的核心是「登记一段地址范围」,不是「分配物理内存」:

/* mm/mmap.c: do_mmap() —— 6.8 节选,已简化 */
unsigned long do_mmap(struct file *file, unsigned long addr,
		      unsigned long len, unsigned long prot,
		      unsigned long flags, unsigned long pgoff,
		      unsigned long *populate, struct list_head *uf)
{
	struct mm_struct *mm = current->mm;
	...
	addr = get_unmapped_area(file, addr, len, pgoff, flags);
	...
	addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);
	...
	return addr;
}

mmap_region() 做四件事:找空隙、建 vm_area_struct、插入 maple tree、调用 file->f_op->mmap(文件映射)或 shmem_zero_setup()(匿名共享)。此时没有任何 PTE——第一次访问才触发缺页。

常用标志的语义差别:

标志作用常见误解
MAP_PRIVATE私有映射,写时 COW不是「不共享」,是与别人不共享修改
MAP_SHARED共享映射,写进 page cache匿名共享实际由 shmem/tmpfs 承载
MAP_ANONYMOUS无文件后端fd 必须为 -1,offset 必须为 0
MAP_FIXED强制落在指定地址会覆盖已有映射,危险
MAP_FIXED_NOREPLACE指定地址但不覆盖冲突时返回 EEXIST(推荐)
MAP_POPULATE预读页表(prefault)只对文件映射有效,匿名映射仍惰性
MAP_LOCKED锁定不换出不等价 mlock,失败也不报错
MAP_NORESERVE不预留 swap配合 overcommit 使用
MAP_HUGETLB使用 hugetlbfs 大页需要预留 hugepage 池

/proc/PID/maps 的每一行:

55f0c0e1d000-55f0c0e3e000 r-xp 00001000 08:01 131074  /usr/bin/bash
│                │         │    │        │     │       │
│                │         │    │        │     │       └─ 路径(匿名映射为空或 [heap]/[stack])
│                │         │    │        │     └───────── inode
│                │         │    │        └─────────────── 设备号
│                │         │    └──────────────────────── 文件内偏移
│                │         └───────────────────────────── 权限(rwxp = 私有,rwxs = 共享)
│                └─────────────────────────────────────── 结束地址(开区间)
└──────────────────────────────────────────────────────── 起始地址

匿名映射显示为 [heap]、[stack]、[vdso]、[vvar] 或空路径。[vvar] 是内核只读数据页(供 vDSO 读时间),[vdso] 是用户态可执行的系统调用捷径(gettimeofday 常常不真正进内核)。

mprotect() 改权限,mremap() 扩/缩/搬家,munmap() 拆 VMA 并释放页表。三者都要拿 mmap_lock 的写锁,所以高频 mmap/munmap 的分配器(如 glibc malloc 的大块路径)会成为多线程程序的扩展性瓶颈——这是 tcmalloc/jemalloc 用 arena 与缓存的原因之一。

3.8.4 缺页异常:五条分支

缺页(#PF)是虚拟内存的核心机制。exc_page_fault() 先判断地址来自用户态还是内核态,用户态走 do_user_addr_fault(),再进 handle_mm_fault():

/* mm/memory.c: handle_mm_fault() —— 6.8 节选 */
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
			   unsigned int flags, struct pt_regs *regs)
{
	struct vm_fault vmf = {
		.vma = vma,
		.address = address & PAGE_MASK,
		.real_address = address,
		.flags = flags,
		.pgoff = linear_page_index(vma, address),
		.gfp_mask = __get_fault_gfp_mask(vma),
	};
	...
	if (unlikely(is_vm_hugetlb_page(vma)))
		ret = hugetlb_fault(vma->vm_mm, vma, address, flags);
	else
		ret = __handle_mm_fault(vma, address, flags);
	...
}

__handle_mm_fault() 按「页表项当前是什么状态」分派:

                      缺页异常(用户态地址)
                              │
                   ┌──────────┴──────────┐
                   │  PTE 存在吗?        │
                   └──────────┬──────────┘
              不存在          │          存在
        ┌─────────────────────┴──────────────────────┐
        ▼                                            ▼
  ┌───────────┐  匿名 VMA 且无 swap  ┌──────────────────────────┐
  │ do_anon   │◄─────────────────────│ PTE 只读、VMA 可写?      │
  │ _page     │                      │  → do_wp_page(COW)     │
  └───────────┘                      └──────────────────────────┘
        │ 有文件后端
        ▼
  ┌─────────────────────────────────────────────┐
  │ do_fault → do_read_fault / do_cow_fault /   │
  │            do_shared_fault(filemap_fault)  │
  └─────────────────────────────────────────────┘
        │ PTE 指向 swap
        ▼
  ┌───────────┐
  │ do_swap   │  swapin:读回页面,更新 PTE
  │ _page     │
  └───────────┘
        │ NUMA hint fault
        ▼
  ┌───────────┐
  │ do_numa   │  统计本地/远程,决定是否迁移
  │ _page     │
  └───────────┘

匿名页的第一次写(do_anonymous_page):

/* mm/memory.c: do_anonymous_page() —— 6.8 节选,已简化 */
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
	struct vm_area_struct *vma = vmf->vma;
	struct folio *folio;
	pte_t entry;

	/* 只读缺页且未强制写:直接映射到全局零页,不分配内存 */
	if (!(vmf->flags & FAULT_FLAG_WRITE) &&
	    !mm_forbids_zeropage(vma->vm_mm)) {
		entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
					      vma->vm_page_prot));
		...
		return 0;
	}
	/* 写缺页:分配一个清零的匿名页 */
	folio = vma_alloc_zeroed_movable_folio(vma, vmf->address);
	if (!folio)
		return VM_FAULT_OOM;
	...
	entry = mk_pte(&folio->page, vma->vm_page_prot);
	entry = pte_mkwrite(pte_mkdirty(entry), vma);
	...
	page_add_new_anon_rmap(&folio->page, vma, vmf->address, false);
	lru_cache_add_inactive_or_unevictable(&folio->page, vma);
	...
	set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
	return 0;
}

两个容易忽略的点:

  1. 读缺页不分配内存。读一个从未写过的匿名页,内核把 PTE 指向全局零页(empty_zero_page),只读、共享。直到第一次写才真正分配。这就是「malloc 1 GiB 后 RSS 是 0」的底层原因。
  2. 写缺页分配的是「可迁移、清零」的页(vma_alloc_zeroed_movable_folio()),并加入 LRU 的 inactive 链表。清零由 __GFP_ZERO 保证,用户态永远看不到别人的数据。

文件映射的缺页走 do_fault(),三分支:

  • do_read_fault():读缺页,调 filemap_fault() 从 page cache 取页(不在就发起 readahead 并睡眠等待),PTE 设只读;
  • do_cow_fault():私有可写映射的写缺页,先取页缓存页,再复制一份新的匿名页(COW);
  • do_shared_fault():共享映射的写缺页,直接映射页缓存页并标脏,由 writeback 负责回写。

swap 缺页(do_swap_page())从 swap 区读回页面;NUMA hint fault(do_numa_page())不改数据,只统计访问位置,供 AutoNUMA 决策(见 3.6)。

统计在 /proc/vmstat:

pgfault 1234567        # 总缺页次数(minor + major)
pgmajfault 890         # 需要读磁盘的缺页(major)
pgalloc_normal 456789  # 从 zone 分配的页数
pgsteal_normal 12345   # 回收走的页数
thp_fault_alloc 678    # 透明大页分配成功
thp_fault_fallback 90  # 透明大页分配失败回退

pgmajfault 高说明频繁读盘(内存不足或访问模式随机);pgfault 高但 pgmajfault 低是正常的(每次首次触碰都算 minor)。

缺页相关的五个误解
  1. 「mmap 之后就有内存了」:只有 VMA,没有 PTE,更没有物理页。MAP_POPULATE 也只预读文件页,不保证匿名页。
  2. 「读缺页也要分配内存」:读匿名页映射到全局零页,零分配;只有写才分配。
  3. 「COW 的页一定是匿名页」:文件私有映射的写缺页也走 COW(do_cow_fault),复制出来的是匿名页。
  4. 「缺页一定很慢」:minor fault 只是分配一页 + 填 PTE,微秒级;major fault 要读盘,毫秒级。两者在 perf stat 里是 minor-faults 与 major-faults。
  5. 「MADV_DONTNEED 会把数据写回磁盘」:对匿名页它直接丢弃(下次读为 0);对文件映射只丢 PTE,page cache 仍在,脏页仍由 writeback 回写。

3.8.5 内核地址空间与用户地址空间的交界

每个进程的页表上半部分(0xffff8000... 以上)映射的是同一份内核空间。这意味着:

  • 系统调用进入内核后不需要换页表(switch_mm 只在进程切换时发生);
  • 内核可以直接解引用用户指针的内核别名吗?不行——用户页可能不在直接映射区,且 SMAP(Supervisor Mode Access Prevention)会阻止内核访问用户页,除非用 stac/clac 临时放开。copy_from_user() 内部就是这套操作。
  • CONFIG_PAGE_TABLE_ISOLATION(KPTI,熔断漏洞的缓解)在用户态时把内核映射几乎全部撤掉,只留 entry 蹦床;代价是每次进内核换一次 CR3,用 PCID 缓解。

内核访问用户内存的三条路:

方式用途会不会触发缺页
copy_from_user()/copy_to_user()拷贝数据会(可能睡眠)
get_user_pages()(GUP)长期持有用户页(DMA、RDMA)会,且可能触发 COW
pin_user_pages()同上,但用「pin」计数会

GUP 与 COW 的交互是 Dirty COW(CVE-2016-5195)的根源:GUP 拿到的是只读的 COW 页,若内核随后写它,会改到共享的物理页。修复方式是在 GUP 路径里对 FOLL_WRITE 的私有映射先破 COW。这段历史在第 3.10 章复盘。

3.8.6 五级页表:57 位地址

4 级页表给用户态 128 TiB(47 位)地址空间,对绝大多数程序够用,但对「映射整个数据库文件」的场景不够。Intel 的 LA57 把虚拟地址扩到 57 位(128 PiB),多出 p4d 这一级真正生效:

模式虚拟地址位用户空间页表级数内核配置
4 级48128 TiBpgd→pud→pmd→pte默认
5 级5764 PiBpgd→p4d→pud→pmd→pteCONFIG_X86_5LEVEL=y

5 级页表是运行时可选的:内核编译时支持,启动时由 la57 或硬件能力决定是否启用。pgtable_l5_enabled() 返回真假,p4d_offset() 在 4 级下退化为 pgd。用户态要显式申请高地址,得给 mmap 一个大于 47 位的 hint,且内核返回的地址才可能落在扩展区——默认的 mmap_base 仍在 47 位内,避免破坏老程序。

3.8.7 观测:maps、smaps、pagemap

文件内容用途
/proc/PID/mapsVMA 列表(地址、权限、偏移、路径)看布局、找泄漏的映射
/proc/PID/smaps每个 VMA 的详细统计RSS/PSS/共享/私有/swap
/proc/PID/smaps_rollup全进程汇总快速看总量
/proc/PID/pagemap每个虚拟页的 pfn 与状态页级分析(需 CAP_SYS_ADMIN)
/proc/PID/numa_maps每个 VMA 的 NUMA 分布跨节点诊断
/proc/PID/clear_refs清 accessed/dirty 位测工作集

smaps 里最容易被误读的是 RSS 与 PSS:

7f2c40000000-7f2c44000000 rw-p 00000000 00:00 0
Size:              65536 kB
Rss:               16384 kB
Pss:                8192 kB
Shared_Clean:          0 kB
Shared_Dirty:      16384 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Swap:                  0 kB
  • RSS 是「这个 VMA 映射了多少物理页」,共享页在每个进程里都全额计入;
  • PSS 是「按共享比例分摊后的份额」,共享页被 N 个进程映射就除以 N。所以所有进程的 PSS 之和才是真实内存占用,RSS 之和会重复计算。

pagemap 每 8 字节一项,格式:

位含义
0–54pfn(页帧号)
55soft-dirty
56exclusively mapped
61file/shared-anon
62swapped
63present

读 pfn 需要 CAP_SYS_ADMIN(4.0 起),普通用户只能看 present/swapped 位。用它可以做「这个进程的工作集到底在哪些物理页」的分析,但注意 pfn 不是稳定接口。

3.8.8 实验:惰性分配与页表走查

第一个实验验证「mmap 不分配,写才分配,MADV_DONTNEED 会还」:

/* lazy_alloc.c — 观察 mmap 的惰性分配
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o lazy_alloc lazy_alloc.c
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

static long rss_kb(void)
{
	FILE *f = fopen("/proc/self/statm", "r");
	long total = 0, resident = 0;
	if (!f) return -1;
	if (fscanf(f, "%ld %ld", &total, &resident) != 2) resident = -1;
	fclose(f);
	return resident * (sysconf(_SC_PAGESIZE) / 1024);
}

int main(void)
{
	const size_t len = 512UL << 20;          /* 512 MiB */
	char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
		       MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
	if (p == MAP_FAILED) { perror("mmap"); return 1; }

	printf("after mmap        : rss=%ld kB\n", rss_kb());
	memset(p, 0x41, len);                    /* 触碰每一页,触发 131072 次写缺页 */
	printf("after touching all: rss=%ld kB\n", rss_kb());
	madvise(p, len, MADV_DONTNEED);          /* 丢弃 PTE 与页,下次访问为 0 */
	printf("after MADV_DONTNEED: rss=%ld kB\n", rss_kb());

	munmap(p, len);
	return 0;
}

预期:mmap 后 RSS 几乎不变(约 1–2 MiB,进程本身),触碰后涨到约 512 MiB,MADV_DONTNEED 后回落到几 MiB。中间那 13 万次缺页在 perf stat -e minor-faults ./lazy_alloc 里能看到。

第二个实验是内核模块,走一遍内核页表,验证「虚拟地址 → pfn → 物理地址」:

// ptwalk.c — 走查内核页表,打印某一级的表项(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/mm.h>
#include <linux/pgtable.h>
#include <linux/sched.h>

static int __init ptwalk_init(void)
{
	unsigned long addr = (unsigned long)&init_task;
	pgd_t *pgd; p4d_t *p4d; pud_t *pud; pmd_t *pmd; pte_t *pte;
	unsigned long pfn;

	pgd = pgd_offset_k(addr);
	if (pgd_none(*pgd) || pgd_bad(*pgd)) {
		pr_info("ptwalk: no pgd for %#lx\n", addr);
		return 0;
	}
	p4d = p4d_offset(pgd, addr);
	if (p4d_none(*p4d)) {
		pr_info("ptwalk: no p4d\n");
		return 0;
	}
	pud = pud_offset(p4d, addr);
	if (pud_none(*pud)) {
		pr_info("ptwalk: no pud\n");
		return 0;
	}
	pmd = pmd_offset(pud, addr);
	if (pmd_none(*pmd)) {
		pr_info("ptwalk: no pmd\n");
		return 0;
	}
	pr_info("ptwalk: addr=%#lx pgd=%px p4d=%px pud=%px pmd=%px pmd_val=%#llx\n",
		addr, pgd, p4d, pud, pmd, (unsigned long long)pmd_val(*pmd));

	if (pmd_trans_huge(*pmd) || pmd_leaf(*pmd)) {
		pfn = pmd_pfn(*pmd);
		pr_info("ptwalk: 2MiB huge page, pfn=%#lx phys=%#llx\n",
			pfn, (unsigned long long)(pfn << PAGE_SHIFT));
		return 0;
	}
	pte = pte_offset_kernel(pmd, addr);
	if (!pte || pte_none(*pte) || !pte_present(*pte)) {
		pr_info("ptwalk: pte not present\n");
		return 0;
	}
	pfn = pte_pfn(*pte);
	pr_info("ptwalk: pte=%px pte_val=%#llx pfn=%#lx phys=%#llx\n",
		pte, (unsigned long long)pte_val(*pte),
		pfn, (unsigned long long)(pfn << PAGE_SHIFT));
	return 0;
}

static void __exit ptwalk_exit(void) { }

module_init(ptwalk_init);
module_exit(ptwalk_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("walk kernel page tables for a known symbol");

说明:这里走的是 init_mm 的内核映射,属于「启动时就固定、不会被回收」的区域,所以不加锁直接读是安全的;要遍历用户进程的页表,必须拿 mmap_read_lock(mm) 并处理 pmd_trans_unstable() 等并发情况(follow_page_mask() 就是干这个的)。

例 3.8.1 · 一个缺页延迟的算术

某服务启动时 mmap 了 8 GiB 匿名内存并逐页 memset,启动耗时 12 s。perf stat 显示 minor-faults 约 210 万次。

每次 minor fault 的成本约 2–5 µs(分配页 + 清零 4 KiB + 填 PTE + 可能的 LRU 操作)。210 万 × 4 µs ≈ 8.4 s,加上 memset 本身的内存带宽时间,12 s 就对上了。

优化方向:① 用 MAP_POPULATE 让内核批量预读(仍然要填 PTE,但省掉反复陷入内核的开销);② 用透明大页(MADV_HUGEPAGE),缺页次数降到 1/512;③ 用 mmap 文件 + MAP_SHARED,让 page cache 承担(仍然缺页,但不经过匿名页分配);④ 根本不要预触碰,改成按需访问(最有效,但改变业务语义)。

3.8.9 mm_struct 与 VMA 的字段、锁和生命周期

3.8.1 只列了 mm_struct 和 vm_area_struct 的骨架。真正读代码时,问题会落到字段上:这个计数器谁加、什么时候减?mmap_lock 保护的是 VMA 还是页表?为什么 6.4 之后缺页可以先不拿 mmap_lock?这一节把两个结构拆成字段表,并给出 4.x 到 6.8 的结构演进。

mm_struct 是「一个地址空间」的句柄。它不描述某一段内存,而描述「页表根 + 所有 VMA + 地址空间边界 + 统计」:

字段类型含义谁维护 / 何时变
mm_mtstruct maple_tree6.1+ 的 VMA 索引树,替代 mmap 链表 + 红黑树mmap/munmap/mprotect 增删改时
mmap_baseunsigned longmmap 区的起始地址(向上增长)arch_pick_mmap_layout() 在 exec 时定
task_sizeunsigned long用户空间上限,x86-64 4 级为 0x7fff_ffff_f000进程创建时按架构定
pgdpgd_t *页表根;进程切换时写入 CR3pgd_alloc() 在 fork 时复制/新建
mm_usersatomic_t共享该地址空间的 task 数(线程数)copy_mm/mmget/mmput
mm_countatomic_t结构本身的强引用数(含 active_mm)mmgrab/mmdrop
map_countintVMA 个数插入/删除 VMA;用于 max_map_count 检查
mmap_lockrw_semaphore保护 VMA 拓扑与页表结构写锁改拓扑,读锁查 VMA/缺页
page_table_lockspinlock_t保护页表级的某些计数(历史遗留)只在少数路径使用
arg_start/arg_endunsigned long命令行参数字符串区间exec 时建立
env_start/env_endunsigned long环境变量区间exec 时建立
start_code/end_codeunsigned long代码段区间exec 时建立
start_data/end_dataunsigned long数据段区间exec 时建立
start_brk/brkunsigned long堆的起点与当前边界brk/sbrk 改 brk
start_stackunsigned long栈顶附近位置exec 时建立
contextstruct mm_context_t架构私有(ASID、PCID、LDT 等)切进程/分配 ASID 时
flagsunsigned longMMF_*:MMF_DUMPABLE、MMF_OOM_SKIP 等各子系统按需置位
core_statestruct core_state *core dump 进行中的状态do_coredump()
exe_filestruct file *当前可执行文件exec 时替换
notifier_subscriptions指针mmu_notifier(KVM、RDMA 用)注册/注销时
numa_next_scanunsigned long下一次 NUMA hint fault 扫描地址task_numa_work()
tlb_flush_pendingatomic_t延迟 TLB flush 的批次计数tlb_gather_mmu()/tlb_finish_mmu()

两个引用计数的生命周期可以用一张图说清:

  fork/clone(CLONE_VM)
        │
        ├── mm_users++  ──► 线程退出 mmput() ──► 归零 ──► exit_mmap() 拆 VMA/页表
        │                                              │
        └── mm_count++  ──► mmdrop() ◄────────────────┘
                               │
                               └── 归零 ──► __mmdrop() 释放 pgd、mm_struct
  内核线程没有自己的 mm,借用上一个进程的 active_mm:mmgrab(active_mm) 只加 mm_count

读图:mm_users 归零只表示「没有用户态线程再用这个地址空间」,此时页表必须拆;mm_count 归零才表示「结构可以释放」。内核线程的 active_mm 是只借不用的引用,所以只加 mm_count,不会阻止 exit_mmap()。

定义 3.8.3 · VMA 的字段职责

vm_area_struct 是「一段连续虚拟地址 + 一套属性」的登记项。字段分四组:

组 字段 说明
区间 vm_start、vm_end [start, end),按页对齐;同一 mm 内不重叠
属性 vm_flags、vm_page_prot 前者是内核语义位,后者是 PTE 权限模板
后端 vm_file、vm_pgoff、vm_ops、vm_private_data 文件映射的「文件 + 偏移 + 操作集」;匿名映射 vm_file=NULL
反查 anon_vma、vm_anon、vm_sequence 匿名页 RMAP 的锚点;vm_sequence 用于 VMA 修改的序列化
锁 vm_lock、vm_refcnt 6.4+ per-VMA lock,缺页读路径可无 mmap_lock
策略 vm_policy NUMA mempolicy(set_mempolicy)
用户态 vm_userfaultfd_ctx userfaultfd 的注册上下文

vm_page_prot 不是「当前 PTE 的权限」,而是「新建 PTE 时用的模板」。mprotect() 改的是 VMA 的 vm_flags 与 vm_page_prot,已存在的 PTE 要逐个改(change_protection_range()),并刷 TLB。两者不同步是驱动里常见的 bug。

4.x 的 VMA 索引是「双向链表 + 红黑树」:链表按地址有序,红黑树用于 find_vma() 的 O(log n) 查找。6.1 起换成了 maple tree(mm_mt),它是一棵 B 树变体,支持区间查询、无锁读(RCU)和更紧凑的节点。这个变化直接影响你读代码时看到的 API:

操作4.x6.8
查找包含地址的 VMAfind_vma(mm, addr) 走 mm->mmap 红黑树find_vma() 内部走 mm_mt,vma_find()
遍历 VMAfor (vma = mm->mmap; vma; vma = vma->vm_next)VMA_ITERATOR(vmi, mm, addr) + for_each_vma(vmi, vma)
插入vma_link() 同时挂链表和红黑树vma_iter_store() 写入 maple tree
删除vma_unlink() 两边摘vma_iter_clear() / mas_store_gfp()
读并发必须拿 mmap_lock 读锁部分路径可 RCU 读,写仍要 mmap_lock

读 4.x 的书时看到的 vm_next/vm_rb 在 6.8 仍然存在(用于兼容和某些遍历),但不再是权威索引。判断一段代码是不是新代码,看它用 vma_iter_* 还是 mm->mmap 就知道。

VMA 的修改有三类锁:

锁保护范围典型持有者
mmap_lock 写锁VMA 树、map_count、地址空间布局mmap、munmap、mprotect、mremap、brk
mmap_lock 读锁查 VMA、缺页、/proc/PID/mapsfind_vma、handle_mm_fault
per-VMA vm_lock单个 VMA 的缺页与属性读6.4+ 的 lock_vma_under_rcu()
anon_vma 锁匿名页 RMAP 链anon_vma_lock_read/write()
i_mmap_lock文件映射区间树vma_interval_tree_insert()

mmap_lock 是进程级的读写信号量,读多写少。它的两个著名问题:① 写锁持有时间长(mmap 大段、munmap 大段)会阻塞同进程所有缺页;② 读锁在缺页路径上仍可能争用。6.4 引入 per-VMA lock,让「缺页只读一个 VMA」不必拿进程级读锁;但 mmap/munmap 仍要写锁。Google 的 maple tree 与 per-VMA lock 都是为了缓解这个瓶颈。

为什么 fork 后 VMA 数量会翻倍

fork() 复制地址空间时,dup_mmap() 逐个复制 VMA(不是复制物理页),并建立新的 anon_vma_chain 链接。Linux 用 vm_sequence 和 vm_lock 让子进程的 COW 缺页不需要拿父进程的锁。VMA 数量大(例如 JVM、Redis)时,fork 的耗时主要花在复制 VMA 与页表上,而不是复制数据页。vfork()/posix_spawn() 就是为绕开这笔开销。

VMA 的合并与拆分是 mmap 语义的核心。vma_merge() 只在四种边界条件下合并:新映射与前一 VMA 尾部相接、与后一 VMA 头部相接、同时与两者相接、或完全覆盖中间一段。合并前要逐项比较:

  • vm_flags 必须完全兼容(VM_SPECIAL 组合不允许合并);
  • vm_file、vm_pgoff 必须连续(文件映射的偏移要接得上);
  • anon_vma 必须可合并(is_mergeable_anon_vma(),否则父子进程的 RMAP 会串);
  • vm_policy、vm_userfaultfd_ctx 必须一致;
  • 不能跨越 VM_DONTEXPAND、VM_MIXEDMAP 等特殊标志。

mprotect() 改一段 VMA 的中间部分时,先 split_vma() 切成三段,再改中间那段,最后尝试与邻居合并。一次 mprotect 可能产生「拆分 + 合并」两次树操作,这是它比 mmap 慢的原因之一。

3.8.10 手算地址翻译:从 48 位到物理地址

3.8.2 给了索引位的定义,但没有走一遍完整的算术。这一节用一个真实地址把四级 walk 算到底,再对比五级页表与 TLB。

取 VA = 0x00007f2c40001234。先写成 48 位二进制(高 16 位是符号扩展,不参与索引):

0x00007f2c40001234
= 0000 0000 0000 0000  0111 1111 0010 1100  0100 0000 0000 0000  0001 0010 0011 0100
  └── 不用 ──────────┘ └──────────── 48 位 ──────────────────────────────────────┘

从高到低每 9 位切一刀:

级别取位计算索引
pgdVA[47:39](VA >> 39) & 0x1ff0x0fe
pudVA[38:30](VA >> 30) & 0x1ff0x0b1
pmdVA[29:21](VA >> 21) & 0x1ff0x000
pteVA[20:12](VA >> 12) & 0x1ff0x001
offsetVA[11:0]VA & 0xfff0x234

注意 pud 索引是 0,pmd 索引是 1,不要因为地址里「看起来有 40」就以为索引是 0x40。手算时先在纸上按 9 位分组,再用移位核对,两步都做能避免九成错误。

假设当前进程的 CR3 = 0x1a2b3000,四级页表恰好连续分配,且每一级的第 0 项指向下一级:

CR3 ──► 0x1a2b3000  PML4[0x0fe] = 0x1a2b4067  (present|rw|user, 下一级 0x1a2b4000)
     ──► 0x1a2b4000  PDPT[0x0b1] = 0x1a2b5067  (下一级 0x1a2b5000)
     ──► 0x1a2b5000  PD  [0x000] = 0x1a2b6067  (下一级 0x1a2b6000)
     ──► 0x1a2b6000  PT  [0x001] = 0x80000067  (pfn=0x80000, present|rw|user|dirty)
     ──► 物理地址 = 0x80000 << 12 | 0x234 = 0x80000234

表项的低 12 位是标志,高位是 pfn。0x1a2b4067 & ~0xfff = 0x1a2b4000,0x80000067 & ~0xfff = 0x80000000,所以 pfn 是 0x80000。如果这一项带 _PAGE_PSE(bit 7),walk 在 pmd 或 pud 就结束:

大页级别标志覆盖物理地址算法
pmd 2 MiB_PAGE_PSEVA[20:0](pmd & 0x000f_ffff_ffff_f000) + (VA & 0x1f_ffff)
pud 1 GiB_PAGE_PSEVA[29:0](pud & 0x000f_ffff_c000_0000) + (VA & 0x3fff_ffff)

写一个用户态程序把这三步打印出来,比背宏更可靠:

/* va_split.c — 打印一个虚拟地址的四级索引与 offset
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o va_split va_split.c
 */
#define _GNU_SOURCE
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
	uint64_t va = argc > 1 ? strtoull(argv[1], NULL, 0) : 0x00007f2c40001234ULL;

	printf("VA            = 0x%016" PRIx64 "\n", va);
	printf("pgd  index    = 0x%03" PRIx64 "\n", (va >> 39) & 0x1ff);
	printf("p4d  index    = 0x%03" PRIx64 "\n", (va >> 30) & 0x1ff);
	printf("pud  index    = 0x%03" PRIx64 "\n", (va >> 21) & 0x1ff);
	printf("pmd  index    = 0x%03" PRIx64 "\n", (va >> 12) & 0x1ff);
	printf("page offset   = 0x%03" PRIx64 "\n", va & 0xfff);
	/* 反推:把索引拼回去,验证没有取错位 */
	uint64_t rebuilt = (((va >> 39) & 0x1ff) << 39) |
			   (((va >> 30) & 0x1ff) << 30) |
			   (((va >> 21) & 0x1ff) << 21) |
			   (((va >> 12) & 0x1ff) << 12) |
			   (va & 0xfff);
	printf("rebuilt       = 0x%016" PRIx64 "  %s\n", rebuilt,
	       rebuilt == va ? "OK" : "MISMATCH");
	return rebuilt == va ? 0 : 1;
}

预期输出里 rebuilt == VA。这个「拼回去」的自检很重要:它同时验证了每一级的掩码与移位,比你盯着十六进制数猜要可靠。

五级页表把 p4d 变成真的一级:57 位地址切成 pgd[56:48]、p4d[47:39]、pud[38:30]、pmd[29:21]、pte[20:12]、offset[11:0]。在 4 级模式下,p4d_offset() 直接返回 pgd,编译器会把这一级优化掉;同一个二进制可以在两种模式下跑,靠的是 pgtable_l5_enabled() 和 p4d_none() 的折叠。

模式地址位用户空间上限页表级数每级索引位
4 级480x0000_7fff_ffff_f00049/9/9/9
5 级570x00ff_ffff_ffff_f00059/9/9/9/9

5 级不是「默认开启」:需要 CPU 支持 LA57、内核 CONFIG_X86_5LEVEL=y,并在启动时确认 la57 未被禁用。/proc/cpuinfo 的 la57 标志、dmesg | grep "5-level"、pgtable_l5_enabled() 三处可以交叉验证。用户态要拿到 57 位地址,必须给 mmap 一个高于 47 位的 hint(且 mmap_base 允许),否则内核仍把映射放在低 47 位。

TLB 是这条 walk 的缓存。一次 4 级 walk 最多 4 次内存访问(每级一次),TLB 命中时 0 次;PWC(page walk cache)能缓存中间级表项,部分命中时减少到 1–2 次。大页的价值在这里:

页大小一个 TLB 项覆盖覆盖 1 GiB 需要
4 KiB4 KiB262144 项
2 MiB2 MiB512 项
1 GiB1 GiB1 项

所以「大页省 TLB」省的是项数,不是 walk 的级数。数据库、JVM 堆用 MADV_HUGEPAGE 或 hugetlbfs,主要收益就在这里。

观测 TLB 与 walk:

# 缺页与 TLB 未命中(事件名因 CPU 而异,先 perf list 确认)
perf stat -e minor-faults,major-faults,dTLB-load-misses,iTLB-load-misses \
	  -e dtlb_load_misses.walk_completed ./va_split 0x7f2c40001234

# 页表 walk 的耗时占比(Intel)
perf stat -e cycles,dtlb_load_misses.walk_active,dtlb_load_misses.walk_pending \
	  -a sleep 5

dTLB-load-misses 高不一定是页表问题:也可能是工作集太大、TLB 太小。要区分「miss 但 walk 很快」与「walk 很慢」,看 walk_completed 与 walk_active 的比值,或者用 perf record -e cycles:u 看热点是否落在 __handle_mm_fault。

follow_page() 是内核里「给定 VMA 和地址,走一次页表并返回 struct page」的函数。它和 get_user_pages() 的关系是「零件与整机」:

/* mm/gup.c: follow_page() —— 6.8 节选,已简化 */
struct page *follow_page(struct vm_area_struct *vma, unsigned long address,
			 unsigned int foll_flags)
{
	struct follow_page_context ctx = { NULL };
	struct page *page;

	if (vma_is_secretmem(vma))
		return NULL;
	if (WARN_ON_ONCE(foll_flags & FOLL_PIN))
		return NULL;

	page = follow_page_mask(vma, address, foll_flags, &ctx);
	if (ctx.pgmap)
		put_dev_pagemap(ctx.pgmap);
	return page;
}

它的语义边界:

  • 只走一次页表,不保证页存在、不保证可写、不保证不迁移;这些由 foll_flags 决定。
  • FOLL_GET 才增加页引用;不带的调用者拿到的是「借用」的 struct page,随时可能被回收。
  • 遇到 PTE 不存在返回 NULL;遇到 pte_devmap 走 follow_devmap_pmd();遇到 migration entry 返回 NULL 或等待。
  • FOLL_WRITE 且 PTE 只读时,follow_page_pte() 会拒绝(这正是 Dirty COW 修复的关键判断)。
  • 它不处理 mmap_lock,调用者必须自己保证 VMA 不会被 munmap;这也是它不适合外部模块的原因之一(6.8 未 EXPORT_SYMBOL)。
FOLL_* 标志含义风险
FOLL_WRITE要求可写;只读 PTE 返回失败必须尊重 COW,不能硬写
FOLL_FORCE强制写(/proc/self/mem、ptrace)可绕过 VMA 权限,需 CAP_SYS_PTRACE 语义
FOLL_GET增加页引用忘记 put_page() 会泄漏
FOLL_PIN长期 pin(取代 FOLL_GET 的新接口)与 follow_page 互斥,必须用 pin_user_pages
FOLL_LONGTERM长期 pin,禁止可迁移页与 CMA/热插拔冲突
FOLL_NOWAIT不睡眠,失败即返回可能返回部分结果
FOLL_TOUCH更新 accessed/dirty影响 LRU 判断
FOLL_MLOCK顺手 mlock可能触发 RLIMIT_MEMLOCK
FOLL_SPLIT_PMD先拆大页增加分配失败风险
FOLL_HWPOISON允许返回 hwpoison 页调用者要检查 PageHWPoison

内核里真正做「用户页 → pfn」的稳定接口是 get_user_pages()/pin_user_pages(),它们内部会重试、处理 COW、处理 mmap_lock 的释放与重取。follow_page() 只适合「已经持有锁、明确知道页不会跑」的内核内部路径。写模块时不要图省事直接调它;用 pin_user_pages() 并配对 unpin_user_pages()。

页表与地址翻译的六个版本差异
  1. 4.x 的 mmap 红黑树:vma->vm_rb 是权威索引;6.8 换成 mm_mt,vm_rb 只用于区间树等局部结构,别再按红黑树写遍历。
  2. p4d 在 4 级下是空宏:p4d_offset() 返回 pgd,p4d_none() 恒假;给 4 级内核写「遍历五级」的代码会编译不过或永远走不到。
  3. 5 级页表的用户上限不是 0x7fff...:task_size 与 TASK_SIZE_MAX 随 LA57 变,硬编码 47 位会误判地址合法性。
  4. pmd_trans_huge() 不等于「大页」:6.x 用 pmd_leaf() 判断所有叶子项(含 devmap),pmd_trans_huge() 只覆盖 THP。
  5. follow_page() 不是导出符号:6.8 的外部模块不能直接调;要么编进内核,要么用 get_user_pages()/pin_user_pages()。
  6. _PAGE_ACCESSED 不是「最近访问」的精确计时:CPU 只在第一次访问时置位,内核清位后才知道「这段时间内访问过」,无法区分访问次数。

3.8.11 缺页的完整路径、返回值与失败注入

3.8.4 画了五条分支,但没回答三个工程问题:一次缺页返回什么?失败时用户态看到 SIGSEGV、SIGBUS 还是 OOM?怎么人为制造这些失败?这一节沿 exc_page_fault() 走到 handle_mm_fault(),把返回值和失败路径逐层拆开。

x86-64 的入口在 arch/x86/mm/fault.c:

/* arch/x86/mm/fault.c: exc_page_fault() —— 6.8 节选,已简化 */
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
	unsigned long address = read_cr2();
	...
	if (unlikely(is_prefetch(&regs->orig_ax))) {
		...
		return;
	}
	...
	handle_page_fault(regs, error_code, address);
}

error_code 的位定义是硬件给的,读它比读 regs 快:

位名称含义
0X86_PF_PROT0=页不存在,1=权限违例
1X86_PF_WRITE1=写访问
2X86_PF_USER1=用户态(CPL=3)
3X86_PF_RSVD1=保留位被置(页表项损坏)
4X86_PF_INSTR1=取指(NX 违例)
5X86_PF_PK1=protection key 违例
6X86_PF_SHSTK1=shadow stack 违例
7X86_PF_SGX1=SGX 相关

用户态地址走 do_user_addr_fault():先 find_vma(),找不到就检查栈能否增长(VM_GROWSDOWN),再检查 address >= TASK_SIZE_MAX(内核地址误入)。都失败就 bad_area() → SIGSEGV。这一层不分配内存,只做「地址是否属于某个 VMA」的判定。

找到 VMA 后进 handle_mm_fault()。它的 flags 不是硬件 error_code,而是内核语义位:

FAULT_FLAG_*含义谁置位
WRITE写缺页error_code & X86_PF_WRITE
INSTRUCTION取指缺页error_code & X86_PF_INSTR
USER用户态发起error_code & X86_PF_USER
ALLOW_RETRY允许释放 mmap_lock 后重试用户态缺页默认置
RETRY_NOWAIT重试时不睡眠FAULT_FLAG_RETRY_NOWAIT
KSMKSM 合并路径ksm_might_need_to_copy()
UFFD_WPuserfaultfd 写保护handle_userfault()
UNSHARE拆共享匿名页(GUP 用)follow_page_pte() 的 FOLL_WRITE
REMOTE远程 mm(process_madvise)madvise 跨进程

返回值 VM_FAULT_* 决定上层怎么办:

返回值含义用户态结果
VM_FAULT_MINOR已解决,不需要 I/O正常返回
VM_FAULT_MAJOR已解决,但有 I/O正常返回,计入 majflt
VM_FAULT_RETRY锁被释放,需重试重新走 fault
VM_FAULT_OOM内存耗尽pagefault_out_of_memory() → OOM 或 SIGKILL
VM_FAULT_SIGBUS总线错误(文件截断、对齐)SIGBUS,si_addr 是出错地址
VM_FAULT_SIGSEGV段错误(权限、地址非法)SIGSEGV
VM_FAULT_NOPAGE页表已装好但页不可用(设备)由调用者决定
VM_FAULT_HWPOISON硬件毒页SIGBUS(MCE 相关)
VM_FAULT_DELAY架构要求延迟极少见
VM_FAULT_NEED_PER_PTE需要 per-PTE 锁重试内部使用

这些位可以组合(VM_FAULT_ERROR 是错误掩码,VM_FAULT_RETRY 可与 MAJOR 并存)。读内核日志时看到 VM_FAULT_RETRY 不一定是 bug:文件页锁竞争时,filemap_fault() 会主动返回它,让调用者释放 mmap_lock 后重来,避免长时间持锁。

handle_pte_fault() 是真正的分派点。它先读 PTE,再按「PTE 当前是什么」分:

/* mm/memory.c: handle_pte_fault() —— 6.8 节选,已简化 */
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
	pte_t entry;

	if (unlikely(pmd_none(*vmf->pmd))) {
		vmf->pte = NULL;
	} else {
		vmf->pte = pte_offset_map(vmf->pmd, vmf->address);
		...
		entry = ptep_get(vmf->pte);
		if (pte_none(entry)) {
			if (vma_is_anonymous(vmf->vma))
				return do_anonymous_page(vmf);
			else
				return do_fault(vmf);
		}
		if (pte_protnone(entry))
			return do_numa_page(vmf);
		if (unlikely(pte_devmap(entry)))
			return do_devmap(vmf);
		if (unlikely(is_swap_entry(entry)))
			return do_swap_page(vmf);
		if (unlikely(is_migration_entry(entry)))
			return do_migration(vmf);
		if (unlikely(pte_special(entry)))
			return do_special(vmf);
	}
	if (vmf->flags & FAULT_FLAG_WRITE) {
		if (!pte_write(entry))
			return do_wp_page(vmf);
		entry = pte_mkdirty(entry);
	}
	entry = pte_mkyoung(entry);
	...
	return 0;
}

几个容易忽略的失败点:

  1. pmd_none() 不等于 pte_none():中间级页表还没分配时,vmf->pte 为空,要先 __pte_alloc()。分配失败返回 VM_FAULT_OOM。
  2. pte_protnone() 是 NUMA hint fault:PTE 被故意设成「不可访问但存在」,用来采样访问位置,不是错误。
  3. pte_special() 是「没有 struct page 的映射」:vm_normal_page() 返回 NULL,不能 get_page();VM_PFNMAP/VM_MIXEDMAP 走这条。
  4. 写缺页但 PTE 只读:do_wp_page()。它可能是 COW,也可能是 mprotect(PROT_READ) 后的权限恢复,两者处理不同。

匿名缺页的分配链:

/* mm/memory.c: do_anonymous_page() —— 6.8 节选,已简化 */
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
	struct vm_area_struct *vma = vmf->vma;
	struct folio *folio;
	pte_t entry;

	if (unlikely(anon_vma_prepare(vma)))
		return VM_FAULT_OOM;

	if (!(vmf->flags & FAULT_FLAG_WRITE) &&
	    !mm_forbids_zeropage(vma->vm_mm)) {
		entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
					      vma->vm_page_prot));
		...
		return 0;
	}
	folio = vma_alloc_zeroed_movable_folio(vma, vmf->address);
	if (!folio)
		return VM_FAULT_OOM;
	if (mem_cgroup_charge(folio, vma->vm_mm, GFP_KERNEL)) {
		folio_put(folio);
		return VM_FAULT_OOM;
	}
	...
	entry = mk_pte(&folio->page, vma->vm_page_prot);
	entry = pte_mkwrite(pte_mkdirty(entry), vma);
	...
	page_add_new_anon_rmap(&folio->page, vma, vmf->address, false);
	lru_cache_add_inactive_or_unevictable(&folio->page, vma);
	...
	set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
	return 0;
}

三条失败路径:anon_vma_prepare() 失败(分配 anon_vma 失败)、vma_alloc_zeroed_movable_folio() 失败(伙伴系统无页)、mem_cgroup_charge() 失败(memcg 限额)。三者都返回 VM_FAULT_OOM,但语义不同:前两者是全局内存不足,第三者可能只是这个 cgroup 到顶了。pagefault_out_of_memory() 会先尝试 memcg OOM,再走全局 OOM。

文件缺页的三分支在 3.8.4 已给,这里补失败与重试:

/* mm/memory.c: do_read_fault() —— 6.8 节选,已简化 */
static vm_fault_t do_read_fault(struct vm_fault *vmf)
{
	struct vm_fault vmf_orig;
	vm_fault_t ret;

	/*
	 * 让 ->map_pages() 一次性映射周围 16 页(fault-around),
	 * 减少后续缺页;失败不影响主页。
	 */
	if (vma->vm_ops->map_pages && fault_around_bytes >> PAGE_SHIFT > 1) {
		if (likely(!(vmf->flags & FAULT_FLAG_RETRY_NOWAIT))) {
			ret = do_fault_around(vmf);
			if (ret)
				return ret;
		}
	}
	...
	ret = __do_fault(vmf);
	if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
		return ret;
	...
}

filemap_fault() 在页不在 page cache 时会发起 readahead 并睡眠等 I/O;如果页被锁住且 FAULT_FLAG_ALLOW_RETRY 置位,它返回 VM_FAULT_RETRY,由 fault_retry 标签释放 mmap_lock 后重来。这个「释放锁重试」机制是文件缺页在高并发下不阻塞其他线程的关键。

SIGBUS 的触发条件比想象中窄:文件被 ftruncate() 缩短后,访问超出新 EOF 的页;mmap 的 offset 超过文件末尾且没有 MAP_NORESERVE 保护;DAX/PMEM 的媒体错误;MADV_HWPOISON 注入。普通的「页不在内存」不会 SIGBUS,只会 major fault。

MADV_DONTNEED 与 MADV_FREE 的差别也要在这里说清:前者立刻丢 PTE 与页(匿名页内容丢失,下次读为零;文件页只丢 PTE,page cache 保留);后者只标记「可回收」,真正回收发生在内存压力下,且回收前读仍能看到旧值。MADV_FREE 的语义更弱,适合「可重建的缓存」。

3.8.12 mmap/mremap/munmap:参数、errno 与失败路径

3.8.3 讲了 mmap 建 VMA 的主流程,但工程代码里更多是「为什么返回 EINVAL」「mremap 什么时候搬家」「munmap 之后 TLB 怎么办」。这一节把四个系统调用的参数、返回值和真实 errno 列全。

mmap 的原型与参数:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
int mprotect(void *addr, size_t len, int prot);
void *mremap(void *old_address, size_t old_size, size_t new_size, int flags, ...);
int msync(void *addr, size_t length, int flags);
int mlock(const void *addr, size_t len);
int munlock(const void *addr, size_t len);
int madvise(void *addr, size_t length, int advice);

prot 是「期望的权限」,不是「当前 PTE 的权限」:

PROT_*值含义与 VMA 的对应
PROT_NONE0不可访问VM_NONE,缺页一律 SIGSEGV
PROT_READ1可读VM_READ
PROT_WRITE2可写VM_WRITE(隐含 VM_MAYWRITE)
PROT_EXEC4可执行VM_EXEC(受 NX、SELinux execmem 限制)
PROT_GROWSDOWN0x01000000向下增长(栈)VM_GROWSDOWN
PROT_GROWSUP0x02000000向上增长VM_GROWSUP(少数架构)
PROT_SAO0x04000000强序(PowerPC)架构相关

flags 必须包含 MAP_PRIVATE、MAP_SHARED 或 MAP_SHARED_VALIDATE 之一,否则 EINVAL。常用组合的语义:

组合写时行为典型用途
`MAP_PRIVATEMAP_ANONYMOUS`COW,不落盘
`MAP_SHAREDMAP_ANONYMOUS`共享,由 shmem 承载
`MAP_PRIVATEfd`读共享 page cache,写 COW
`MAP_SHAREDfd`写回文件,页缓存共享
`MAP_SHARED_VALIDATEfd`同 MAP_SHARED,但未知 flag 报 EOPNOTSUPP

mmap 的真实 errno:

errno触发条件常见误判
EINVALlength==0;offset 未页对齐;flags 缺共享/私有位;prot 与 flags 冲突把 length 传成 0 当成「映射全部」
ENOMEM地址空间不够;VMA 数超 vm.max_map_count;overcommit 拒绝与 EAGAIN 混淆
EACCES文件权限不允许 PROT_READ/WRITE;MAP_SHARED 但文件只读以为 root 一定能写
EBADFfd 无效;MAP_ANONYMOUS 时 fd 不是 -1(旧内核)传了 fd=0
ENODEV文件系统不支持 mmap(如某些 procfs 文件)对 /proc 文件盲目 mmap
ENOTTY设备不支持 mmap驱动没实现 .mmap
EOVERFLOWoffset 超出文件最大偏移(32 位 off_t)32 位程序映射大文件
EPERMMAP_HUGETLB 但无权限;/proc/sys/vm/mmap_min_addr 限制低地址映射被拒
ETXTBSYMAP_DENYWRITE 且文件正被写旧二进制格式

mremap 的 flags 决定「能不能搬家」:

flag语义失败/边界
MREMAP_MAYMOVE允许内核把映射搬到新地址不置位且原地扩不下 → ENOMEM
MREMAP_FIXED必须搬到 new_address(需 MAYMOVE)目标被占用 → EINVAL/ENOMEM;会覆盖目标
MREMAP_DONTUNMAP搬走后保留旧映射(5.7+)只支持私有匿名映射;用于 CRIU 无暂停迁移
`MREMAP_DONTUNMAPMREMAP_FIXED`搬到指定地址且保留旧映射

mremap 返回新地址,旧地址不再有效(除 DONTUNMAP)。它可能只改 VMA 边界(原地扩展),也可能分配新 VMA、复制页表、刷 TLB。调用后必须用返回值,不能假设「没搬家」。

mprotect 的坑在「部分改」:它会把跨界的 VMA 拆开,改中间那段,再尝试合并。PROT_GROWSDOWN/PROT_GROWSUP 只对栈式 VMA 有意义,且要求 addr 在边界上。pkey_mprotect() 是它的扩展,用 protection key 给页打标签(x86 的 PKU),适合「同一进程内分区权限」。

msync 只对 MAP_SHARED 有意义:

flag行为
MS_ASYNC只把脏页排入回写队列,立即返回
MS_SYNC等待回写完成
MS_INVALIDATE让其他映射的缓存副本失效(实现相关)

msync 返回 0 不代表数据已落盘(MS_ASYNC),也不保证元数据(文件大小)已更新。要持久化,msync(MS_SYNC) 后还需 fsync(fd)(元数据)或使用 sync_file_range() 控制范围。

mlock 的边界是 RLIMIT_MEMLOCK:

ulimit -l                 # KiB,容器里常见 64 或 8192
cat /proc/self/limits | grep -i locked

mlock 失败返回 ENOMEM(不是 EPERM),且已锁的部分不会自动解锁。mlock2(..., MCL_ONFAULT)(4.4+)只锁「已经缺页进来」的页,避免一次性触碰全部内存;mlockall(MCL_CURRENT|MCL_FUTURE) 锁当前与未来映射,实时程序常用,但要算清限额。

munmap 的语义:

  • addr 必须页对齐,否则 EINVAL;
  • 可以只解除一段,内核会拆分 VMA;
  • 解除后访问是 SIGSEGV,不是「读旧值」;
  • 页表项与 TLB 要同步失效;多线程程序里其他线程可能还在用旧指针(这是 use-after-munmap,不是内核 bug);
  • munmap 不会因为「还有别的映射指向同一页」而保留页;page cache 的页由引用计数决定。

vm.max_map_count 是 VMA 数量的硬上限(默认 65530):

sysctl vm.max_map_count
# 超过时 mmap 返回 ENOMEM,dmesg 不一定有记录

内存分配器(glibc malloc 的 arena、JVM 的 GC、Go 的 heap)在大量小映射时会撞上这个限制。调大它能缓解,但根因通常是「映射太碎」,应该用 arena 或大块 mmap。

overcommit 决定「虚拟地址能否超过物理内存 + swap」:

/proc/sys/vm/overcommit_memory策略mmap 行为
0启发式(默认)明显过大的请求拒绝,其余允许
1总是允许不检查,OOM 在触碰时发生
2严格记账CommitLimit 用尽后 ENOMEM

CommitLimit = swap + RAM * overcommit_ratio / 100(模式 2),Committed_AS 是已承诺的地址空间。MAP_NORESERVE 让这段映射不参与记账,但不保证触碰时一定有页——它只是把风险推迟到缺页。

mmap_lock 的争用可以用 perf 看:

# 内核锁的争用(需要 CONFIG_LOCK_STAT)
perf lock stat -i sleep 5
# 或看 mmap_lock 的持有时长(bpftrace)
bpftrace -e 'kprobe:down_write { @start[tid] = nsecs; }
             kretprobe:down_write /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }'

高频 mmap/munmap 的服务(如大量短连接、频繁加载 so)会把 mmap_lock 变成瓶颈。优化方向:对象池、mmap 大段后自己切分、MADV_DONTNEED 代替 munmap(保留 VMA,减少树操作)、升级到 6.4+ 用 per-VMA lock 缓解缺页侧争用。

3.8.13 观测与实验:smaps、pagemap、SIGBUS 与 bpftrace

3.8.7 介绍了 maps/smaps/pagemap 的用途,但没有解释每个字段怎么算、pagemap 的 pfn 为什么常是 0、怎么用 tracepoint 把缺页分支数出来。这一节补三样东西:smaps 字段表、两个可运行程序、一组 perf/bpftrace 命令。

/proc/PID/smaps 的每个 VMA 一段,字段比 maps 多得多:

字段含义计算/判读
SizeVMA 虚拟大小vm_end - vm_start
KernelPageSize内核页大小通常是 4 KiB
MMUPageSizeMMU 页大小大页映射时为 2 MiB/1 GiB
Rss常驻物理页共享页全额计入
Pss按共享比例分摊共享页除以映射数
Pss_Dirty分摊后的脏页估算回写成本
Shared_Clean共享干净页多进程共享的只读页
Shared_Dirty共享脏页MAP_SHARED 写过的页
Private_Clean私有干净页私有文件映射、未写 COW 页
Private_Dirty私有脏页匿名页、已写 COW 页
Referenced最近被访问的页近似工作集
Anonymous匿名页不含 shmem
LazyFreeMADV_FREE 标记的页可丢弃但尚未丢
AnonHugePages匿名大页THP 折叠量
ShmemPmdMappedshmem 大页tmpfs
FilePmdMapped文件大页DAX/大页文件系统
Shared_Hugetlb/Private_Hugetlbhugetlbfs 页不计入 Rss
Swap已换出的页每页 swap 用量
SwapPss分摊后的 swap共享页换出时按比例
Lockedmlock 的页不可回收
THPeligible是否适合 THP0/1
ProtectionKeypkey 编号pkey_mprotect
VmFlagsVMA 标志的字母编码见下

两个恒等式要先记住:

Rss = Shared_Clean + Shared_Dirty + Private_Clean + Private_Dirty
Pss ≈ Private_Clean + Private_Dirty + (Shared_Clean + Shared_Dirty) / 共享映射数

Pss 不是精确等式,因为「共享映射数」按物理页的 mapcount 算,而 smaps 只按当前 VMA 聚合。所以 smaps_rollup 的 Pss 才是全进程口径,smaps 单段相加会因四舍五入略有出入。

VmFlags 是一串两字母编码,常用的几个:

编码含义编码含义
rd可读wr可写
ex可执行sh共享
mrVM_MAYREADmwVM_MAYWRITE
meVM_MAYEXECmsVM_MAYSHARE
gdVM_GROWSDOWNpfVM_PFNMAP
dwVM_DONTWRITE?(6.x 为 VM_DROPPABLE 等)loVM_LOCKED
ioVM_IOsrVM_SEQ_READ
rrVM_RAND_READdcVM_DONTCOPY
deVM_DONTEXPANDacVM_ACCOUNT
nrVM_NORESERVEhtVM_HUGETLB
arVM_ARCH_1wfVM_WIPEONFORK
ddVM_DONTDUMPsdVM_SOFTDIRTY
mmVM_MIXEDMAPhgVM_HUGEPAGE
nhVM_NOHUGEPAGEmgVM_MERGEABLE

编码随版本增删,别把它当稳定 ABI。真正要判断语义时,用 smaps 的字段与 maps 的权限位交叉验证。

实验一:mmap 的边界与 SIGBUS

这个程序故意触发四类失败:length==0、offset 未对齐、MAP_FIXED_NOREPLACE 撞车、文件截断后的 SIGBUS。它验证的是「errno 与信号的分工」:参数错误走 errno,访问越界走信号。

/* mmap_edge.c — 触发 mmap 的边界错误与 SIGBUS
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o mmap_edge mmap_edge.c
 * 运行: ./mmap_edge
 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <fcntl.h>
#include <setjmp.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

#ifndef MAP_FIXED_NOREPLACE
#define MAP_FIXED_NOREPLACE 0x100000	/* 4.17+;老内核会退化成 hint */
#endif

static sigjmp_buf jb;
static volatile sig_atomic_t got_bus;

static void bus_handler(int sig, siginfo_t *si, void *uc)
{
	char buf[128];
	int n;

	(void)sig;
	(void)uc;
	got_bus = 1;
	/* write() 是 async-signal-safe;printf() 不是 */
	n = snprintf(buf, sizeof buf, "caught SIGBUS at %p\n", si->si_addr);
	if (n > 0)
		write(STDERR_FILENO, buf, (size_t)n);
	siglongjmp(jb, 1);
}

int main(void)
{
	long ps = sysconf(_SC_PAGESIZE);
	int fd, ret;
	char *base, *filemap;
	void *p;

	if (ps <= 0) { perror("sysconf"); return 1; }

	/* 1. length == 0:EINVAL */
	errno = 0;
	p = mmap(NULL, 0, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
	printf("mmap(len=0)                 -> %s, errno=%d (%s)\n",
	       p == MAP_FAILED ? "MAP_FAILED" : "ok", errno, strerror(errno));

	/* 2. offset 未页对齐:EINVAL */
	fd = open("/tmp/mmap_edge.bin", O_RDWR | O_CREAT | O_TRUNC, 0600);
	if (fd < 0) { perror("open"); return 1; }
	if (ftruncate(fd, ps * 2) < 0) { perror("ftruncate"); return 1; }
	errno = 0;
	p = mmap(NULL, (size_t)ps, PROT_READ, MAP_SHARED, fd, 1);
	printf("mmap(offset=1)              -> %s, errno=%d (%s)\n",
	       p == MAP_FAILED ? "MAP_FAILED" : "ok", errno, strerror(errno));

	/* 3. MAP_FIXED_NOREPLACE 撞上已有映射:EEXIST */
	base = mmap(NULL, (size_t)ps, PROT_READ | PROT_WRITE,
		    MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
	if (base == MAP_FAILED) { perror("mmap base"); return 1; }
	errno = 0;
	p = mmap(base, (size_t)ps, PROT_READ,
		 MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
	printf("mmap(FIXED_NOREPLACE hit)   -> %s, errno=%d (%s)\n",
	       p == MAP_FAILED ? "MAP_FAILED" : "ok", errno, strerror(errno));
	munmap(base, (size_t)ps);

	/* 4. 文件被截断后访问映射页:SIGBUS */
	filemap = mmap(NULL, (size_t)ps * 2, PROT_READ | PROT_WRITE,
		       MAP_SHARED, fd, 0);
	if (filemap == MAP_FAILED) { perror("mmap file"); return 1; }
	filemap[0] = 'A';			/* 先触碰,建立 PTE */
	if (ftruncate(fd, 0) < 0) { perror("ftruncate 0"); return 1; }

	struct sigaction sa = { 0 };
	sa.sa_sigaction = bus_handler;
	sa.sa_flags = SA_SIGINFO;
	sigemptyset(&sa.sa_mask);
	if (sigaction(SIGBUS, &sa, NULL) < 0) { perror("sigaction"); return 1; }

	if (sigsetjmp(jb, 1) == 0) {
		volatile char *vp = filemap;
		(void)vp[0];			/* 访问超出新 EOF 的页 */
		printf("no SIGBUS (unexpected)\n");
	} else {
		printf("SIGBUS handled, got_bus=%d\n", (int)got_bus);
	}

	ret = munmap(filemap, (size_t)ps * 2);
	printf("munmap(file)                -> %d, errno=%d\n", ret, errno);
	close(fd);
	unlink("/tmp/mmap_edge.bin");
	return 0;
}

预期输出(内核 6.x,非 root):

mmap(len=0)                 -> MAP_FAILED, errno=22 (Invalid argument)
mmap(offset=1)              -> MAP_FAILED, errno=22 (Invalid argument)
mmap(FIXED_NOREPLACE hit)   -> MAP_FAILED, errno=17 (File exists)
caught SIGBUS at 0x7f...
SIGBUS handled, got_bus=1
munmap(file)                -> 0, errno=0

关键行:

  • mmap(len=0) 在进入内核前就被 glibc 检查,返回 EINVAL;不要用 0 表示「映射整个文件」。
  • MAP_FIXED_NOREPLACE 撞上已有映射返回 EEXIST,不是 ENOMEM;4.17 之前没有这个 flag,会退化成 hint,代码要判断内核版本或先 mmap 探测。
  • SIGBUS 的 si_addr 是出错地址,si_code 通常是 BUS_ADRERR;SIGSEGV 的 si_code 是 SEGV_MAPERR/SEGV_ACCERR。两者不要混用处理逻辑。
  • 文件截断后,已经缺页进来的页仍可读(内容在 page cache),但超出新 EOF 的页访问会 SIGBUS;这是「文件大小是映射的边界」的直接体现。

实验二:pagemap 扫描与 pfn

这个程序分配 2 MiB,只写偶数页,然后从 /proc/self/pagemap 读每一项,统计 present/swapped,并用 mincore 交叉验证。

/* pagemap_scan.c — 用 /proc/self/pagemap 把虚拟页映射到 pfn
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o pagemap_scan pagemap_scan.c
 * 运行: ./pagemap_scan           (读 pfn 需要 CAP_SYS_ADMIN)
 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

#define NPAGES 512

static int read_entry(int fd, const void *addr, uint64_t *entry)
{
	uintptr_t vpn = (uintptr_t)addr / (uintptr_t)sysconf(_SC_PAGESIZE);
	off_t off = (off_t)vpn * 8;

	if (pread(fd, entry, sizeof *entry, off) != (ssize_t)sizeof *entry)
		return -1;
	return 0;
}

int main(void)
{
	long ps = sysconf(_SC_PAGESIZE);
	size_t len = (size_t)NPAGES * (size_t)ps;
	unsigned char *p;
	int fd, i, present = 0, swapped = 0, zero_pfn = 0;
	uint64_t entry;

	if (ps <= 0) { perror("sysconf"); return 1; }
	p = mmap(NULL, len, PROT_READ | PROT_WRITE,
		 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
	if (p == MAP_FAILED) { perror("mmap"); return 1; }

	/* 只触碰偶数页:奇数页保持未分配 */
	for (i = 0; i < NPAGES; i += 2)
		p[(size_t)i * (size_t)ps] = (unsigned char)i;

	fd = open("/proc/self/pagemap", O_RDONLY);
	if (fd < 0) { perror("/proc/self/pagemap"); return 1; }

	printf("%-6s %-8s %-8s %-8s %-8s %-12s\n",
	       "page", "present", "swapped", "file", "softdirty", "pfn");
	for (i = 0; i < NPAGES; i++) {
		if (read_entry(fd, p + (size_t)i * (size_t)ps, &entry) < 0) {
			perror("pread");
			break;
		}
		int pr = (int)(entry >> 63);
		int sw = (int)((entry >> 62) & 1);
		int fl = (int)((entry >> 61) & 1);
		int sd = (int)((entry >> 55) & 1);
		uint64_t pfn = entry & ((1ULL << 55) - 1);

		present += pr;
		swapped += sw;
		if (pr && pfn == 0)
			zero_pfn++;
		if (i < 8 || i == NPAGES - 1)
			printf("%-6d %-8d %-8d %-8d %-8d 0x%010" PRIx64 "\n",
			       i, pr, sw, fl, sd, pfn);
	}

	/* mincore 只看「在不在内存」,不需要特权 */
	void *vec = calloc(NPAGES, 1);
	if (vec && mincore(p, len, vec) == 0) {
		int resident = 0;
		for (i = 0; i < NPAGES; i++)
			resident += ((unsigned char *)vec)[i] & 1;
		printf("mincore resident pages: %d / %d\n", resident, NPAGES);
	}
	free(vec);

	printf("present=%d swapped=%d present_but_pfn0=%d\n",
	       present, swapped, zero_pfn);
	if (present > 0 && zero_pfn == present)
		printf("note: pfn 全为 0,通常是缺少 CAP_SYS_ADMIN\n");

	/* 丢掉后半段,再看 present 的变化 */
	madvise(p + len / 2, len / 2, MADV_DONTNEED);
	present = 0;
	for (i = NPAGES / 2; i < NPAGES; i++) {
		if (read_entry(fd, p + (size_t)i * (size_t)ps, &entry) == 0)
			present += (int)(entry >> 63);
	}
	printf("after MADV_DONTNEED, second half present=%d / %d\n",
	       present, NPAGES / 2);

	close(fd);
	munmap(p, len);
	return 0;
}

预期输出(非 root,pfn 被抹零):

page   present  swapped  file     softdirty  pfn
0      1        0        0        0          0x0000000000
1      0        0        0        0          0x0000000000
...
mincore resident pages: 256 / 512
present=256 swapped=0 present_but_pfn0=256
note: pfn 全为 0,通常是缺少 CAP_SYS_ADMIN
after MADV_DONTNEED, second half present=0 / 256

以 root 运行时 pfn 非零,可以看到偶数页的 pfn 不连续(伙伴系统分配的页不一定相邻)。这个「pfn 不连续」就是 vmalloc 与 mmap 的本质:虚拟连续不等于物理连续。

pagemap 的位布局(6.8):

位含义
0–54pfn(present 时)或 swap 编码(swapped 时)
55soft-dirty
56exclusively mapped(PageAnonExclusive)
57uffd-wp 写保护
58–60保留(0)
61文件页或共享匿名页
62swapped
63present

注意:present=1 且 pfn=0 不一定是「零页」,也可能只是「无权限读 pfn」。要区分,读 /proc/self/status 的 CapEff,或用 mincore 确认页确实在内存。

perf 与 bpftrace:把缺页分支数出来

# 1. 进程级缺页计数(minor/major 分开)
perf stat -e minor-faults,major-faults,page-faults ./pagemap_scan

# 2. 跟踪每一次用户态缺页的地址与错误码(需要 root)
perf trace -e exceptions:page_fault_user -a sleep 3

# 3. 按「页不存在 / 权限违例」分组(bpftrace)
sudo bpftrace -e '
tracepoint:exceptions:page_fault_user {
	@faults[comm, args->error_code & 1] = count();
}
interval:s:5 { exit(); }'

# 4. 五条分支各触发多少次
sudo bpftrace -e '
kprobe:do_anonymous_page,
kprobe:do_wp_page,
kprobe:do_fault,
kprobe:do_swap_page { @[probe] = count(); }
interval:s:10 { exit(); }'

# 5. handle_mm_fault 的返回值分布(VM_FAULT_*)
sudo bpftrace -e '
kretprobe:handle_mm_fault { @ret[retval] = count(); }
interval:s:10 { exit(); }'

exceptions:page_fault_user 的 args->address 是故障地址,args->error_code 是硬件错误码(位定义见 3.8.11)。把 error_code & 1 当 key,0 是「页不存在」,1 是「权限违例」;两者在 perf stat 里都算 page-faults,但处理路径完全不同。

kretprobe:handle_mm_fault 的 retval 是 vm_fault_t,常见值:0(VM_FAULT_MINOR 的数值是 0)、0x100(VM_FAULT_MAJOR? 以 6.8 头文件为准)、0x400(VM_FAULT_RETRY)。不要背数值,直接用 bpftrace 打印十六进制并与 include/linux/mm_types.h 的 VM_FAULT_* 对照。

例 3.8.2 · 一次 major fault 的定位

某服务启动后 perf stat 显示 major-faults 约 3 万,minor-faults 只有 5 万,比例异常。用 perf trace -e exceptions:page_fault_user 抓 3 秒,发现故障地址集中在同一个 200 MiB 文件的固定偏移。

进一步用 bpftrace 统计 do_fault 的调用栈:

sudo bpftrace -e 'kprobe:do_fault { @[ustack(5)] = count(); } interval:s:5 { exit(); }'

栈顶是 filemap_fault,说明不是匿名页;/proc/PID/smaps 对应 VMA 的 Rss 很小、Swap 为 0,说明页缓存没命中。根因是文件被 posix_fadvise(POSIX_FADV_DONTNEED) 周期性丢弃,每次访问都要读盘。修复是去掉这条建议,或把热数据 mlock/MAP_POPULATE 预读。

3.8.14 虚拟内存的十个版本陷阱

这一节按「现象 → 原因 → 修复」列 10 条,覆盖 4.x 到 6.8 的差异与用户态 API 的边界。它们和 3.8.4 的五条误解不重复。

十个容易踩的坑
  1. mmap 长度传 0:现象是 EINVAL;原因是 length 必须大于 0,内核不会把 0 解释为「到文件末尾」;修复是先用 fstat 取大小,或显式传 st_size。文件大小可以在 mmap 后变化,映射长度不会自动跟随。

  2. 用 MAP_FIXED 覆盖了 libc 的映射:现象是随机崩溃、malloc 内部断言失败;原因是 MAP_FIXED 会静默拆掉目标地址上的旧 VMA;修复是改用 MAP_FIXED_NOREPLACE(4.17+),或先 mmap(NULL, ...) 拿 hint 再 mremap。

  3. 以为 MAP_POPULATE 会为匿名映射分配物理页:现象是 mmap(MAP_POPULATE) 后 Rss 仍然接近 0;原因是匿名读缺页只装全局零页,不分配私有页,MAP_POPULATE 走一遍 GUP 也只是把 PTE 指向零页;修复是要预分配就逐页写(或 MADV_POPULATE_WRITE,5.14+)。

  4. fork 后子进程改了 MAP_SHARED 匿名内存,父进程也变:现象是「明明没通信却串了数据」;原因是 MAP_SHARED|MAP_ANONYMOUS 由 shmem 承载,本来就在父子间共享;修复是用 MAP_PRIVATE,或在 fork 前 madvise(MADV_DONTFORK)(子进程看不到这段映射)。

  5. mremap 后继续用旧指针:现象是偶发 SIGSEGV 或读到旧数据;原因是 mremap 可能搬家并释放旧 VMA;修复是只用返回值,必要时用 MREMAP_DONTUNMAP(5.7+)保留旧映射,但那会额外占用地址空间。

  6. munmap 只解除一部分,邻居没有合并:现象是 VMA 数量持续上涨、vm.max_map_count 被打满;原因是频繁 mmap/munmap 留下碎片;修复是用 MADV_DONTNEED 保留 VMA,或一次映射大段自己切分,减少树操作。

  7. 用 pagemap 的 pfn 做长期缓存:现象是迁移/THP 折叠后 pfn 变了,DMA 读到错页;原因是 pfn 不是稳定标识,页可以被迁移、合并、回收;修复是用 pin_user_pages() 锁住页,或通过 IOMMU/dma_map 拿设备地址。

  8. mlock 失败被忽略:现象是实时程序仍被换出/回收,延迟尖峰;原因是 mlock 返回 ENOMEM(RLIMIT_MEMLOCK 不够),代码没检查;修复是检查返回值、调大 ulimit -l,或用 mlock2(MCL_ONFAULT) 降低峰值需求。

  9. 非 root 用 userfaultfd 失败:现象是 userfaultfd() 返回 EPERM;原因是 vm.unprivileged_userfaultfd=0(默认)且进程没有 CAP_SYS_PTRACE;修复是授权、调 sysctl(有安全风险),或改用 UFFD_USER_MODE_ONLY(5.11+)只处理用户态缺页。

  10. 在 4 级内核上假设 5 级地址布局:现象是高位地址被拒绝、mmap 返回 ENOMEM;原因是 TASK_SIZE_MAX 与 pgd_index 的位宽随 LA57 变;修复是用 pgtable_l5_enabled()/task_size 判断,不硬编码 47 位或 56 位。

3.8.15 本章出口检查

检查项达标线没达标去哪
画出 mm → VMA → PTE → page能说出每层谁指向谁3.8.1
手算四级索引给定地址说出 pgd/pud/pmd 索引3.8.2
解释大页为什么省 TLB覆盖率而非 walk 次数3.8.2
说清 mmap 建了什么VMA 与 maple tree,没有 PTE3.8.3
说出缺页五条分支anon/wp/fault/swap/numa3.8.4
区分 RSS 与 PSS共享页的分摊规则3.8.7
走查页表拿到 pfn模块跑通并解释输出3.8.8
说出 mm_users/mm_count 的差别谁能阻止 exit_mmap()3.8.9
手算一个地址的四级索引0x7f2c40001234 算出 0xfe/0xb1/0/1/0x2343.8.10
区分 VM_FAULT_* 返回值OOM/SIGBUS/RETRY 各自的去向3.8.11
说出 mmap 的三种 errnoEINVAL/ENOMEM/EEXIST 的条件3.8.12
用 pagemap 验证 pfn区分「零页」与「无权限」3.8.13
避免 MAP_FIXED 覆盖知道 MAP_FIXED_NOREPLACE 的语义3.8.14
即时自测 · 3.8

Q1 一个 48 位虚拟地址 0x00007f2c40001234,四级索引分别是多少?页内偏移是多少?

Q2 mmap(1 GiB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS) 之后,/proc/self/statm 的第二列(resident)为什么接近 0?什么时候会涨?

Q3 读一个从未写过的匿名页,内核做了什么?为什么多个进程读同一段匿名内存会共享同一个物理页?

Q4 pmd_trans_huge() 为真时,还需要查 PTE 吗?pmd_leaf() 与它有什么区别?

Q5 一个进程的 RSS 是 1 GiB,PSS 是 600 MiB,说明什么?如果所有进程的 RSS 之和是 32 GiB 而机器只有 16 GiB,可能吗?

答案

A1 取位:VA[47:39] = 0x7f2c40001234 >> 39 & 0x1ff;VA[38:30];VA[29:21];VA[20:12];偏移 VA[11:0] = 0x234。手算时先写成二进制或用 (addr >> 39) & 0x1ff 逐级取,不要试图心算十六进制。

A2 因为 mmap 只建 VMA,不建 PTE;resident 统计的是已映射的物理页。第一次读(映射零页)也不涨 RSS;第一次写每页才分配,RSS 才涨。MAP_POPULATE 对匿名映射不预分配,所以也不涨。

A3 读缺页时 do_anonymous_page() 把 PTE 指向全局零页(my_zero_pfn()),只读、共享,不分配。所有进程的未写匿名页都指向同一个零页,所以物理上共享;写入时触发保护缺页,才复制/分配。

A4 不需要。pmd_trans_huge() 为真说明这一项是 2 MiB 大页的直接映射,翻译在 pmd 级结束。pmd_leaf() 是更一般的判断(包含 devmap 等非透明大页的叶子项),6.x 用它替代部分 pmd_trans_huge() 的场景;pmd_trans_huge() 专指透明大页。

A5 PSS < RSS 说明有相当一部分页是共享的(共享库、MAP_SHARED、COW 未写页),PSS 按映射数分摊后变小。所有进程 RSS 之和大于物理内存完全可能——RSS 重复计算共享页;PSS 之和才接近真实占用(仍会略高,因为页表、slab 等不在 PSS 里)。

母本对应

母本章节本书落点程度
奔跑吧 Linux 内核第 2 章 2.2 页表的映射过程3.8.2完整(四级页表与地址翻译)
奔跑吧 Linux 内核第 2 章 2.7 VMA 操作3.8.1、3.8.3完整(VMA 查找、mmap)
奔跑吧 Linux 内核第 2 章 2.9 mmap3.8.3完整
奔跑吧 Linux 内核第 2 章 2.10 缺页中断处理3.8.4完整(五条分支)
CSAPP第 9 章 虚拟内存3.8.1–3.8.4对照(三重身份、地址翻译、mmap)
OSTEP第 13–23 章 地址空间、分页、TLB、交换3.8.2、3.8.4对照(理论模型)
Kerrisk第 49 章 内存映射3.8.3完整(mmap/mremap/msync 的用户态语义)

下一站:3.9 slab、vmalloc 与内核内存分配 讲内核自己怎么要内存:为什么不用 alloc_pages 直接分配小对象、SLUB 的空闲链表怎么组织、vmalloc 与 kmalloc 的边界在哪。