Brief
虚拟内存是现代计算中最基础的构件之一;要想构建和调试高性能、数据密集型系统,就必须掌握它,尤其是优化国产CPU/GPGPU,缺乏手册,要深入理解虚拟内存,更重要的是,方便理解Agent里Memory Layout。
通常我们把虚拟内存理解为:在内存层面为进程提供隔离,使操作系统(OS)能并发运行多个进程,而它们不会互相干扰或破坏彼此内存中的数据。但虚拟内存能做的远不止这些,例如:
- 通过写时复制在进程间共享内存,并通过
fork 快速创建进程; - 借助
mmap 避免「页缓存→用户缓冲」的额外拷贝来完成文件 I/O; - 访问模式、大页、TLB 击落与 NUMA 放置带来的性能影响。
让我们从实践出发,广泛覆盖虚拟内存是什么、如何工作,以及对数据密集型系统性能的影响。读完全文后,我们将建立如下模型与理解:
- 为何需要虚拟内存:进程隔离、内存保护,以及「内存仿佛用之不竭」的假象。
- 虚拟地址空间:进程的内存如何组织为代码、数据、堆、栈与内存映射等区域。
- 地址翻译:虚拟地址如何通过多级页表转为物理地址,以及层级页表如何避免浪费内存。
- 硬件角色:MMU 与 TLB 如何加速地址翻译,以及 TLB 命中率为何关乎性能。
- 按需分页:内核如何把物理帧的分配推迟到页真正被访问时,缺页如何驱动这种惰性分配。
- 内存类型与回收:匿名页、文件后端页、共享页与 tmpfs 后端页有何不同,内核为何用不同方式回收它们。
- 写时复制:进程如何高效共享内存,
fork 如何近乎瞬时创建新进程。 - 内存映射 I/O:
mmap 如何把文件数据映射进进程地址空间、去掉额外的用户缓冲拷贝,并在进程间共享内存。 - 性能含义:页大小、TLB 覆盖范围与访问模式如何影响数据密集型负载。
- 可观测性:如何在 Linux 上查看 VMA、RSS/PSS、缺页、TLB 行为与 NUMA 放置。
如何阅读本文
本文用叙事讲虚拟内存:新创建的进程 Alloca 与 Kernel 之间的一系列对话。Alloca 执行代码时遇到问题,Kernel 用问答把机制摊开。
结构:每一节先是对话展开概念,然后是 Key Takeaway。
篇幅与节奏:从地址翻译、按需分页、页回收、写时复制、可观测性到性能影响都有涉及,不必一次读完。虚拟内存各块相互勾连,建议分多次、按顺序阅读。若你学过操作系统,前半可能偏基础,可直接跳到感兴趣的后半进阶内容。
实现细节:概念多数跨操作系统通用;涉及大页、TLB、缺页处理等实现时,默认以 Linux 内核 与 x86-64 为准。文中以仍在大量部署的 4 级页表 为主;最新 Linux 也支持 5 级页表,掌握 4 级后类推即可。
插叙(Aside):除对话主线外,文中穿插 Aside 补充细节。
下面请随 Alloca 进入虚拟内存世界。
为何需要虚拟内存
Alloca 开始执行代码,遇到第一个挑战:要从内存读数据。指令里带着地址,她想:「这不难,走到这个地址把值读出来就行。」结果大吃一惊。
她来到那个地址,发现什么也没有,像一层布景。她正困惑时,阴影里走来一个高大的身影。
Alloca:「你是谁?」Kernel:「我是内核。我管这一整片世界,让所有进程顺畅干活。你在这儿干什么?这里什么也没有!」Alloca:「我好像迷路了。本该从这个地址读数据,可看起来全是假的,我不知道怎么办。」Kernel(微笑):「可以理解。你手里的不是真实地址,而是虚拟地址。」Alloca:「虚拟地址是什么意思?」Kernel:「你以为的内存并不是物理内存本体,而是虚拟内存。你拿的是虚拟地址;要读物理内存里的数据,需要的是物理地址。」
Alloca:「什么是虚拟内存?为什么不让我直接访问物理内存?」Kernel:「从第一性原理想:我不只负责你,还要同时跑成百上千个进程。你可能没察觉,此刻就有许多进程与你并行。若大家都直接碰物理内存,怎么协调谁用哪一段地址?」Alloca:「会很难——我甚至不知道还有谁在跑,进程来来去去,根本没法协调。」Kernel:「对。即便进程能互相说话,每次访存都要问一圈谁占用了哪些地址,系统会极慢,而且安全是噩梦:一个进程的小 bug 就可能踩坏别人的数据。」Alloca:「那怎么解决?」Kernel:「靠,虚拟内存。要解决两件事:第一,每个进程都能用内存,而不必担心地址是否被别的进程占用;第二,访存要安全,还不能牺牲性能。」
Alloca:「虚拟内存怎么做到?」Kernel:「虚拟内存是软件抽象,看起来像真内存,有你可读写的地址。我给每个进程一片私有的虚拟地址空间,你可以自由导航、随意使用,而不必担心别人占用同一片——这就解决了隔离问题。」Alloca:「可若地址不是真的,读写落到哪里?安全又怎么保证?」Kernel:「细节后面再展开,先简化:抽象在我手里控制。我把进程用到的一组虚拟地址映射到一组物理地址;我又知道别的进程占用了哪些物理页,就能保证不会有两个进程映射到同一物理位置。」
Key Takeaway
虚拟内存存在的根本理由,是给进程提供内存级隔离。多任务系统里,进程并行或分时运行,彼此不能读写对方数据。内核为每进程提供私有虚拟内存,从机制上杜绝越界。每个进程以为独占整台机器的物理内存,实际上只是虚拟视图;背后是每进程不同的虚拟→物理映射。下一节讲映射如何工作。
叙事准确性说明
真实硬件上,访存由 MMU 透明拦截,进程通常毫无感知。但要准确描述 MMU、页表与内核对内存事件的处理,又必须先有虚拟内存模型,从简化的视图起步;后文会逐步收紧为更精确的模型。
虚拟地址空间有多大
Alloca 明白了为何要有虚拟内存,但仍不清楚它长什么样、如何运作。她继续追问。
Alloca:「既然我看见的是虚拟的,那它是无限的吗?」Kernel:「不算无限,但非常大。你知道 CPU 里地址怎么表示吗?」Alloca:「x86-64 上地址存在 64 位寄存器里,所以能寻址 2⁶⁴ 字节?」Kernel:「直觉如此,但有但是:寄存器虽是 64 位,并非所有位都参与寻址。常见模式下只有 48 位参与地址翻译。」
Alloca:「为什么只用 48 位?」Kernel:「工程折中:48 位可寻址 2⁴⁸ 字节,即 256 TiB,已经巨大。当今应用远用不满。设计者认为在可预见未来够用,于是简化翻译逻辑;仍保留将来扩到 52、56 位的余地。」Alloca:「所以我能用到 256 TiB 虚拟空间?」Kernel:「不完全是。你一般只能用其中一半,约 128 TiB。另一半高地址我用来把自己的代码和数据映射进每个进程的地址空间。」
Alloca:「你住在我的地址空间里?」Kernel:「必须如此。系统调用或中断时,执行会切入内核态跑我的代码;若我的代码不在你的地址空间里映射好,CPU 不知道该跳到哪里。所以我住在每个进程地址空间的高半部;你不能直接碰我的内存,但它在需要进内核时已就绪。」Alloca:「可虚拟空间这么大,机器往往只有 16 GB、32 GB 物理内存,怎么成立?」Kernel:「这正是虚拟内存的妙处:虚拟地址空间大小与机器安装了多少 DRAM 无关。即便只有 16 GB RAM,你的规范虚拟范围仍是 256 TiB。虚拟到物理的映射才是两世界接口,由我维护,并保证映射落在实际物理能力之内。」
Key Takeaway
虚拟地址空间因「虚拟」而可以远大于已安装 RAM。常见 x86-64 的 48 位规范寻址范围约为 256 TiB;Linux 通常将其切成低半用户空间与高半内核映射。低半约 128 TiB 给用户进程;高半留给进入内核态时使用的内核映射。物理地址能力由 CPU 与平台决定,与虚拟容量是两条线。
虚拟地址空间布局
Alloca:「你说高半部映射你的代码和数据,我那半里又映射了什么?」Kernel:「你那半映射你的代码与数据。」Alloca:「有固定形状吗?」Kernel:「有,以**段(segment)**组织,每段承载一类数据。我给你看一眼。」
Kernel 一挥手,Alloca 突然看见自己虚拟内存的竖向地图。
Kernel:「最底下低地址是代码,你执行的指令。创建你时加载。这叫 text 段。」Alloca:「上面是 data 段?是不是所有数据都在那儿?」Kernel:「不是所有,而是一类:已初始化为非零的全局/静态变量。例如常量 pi = 3.14 在 data 段。」Alloca:「未初始化的全局变量呢?」Kernel:「BSS 段。」
Alloca:「为什么要单独一段?」Kernel:「省空间。未初始化全局在启动时应为多少?」Alloca:「零。」Kernel:「对。若有成千上万个零初始化全局,全写进二进制就全是零。链接器只记一笔:『需要例如 50 KB 的零初始化区』,文件里不存零。加载时我分配 50 KB、填零、映射为 BSS。二进制更小、加载更快。」
Alloca:「data 和 BSS 装静态数据;运行时往链表加节点呢?」Kernel:「data/BSS 编译链接期尺寸就定了,不能随运行增长。动态需求走 堆。」Alloca:「堆从哪来?」Kernel:「紧挨在 BSS 之上;图上它上面有一大片空地址。」Alloca:「堆能长进那片空白?」Kernel:「对。malloc() 时分配器通常通过抬高上边界来扩展堆,这条上边界叫 program break,简称 brk。」
Alloca:「图上堆上面空白比堆、栈都大得多,那是什么?」Kernel:「大体是尚未映射的地址区间。」Alloca:「未映射?为什么会有未映射地址?」Kernel:「你记得用户半空间约 128 TiB,而物理 RAM 往往只有 GB 级?」Alloca:「记得。」
Kernel:「不可能把每个虚拟字节都映射到物理页;多数程序只用几百 MB。只映射需要的部分,其余保持未映射,按需再映射。」
Alloca:「若访问未映射地址?」Kernel:「若是我通过 malloc/mmap 合法给你的区间,你可用;若在空白里随便指一处读写,会 segmentation fault——硬件拒绝,因为没有有效映射。」Alloca:「所以空白不是浪费,而是可随需映射的池子?」Kernel:「正是。加载 libc.so 等共享库、用 mmap 映文件、malloc 大块,常都落在这片中部区域。」Alloca:「栈在顶上?」Kernel:「专门管函数调用,每次调用都会用到。」Alloca:「为何调用函数要单独区域?」
Kernel:「调用时要什么数据?」Alloca:「局部变量、返回地址……还有要保存/恢复的寄存器。」
Kernel:「这些在调用时分配、返回时自动回收。哪个段适合?」Alloca:「data/BSS 尺寸固定。」Kernel:「堆呢?」Alloca:「堆能长,但每次 malloc/free 管函数调用太烦、慢且易错。」
Kernel:「你需要的是随调用自动伸缩、且遵循后进先出的区域。」Alloca:「像栈这种数据结构!」Kernel:「故名 stack。CPU 有 push/pop 和 栈指针。调用时局部变量、保存的寄存器、返回地址进栈帧;返回时弹出。全自动。」Alloca:「调用链很深时栈能无限长吗?」Kernel:「不能。x86-64 上默认栈上限常见 8 MB。」
Alloca:「栈在地址空间顶端,往哪长?」Kernel:「栈通常映射在较高地址,向低地址增长。例如栈指针在 0x120008,压入 8 字节后变为 0x120000。」
Alloca:「堆向上、栈向下?」Kernel:「对。中间空隙让二者不易相撞;实务上往往在相遇前就因栈深或堆大而耗尽其一。」Alloca:「为何非要这种布局,而不是哪里有洞塞哪里?」Kernel:「两大原因:性能与安全。先听性能。」Alloca:「好。」Kernel:「读数组下标 5 后你通常读 6、7……代码也大体顺序执行。」
Alloca:「是,除循环与调用外多顺序访问。」Kernel:「空间局部性很常见,硬件围绕它设计。主存很慢,动辄数百周期。」Alloca:「那很糟?」Kernel:「CPU 有片上缓存;读一字节时往往顺带拉一整块,常见 64 字节 cache line。」Alloca:「赌我接着会读附近?」
Kernel:「对,数组遍历、顺序执行时赌赢概率高,下一访问已在缓存里,这叫空间局部性。」Alloca:「所以布局帮助性能?堆上链表节点也会近吗?」
Kernel:「链表反例,节点可能散落堆各处;数组更好。更重要的是栈帧内局部变量挤在一起,访存易命中缓存。」Alloca:「text 段代码也一样?」Kernel:「指令顺序执行,硬件可预取下一条 cache line。代码与数据分区,有利于缓存友好访问。」
Alloca:「安全呢?」Kernel:「若攻击者通过溢出往堆写任意字节,最坏是什么?」Alloca:「破坏数据结构、崩溃?」Kernel:「更糟:若写的是机器码,又骗程序跳过去执行?」
Alloca:「CPU 会把恶意码当我的程序执行!」Kernel:「还可能改 text 段植入后门。」
Alloca:「怎么防?」Kernel:「每段设权限位。代码段应可写吗?」Alloca:「不应,代码固定。」
Kernel:「text 只读+可执行;堆与栈可读写但不可执行。若跳到堆上执行,处理器拒绝并杀进程。」Alloca:「分离代码与数据才能分别设权?」Kernel:「对。常称 W^X(写异或执行):一页不能同时可写又可执行。分段让模型干净、可强制执行。」
Key Takeaway
虚拟地址空间划分为若干 段:
- Text(代码)段:编译后的指令,启动加载,只读且可执行;进程不能自写代码页。
- Data 段:已显式初始化的全局/静态变量,链接期尺寸固定。
- BSS 段:零初始化全局/静态;二进制不存数据,由加载器启动时提供零填充内存。
- Heap:
malloc/new 等动态分配;在 data/BSS 之上向上扩展;上边界为 program break(brk);大块也常直接 mmap 而非抬高 brk。 - Memory-mapped 区域:地址空间中部大片灵活区,用于共享库、文件映射、大块匿名映射等(如
libc)。 - Stack:当前调用链的栈帧;靠近用户空间顶部向低地址增长;每次调用压入局部变量、保存寄存器、返回地址,返回时弹出。
Aside:匿名内存(Anonymous memory)
- 匿名内存:
malloc 或带 MAP_ANONYMOUS 的 mmap 分配;堆、栈等也常属匿名。 - 文件后备内存(File-backed):由文件内容支撑,通常通过给
mmap 传文件描述符建立。
后文会细讲二者,先共享这套词汇便于推进。
虚拟地址如何翻译成物理地址
Alloca:「布局懂了。可都是虚拟地址,怎么变真?我想象你有一张表:虚拟字节 0→物理字节 X……每个地址一项?」Kernel:「自然想法。算算成本:用户半空间 128 TiB,若每字节一项、每项 8 字节,单进程表就要 PiB 量级——不现实。」
Alloca:「那总得查表。」Kernel:「对,但按固定块映射:虚拟空间切成 页(page),物理内存切成等大的 帧(frame)。虚拟页一次映射到一个物理帧,每页一项而非每字节。」Alloca:「块多大?」
Kernel:「常见 4 KB。这样 128 TiB 地址空间对应的页表项数量约为 2³⁵ 量级(原文排版作 235,意指 2 的 35 次方规模)。」Alloca:「为何是 4 KB?」Kernel:「访存有簇集性,硬件已用 64B cache line 利用局部性;页在更粗粒度上做同样的事。4 KB 在粒度与浪费之间较平衡。」
Alloca:「页与帧等大,任意空闲帧可背任意页。」Kernel:「对。」Alloca:「那给定地址读 8 字节,如何知道属于哪一虚拟页?」Kernel:「答案在地址本身,像索书号:楼层-架-位一体编码。虚拟地址同时编码页号与页内偏移。」
Alloca:「高位页号、低位偏移?」Kernel:「是。虚拟页号在高位,页内偏移在低 12 位(2¹²=4096)。把虚拟页 N 映射到物理帧 M 后,页内偏移不变,因帧也是 4 KB。查表得帧号,拼上偏移即物理地址。」Alloca:「128 TiB / 4KB 一页一项 8 字节,扁平表仍要数百 GiB 每进程?」
Kernel:「扁平表的问题。若只跟踪哪些大区在用呢?」Alloca:「分区、成组?」Kernel:「底端代码、中间库、顶端栈,中间大片空着。若顶层索引只记大区,小区内再细分,直到单页?」
Alloca:「像树,每层缩小范围?」Kernel:「这叫多级页表。顶层 512 项,每项覆盖 512 GB;整段 512 GB 未用则项标 absent,不再分配子表。」Alloca:「只为实际使用部分建深层表?」Kernel:「是。顶层项可指二级表,512 项各盖 1 GB,再向下直到 4 KB 页。」Alloca:「四级仍费空间?」Kernel:「只用一页时,沿路径需要顶层一项 + 三层各 512 项的表,总量约 12 KB 量级;对比扁平表 256 GiB 量级,节省多个数量级。」Alloca:「表只为实际用过的虚拟区间存在。」Kernel:「正确。」
Aside:Linux 与 x86 对页表层级的命名
读 Linux 源码与读 Intel/AMD 手册时,四级结构名字不同。x86 名与具体架构绑定;Linux 的 PGD、PUD、PMD、PTE 更通用,在 x86-64、ARM64、RISC-V 等上一致使用(即便硬件级数不同)。本文统一用 Linux 名:PGD、PUD、PMD、PTE。
Alloca:「虚拟地址怎么沿树走?」Kernel:「64 位宽地址常用 48 位参与翻译:四组各 9 位索引 + 低 12 位偏移。四组 9 位逐级索引 PGD→PUD→PMD→PTE,定位帧号,再与 12 位偏移合成物理地址。」Alloca:「具体怎么切分?」Kernel:「[47:39] 索引 PGD 得 PUD;[38:30] 索引 PUD 得 PMD;[29:21] 索引 PMD 得 PTE;[20:12] 在末级表取物理帧号。」Alloca:「低 12 位是页内字节偏移?」Kernel:「对,2¹²=4096,即页大小。」
Aside:48 位虚拟地址
64 位寄存器存地址,参与翻译的常是 48 位,高 16 位呢?须为第 47 位的符号扩展:用户低半为全 0,内核高半为全 1,称 canonical(规范)地址;非规范地址会在正常页表遍历完成前就 fault。这造成 64 位虚拟空间中低半与高半之间的巨大空洞。
较新的 x86-64 与 Linux 支持 5 级页表,用 57 位翻译(在 PGD 与 PUD 间增加 P4D 等),每进程可达 2⁵⁷ 字节(128 PiB)虚拟空间;多出来的一级用 [56:48] 索引,[63:57] 为第 56 位符号扩展。
Alloca:「谁来做翻译?每次访存都要查表。」Kernel:「MMU(Memory Management Unit,内存管理单元) 拦截你发出的每个地址;对你而言像直接读虚拟地址。」Alloca:「MMU 每次自动查?从哪开始?」
Kernel:「CPU 的 CR3 寄存器保存当前进程 PGD 的物理地址;每次上下文切换我更新它,MMU 才知道用哪套表。」Alloca:「再用地址里的各位逐级走?」Kernel:「是,**[47:39]→PGD,[38:30]→PUD,[29:21]→PMD,[20:12]**→PTE,得帧号后与 12 位偏移合成物理地址。」

Alloca:「那岂不是每次访存要多读四次内存做翻译?」Kernel:「若每次都 walk 会慢。MMU 还有小硬件缓存 TLB(Translation Lookaside Buffer,转译后备缓冲):walk 成功后记下「虚拟页 P→物理帧 F」;下次先查 TLB,命中则只需少量周期,不必再 walk。」Alloca:「命中率多高?」
Kernel:「紧循环、热函数、复用缓冲区会留在较小 working set(工作集) 内,TLB 热、walk 少;否则看访问模式,不保证。」
Aside:工作集(Working set)
进程 working set 是在某段执行窗口内活跃需要的虚拟页子集,会随程序阶段变化:小数组上的紧循环工作集很小;扫大表的数据库引擎则大得多。
工作集影响两类硬件:
- TLB:若工作集规模落在 TLB 容量(通常数百到数千项)内,翻译多被缓存、walk 少;超出则 TLB miss 增多,可能伤性能。
- 物理 RAM:工作集若能放进 RAM,页保持驻留;否则内核要换出到 swap 再按需换回,昂贵得多(后文讲回收与 swap)。
把 working set 控制得小且稳定,是改善内存性能最有效的手段之一。
Key Takeaway
虚拟内存以 页(常见 4 KB 虚拟块)映射到等大的物理 帧。每个虚拟地址编码两部分:虚拟页号(高位)与页内偏移(低 12 位);翻译时偏移不变,只把页号换成帧号。
x86-64 上内核用 四级层次页表:PGD / PUD / PMD / PTE。48 位虚拟地址拆成四级各 9 位索引 + 12 位偏移。层次是稀疏的:只为实际使用区间分配页表结构,避免扁平表数百 GiB 量级的开销。
各虚拟页独立映射,相邻虚拟页不必落在相邻物理帧;进程页可与其它进程帧在 RAM 中交错,进程仍看到连续干净的虚拟空间。
MMU 在硬件中完成翻译;x86 上 CR3 指向当前进程 PGD。每次访存 MMU 先查 TLB;未命中则做完整 page table walk,再把结果写入 TLB。