理解虚拟内存:程序看到的地址为什么不是真实内存
2026/5/14 22:09:35 网站建设 项目流程

这几天学习了「《Virtual Memory: A Deep Dive into Page Tables, TLBs, and Linux Internals》」里面关于虚拟内存的知识,这篇文章我们来聊聊一个操作系统里的基础概念:虚拟内存

学过「操作系统」的小伙伴可能知道,进程运行时看到的地址,并不一定等同于真实的物理内存地址。它看到的是一套由操作系统和硬件共同维护的虚拟地址空间。CPU 访问内存时,还需要通过页表、TLB 等机制,把虚拟地址翻译成物理地址。

原文讲得很细,从虚拟地址空间、页表、TLB、缺页异常,一路讲到mmap、写时复制和 Linux 内核行为,不太适合作为一篇简单的入门学习内容。所以这篇文章会做一个简化版梳理,帮助你先理解几个基本问题:虚拟内存解决什么问题,虚拟地址如何映射到物理内存,缺页异常为什么存在,以及这些机制如何影响我们理解程序内存和性能问题。

为什么程序不能直接使用真实内存?

我们知道日常工作中,一台电脑会经常同时开着浏览器、编辑器、终端,连着数据库,以及跑着一堆后台进程。如果每个程序都直接拿着物理内存地址读写,就会遇到两个问题:第一,程序之间如何协调。程序 A 不知道程序 B 是否占用了某块物理内存,如果两个程序直接读写同一块物理地址,就可能互相覆盖数据,导致程序异常甚至系统不稳定。第二,安全性很差。程序 A 中的普通 bug,可能直接修改甚至破坏另一个程序的数据,导致程序异常。

所以,操作系统开始给每个进程安排了一套自己的“地址空间”。在进程眼里,它好像拥有一大片独立内存;但在操作系统眼里,这些地址还需要再翻译成真正的物理内存位置。

这就是虚拟内存的核心作用:让每个进程看到一套独立、受控、可管理的内存视图。

进程看到的内存布局

一个进程的虚拟地址空间通常会被分成几个区域:代码段、数据段、BSS、堆、内存映射区域、栈,以及内核保留区域。每个区域的分工如下:

  • 代码段放程序指令;

  • 数据段放已初始化的全局变量和静态变量;

  • BSS 放未初始化或零初始化的全局变量;

  • 堆用于运行时动态分配,比如mallocnew

  • 栈用于函数调用、局部变量、返回地址等;

  • 中间的大块区域可以放共享库、文件映射、大块匿名内存分配。

上图就是各区域分工的直观体现,我们可以看到 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 发现 present=0 → 内核处理 → 更新 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 Upadhyay:Virtual Memory: A Deep Dive into Page Tables, TLBs, and Linux Internals https://blog.codingconfessions.com/p/virtual-memory

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询