2026-09-04 · 操作系统 · 内存管理
标签:Linux内存管理MMUPage Faultmmap
引言
在 Linux 系统中,当你调用malloc(8192)申请 8KB 内存时,操作系统真的立刻从内存条上划出 8KB 给你了吗?
答案是否定的。
Linux 采用惰性分配(Lazy Allocation,也称 Demand Paging 按需分页)策略:malloc和mmap只是"预定"了一段虚拟地址空间,真正的物理内存分配,要等到程序第一次读写这片地址时才会发生。这一机制是现代操作系统内存管理的核心支柱之一。
一、完整 C 语言伪代码示例
以下伪代码完整展示了从申请内存到触发缺页、再到物理页分配的完整生命周期:
#include<stdio.h>#include<stdlib.h>intmain(void){/* =========================================================== 阶段 1: 申请虚拟地址空间 malloc 底层通过 brk/mmap 向内核申请 8KB(2 个页) 内核只记录 "0x7f0000 ~ 0x7f1fff 这 8KB 属于该进程" 但此时没有分配任何物理内存! =========================================================== */char*heap=malloc(8192);printf("虚拟地址: %p\n",heap);// 例如 0x7f0000printf("物理内存占用: 0 KB\n");// 真的 0 KB/* =========================================================== 阶段 2: 第一次写入,触发缺页异常 (Page Fault) CPU 将虚拟地址 0x7f0000 送给 MMU 翻译 MMU 查页表发现 "Present 位 = 0"(未映射) 触发缺页异常,CPU 陷入内核态 =========================================================== */heap[0]='A';/* =========================================================== 阶段 3: 内核处理缺页异常(对用户态完全透明) 1) 内核检查: 0x7f0000 是否在已申请的 vma 范围内?是。 2) 内核从伙伴系统分配一个 4KB 物理页(如 PFN = 0x3a) 3) 内核更新页表: 虚拟页 0x7f0 -> 物理页 0x3a, 权限 R/W 4) 内核将物理页清零,返回用户态,重新执行 heap[0] = 'A' =========================================================== *//* =========================================================== 阶段 4: 第二次写入,页表命中 (Page Table Hit) 虚拟地址 0x7f0064 和 0x7f0000 同属一个页 MMU 查页表命中,直接翻译为物理地址,无异常开销 =========================================================== */heap[100]='B';/* =========================================================== 阶段 5: 访问第二页,再次缺页 虚拟地址 0x7f1000 属于页 2,页表中仍未映射 再次触发缺页异常,内核再分配一个物理页 =========================================================== */heap[4096]='C';/* =========================================================== 最终状态: - 申请了 8KB 虚拟地址 - 实际占用了 8KB 物理内存 - 如果这 8KB 从未被访问,物理内存占用永远是 0 =========================================================== */return0;}关键洞察:如果程序申请了 1GB 内存但只读了其中 1MB,那么物理内存实际占用只有 1MB。这就是惰性分配的力量。
二、动图演示:惰性分配的完整生命周期
下面这张动图以三栏视角(虚拟地址空间 / 页表 / 物理内存)完整演示了上面代码的七个关键时刻:
逐阶段解读:
| 阶段 | 操作 | 虚拟地址空间 | 页表 | 物理内存 |
|---|---|---|---|---|
| ① | malloc(8192) | 两页标记"已预留" | 两项均为P=0未映射 | 占用 0 KB |
| ② | 首次写heap[0] | — | MMU 发现P=0→ 触发#PF 缺页异常 | — |
| ③ | 内核do_page_fault | — | 建立映射0x7f0 → 0x3a | 伙伴系统分配 4KB(清零) |
| ④ | 重新执行写入指令 | 页1 已映射 | P=1, R/W | 'A'落入物理页,占用 4 KB |
| ⑤ | 写heap[100] | 同页访问 | 页表命中,直通 | 占用不变,纳秒级完成 |
| ⑥ | 写heap[4096] | 页2 未映射 | 再次P=0→ 再次 #PF | — |
| ⑦ | 内核再分配一页 | 页2 已映射 | 0x7f1 → 0x15 | 占用 8 KB |
内核处理缺页异常的核心逻辑(伪代码):
voiddo_page_fault(unsignedlongaddress,intwrite){// address = 0x7f0000(触发缺页的虚拟地址)structvm_area_struct*vma=find_vma(address);// 1. 检查该虚拟地址是否在已申请的 vma 范围内if(!vma||address<vma->vm_start)send_sigsegv();// 非法访问 -> 段错误 (SIGSEGV)// 2. 从伙伴系统分配一个物理页(4KB)structpage*pg=alloc_page(GFP_KERNEL);unsignedlongpfn=page_to_pfn(pg);// 例如 PFN = 0x3a// 3. 建立页表映射:VPN 0x7f0 -> PFN 0x3aset_pte_at(mm,address,pte,pfn_pte(pfn,PAGE_RW));}异常处理完毕后,CPU返回到触发异常的那条指令重新执行——这次 MMU 翻译成功,对用户态来说整个过程完全透明,仿佛什么都没有发生过。
全景流程图:申请 → 首次访问 → 缺页处理 → 后续命中
三、动图演示:MMU 地址翻译的两条路径
每次内存访问,MMU 都要走一次"查页表"。结果只有两种——命中(快路径)或缺页(慢路径):
- 快路径(页表命中):页表项
P=1,MMU 纯硬件完成翻译,纳秒级,内核完全无感知。这是 99.9% 的情况。 - 慢路径(缺页异常):页表项
P=0,MMU 触发 #PF 异常(x86 中断向量 14),CPU 保存现场、陷入内核态,走do_page_fault分配物理页并更新页表,然后返回用户态重新执行同一条指令。每页只发生一次,之后都走快路径。
四、核心概念详解
| 名词 | 通俗解释 | 技术细节 |
|---|---|---|
| 虚拟地址(Virtual Address) | 进程"以为"自己在用的地址 | 每个进程有独立的虚拟地址空间(64 位系统上 128TB),彼此隔离。进程 A 的0x7f0000和进程 B 的0x7f0000完全是两码事 |
| 物理地址(Physical Address) | 内存条上真实的电路地址 | DRAM 芯片的硬件地址,范围由实际安装的内存容量决定(如 32GB) |
| MMU | CPU 里的"地址翻译官" | Memory Management Unit,硬件电路,负责把虚拟地址翻译成物理地址。翻译失败时向 CPU 报告缺页异常 |
| 页表(Page Table) | 虚拟地址到物理地址的"对照表" | 每个进程一张页表,存于物理内存。页表项(PTE)记录物理页号(PFN)、读写权限、Present 位等。x86-64 通常采用 4 级页表(PGD/PUD/PMD/PTE) |
| VPN | 虚拟页编号 | Virtual Page Number,虚拟地址的高位部分。如0x7f0000按 4KB 分页,VPN =0x7f0 |
| PFN | 物理页框编号 | Page Frame Number,物理页的高位地址。如物理地址0x3a000对应的 PFN =0x3a |
| mmap | 向内核"预定"一段虚拟地址 | mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)是分配匿名内存的核心系统调用。它只创建 vma,不分配物理页 |
| vma | 内核记录的虚拟地址区间 | vm_area_struct,描述进程地址空间中的一段连续区域,记录起止地址、权限、映射文件等。缺页处理时内核先查 vma 确认地址合法性 |
| 缺页异常(Page Fault) | MMU"找不到路"时触发的 CPU 异常 | MMU 发现页表项 Present 位为 0 时触发(x86 #PF,中断向量 14)。CPU 保存现场、切换内核态、执行do_page_fault()。惰性分配的核心触发点 |
| 伙伴系统(Buddy System) | 内核管理物理页分配的数据结构 | 物理内存划分为 4KB 页框,空闲页框由伙伴系统管理。分配时找合适的连续页框,释放时合并相邻空闲块减少碎片。alloc_page()就是从这里取页 |
| brk / mmap | 用户态申请堆内存的两条路径 | 小块内存(< 128KB 默认阈值)用brk扩展堆顶;大块用mmap匿名映射。两者最终都走惰性分配路线 |
五、惰性分配的本质
一句话总结:操作系统在
malloc/mmap时只记账、不掏钱(不分配物理内存),等到程序真正读写那片地址时,才按需掏钱(分配物理页并建立映射)。
为什么这么做?
- 节省物理内存:程序经常申请大内存但只用一小部分(如稀疏数组、Java 预分配的大堆)。惰性分配确保"没用过的一分钱不花"。
- 加速内存申请:
mmap只需修改内核数据结构(创建 vma),无需遍历物理内存找空闲页、清零页等重操作。 - 提高 fork 效率:
fork子进程时拷贝页表但标记只读,父子共享物理页,真正写入时才复制(Copy-on-Write),这也是惰性分配思想的延伸。
代价是什么?
| 代价 | 说明 |
|---|---|
| 第一次访问有延迟 | 缺页异常处理涉及内核路径、物理页分配、页表更新,约需几微秒到几十微秒(相比直接访问的纳秒级) |
| 可能触发 OOM | 申请了 10GB 虚拟内存但物理内存只有 8GB,如果程序真的逐页访问满 10GB,就会触发OOM Killer |
动图演示:申请 ≠ 占用
下面这张动图模拟了malloc(1GB)后逐步写入的过程——VSZ(虚拟地址空间)一开始就占满 1024MB,但 RSS(实际物理占用)只随真实触碰的页面阶梯增长:
这也是为什么top/ps里经常看到进程 VSZ 巨大但 RSS 很小——灰色区域就是"申请了但从未触碰、物理内存零消耗"的部分。
六、总结
| 阶段 | 发生了什么 | 物理内存占用 |
|---|---|---|
malloc(8192) | 内核创建 vma,记录虚拟地址区间 | 0 KB |
第一次写入heap[0] | 缺页异常 → 内核分配 4KB 物理页 → 建立映射 | 4 KB |
写入heap[100] | 页表命中,直接访问,无内核介入 | 4 KB |
第一次写入heap[4096] | 缺页异常 → 内核再分配 4KB 物理页 | 8 KB |
核心记忆点(口诀):
申请堆 = 只拿虚拟地址;第一次写入 = 才分配物理内存;没被访问过的堆虚拟地址,全程零物理占用。
快路径走 MMU,慢路径进内核;每页只慢一次,之后全是直通。
参考资料
- Page Fault Handler — Linux Kernel Internals — 从硬件异常到页表映射的完整 fault path,含 x86 错误码与各类 fault 的 handler 分派表
- Life of a malloc — Linux Kernel Internals — malloc 在内核侧的完整旅程:per-CPU page list、zone free list、伙伴系统
- Linux Page Faults, mmap, and userfaultfd — Shayon Mukherjee — 从 VM 快照恢复的实际问题出发讲透缺页与惰性填充
- 深入理解 Linux 内存管理核心机制:从分页、页表到大页内存实战 — 博客园 — 中文优质长文,分页/页表/缺页中断到 HugePages
- linux mmap 底层实现 — CSDN — mmap 系统调用六步拆解:建 vma → 首次访问缺页 → 分配物理页建映射
- Why malloc Isn’t ‘Using Up’ Memory on Linux — LinuxVox — "malloc 为什么不占内存"的通俗解释