2026-09-04 · 操作系统 · 内存管理
标签:
Linux内存管理MMUPage Faultmmap
引言
在 Linux 系统中,当你调用 malloc(8192) 申请 8KB 内存时,操作系统真的立刻从内存条上划出 8KB 给你了吗?
答案是否定的。
Linux 采用惰性分配(Lazy Allocation,也称 Demand Paging 按需分页)策略:malloc 和 mmap 只是"预定"了一段虚拟地址空间,真正的物理内存分配,要等到程序第一次读写这片地址时才会发生。这一机制是现代操作系统内存管理的核心支柱之一。
一、完整 C 语言伪代码示例
以下伪代码完整展示了从申请内存到触发缺页、再到物理页分配的完整生命周期:
c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
/* ===========================================================
阶段 1: 申请虚拟地址空间
malloc 底层通过 brk/mmap 向内核申请 8KB(2 个页)
内核只记录 "0x7f0000 ~ 0x7f1fff 这 8KB 属于该进程"
但此时没有分配任何物理内存!
=========================================================== */
char *heap = malloc(8192);
printf("虚拟地址: %p\n", heap); // 例如 0x7f0000
printf("物理内存占用: 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
=========================================================== */
return 0;
}
关键洞察: 如果程序申请了 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 |
内核处理缺页异常的核心逻辑(伪代码):
c
void do_page_fault(unsigned long address, int write) {
// address = 0x7f0000(触发缺页的虚拟地址)
struct vm_area_struct *vma = find_vma(address);
// 1. 检查该虚拟地址是否在已申请的 vma 范围内
if (!vma || address < vma->vm_start)
send_sigsegv(); // 非法访问 -> 段错误 (SIGSEGV)
// 2. 从伙伴系统分配一个物理页(4KB)
struct page *pg = alloc_page(GFP_KERNEL);
unsigned long pfn = page_to_pfn(pg); // 例如 PFN = 0x3a
// 3. 建立页表映射:VPN 0x7f0 -> PFN 0x3a
set_pte_at(mm, address, pte,
pfn_pte(pfn, PAGE_RW));
}
异常处理完毕后,CPU 返回到触发异常的那条指令重新执行------这次 MMU 翻译成功,对用户态来说整个过程完全透明,仿佛什么都没有发生过。
全景流程图:申请 → 首次访问 → 缺页处理 → 后续命中
#mermaid-svg-K5yJvL3wXUwhVHEs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K5yJvL3wXUwhVHEs .error-icon{fill:#552222;}#mermaid-svg-K5yJvL3wXUwhVHEs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K5yJvL3wXUwhVHEs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K5yJvL3wXUwhVHEs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K5yJvL3wXUwhVHEs .marker.cross{stroke:#333333;}#mermaid-svg-K5yJvL3wXUwhVHEs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K5yJvL3wXUwhVHEs p{margin:0;}#mermaid-svg-K5yJvL3wXUwhVHEs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-K5yJvL3wXUwhVHEs .cluster-label text{fill:#333;}#mermaid-svg-K5yJvL3wXUwhVHEs .cluster-label span{color:#333;}#mermaid-svg-K5yJvL3wXUwhVHEs .cluster-label span p{background-color:transparent;}#mermaid-svg-K5yJvL3wXUwhVHEs .label text,#mermaid-svg-K5yJvL3wXUwhVHEs span{fill:#333;color:#333;}#mermaid-svg-K5yJvL3wXUwhVHEs .node rect,#mermaid-svg-K5yJvL3wXUwhVHEs .node circle,#mermaid-svg-K5yJvL3wXUwhVHEs .node ellipse,#mermaid-svg-K5yJvL3wXUwhVHEs .node polygon,#mermaid-svg-K5yJvL3wXUwhVHEs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-K5yJvL3wXUwhVHEs .rough-node .label text,#mermaid-svg-K5yJvL3wXUwhVHEs .node .label text,#mermaid-svg-K5yJvL3wXUwhVHEs .image-shape .label,#mermaid-svg-K5yJvL3wXUwhVHEs .icon-shape .label{text-anchor:middle;}#mermaid-svg-K5yJvL3wXUwhVHEs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-K5yJvL3wXUwhVHEs .rough-node .label,#mermaid-svg-K5yJvL3wXUwhVHEs .node .label,#mermaid-svg-K5yJvL3wXUwhVHEs .image-shape .label,#mermaid-svg-K5yJvL3wXUwhVHEs .icon-shape .label{text-align:center;}#mermaid-svg-K5yJvL3wXUwhVHEs .node.clickable{cursor:pointer;}#mermaid-svg-K5yJvL3wXUwhVHEs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-K5yJvL3wXUwhVHEs .arrowheadPath{fill:#333333;}#mermaid-svg-K5yJvL3wXUwhVHEs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-K5yJvL3wXUwhVHEs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-K5yJvL3wXUwhVHEs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K5yJvL3wXUwhVHEs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-K5yJvL3wXUwhVHEs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K5yJvL3wXUwhVHEs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-K5yJvL3wXUwhVHEs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-K5yJvL3wXUwhVHEs .cluster text{fill:#333;}#mermaid-svg-K5yJvL3wXUwhVHEs .cluster span{color:#333;}#mermaid-svg-K5yJvL3wXUwhVHEs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-K5yJvL3wXUwhVHEs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-K5yJvL3wXUwhVHEs rect.text{fill:none;stroke-width:0;}#mermaid-svg-K5yJvL3wXUwhVHEs .icon-shape,#mermaid-svg-K5yJvL3wXUwhVHEs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K5yJvL3wXUwhVHEs .icon-shape p,#mermaid-svg-K5yJvL3wXUwhVHEs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-K5yJvL3wXUwhVHEs .icon-shape .label rect,#mermaid-svg-K5yJvL3wXUwhVHEs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K5yJvL3wXUwhVHEs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-K5yJvL3wXUwhVHEs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-K5yJvL3wXUwhVHEs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段四:后续访问(全程快路径)
阶段三:内核缺页处理 do_page_fault
阶段二:首次访问(按需掏钱)
阶段一:申请内存(只记账,不掏钱)
读访问
第一次写入
之后再写
非法地址 / 越权
合法
COW 场景:PTE 存在但只读
用户态 malloc(大内存)
glibc 调用 brk / mmap 匿名映射
内核创建 VMA(vm_area_struct)
仅记录虚拟地址区间 + 权限
✅ 分配虚拟地址 ❌ 不分配物理页 ❌ 不填充 PTE
malloc 返回有效虚拟地址
进程首次操作虚拟地址?
Minor Fault
映射共享 zero page(全零页)
多进程共用,不占私有物理页
MMU 查页表:PTE 无效(P=0)
触发缺页异常 #PF,陷入内核态
find_vma 校验
地址在 VMA 内 && 权限允许写?
发送 SIGSEGV 段错误
进程崩溃
伙伴系统 alloc_page
分配 4KB 物理页并清零
填充 PTE:VPN ↔ PFN 建立映射(P=1, R/W)
返回用户态
重新执行触发异常的指令
第二次及后续读写同一页
MMU 页表命中(P=1)
直接翻译物理地址,纳秒级,无缺页
💡 写时复制(fork)
初始父子共享物理页并标记只读
写入时触发缺页 → do_wp_page 拷贝新页
三、动图演示: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 |
| 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 为什么不占内存"的通俗解释