Linux 内存惰性分配:从虚拟地址到物理页的深度解析

2026-09-04 · 操作系统 · 内存管理

标签:Linux 内存管理 MMU Page Fault mmap

引言

在 Linux 系统中,当你调用 malloc(8192) 申请 8KB 内存时,操作系统真的立刻从内存条上划出 8KB 给你了吗?

答案是否定的。

Linux 采用惰性分配(Lazy Allocation,也称 Demand Paging 按需分页)策略:mallocmmap 只是"预定"了一段虚拟地址空间,真正的物理内存分配,要等到程序第一次读写这片地址时才会发生。这一机制是现代操作系统内存管理的核心支柱之一。


一、完整 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,慢路径进内核;每页只慢一次,之后全是直通。


参考资料

  1. Page Fault Handler --- Linux Kernel Internals --- 从硬件异常到页表映射的完整 fault path,含 x86 错误码与各类 fault 的 handler 分派表
  2. Life of a malloc --- Linux Kernel Internals --- malloc 在内核侧的完整旅程:per-CPU page list、zone free list、伙伴系统
  3. Linux Page Faults, mmap, and userfaultfd --- Shayon Mukherjee --- 从 VM 快照恢复的实际问题出发讲透缺页与惰性填充
  4. 深入理解 Linux 内存管理核心机制:从分页、页表到大页内存实战 --- 博客园 --- 中文优质长文,分页/页表/缺页中断到 HugePages
  5. linux mmap 底层实现 --- CSDN --- mmap 系统调用六步拆解:建 vma → 首次访问缺页 → 分配物理页建映射
  6. Why malloc Isn't 'Using Up' Memory on Linux --- LinuxVox --- "malloc 为什么不占内存"的通俗解释
相关推荐
清风玉骨1 小时前
zero3W的镜像制作和镜像烧录
linux
光电笑映2 小时前
Linux 线程编程:从进程、分页到线程控制与封装
linux·运维·服务器·c++
wzg20162 小时前
本地部署Deepseek-coder + Vscode + Continue 插件(3)启动与关闭deepseek-coder大模型
linux·运维·服务器
H_oRIZoN_2 小时前
Linux入门DAY37(IO 多路复用、TCP 并发服务器与数据库 SQLite3 详解)
linux·服务器·数据库
新时代牛马2 小时前
Linux 块设备驱动完整篇:从gendisk、submit_bio 到blk-mq queue_rq 与排障
linux·运维·服务器
不吃鱼的羊2 小时前
Davinci Developer的IRV
服务器
德迅云安全杨德俊2 小时前
裸金属服务器的运用与适用场景
运维·服务器
chinesegf2 小时前
现代互联网服务的经典架构
服务器·架构·llama
Rsingstarzengjx2 小时前
第二十六章 Linux 蜂鸣器实验笔记
linux·驱动开发·笔记·stm32·嵌入式硬件·stm32mp157