这几天学习了「《Virtual Memory: A Deep Dive into Page Tables, TLBs, and Linux Internals》」里面关于虚拟内存的知识这篇文章我们来聊聊一个操作系统里的基础概念虚拟内存。学过「操作系统」的小伙伴可能知道进程运行时看到的地址并不一定等同于真实的物理内存地址。它看到的是一套由操作系统和硬件共同维护的虚拟地址空间。CPU 访问内存时还需要通过页表、TLB 等机制把虚拟地址翻译成物理地址。原文讲得很细从虚拟地址空间、页表、TLB、缺页异常一路讲到mmap、写时复制和 Linux 内核行为不太适合作为一篇简单的入门学习内容。所以这篇文章会做一个简化版梳理帮助你先理解几个基本问题虚拟内存解决什么问题虚拟地址如何映射到物理内存缺页异常为什么存在以及这些机制如何影响我们理解程序内存和性能问题。为什么程序不能直接使用真实内存我们知道日常工作中一台电脑会经常同时开着浏览器、编辑器、终端连着数据库以及跑着一堆后台进程。如果每个程序都直接拿着物理内存地址读写就会遇到两个问题第一程序之间如何协调。程序 A 不知道程序 B 是否占用了某块物理内存如果两个程序直接读写同一块物理地址就可能互相覆盖数据导致程序异常甚至系统不稳定。第二安全性很差。程序 A 中的普通 bug可能直接修改甚至破坏另一个程序的数据导致程序异常。所以操作系统开始给每个进程安排了一套自己的“地址空间”。在进程眼里它好像拥有一大片独立内存但在操作系统眼里这些地址还需要再翻译成真正的物理内存位置。这就是虚拟内存的核心作用让每个进程看到一套独立、受控、可管理的内存视图。进程看到的内存布局一个进程的虚拟地址空间通常会被分成几个区域代码段、数据段、BSS、堆、内存映射区域、栈以及内核保留区域。每个区域的分工如下代码段放程序指令数据段放已初始化的全局变量和静态变量BSS 放未初始化或零初始化的全局变量堆用于运行时动态分配比如malloc或new栈用于函数调用、局部变量、返回地址等中间的大块区域可以放共享库、文件映射、大块匿名内存分配。上图就是各区域分工的直观体现我们可以看到 Stack、Heap、BSS、Data、Text / Code、memory-mapped region 在地址空间里的位置。在常见的 48-bit x86-64 虚拟地址模式下虚拟地址最多可以表示 2^48 个字节位置也就是 256 TiB 的地址空间。这里的 256 TiB 指的是“可寻址范围”不代表机器真的有这么多物理内存。Linux 通常会把这段虚拟地址空间分成两部分低地址区域留给用户态进程高地址区域用于内核映射。对应到上图就是 low address 和 high address。这也变相地解释了一个常见疑问为什么一台只有 16GB / 32GB 内存的机器进程却能拥有远大于物理内存容量的虚拟地址空间。虚拟地址空间的大小和真实 RAM 容量本就是两码事。虚拟地址的空间可以很大但只有被进程实际使用、并且建立了有效页表映射的部分才会进一步对应到物理内存或页缓存中的数据页。地址翻译的基本过程下面轮到页表出场了。操作系统不会按“每一个字节”去管理映射关系那样太细、太繁琐了。它通常按页来管理。常见的页面大小是 4KB。虚拟地址空间被切成一页一页的虚拟页物理内存也被切成一块一块的页框。当程序访问一个虚拟地址时CPU 里的 MMU也就是内存管理单元会根据页表把它翻译成物理地址。在 x86-64 的四级页表结构中虚拟地址会被拆成多个字段分别用于索引 PGD、PUD、PMD、PTE最后 12 位作为页内偏移。寻址过程上图解释了“一个虚拟地址是怎么被拆开并逐级查表的”。把它想成查地图的话大概流程就是先用第一段地址信息找到大区 PGD再用第二段找到街区 PUD再用第三段找到楼 PMD最后用第四段找到房间 PTE最后的页内偏移告诉你具体是哪一个字节。这样做的好处是不用为整个巨大地址空间提前准备一张完整大表。只有真正用到的区域才需要建立相应的页表结构。页表的层级是稀疏的只为实际使用的地址空间分配结构避免平铺页表带来的巨大开销。连续地址背后的物理映射这是虚拟内存里很重要的一点。程序看到的数组可能是连续地址。但这些虚拟页映射到物理内存时可以分散在不同位置。程序并不需要知道这些细节。它只看到一段连续空间页表负责把这些虚拟页指向真实的物理页框。这个图解释了什么叫“程序看到连续物理上可以分散”。物理映射也是虚拟内存让系统更灵活的地方。操作系统可以把不同进程的物理页框交错放在 RAM 里同时保证每个进程看到的仍然是一套干净、连续、独立的地址空间。上图就在说这一点相邻虚拟页可以落在相距很远的物理页框中不同进程的页框也可以交错分布在物理内存里。TLB地址翻译的缓存层每次访问内存都查页表会不会很慢答案是会。所以硬件还准备了一个缓存TLB全称Translation Lookaside Buffer地址转换后备缓冲器。TLB 可以理解成“地址翻译缓存”。如果某个虚拟页到物理页框的映射刚刚查过MMU全称Memory Management Unit内存管理单元下次就可以先查 TLB。命中之后不需要完整走一遍页表。TLB 会自行缓存已经完成的地址翻译只有 TLB 未命中时MMU 才需要进行完整的页表遍历。程序的访问模式会显著影响 TLB 命中率。小工作集、重复访问的循环、复用的缓冲区通常更容易保持 TLB 友好。上图展示了 CR3 和四级页表遍历的大致路径。结合 TLB 来看它也能解释为什么内存访问模式会影响性能同样是读数据顺序访问、局部性好的访问往往比到处跳着访问更友好。内存申请背后的按需分配可能会有人以为程序一申请内存操作系统就立马分配对应的物理内存。实际上操作系统会更懒一点。操作系统可以先给你一段合法的虚拟地址范围等你真正访问它时再分配物理页框。这叫 demand paging也就是按需分页。比如程序调用malloc后可能已经拿到了一段可用的虚拟地址范围。但这不代表对应的物理内存已经全部分配好了。只有当程序第一次读写某一页时CPU 发现页表里没有有效映射就会自动触发 page fault也就是缺页异常。内核接手后先检查这个地址是否合法。如果合法就分配一个物理页框更新页表然后让程序继续执行。这个过程可以概括为page fault 发生后内核检查 faulting address 是否落在有效 VMA 内如果合法就分配物理 frame、更新页表并恢复执行如果不合法就会变成 segmentation fault。上图为“程序访问 → MMU 发现 present0 → 内核处理 → 更新 PTE → 程序继续运行”整个流程是如何运作的。有时候我们的程序会看起来申请了很多内存但实际 RSS全称Resident Set Size常驻集大小没有立刻涨那么多。因为虚拟地址可以先被预留出来物理页框往往等到真正访问时才会分配并建立映射。写时复制背后的进程复制虚拟内存还支撑了一个很经典的机制copy-on-write写时复制。在 Unix/Linux 系统里fork()会创建一个子进程。我们可能会觉得子进程要复制父进程的整个地址空间这应该很贵。但系统很聪明不会一开始就把所有内存都复制一遍。它会让父进程和子进程先共享同一批物理页并把相关页标记成只读。等其中一个进程真的要写某一页时CPU 再触发权限异常。然后内核来复制那一页给写入方一份私有副本。下图展示了这个过程fork()后两个页表先指向同一批物理 frame当 Alloca 写入 page A 时内核分配新 frame、复制内容并只更新 Alloca 的 PTE。这个机制让fork()的执行成本变低了。尤其是常见的fork exec模式里子进程马上加载新程序很多旧页面根本没有复制的必要。mmap背后的文件映射虚拟内存还有一个常见用途mmap一种把文件或内存区域映射到进程虚拟地址空间里的机制。普通read()操作读取文件时数据通常会先进入内核的页缓存再复制到用户态 buffer。mmap()会把文件的一段内容映射到进程地址空间里程序可以像访问内存一样访问文件内容。上图为read()与mmap()的 I/O 路径对比。使用read()时数据会从磁盘读入页缓存然后再复制到进程的用户态缓冲区。使用mmap()时进程的 PTE 会映射到承载页缓存数据的物理页框上从而省去从页缓存到用户态缓冲区的这次复制。代价是mmap()不再通过显式的read调用来完成读取而是需要承担缺页异常和页表管理带来的开销。小结虚拟内存听起来很底层但它会影响很多日常开发问题为什么空指针访问会崩为什么栈溢出会触发 segfault为什么进程虚拟内存很大实际占用却没那么大为什么随机访问大数组可能很慢为什么fork()没有想象中那么贵为什么mmap()有时快有时并不快这些问题背后都绕不开虚拟地址、页表、TLB、缺页异常和内核内存管理。理解虚拟内存不是为了记住一堆术语而是为了知道程序眼里的“内存地址”只是操作系统和硬件共同维护出来的一层抽象。真正的数据在哪里、什么时候分配、能不能访问、访问是否高效都要经过这一层机制来决定。所以下次你看到一个指针地址时可以多想一步这个地址看起来像真实位置但它首先是一张地图上的坐标。真正把它带到物理内存里的是 MMU、页表、TLB 和内核。参考资料Abhinav UpadhyayVirtual Memory: A Deep Dive into Page Tables, TLBs, and Linux Internals https://blog.codingconfessions.com/p/virtual-memory