
引用对于计算机软件的正常运作内存Main Memory所发挥作用的重要性不言而喻。无论是处在“幕后”的操作系统还是位于“台前”的用户应用程序它们在运行时都会将所需数据从磁盘等外部存储器转移至内存。实际上内存和 CPU 芯片上的 L1、L2 等高速缓存一同构成了计算机中用于支撑程序高效运行的缓存系统。今天我们会先从整体的视角看看内存在计算机系统中的作用然后再一起探究进程是如何在操作系统的控制下与计算机内存交互的。计算机内部的缓存系统通常文件会被存放在容量较大的磁盘中。但磁盘作为一种提供数据持久化存储的设备采用了机械式的数据寻址方式这就使得它无法匹配 CPU 在完成相关操作时所需数据在访问速度上的要求。而内存则以快于磁盘几万甚至十几万倍的读写效率承担起了与 CPU 直接交互的重任。但随着摩尔定律的不断应验CPU 与内存两者在数据访问效率的“供需关系”上又出现了问题。因此现代计算机通过在这两者之间引入更多读写速度更快容量却更小的高速缓存层并基于局部性原理让 CPU 经常使用到的数据可以被更快地再次访问。通过这种方式由 L1、L2 等片上高速缓存以及内存组成的缓存系统便成为了计算机中用于承载应用运行时数据的主要部件。网上有一个十分形象的例子描述了内存在整个计算机系统中的“地位”假设你作为负责人每天都在一间专属办公室里处理各种事务。办公室内有一个硕大的档案柜里面存放着你有权限接触的所有办公材料。当每一次需要处理某个具体事务时你都会首先将需要的相关材料一次性地从档案柜中全部拣选出来并将它们陈列在书桌上然后再继续进行处理。而随着时间的推移那些经常被翻阅的、相关性较强的材料会被摆放在距离你手边较近的位置而相关性较弱的材料则会被放在较远的位置。将这个例子类比到计算机你会发现两者之间有着类似的行为模式。每一个程序的机器代码事务在可以被 CPU负责人正常执行前操作系统都需要先将它们从磁盘档案柜“搬移”到内存书桌中。而随着程序的不断运行那些被经常访问的数据便会被存放到较高级别的缓存较近位置中。相反不常用的数据则会被存放在较低级别的缓存较远位置甚至驻留在内存中。但是对计算机而言程序与内存之间的交互细节远比“办公室日常”要复杂得多。现代操作系统会同时执行十几个甚至几十个程序。因此如何从有限的内存中合理地为它们分配所需资源并同时兼顾安全性、高效性便成为需要考虑的首要问题。现代计算机通过名为“虚拟内存”的机制做到了这一点。下面我们来进一步看看这个机制的具体工作原理。虚拟内存机制顾名思义虚拟内存Virtual Memroy对应于物理内存Physical Memory。其中前者是由操作系统抽象出来的一个概念它在后者的基础之上进行了一层抽象以帮助运行于其上的应用程序合理地分配内存并管理内存使用。因此如下面的代码所示我们在应用程序中打印出的各种指针值它们实际上都对应于虚拟内存中的某个地址而非实际的物理内存地址Physical AddressPA。这些地址被称为“虚拟地址Virtual AddressVA”。所有程序可以使用的虚拟地址则构成了虚拟地址空间VAS。#include stdio.h int main(void) { int x 10; printf(%p, x); // 0x7fff32cf54fc. getchar(); return 0; }CPU 在访问内存中的数据时会借助其芯片上名为“内存管理单元MMU”的硬件首先将虚拟地址动态翻译为对应的物理地址然后再进行实际的数据获取。你可以通过下图来直观地理解这个过程。虚拟内存机制的一个最重要特征就是为每一个应用程序进程都抽象出了独立于物理内存的虚拟地址空间。这意味着从进程的角度来看它可以独享整个计算机上的所有内存。现代操作系统通常采用 32 或 64 位地址空间两者分别拥有 232 与 264 个地址。通过这种方式编译器在构建应用时便不需要考虑各二进制数据段应该被实际加载到内存中的何处所有应用均可使用统一的静态文件结构。比如在 64 位 Linux 系统中与应用代码相关的 Segment 会从 VAS 的固定地址 0x400000 处开始加载。而其他 Section 内容将在满足一定对齐要求的情况下按顺序被连续加载到高地址方向的虚拟内存中。这样无论是程序在二进制文件内的静态视图还是被加载到 VAS 后的运行时视图它们都可以在虚拟内存的隔离下在表现层有着稳定一致的布局。而通过下面这行命令我们便可以查看某个运行进程的 VAS 布局情况。注意其中的 “” 需要被替换为进程对应的 ID。cat /proc/pid/maps这里我们尝试将本小节开头处的那个 C 程序的 ID 替换到上述命令中。在运行该命令后可以得到如下图所示的输出结果。这里命令按照地址由低到高的顺序打印出了进程 VAS 内每一块已经被占用的连续虚拟内存地址对应的映射信息。如最右侧一列所示这些内存中的内容或是来自于某个具体文件/www/workspace/main或被用作其他用途[heap]。VAS 中的数据布局将上图中的信息进行归类我们可以得到如下图所示的 Linux 进程在 VAS 内的统一数据布局结构。这里我根据类别将不同的数据用不同的颜色进行了标注。并且为了方便你找到这两个图之间的内存段对应关系我将上图中的一些关键性地址信息也选择性地标注在了下图中。总的来看Linux 进程 VAS 中的数据按照地址由低到高的顺序可以被分为下面这几个主要部分。LOAD Segments这部分数据被加载到从地址 0x400000 开始的虚拟内存中。其主要内容为应用程序 ELF 二进制文件内定义的各种 LOAD Segment 结构。按照顺序与代码相关的 Text Segment包含 .text、.rodata 等多个 Section位于最低地址处紧接着为包含有已初始化和未初始化数据的 Data Segment包含 .data.bss 等多个 Section。堆Heap关于堆内存我已经在 08 讲 中介绍过。随着动态数据的不断产生它将向 VAS 的高地址方向不断增长。共享库数据这部分内存中包含有与各类 .so 共享库相关的数据程序会在运行时通过动态链接器来完成对它们的加载和处理。我会在第 29 讲再为你详细介绍。栈Stack关于栈内存我已经在 05 讲 中介绍过。随着各种局部变量的不断产生它将向 VAS 的低地址方向不断增长。用于系统调用加速的内核数据接下来的三个虚拟内存区域 [vvar]、[vdso]以及 [vsyscall] 中包含有操作系统内核的代码和数据结构它们主要提供了用户进程可以直接与内核进行交互的接口。其中[vvar] 中包含有只读的内核数据。而另外的 [vdso] 与 [vsyscall] 则包含有用于辅助操作系统加速用户进程执行某些系统调用过程的信息。其他内核数据除此之外在进程 VAS 的高地址处还可能包含有与当前进程相关的各种数据结构。甚至该区域内的某段虚拟内存页还会被直接映射到某段被所有进程共享的物理内存页上比如用于 MMIO可以看到得益于虚拟内存机制的抽象进程可以使用完全统一、独立的内存数据布局而不用考虑这些数据在真实物理内存中的具体存储细节。那么虚拟内存机制究竟是如何对物理内存进行管理的呢接下来我们具体看看。使用页表维护虚拟页状态为了保证效率操作系统通常会以“页”为单位来在磁盘与内存之间传递数据。而实际上它也正是通过为每一个进程提供独立的“页表”结构来维护 VAS 中的虚拟页在对应物理内存中的映射状态的。页表本身被维护在物理内存中其内部由众多的“页表项Page Table EntryPTE”组成。进程 VAS 中的每个虚拟页都对应于页表中的某个 PTE而 PTE 中则包含有用于描述该虚拟页状态的众多字段。每一次 MMU 需要将一个虚拟地址翻译为物理地址时它都会首先读取页表以查询相关的 PTE 信息。然后再根据虚拟地址内隐含的偏移信息找到对应页中的目标位置。在简化的实现中PTE 可能由一个“有效位”字段与一个“地址”字段组成。其中有效位用于表明该虚拟页是否已被缓存在物理内存中。若该位置位则地址字段中存放有该页在物理内存中的起始位置。而在该位复位的情况下若地址字段为空则表明该虚拟页还未被分配。否则地址字段中便保存有虚拟页内容在磁盘上的起始位置。当 CPU 需要访问某个虚拟地址上的数据时通常会发生以下两种情况MMU 查找进程页表发现目标数据已被缓存进而直接通过 PTE 中的物理地址获取并返回所需要的数据MMU 查找进程页表发现目标数据未被缓存此时它会触发一个“缺页异常”。在第二种情况中缺页异常将会调用内核中特定的异常处理程序该程序会在物理内存中选择一个页以用来承载当前虚拟地址所对应的物理数据。其中对于空闲页内核会直接将虚拟页对应的内容从磁盘拷贝到该物理页中而对于非空闲页若该页已经被修改则内核会首先将它的内容换出即更新到磁盘。然后再将磁盘上的内容拷贝至这块物理页中。这里你可以先暂缓脚步通过下图来直观地理解 CPU、MMU、页表、物理内存以及磁盘五者之间的协作关系。页表隔离了进程的 VAS 与物理内存使得两者之间的映射关系变得更加自由。而在这种方式下当不同进程使用不同页表维护其各自 VAS 中虚拟页的映射时多个进程之间便可做到真正的数据共享。而我将在 29 讲中介绍的“动态链接”技术便以此为基础。不仅如此独立的 VAS 与页表也使得进程之间的私有内存不会被相互访问。另外通过在 PTE 中增加用于访问控制的相关字段如可读、可写、可执行CPU 可以在程序尝试非法访问某块内存数据时做出异常响应。使用多级页表压缩页表体积但是上面介绍的一级页表有时却可能无法满足需求。试想以目前常用的 64 位地址空间为例假设页大小为已知最大的 2MiB为保证完整映射每个 PTE 大小为 8 字节。而为了能够在单一页表内维护进程整个 64 位 VAS 中所有虚拟页的信息那么便需要为其匹配一个大小为 65535 GiB 的页表而这显然是不现实的。因此现代计算机通常会采用“多级页表”的方式来优化页表的大小。多级页表的思路很简单。以二级页表为例假设在一个 32 位地址空间中页大小为 4KiB每个页表的大小也为 4KiB且其中的每个 PTE 大小为 4 字节。此时MMU 在进行物理地址查询时首先会根据虚拟地址中隐含的虚拟页号信息来查找一级页表内的目标 PTE而一级页表中的每个 PTE此时实际上负责映射 VAS 中的一个 4MiB 的片。按照树的形式展开每个一级页表也都对应着一个独立的二级页表二级页表中的每一个 PTE 则负责映射当前一级页表的 4MiB 的片中的某个 4KiB 的块。当一级页表查询完毕后MMU 便可得到指向目标二级页表的地址。通过该地址再联合虚拟地址中的另一部分虚拟页号信息它便可找到目标数据所在物理内存的具体页。最后结合虚拟地址中的页偏移信息目标数据的最终物理地址便可被成功地“转换”出来。你可以通过下图来进一步理解上述流程多级页表可以节省内存空间的两个最重要因素是当一级页表中的某个 PTE 没有实际映射时其对应的二级页表便不会被创建只有一级页表才需要常驻内存二级页表可以仅在需要时再创建或从磁盘调入。实际上上述二级页表的使用形式可以被推广到任意的 N 级。但总体来看页表的级数并非越多越好因为更多的页表级数也就意味着更长的物理地址查询时间。目前常见的多级页表为 4 级而在 Ice Lake 等处理器中也出现过 5 级页表。使用 TLB 加速 PTE 查询多级页表虽然可以压缩页表占用的内存量但用 MMU 进行页表的逐级查询这个过程也并不是毫无成本的。现实情况中计算机通常会结合使用名为“翻译后备缓冲器Translation Lookaside BufferTLB”的硬件设备来加速这一流程。TLB 属于 MMU 的一部分它可以加快 MMU 根据虚拟地址查询 PTE 的过程。你可以将 TLB 理解为一个简单的具有 N 行 M 列的矩阵MMU 会从对应虚拟地址中提取出用于查询表项的 TLB 索引与 TLB 标记。这两个值可以联合起来使用并定位到 TLB 中的一个具体单元格。而此时若该单元格内有值则 MMU 可以直接使用该值来与虚拟地址中的其他信息一起组成最终的物理地址。否则MMU 仍然需要通过逐级查询页表的方式来获取目标页的物理地址。再谈共享对象与私有对象上面我曾在“使用页表维护虚拟页状态”一节的最后提到借助虚拟内存机制不同进程之间可以共享物理内存上的同一段数据。这些数据在物理内存中实际存放时可能并不是连续的。而借助于页表实现的“虚拟页与物理页映射关系分离”我们可以确保 CPU 能够按照连续的方式来使用这些数据。而当某个共享进程试图对这些共享数据进行修改时操作系统便会通过“写时复制Copy-on-write”的方式来将被变更数据所在的物理页进行复制并通过修改页表来让修改进程可以私有化这部分数据。总结这一讲我主要为你介绍了进程是如何在操作系统的控制下使用内存资源的。在现代计算机中内存和 CPU 芯片上的高速缓存一起构成了用于承载应用运行时数据的缓存系统。而这个缓存系统在名为“虚拟内存”机制的帮助下能够以一种更加优雅的方式运作。虚拟内存机制为每一个进程都抽象出了独立且私有的虚拟地址空间VAS。VAS 中使用虚拟地址进行寻址当 CPU 需要通过该地址访问内存中的某个数据时芯片上的内存管理单元MMU会将该地址转换为对应的物理地址。不同的操作系统都会在 VAS 中为进程使用相对统一的数据布局方式这样编译器便可简化其构建应用的流程。操作系统使用名为“页表”的数据结构来维护 VAS 中虚拟页与物理页之间的映射关系。通过查询页表项PTE的状态操作系统可以直接获得目标数据所在页的物理地址或是通过触发缺页异常来让操作系统内核将目标数据从磁盘加载到物理内存中然后再重新获取该地址。在这个过程中内核可能会将物理内存中某个已修改的非空闲页的内容换出到磁盘。为了减小分配给每个进程的页表大小现代计算机通常采用多级页表的方式来管理虚拟页与物理页的映射关系。而在这种方式下由于需要查询的表项过多计算机还会采用名为 TLB 的硬件设备来缓存之前的表项查询结果并加速下一次相同虚拟页的查询过程。最后虚拟内存机制使得多个进程可以同时共享物理内存中的某段数据而无需将数据拷贝多份。但当某个进程试图修改这些共享数据时操作系统会通过“写时复制”的方式来将被修改数据进行拷贝并使其对修改进程私有化。