〇、全景:页表 + 缺页 = 按需分配
进程看到的是一整片连续的虚拟地址空间,但实际内存是"按需分配"的——大部分地址只是"承诺"了,访问到的那一瞬才真正映射物理页。实现这个机制靠两样东西:
- 多级页表:虚拟地址 → 物理地址的映射,硬件(MMU)逐级翻译;
- 缺页(page fault):访问一个"还没映射"或"权限不够"的地址,MMU 翻译失败、触发缺页异常(#PF),内核补上映射、再重跑那条指令。
一句话主线:虚拟地址靠多级页表(PGD/P4D/PUD/PMD/PTE)映射到物理页;访问一个"页表项不存在/权限不符"的地址时,MMU 触发缺页异常(#PF),内核
do_page_fault→handle_mm_fault→handle_pte_fault,根据 vma 判断是匿名页(do_anonymous_page分配零页)还是文件页(do_fault从文件读),补好页表项后重新执行——这就是"按需分配":进程地址空间大部分是"承诺"了但没实际映射的,访问到才真正分配。
一、5 级页表:虚拟地址切成几段逐级翻译
1.1 层级结构
x86_64 支持4 级(LA48,48 位虚拟地址)和5 级(LA57,57 位虚拟地址)页表。5 级时,虚拟地址切成 6 段,逐级索引:
虚拟地址(5 级 / LA57): | PGD | P4D | PUD | PMD | PTE | offset | | 9b | 9b | 9b | 9b | 9b | 12b |每一级都是 9 位(= 512 项,正好一页),shift 值定义在pgtable_64_types.h:
// arch/x86/include/asm/pgtable_64_types.h (v6.6, line 62 / 84 / 91)#defineP4D_SHIFT39#definePUD_SHIFT30#definePMD_SHIFT21// PAGE_SHIFT = 12,offset 占低 12 位- 5 级:
PGDIR_SHIFT = pgdir_shift(= 48,:56)→ 虚拟地址 6 段; - 4 级:
PGDIR_SHIFT = 39(:75),P4D折叠成 1 项(MAX_PTRS_PER_P4D = 1,:77),等效 PGD/PUD/PMD/PTE 四级。
翻译过程就是"用每一段当前层的索引查表、拿到下一层表的基址",最后 PTE 指向真正的物理页。顶层表指针存在每个进程的mm_struct里(mm_struct.pgd)。
注意这是MMU 页表(管 CPU 访存:进程虚拟地址 → 物理地址),和 IOMMU 页表(管设备 DMA:设备地址 → 物理地址)是两套独立的页表,别混(见 IOMMU 系列)。
1.2 页表项的"状态"决定缺页行为
每个 PTE 不只是"物理页号",还带一堆状态位(present、write、dirty、accessed…)。这些状态正是 §四 缺页分流判断的依据——比如present=0说明"这一页还没映射(或缺页到 swap)“,write=0说明"只读,写它就触发写时复制”。
二、vma:进程地址空间的"登记簿"
页表是"物理映射"的视角,但内核还需要"逻辑归属"的视角——这段地址是代码、堆、栈,还是 mmap 的文件?这就是vma(virtual memory area):
// include/linux/mm_types.h (v6.6, line 565)structvm_area_struct{// VMA covers [vm_start; vm_end) addresses within mmunsignedlongvm_start;// 起始地址unsignedlongvm_end;// 结束地址(开区间)// …constvm_flags_tvm_flags;// 权限/性质:可读可写、匿名/文件…// …unsignedlongvm_pgoff;// 在 vm_file 里的页偏移structfile*vm_file;// 映射的文件(匿名映射则为 NULL)// …};一个进程的地址空间(mm_struct,mm_types.h:673)由一串 vma 串起来(mm->mmap链表 / 红黑树)。关键:vm_file是否为空,决定了这段地址是"匿名"还是"文件映射"——这正是缺页时走do_anonymous_page还是do_fault的分水岭(见 §五)。
三、缺页入口:硬件 #PF →do_page_fault
访问非法地址时,CPU 触发 #PF 异常,经 IDT 进到:
// arch/x86/mm/fault.c (v6.6, line 1517)DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault){// …handle_page_fault(regs,error_code,address);// fault.c:1493}handle_page_fault按 faulting 地址在内核空间还是用户空间分流(fault_in_kernel_space,fault.c:1502),用户地址走do_user_addr_fault(fault.c:1239)。do_user_addr_fault的核心逻辑:
- 查 vma:
lock_mm_and_find_vma(fault.c:1385)找到覆盖这个地址的 vma;地址不在任何 vma 里 → 非法访问 → 发SIGSEGV; - 权限检查:访问类型(读/写/执行)和
vm_flags不符 →SIGSEGV(保护违例); - 调
handle_mm_fault:合法的缺页,交给 mm 子系统补映射; - 补好了 → 返回用户态重跑那条指令;补不了 → 发信号终止进程(
SIGSEGV/SIGBUS,OOM 则触发 OOM killer)。
handle_mm_fault(memory.c:5255)→__handle_mm_fault(memory.c:5031)逐级往下走页表(PGD→P4D→PUD→PMD,中间的中间表项不存在就现建),直到PTE 层,交给handle_pte_fault做最终分流。
四、handle_pte_fault:按 PTE 状态分流
handle_pte_fault(memory.c:4947)是缺页处理的核心分岔口——看 PTE 处于什么状态,决定怎么办:
// mm/memory.c (v6.6, line 4947)staticvm_fault_thandle_pte_fault(structvm_fault*vmf){// … 定位/建立 PTE …if(!vmf->pte)returndo_pte_missing(vmf);// ① PTE 不存在 → 真正的"缺页"if(!pte_present(vmf->orig_pte))returndo_swap_page(vmf);// ② 页被换出到 swap → 换回来if(pte_protnone(vmf->orig_pte)&&vma_is_accessible(vmf->vma))returndo_numa_page(vmf);// ③ NUMA 迁移标记 → 迁回本节点// ④ PTE 存在:写访问 + 只读 → 写时复制(COW)if(vmf->flags&(FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)){if(!pte_write(entry))returndo_wp_page(vmf);// …}// ⑤ 其他:只是刷新 accessed/dirty 位,直接返回}四种典型分支:
| PTE 状态 | 走哪个 handler | 含义 |
|---|---|---|
pte_none(不存在) | do_pte_missing | 真正的"第一次访问",按需分配/读入 |
!pte_present | do_swap_page | 页被 swap 出去了,从 swap 换回 |
pte_protnone | do_numa_page | NUMA 自动平衡,按需把页迁到访问它的节点 |
present 但!pte_write且写访问 | do_wp_page | 写时复制(COW) |
其中 ①(do_pte_missing)是最常见的"缺页"本义,也是"按需分配"的核心——往下看。
五、匿名页 vs 文件页:do_pte_missing的分水岭
do_pte_missing(memory.c:3667)的判断极简,就一个条件——这个 vma 是不是匿名的:
// mm/memory.c (v6.6, line 3667)staticvm_fault_tdo_pte_missing(structvm_fault*vmf){if(vma_is_anonymous(vmf->vma))returndo_anonymous_page(vmf);// 匿名:分配零页elsereturndo_fault(vmf);// 文件:从文件读}5.1 匿名页:do_anonymous_page分配零页
堆、栈、mmap(MAP_ANONYMOUS)都是匿名映射(vm_file == NULL)。第一次访问时分配一个清零的物理页(memory.c:4067):
// mm/memory.c (v6.6, line 4067)staticvm_fault_tdo_anonymous_page(structvm_fault*vmf){// 读访问:直接用全局的"零页"(所有进程共享),不用真分配if(!(vmf->flags&FAULT_FLAG_WRITE)&&!mm_forbids_zeropage(vma->vm_mm)){entry=pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),…));// …gotosetpte;}// 写访问:真正分配一个清零页folio=vma_alloc_zeroed_movable_folio(vma,vmf->address);// …}两个细节:读匿名页直接用全局共享的零页(省一次真分配);写才真正从 buddy(上一篇 #2)分配一个清零页。这正是"按需分配"最直观的体现。
5.2 文件页:do_fault从文件读
mmap(文件)的 vma 有vm_file。do_fault(memory.c:4678)按"是否共享 + 是否写"再分三种:
// mm/memory.c (v6.6, line 4678,节选)/* 只读 → 直接从 page cache 读 */ret=do_read_fault(vmf);// :4550/* 私有 + 写 → 先读进来再写时复制 */ret=do_cow_fault(vmf);// :4583/* 共享 + 写 → 读进来直接写(写穿回文件) */ret=do_shared_fault(vmf);// :4627底层都走__do_fault(memory.c:4181)→vma->vm_ops->fault()(具体文件系统的回调)把页从文件读进 page cache,再填 PTE。这里体现了"页缓存(page cache)“的角色:文件页缺页,本质是"page cache 里没有这一页,去磁盘读进来”。
六、为什么这样设计(Why 层)
6.1 为什么用多级页表,而不是一张大平铺表
48 位地址空间,按 4KB 页算有 2^36 个页。若用一张平铺表,每个进程要 2^36 × 8 字节 = 512GB 的表——荒谬。多级页表让"稀疏地址空间"只占实际用到的部分:只有被访问的地址路径,才会一层层分配中间页表。这是"按需"思想在页表结构上的体现。
6.2 为什么缺页才分配(按需分配),而不是 mmap 时就分配
因为进程申请的内存,绝大部分申请了根本用不到(或很久以后才用)。如果malloc(1GB)立刻分配 1GB 物理页,既慢又浪费。“承诺时只登记 vma,访问时才分配物理页”,把成本推迟到真正用的时候——这是虚拟内存最核心的设计。
6.3 为什么把"匿名/文件"当分水岭,而不是别的方式
因为这是两种来源根本不同的内存:匿名页没有"来源"(凭空清零),文件页有"来源"(磁盘文件)。缺页时前者"造一个零页"、后者"从文件读进来",处理逻辑完全不同。用vma->vm_file == NULL这一个位就能分流,简洁且正确。
6.4 为什么读匿名页用"共享零页"而不分配
大量程序会malloc一大块但只读不写(或暂时只读)。这些页内容全是 0,让所有进程共享同一个物理"零页",省下海量内存;一旦有进程要写,再通过 COW(do_wp_page)给它单独复制一份。"读共享零页 + 写时复制"是匿名页省内存的关键组合。
七、与已有博客的交叉引用
| 已有博客 | 本篇覆盖的关系 |
|---|---|
| 内存 #2 buddy | 本篇 §5.1do_anonymous_page写访问时vma_alloc_zeroed_movable_folio就是从 buddy 分配物理页——缺页是 buddy 的"下游消费者" |
| 内存 #1 物理内存初始化 | #1 建好了struct page(vmemmap)和 buddy,本篇的页表/缺页才能工作 |
| CPU #1 BSP 拉起 | #1 讲%cr3切页表(内核页表),本篇讲的是进程级页表 + 缺页 |
| IOMMU 系列 | 本篇是 MMU 页表(CPU 访存);IOMMU 页表(设备 DMA)是另一套,缺页逻辑也不同(见 [[blog-iommu-expansion]]) |
附:本篇关键源码索引
| 符号 | 位置 |
|---|---|
exc_page_fault/handle_page_fault/do_user_addr_fault | arch/x86/mm/fault.c:1517/:1493/:1239 |
PGDIR_SHIFT/P4D_SHIFT/PUD_SHIFT/PMD_SHIFT | arch/x86/include/asm/pgtable_64_types.h:56/:62/:84/:91 |
struct vm_area_struct(vm_start/vm_end/vm_flags/vm_file) | include/linux/mm_types.h:565 |
struct mm_struct(进程地址空间,含pgd) | include/linux/mm_types.h:673 |
handle_mm_fault/__handle_mm_fault | mm/memory.c:5255/:5031 |
handle_pte_fault(PTE 状态分流) | mm/memory.c:4947 |
do_pte_missing(匿名/文件分水岭) | mm/memory.c:3667 |
do_anonymous_page(匿名页) | mm/memory.c:4067 |
do_fault/do_read_fault/do_cow_fault/do_shared_fault | mm/memory.c:4678/:4550/:4583/:4627 |
__do_fault(→vm_ops->fault()) | mm/memory.c:4181 |
do_wp_page(写时复制) | mm/memory.c:3338 |
do_numa_page | mm/memory.c:4739 |