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。
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 位一段的翻译
读图:48 位虚拟地址被切成 5 段——4 个 9 位索引加 12 位页内偏移。每级页表 512 项(2^9),每项 8 字节,正好 4 KiB 一页。CR3 指向 PML4(内核里叫 pgd),逐级查到 PTE,PTE 里的物理页帧号加偏移就是物理地址。
| 级别 | 内核类型 | 索引位 | 表名 | 覆盖范围 |
|---|---|---|---|---|
| 1 | pgd_t | VA[47:39] | PML4 | 512 GiB |
| 2 | pud_t | VA[38:30] | PDPT | 1 GiB |
| 3 | pmd_t | VA[29:21] | PD | 2 MiB |
| 4 | pte_t | VA[20:12] | PT | 4 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),代价很高,所以内核尽量批量刷。
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;
}
两个容易忽略的点:
- 读缺页不分配内存。读一个从未写过的匿名页,内核把 PTE 指向全局零页(
empty_zero_page),只读、共享。直到第一次写才真正分配。这就是「malloc1 GiB 后RSS是 0」的底层原因。 - 写缺页分配的是「可迁移、清零」的页(
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)。
- 「
mmap之后就有内存了」:只有 VMA,没有 PTE,更没有物理页。MAP_POPULATE也只预读文件页,不保证匿名页。 - 「读缺页也要分配内存」:读匿名页映射到全局零页,零分配;只有写才分配。
- 「COW 的页一定是匿名页」:文件私有映射的写缺页也走 COW(
do_cow_fault),复制出来的是匿名页。 - 「缺页一定很慢」:minor fault 只是分配一页 + 填 PTE,微秒级;major fault 要读盘,毫秒级。两者在
perf stat里是minor-faults与major-faults。 - 「
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 级 | 48 | 128 TiB | pgd→pud→pmd→pte | 默认 |
| 5 级 | 57 | 64 PiB | pgd→p4d→pud→pmd→pte | CONFIG_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/maps | VMA 列表(地址、权限、偏移、路径) | 看布局、找泄漏的映射 |
/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–54 | pfn(页帧号) |
| 55 | soft-dirty |
| 56 | exclusively mapped |
| 61 | file/shared-anon |
| 62 | swapped |
| 63 | present |
读 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() 就是干这个的)。
某服务启动时 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_mt | struct maple_tree | 6.1+ 的 VMA 索引树,替代 mmap 链表 + 红黑树 | mmap/munmap/mprotect 增删改时 |
mmap_base | unsigned long | mmap 区的起始地址(向上增长) | arch_pick_mmap_layout() 在 exec 时定 |
task_size | unsigned long | 用户空间上限,x86-64 4 级为 0x7fff_ffff_f000 | 进程创建时按架构定 |
pgd | pgd_t * | 页表根;进程切换时写入 CR3 | pgd_alloc() 在 fork 时复制/新建 |
mm_users | atomic_t | 共享该地址空间的 task 数(线程数) | copy_mm/mmget/mmput |
mm_count | atomic_t | 结构本身的强引用数(含 active_mm) | mmgrab/mmdrop |
map_count | int | VMA 个数 | 插入/删除 VMA;用于 max_map_count 检查 |
mmap_lock | rw_semaphore | 保护 VMA 拓扑与页表结构 | 写锁改拓扑,读锁查 VMA/缺页 |
page_table_lock | spinlock_t | 保护页表级的某些计数(历史遗留) | 只在少数路径使用 |
arg_start/arg_end | unsigned long | 命令行参数字符串区间 | exec 时建立 |
env_start/env_end | unsigned long | 环境变量区间 | exec 时建立 |
start_code/end_code | unsigned long | 代码段区间 | exec 时建立 |
start_data/end_data | unsigned long | 数据段区间 | exec 时建立 |
start_brk/brk | unsigned long | 堆的起点与当前边界 | brk/sbrk 改 brk |
start_stack | unsigned long | 栈顶附近位置 | exec 时建立 |
context | struct mm_context_t | 架构私有(ASID、PCID、LDT 等) | 切进程/分配 ASID 时 |
flags | unsigned long | MMF_*:MMF_DUMPABLE、MMF_OOM_SKIP 等 | 各子系统按需置位 |
core_state | struct core_state * | core dump 进行中的状态 | do_coredump() |
exe_file | struct file * | 当前可执行文件 | exec 时替换 |
notifier_subscriptions | 指针 | mmu_notifier(KVM、RDMA 用) | 注册/注销时 |
numa_next_scan | unsigned long | 下一次 NUMA hint fault 扫描地址 | task_numa_work() |
tlb_flush_pending | atomic_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()。
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.x | 6.8 |
|---|---|---|
| 查找包含地址的 VMA | find_vma(mm, addr) 走 mm->mmap 红黑树 | find_vma() 内部走 mm_mt,vma_find() |
| 遍历 VMA | for (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/maps | find_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() 复制地址空间时,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 位切一刀:
| 级别 | 取位 | 计算 | 索引 |
|---|---|---|---|
pgd | VA[47:39] | (VA >> 39) & 0x1ff | 0x0fe |
pud | VA[38:30] | (VA >> 30) & 0x1ff | 0x0b1 |
pmd | VA[29:21] | (VA >> 21) & 0x1ff | 0x000 |
pte | VA[20:12] | (VA >> 12) & 0x1ff | 0x001 |
| offset | VA[11:0] | VA & 0xfff | 0x234 |
注意 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_PSE | VA[20:0] | (pmd & 0x000f_ffff_ffff_f000) + (VA & 0x1f_ffff) |
pud 1 GiB | _PAGE_PSE | VA[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 级 | 48 | 0x0000_7fff_ffff_f000 | 4 | 9/9/9/9 |
| 5 级 | 57 | 0x00ff_ffff_ffff_f000 | 5 | 9/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 KiB | 4 KiB | 262144 项 |
| 2 MiB | 2 MiB | 512 项 |
| 1 GiB | 1 GiB | 1 项 |
所以「大页省 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()。
- 4.x 的
mmap红黑树:vma->vm_rb是权威索引;6.8 换成mm_mt,vm_rb只用于区间树等局部结构,别再按红黑树写遍历。 p4d在 4 级下是空宏:p4d_offset()返回pgd,p4d_none()恒假;给 4 级内核写「遍历五级」的代码会编译不过或永远走不到。- 5 级页表的用户上限不是
0x7fff...:task_size与TASK_SIZE_MAX随 LA57 变,硬编码 47 位会误判地址合法性。 pmd_trans_huge()不等于「大页」:6.x 用pmd_leaf()判断所有叶子项(含 devmap),pmd_trans_huge()只覆盖 THP。follow_page()不是导出符号:6.8 的外部模块不能直接调;要么编进内核,要么用get_user_pages()/pin_user_pages()。_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(®s->orig_ax))) {
...
return;
}
...
handle_page_fault(regs, error_code, address);
}
error_code 的位定义是硬件给的,读它比读 regs 快:
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | X86_PF_PROT | 0=页不存在,1=权限违例 |
| 1 | X86_PF_WRITE | 1=写访问 |
| 2 | X86_PF_USER | 1=用户态(CPL=3) |
| 3 | X86_PF_RSVD | 1=保留位被置(页表项损坏) |
| 4 | X86_PF_INSTR | 1=取指(NX 违例) |
| 5 | X86_PF_PK | 1=protection key 违例 |
| 6 | X86_PF_SHSTK | 1=shadow stack 违例 |
| 7 | X86_PF_SGX | 1=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 |
KSM | KSM 合并路径 | ksm_might_need_to_copy() |
UFFD_WP | userfaultfd 写保护 | 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;
}
几个容易忽略的失败点:
pmd_none()不等于pte_none():中间级页表还没分配时,vmf->pte为空,要先__pte_alloc()。分配失败返回VM_FAULT_OOM。pte_protnone()是 NUMA hint fault:PTE 被故意设成「不可访问但存在」,用来采样访问位置,不是错误。pte_special()是「没有struct page的映射」:vm_normal_page()返回NULL,不能get_page();VM_PFNMAP/VM_MIXEDMAP走这条。- 写缺页但 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_NONE | 0 | 不可访问 | VM_NONE,缺页一律 SIGSEGV |
PROT_READ | 1 | 可读 | VM_READ |
PROT_WRITE | 2 | 可写 | VM_WRITE(隐含 VM_MAYWRITE) |
PROT_EXEC | 4 | 可执行 | VM_EXEC(受 NX、SELinux execmem 限制) |
PROT_GROWSDOWN | 0x01000000 | 向下增长(栈) | VM_GROWSDOWN |
PROT_GROWSUP | 0x02000000 | 向上增长 | VM_GROWSUP(少数架构) |
PROT_SAO | 0x04000000 | 强序(PowerPC) | 架构相关 |
flags 必须包含 MAP_PRIVATE、MAP_SHARED 或 MAP_SHARED_VALIDATE 之一,否则 EINVAL。常用组合的语义:
| 组合 | 写时行为 | 典型用途 |
|---|---|---|
| `MAP_PRIVATE | MAP_ANONYMOUS` | COW,不落盘 |
| `MAP_SHARED | MAP_ANONYMOUS` | 共享,由 shmem 承载 |
| `MAP_PRIVATE | fd` | 读共享 page cache,写 COW |
| `MAP_SHARED | fd` | 写回文件,页缓存共享 |
| `MAP_SHARED_VALIDATE | fd` | 同 MAP_SHARED,但未知 flag 报 EOPNOTSUPP |
mmap 的真实 errno:
| errno | 触发条件 | 常见误判 |
|---|---|---|
EINVAL | length==0;offset 未页对齐;flags 缺共享/私有位;prot 与 flags 冲突 | 把 length 传成 0 当成「映射全部」 |
ENOMEM | 地址空间不够;VMA 数超 vm.max_map_count;overcommit 拒绝 | 与 EAGAIN 混淆 |
EACCES | 文件权限不允许 PROT_READ/WRITE;MAP_SHARED 但文件只读 | 以为 root 一定能写 |
EBADF | fd 无效;MAP_ANONYMOUS 时 fd 不是 -1(旧内核) | 传了 fd=0 |
ENODEV | 文件系统不支持 mmap(如某些 procfs 文件) | 对 /proc 文件盲目 mmap |
ENOTTY | 设备不支持 mmap | 驱动没实现 .mmap |
EOVERFLOW | offset 超出文件最大偏移(32 位 off_t) | 32 位程序映射大文件 |
EPERM | MAP_HUGETLB 但无权限;/proc/sys/vm/mmap_min_addr 限制 | 低地址映射被拒 |
ETXTBSY | MAP_DENYWRITE 且文件正被写 | 旧二进制格式 |
mremap 的 flags 决定「能不能搬家」:
| flag | 语义 | 失败/边界 |
|---|---|---|
MREMAP_MAYMOVE | 允许内核把映射搬到新地址 | 不置位且原地扩不下 → ENOMEM |
MREMAP_FIXED | 必须搬到 new_address(需 MAYMOVE) | 目标被占用 → EINVAL/ENOMEM;会覆盖目标 |
MREMAP_DONTUNMAP | 搬走后保留旧映射(5.7+) | 只支持私有匿名映射;用于 CRIU 无暂停迁移 |
| `MREMAP_DONTUNMAP | MREMAP_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 多得多:
| 字段 | 含义 | 计算/判读 |
|---|---|---|
Size | VMA 虚拟大小 | vm_end - vm_start |
KernelPageSize | 内核页大小 | 通常是 4 KiB |
MMUPageSize | MMU 页大小 | 大页映射时为 2 MiB/1 GiB |
Rss | 常驻物理页 | 共享页全额计入 |
Pss | 按共享比例分摊 | 共享页除以映射数 |
Pss_Dirty | 分摊后的脏页 | 估算回写成本 |
Shared_Clean | 共享干净页 | 多进程共享的只读页 |
Shared_Dirty | 共享脏页 | MAP_SHARED 写过的页 |
Private_Clean | 私有干净页 | 私有文件映射、未写 COW 页 |
Private_Dirty | 私有脏页 | 匿名页、已写 COW 页 |
Referenced | 最近被访问的页 | 近似工作集 |
Anonymous | 匿名页 | 不含 shmem |
LazyFree | MADV_FREE 标记的页 | 可丢弃但尚未丢 |
AnonHugePages | 匿名大页 | THP 折叠量 |
ShmemPmdMapped | shmem 大页 | tmpfs |
FilePmdMapped | 文件大页 | DAX/大页文件系统 |
Shared_Hugetlb/Private_Hugetlb | hugetlbfs 页 | 不计入 Rss |
Swap | 已换出的页 | 每页 swap 用量 |
SwapPss | 分摊后的 swap | 共享页换出时按比例 |
Locked | mlock 的页 | 不可回收 |
THPeligible | 是否适合 THP | 0/1 |
ProtectionKey | pkey 编号 | pkey_mprotect |
VmFlags | VMA 标志的字母编码 | 见下 |
两个恒等式要先记住:
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 | 共享 |
mr | VM_MAYREAD | mw | VM_MAYWRITE |
me | VM_MAYEXEC | ms | VM_MAYSHARE |
gd | VM_GROWSDOWN | pf | VM_PFNMAP |
dw | VM_DONTWRITE?(6.x 为 VM_DROPPABLE 等) | lo | VM_LOCKED |
io | VM_IO | sr | VM_SEQ_READ |
rr | VM_RAND_READ | dc | VM_DONTCOPY |
de | VM_DONTEXPAND | ac | VM_ACCOUNT |
nr | VM_NORESERVE | ht | VM_HUGETLB |
ar | VM_ARCH_1 | wf | VM_WIPEONFORK |
dd | VM_DONTDUMP | sd | VM_SOFTDIRTY |
mm | VM_MIXEDMAP | hg | VM_HUGEPAGE |
nh | VM_NOHUGEPAGE | mg | VM_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–54 | pfn(present 时)或 swap 编码(swapped 时) |
| 55 | soft-dirty |
| 56 | exclusively mapped(PageAnonExclusive) |
| 57 | uffd-wp 写保护 |
| 58–60 | 保留(0) |
| 61 | 文件页或共享匿名页 |
| 62 | swapped |
| 63 | present |
注意: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_* 对照。
某服务启动后 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 的五条误解不重复。
-
mmap长度传 0:现象是EINVAL;原因是length必须大于 0,内核不会把 0 解释为「到文件末尾」;修复是先用fstat取大小,或显式传st_size。文件大小可以在mmap后变化,映射长度不会自动跟随。 -
用
MAP_FIXED覆盖了 libc 的映射:现象是随机崩溃、malloc内部断言失败;原因是MAP_FIXED会静默拆掉目标地址上的旧 VMA;修复是改用MAP_FIXED_NOREPLACE(4.17+),或先mmap(NULL, ...)拿 hint 再mremap。 -
以为
MAP_POPULATE会为匿名映射分配物理页:现象是mmap(MAP_POPULATE)后Rss仍然接近 0;原因是匿名读缺页只装全局零页,不分配私有页,MAP_POPULATE走一遍 GUP 也只是把 PTE 指向零页;修复是要预分配就逐页写(或MADV_POPULATE_WRITE,5.14+)。 -
fork后子进程改了MAP_SHARED匿名内存,父进程也变:现象是「明明没通信却串了数据」;原因是MAP_SHARED|MAP_ANONYMOUS由 shmem 承载,本来就在父子间共享;修复是用MAP_PRIVATE,或在fork前madvise(MADV_DONTFORK)(子进程看不到这段映射)。 -
mremap后继续用旧指针:现象是偶发SIGSEGV或读到旧数据;原因是mremap可能搬家并释放旧 VMA;修复是只用返回值,必要时用MREMAP_DONTUNMAP(5.7+)保留旧映射,但那会额外占用地址空间。 -
munmap只解除一部分,邻居没有合并:现象是 VMA 数量持续上涨、vm.max_map_count被打满;原因是频繁mmap/munmap留下碎片;修复是用MADV_DONTNEED保留 VMA,或一次映射大段自己切分,减少树操作。 -
用 pagemap 的 pfn 做长期缓存:现象是迁移/THP 折叠后 pfn 变了,DMA 读到错页;原因是 pfn 不是稳定标识,页可以被迁移、合并、回收;修复是用
pin_user_pages()锁住页,或通过IOMMU/dma_map拿设备地址。 -
mlock失败被忽略:现象是实时程序仍被换出/回收,延迟尖峰;原因是mlock返回ENOMEM(RLIMIT_MEMLOCK不够),代码没检查;修复是检查返回值、调大ulimit -l,或用mlock2(MCL_ONFAULT)降低峰值需求。 -
非 root 用 userfaultfd 失败:现象是
userfaultfd()返回EPERM;原因是vm.unprivileged_userfaultfd=0(默认)且进程没有CAP_SYS_PTRACE;修复是授权、调 sysctl(有安全风险),或改用UFFD_USER_MODE_ONLY(5.11+)只处理用户态缺页。 -
在 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,没有 PTE | 3.8.3 |
| 说出缺页五条分支 | anon/wp/fault/swap/numa | 3.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/0x234 | 3.8.10 |
区分 VM_FAULT_* 返回值 | OOM/SIGBUS/RETRY 各自的去向 | 3.8.11 |
说出 mmap 的三种 errno | EINVAL/ENOMEM/EEXIST 的条件 | 3.8.12 |
| 用 pagemap 验证 pfn | 区分「零页」与「无权限」 | 3.8.13 |
避免 MAP_FIXED 覆盖 | 知道 MAP_FIXED_NOREPLACE 的语义 | 3.8.14 |
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 mmap | 3.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的边界在哪。