title: 虚拟内存 date: 2023-03-26 18:05:47 tags: 体系结构
一个系统中的进程与其他进程共享CPU和主存资源, 然而共享主存会形成一些特殊的挑战, 例如如果太多的进程需要太多的内存, 那么它们中有一些就根本无法运行; 如果某个进程不小心写入了另一个进程使用的内存, 就可能导致另一个进程以某种完全和程序逻辑无关的方式失败
因此为了更加有效的管理内存并且减少出错情况, 现代操作系统提供了一种对主存的抽象概念: 虚拟内存(Virtual Memory)
虚拟内存提供了三个重要的能力
虚拟内存赋予应用程序强大的能力, 可以创建和销毁内存片(chunk), 将内存片映射到磁盘文件的某个部分, 以及与其他进程共享内存. 比如我们可以通过读写内存位置读或者修改一个磁盘文件的内容, 或者可以加载一个文件的内容到内存中, 而不需要进行显式的复制
同时虚拟内存也是危险的, 当应用程序引用一个变量, 间接引用一个指针, 或者调用一个诸如 malloc 这样的动态分配程序时, 就会与虚拟内存交互, 如果使用不当可能遇到复杂危险的错误, 例如 "段错误" 或者 "保护错误"
主存被组织成一个有 M 个连续的字节大小的单元组成的数组, 每一个字节都有唯一的物理地址
PU访问内存的最自然的方式就是使用物理地址, 这种方式被称为 物理寻址. 下图表示 CPU 读取从物理地址 4 开始的连续 4 个字节, 当 CPU 执行这条加载指令的时候会生成一个有效的物理地址, 和取址长度, 通过内存总线传递给主存; 主存根据地址找到物理地址为 4 的单元, 取出连续的 4 个字节, 并将其返回给 CPU, CPU 将其存放在一个寄存器之中
在早期 PC 上使用的是物理地址, 现代 CPU 使用的虚拟地址的寻址方式. CPU 通过生成一个虚拟地址(VA)来访问主存, 这个虚拟地址在被送到内存总线之前先传递到 CPU 芯片上的 MMU (Memoryy Management Unit)单元, 将一个虚拟地址转换成物理地址, 在传输给内存. 这一步需要 CPU 硬件和操作系统紧密结合, 如下图所示
系统中实际存在的内存空间是物理地址空间, 一共有 M 字节, 其中 M = $2^m$, 物理地址空间范围是 {0, 1, ..., M - 1}
CPU 从一个 n 位地址空间中构建虚拟地址空间, 一共 N 字节, 其中 N = $2^n$, 虚拟地址空间的范围是 {0, 1, ..., N-1}
值得注意的是, 虚拟地址空间和物理地址的空间没有什么关系, 物理内存实际上就是电脑上的内存,一般是 8GB 16GB(2^34)那样; 虚拟地址空间则可以很大, 如果是 64 位的虚拟地址空间, 则可以表示大约 $2^64$ = 16384P 大小的虚拟地址空间
另外并不是 64 位机器所用的是 64 位的虚拟地址空间, 这对于虚拟内存来说实在是太大了, linux目前使用的是48位的虚拟地址空间, 即256TB. windows 使用的是47位, 即128TB
虚拟内存可以被看作一个存放在磁盘上的数组, 大小为 N, 每字节都有一个唯一的虚拟地址, 作为到数组的索引. VM 系统通过将虚拟内存分割称为虚拟页(Virtual Page, VP)的大小固定块, 每个虚拟页的大小是 P = $2^p$ 字节, 类似的物理内存也被分割为物理页(Physical Page, PP), 物理页大小和虚拟页一样都是P字节 , 物理页也可以称为页帧
Linux 与 Windows 都是采用 4kb作为物理页和虚拟页大小
通常来说由于物理内存有限, 虚拟地址空间要远远大于物理地址空间, 也就是说虚拟页的数量要远远大于物理页的数量, 因此必然不可能将全部的虚拟页面映射到物理页面中, 所以在任意时刻, 虚拟页面的集合都可以分为三个不相交的子集
上图中 VP0 VP4 是未分配的页面, VP2 5 7 是已分配并且缓存的, 剩下的是已分配未缓存的
这里有几个小问题需要解释一下, 上文说虚拟内存可以被看作一个存放在磁盘上的数组, 大小为 N, 也就是按理来说我们希望 N 刚好为磁盘大小, 但实际上计算机组成的磁盘空间是不确定的, 而操作系统 linux 48位, windows 47位, 也就是说实际上虚拟内存已经在操作系统初始化完成之后确定下来了, 256/128TB, 正常来说这个空间对于我的磁盘是足够大的, 假设我有 1TB 的磁盘, 那么多出来的 255/127TB, 也就是最高位并没有用到, 1TB的磁盘根据虚拟页划分, 映射到虚拟内存, 这没有问题
但是当磁盘空间大于256TB时, 48位的虚拟地址空间就不足了, Linux中如果需要访问大于256TB的磁盘空间,可以使用LVM(逻辑卷管理器)等技术来扩展磁盘空间, 这种特殊情况并不在本文讨论范围之内
也并不是说磁盘空间大于 256TB 就一定要一次性全部映射到虚拟地址空间, 分段映射也是合理的
在早期的系统比如 DEC PDP-11上, 虚拟地址空间甚至比物理地址空间还要小, 但使用虚拟地址空间仍然是一个非常有用的机制, 可以大大简化内存管理, 通常来说在现代操作系统上 物理内存空间 M << 虚拟内存空间 N
下面会涉及到几个概念
在存储层级结构中, DRAM 比 SRAM 大约慢 10 倍, 磁盘要比 DRAM 大约慢 100000 倍, 所以 DRAM 缓存不命中带来的惩罚要比 SRAM 缓存不命中多得多, 主要是因为 DRAM 缓存不命中要由磁盘来服务, 而 SRAM 不命中则基于 DRAM 服务, 磁盘扇区的读写速度要比内存慢很多很多
所以首先我们希望磁盘和 DRAM 之间可以有很好的替换策略, 同时也应该尽可能地减少 DRAM 缓存不命中的情况
与缓存类似, 虚拟内存系统需要有办法可以判断一个虚拟页是否缓存在 DRAM 中, 并且确定存放在哪一个物理页中. 如果缓存不命中, 那么还需要判断虚拟页应该对应磁盘的哪个位置, 并且需要从物理内存中选择一个牺牲页, 将虚拟页从磁盘复制到 DRAM 替换掉牺牲页
虚拟内存系统的功能是由软硬件联合提供的, 包括操作系统, MMU(内存管理单元) 中的地址翻译硬件和一个存放在物理内存中的页表(page table)的数据结构, 页表负责保存虚拟内存到物理内存的映射关系, 操作系统负责维护页表的内容, 当地址翻译硬件试图将一个虚拟地址转换到物理地址的时候会读取页表, 然后根据页表中的信息找到对应的物理地址
上图是一个页表的基本结构, 页表是常驻内存的, 它是一个页表条目(PTE, Page Table Entry)的数组, 每一个页表条目有一个有效位(valid bit)和 n 位的磁盘地址组成
这里的 m 是上文提到过的物理地址空间的大小, M = $2^m$
上图中左侧是页表, 我们可以根据其中的索引找到对应的 PTE.
这里注意要与之前的虚拟页和物理页区分开, 虚拟页是在磁盘中的, 物理页是在 DRAM 中, 页表也是在 DRAM 中, 下图中的映射关系没有改变, 只是借助页表这一数据结构实现了一种映射关系
当 CPU 想要读 VP2 虚拟内存中的一个字的时候, CPU 将虚拟地址发送给 MMU, MMU 查找页表进行地址转换, 通过某种技术通过虚拟地址定位到索引, 找到页表中的 PTE2, 发现其有效位为 1, 说明该 PTE 有效, 则取出其中保存的物理内存地址, 找到物理内存中的数据, 如下图所示
但如果 CPU 发出一条指令希望读取 VP3, 此时发现有效位为0, 说明 VP3 并未被缓存, 操作系统触发一个缺页异常, 调用内核的缺页异常处理程序, 选择 DRAM 中的一个物理页作为牺牲页, 假设选中的是位于 PP3 的 VP4, 则内核从磁盘复制 VP3 到内存 PP3, 更新 PTE3, 如下图所示
当异常处理程序返回的时候, CPU会重新启动导致缺页的指令, 把之前导致缺页的虚拟地址重新发送到 MMU, 此时VP3 已经缓存在 DRAM 中了, 所以正常处理执行
关于虚拟内存有如下的一些相关名词, 磁盘和内存之间传送页被叫做 交换 或者 页面调度, 当有不命中发生的时候才换入页面的调度策略叫做 按需页面调度, 这也是现代所有系统都使用的页面调度策略
实际上操作系统为每一个进程都提供了独立的页表, 也就是每一个进程都会对应以一个独立的地址空间, 上图中只展示了 VP 和 PP的部分, 地址翻译的过程对应页表, 我们注意到进程i的 VP2 和进程j的 VP1对应的都是 PP7 的物理页面, 说明多个虚拟页面可以映射到同一个物理页面当中
独立的虚拟地址空间允许每个进程的内存映像使用相同的基本格式, 比如 64 位地址空间中所有代码拗断都是 0x400000 开始的, 数据段在代码短之后, 栈段从用户进程的最高地址空间向下生长
这样的一致性的地址空间大大简化了链接器的设计和实现, 不再需要去关系实际物理内存中的地址映射关系, 而是在一个统一的地址视图中执行程序
当我们运行一个可执行文件的时候, 我们希望将其加载到内存中, Linux 加载器只需要简单的为代码段和数据段分配虚拟页, 然后将其标志位置 0, 加载器实际上并不从磁盘复制任何数据到内存, 只是创建一个未缓存的虚拟页, 该进程分配到时间片开始执行之后再通过缺页中断完成加载
将一组连续的虚拟页映射到任意一个文件的任意位置称为 内存映射, Linux 提供了一个 mmap 的系统调用, 允许应用程序自己做内存映射
每个进程有自己的代码, 数据, 堆, 栈, 不与其他进程共享. 但通常来说我们会需要进程共享代码和数据, 比如每个进程都可能需要调用系统调用以及一些 C 标准库的程序, 比如 printf; 操作系统只需要将这部分经常使用的程序加载到内存一次, 让其他所有进程在使用的时候映射到相同的物理页面即可, 而不是为每个进程都创建一份副本
当程序调用 malloc 希望分配在堆空间分配一块内存的时候, 操作系统只需要分配 k 个连续的虚拟内存页面, 然后将其映射到物理内存中的任意k 个物理页面即可, 虚拟内存页面需要连续, 但是物理内存的页面没有必要连续, 可以由操作系统的内存分配器寻找最合适的位置
一个现代操作系统应该可以做到对内存系统的完全控制, 包括不允许一个用户进程修改它的只读代码段, 不允许读写其他进程的私有内存, 不允许修改与其他进程共享的虚拟页面(除非共享者都显示的允许)
就像我们上文提到过的, 操作系统通过虚拟内存提供了独立的地址空间, 而虚拟内存地址到物理内存地址的映射由操作系统和 MMU 控制, 我们可以在 PTE 页表项中添加一些标志位, 可以在地址翻译阶段更加容易的判断权限和合法性
上图中添加了三个权限标志位
如果进程执行的过程中发出了一条指令, 希望读取一个页面, 但是操作系统发现该进程没有这个页面的写权限(比如修改 const 变量) 或者不是内核态进程, 则 CPU 触发异常, 并将控制传递给内核中的异常处理程序, 也就是我们编程过程中很常见的, Linux shell 一般将这种异常报告称为 "段错误(segmentation fault)"
当然,上图中原先的有效位被隐藏了, 页表中依然保留了这一位用于判断 PTE 是否有效
开始本节内容之前我们先列举一下所需的所有符号简写, 希望读者预览一下有一个大致的印象, 如果后文出现了对应的符号可以到这里对照
符号 | 描述 |
---|---|
N = $2^n$ | 虚拟地址空间中的地址数量 |
M=$2^m$ | 物理地址空间中的地址数量 |
P=$2^p$ | 页的大小 |
PTBR | Page Table Base Register, CPU中的一个控制寄存器, 指向当前页表 |
VPO | 虚拟页面偏移量 offset |
VPN | 虚拟页号 number |
TLB | Translation Lookaside Buffer 快表 |
TLBI | 快表索引 index |
TLBT | 块表标记 tag |
PPO | 物理页面偏移量 |
PPN | 物理页号 |
CO | 缓冲块内字节偏移量 cache offset |
CI | 高速缓冲索引 |
CT | 高速缓冲标记 |
上图展示了 MMU 是如何利用页表实现这种映射的
由于虚拟页和物理页都是 P 字节的, 所以实际上 PPO 就是 VPO, 只是完成了 VPN -> PPN 的替换
当页面命中时, 上图中的12345流程分别对应
这个过程完全是由硬件来处理的
上图中的 PTEA 是 PTE address的缩写, 也可以简写为 PA
当页面不命中的时候, 即有效位为 0, 情况稍微复杂一点
此时 CPU 再次将之前引起缺页异常的虚拟地址发送给 MMU, 由于对应的 PTE 已经被更新了, 所以就可以正常执行了
实际上在既使用高速缓存, 也是用虚拟内存的系统中, 还有一步关于高速缓存是否命中的情况的讨论, 如下图所示
前面我们讨论的页表命中的情况, 如下图, 此时 CPU 产生一个虚拟地址, 需要先由 MMU 根据 PTBR 和 VPN 索引访问一次内存找到对应的 PTE, 在根据 PTE 中的地址找到对应的物理地址访存一次得到数据, 这个过程经历了两次访问内存
许多系统都希望可以消除这种开销, 所以在 MMU 中包含了一个关于 PTE 的小型缓存 TLB 快表
当虚拟地址发送到 MMU 之后, 先根据 P 分离出后 p 位得到 VPO, 这部分是页内偏移量与页表匹配的过程无关, 高位的 VPN 在被 TLB 分为高位和低位, TLB 中有 T=$2^t$个组, 高位为 TLBT, 低位为 TLBI
先根据 TLBI 找到 TLB 的对应索引, 如果该 TLB 表项的 TLBT 和这里的 TLBT 相同, 则认为 TLB 命中, 否则不命中;
对于一个 32 位的地址空间来说, 4KB 的页大小意味着 p = 12, 所以 VPO 是12位, VPN是20位. 页表也要保存在虚拟页中, 即使每一个 PTE 表项只保存物理地址也需要 4B(即不考虑有效位, SUB READ WRITE), 所以一页中最多可以保存的 PTE 数量是 4KB/4B = 1K = $2^{10}$, 一共 VPN 是20位, 所以为了保存所有的虚拟页面, 至少需要 $2^{20}$ / $2^{10}$ = $2^{10}$ 个虚拟页, 这些虚拟页只是用来保存页表. 这些虚拟页一共是 1K x 4KB = 4MB, 所以这 4MB 的内存空间没有用来保存任何程序相关的数据, 只是用来保存页表. 对于每一个进程来说都需要一个 4MB 内存空间来存储其需要的页表, 这未免有些太占空间了
而对于一个 64 位的系统来说(地址8B), 每个进程页表占用内存的大小达到了惊人的 $2^{43}$ x 4KB, 即使只使用 48 位的虚拟地址空间, 也需要 $2^{27}$ x 4KB, 这显然是无法接受的
但事实上, 对于一个 64 位程序来说, 虽然虚拟地址空间很大(256TB), 但实际上我们编写的程序并不需要那么多内存, 也就是说除去虚拟页4KB的12位, 虽然还剩下 52 位, 也就是 $2^{52}$ 个虚拟页需要保存, 但没有必要都把这么多虚拟页同时存在内存中, 最理想的情况是我们只把用到的那几个虚拟页保存在 DRAM 中, 剩下的都不存, 即使突然又要访问另一个虚拟页中的数据, 那么使用缺页中断再将这个页面调入内存即可
那么很容易想到的一个办法就是, 52 位的虚拟页表索引项太多了, 我们可以将其分割开来, 使用多级页表
如上图所示, 假设对于一个 48 位的虚拟地址空间, 我们对于高 32 位每隔 p(12位) 做一个划分, 原先的 VPN 被划分为三段(下文简称 VPN1 VPN2 VPN3), 对应三级页表
其中 VPN1 对应 VPN 的最高12位, 其中索引项是 $2^{12}$ 个, 为了保存这么多索引项我们只需要付出的内存代价是 32KB
64位系统地址 8B, 虚拟页 4KB, 假设每个 PTE 只保留地址(需要8B), 每个虚拟页可以保存 4KB/8B = $2^9$ 个 PTE, 所以一共需要 $2^{12}$ / $2^9$ = 8 个虚拟页, 8 x 4KB = 32KB
由于虚拟地址的使用是接近连续的, 低地址会连续使用到但是高 12 位几乎不会连续使用, 因为高 12 位的加 1 相当于加了 2<<24 的地址空间
所以一级页表的 PTE 被使用的极少, 基本上一两个 PTE. 这是一种巨大的潜在节约, 这意味着我们只需要存有限的必要的一些页表即可
以上图为例, 其中一级页表中只有 1 个 PTE 是有效的, 我们根据 PTBR0 找到一级页表的位置, 再根据 VPN1 的索引找到对应的 PTE, 这个 PTE 中保存的地址是其指向的二级页表的 PTBR1, 再重复这个过程, 通过 PTBR1 定位到了这个二级页表, 再根据二级页表索引 VPN2 找到其中三个有效的 PTE, 这三个 PTE 分别保存着其指向的三级页表的 PTBR2/3/4, 再定位到三级页表, 再根据三级页表索引 VPN3 定位到 PTE, 这个 PTE 联系了这个虚拟地址代表的虚拟页和物理页的映射, 这个虚拟页实际上才是我们需要使用的. 我们最后再通过这个 PTE 的地址找到物理页 PPN, 然后通过偏移量 VPO 读取到对应的数据. 完整过程如下图所示
相信你一定已经多次看到下面这张图了
整个虚拟内存空间分为上下两部分, 最高地址是内核虚拟内存, 下面是进程的虚拟内存; 内核虚拟内存包含内核的代码和全局数据结构
Linux 将一组连续的虚拟页面(大小等同于 DRAM 的总理)映射到相应的一组连续的物理页面, 这就为内核提供了一种变量你的方法来访问物理内存中任何特定的位置. 这就为内核提供了一种便利的方法来访问物理内存中的特定位置; 换句话说, 在虚拟地址空间中如果要访问页表, 页表保存在内核虚拟内存中, 始终处于一个相对固定的虚拟地址位置
上图记录了 Linux 虚拟内存的相关数据结构, 内核为系统每个继承维护一个单独的任务结构 task_struct, 其中 task_struct 中的元素包含指向内核运行该进程所需的所有信息(比如 PID 指向用户栈的指针 可执行目标文件名 程序计数器等等)
task_struct 中的一个元素指向 mm_struct, 它描述了虚拟内存的当前状态, 其中比较重要的是 pgd mmap 字段, pgd 指向一级页表的基地址, 当内核运行这个进程的时候就将 pgd 放入 PTBR; mmap 指向一个 vm_area_structs 的链表, 每个 vm_area_structs 都描述了当前虚拟地址空间的一个区域, 你可以在上图中看到每一个 vm_area_struct 都指向虚拟内存空间中的一段地址, 其中
注意下面是低地址, 上面是高地址, 所以 vm_start 指向下面
相关的元素会在 mmap 函数中再次看到
当 MMU 试图翻译某个虚拟地址 A 但是发现缺页了, 这时候可能会有如上三种情况
这里的第一点要说明一下, 因为一个进程可以创建任意数量的新虚拟内存区域, 所以顺序搜索区域结构的链表花销会很大, 因此在实际中 Linux 在链表中构建一个一棵树并在这个树上进行查找
Linux 通过将一个虚拟内存区域于一个磁盘上的对象关联起来, 以初始化这个虚拟内存区域的内容, 这个过程被称为内存映射
内存映射的概念来源于一个聪明的发现, 如果虚拟内存系统可集成到传统文件系统当中, 那么就能提供一种简单而高效的把程序和数据加载到内存中的方法. 每个运行着 Linux shell程序的bash进程都有相同的代码区域, 每个C程序都需要来自标准C库的诸如 printf 这样的函数, 如果每个进程内都在物理内存中保存相同的代码副本, 那就是极端的浪费了. 幸运的是内存映射给我们提供了一种清晰的机制, 用来控制多个进程如何共享对象
内存映射的对象有两种
一旦一个虚拟页面被初始化了, 他就在一个由内核维护的专门交换文件之间换来换去, 交换文件也叫做交换空间(swap area). 在任何时刻, 交换空间都限制着当前运行中的进程能够分配的虚拟页面的总数
一个对象可以被映射到虚拟内存的一个区域, 要么作为 共享对象, 要么作为 私有对象
上图中假设进程 1 将一个共享对象映射到它的虚拟内存的一个区域中, CPU 引用这个页面之后会在物理内存中创建一份副本
当进程 2 页将同一个共享对象映射到虚拟内存的时候, 由于每个对象都有唯一的一个文件名, 内核可以迅速的判断进程 1 已经影射了这个对象, 并且可以使进程 2 的页表条目指向相应的物理页面. 关键点在于即使对象被映射到了多个共享区域, 物理内存中也只需要存放共享对象的一个副本
当进程 1 修改其虚拟页面的数据的时候, 这个修改会同步到物理内存, 磁盘, 物理内存的修改会影响到其他引用共享对象的进程
对于私有对象来说, 采用一种十分巧妙的写时复制(copy on write)的技术, 其生命周期的方式基本上与共享对象一样, 在物理内存中只保存私有对象的一份副本
对于每个映射私有对象的进程, 相应私有区域的页表条目都被标记为只读, 并且区域结构被标记为 私有的写时复制, 只有没有进程试图写自己的私有区域他们就可以继续共享物理内存中的对象的一份副本, 但是只要有一个进程试图写私有区域的某个页面, 那么这个写操作就会触发一个保护故障
当故障处理程序注意到保护异常是由于进程试图写 "私有的写时复制" 区域中的一个页面的副本, 他就会在物理内存中创建这个页面的一个新副本, 更新页表条目指向这个新的副本, 然后修改这个页面的可写权限, 如上图所示
故障处理程序返回的时候 CPU 重新执行写操作, 现在在新的页面进行写操作就可以进行正常执行了
我们可以看到通过延迟四有对象中的副本直到最后可能的时刻, 写时复制最充分的使用了稀有的物理内存
当 fork 函数被当前进程调用的时候, 内核为新进程创建各种数据结构, 并分配给他一个唯一的PID, 为了给这个新进程创建虚拟内存, 内核复制当前进程的 mm_struct, 区域结构 页表的原样副本, 将两个进程的每个页面都标记为 只读, 将两个进程中的每个区域结构都标记为 私有的写时复制
当fork的新进程中返回时, 新进程现在的虚拟内存和调用fork时存在的虚拟内存相同. 当这两个进程中的任何一个后来进行写操作时, 写时复制机制就会创建新页面, 也就为每个进程都保持了私有地址空间的抽象概念
当新进程现在的虚拟内存和调用fork时存在的虚拟内存相同时,这意味着新进程可以立即开始执行父进程的代码,不需要额外的内存开销和数据拷贝.这是因为在fork函数被调用时,操作系统会通过写时复制技术为新进程创建一个独立的虚拟内存空间,并将父进程的内存映射信息复制到新的虚拟内存空间中,但是并没有为新进程实际分配物理内存空间,只有在新进程试图修改内存内容时,才会为新进程分配独立的物理内存空间.
因此,在新进程返回之前,新进程和父进程共享同一份虚拟内存空间,这可以保证子进程在执行过程中,与父进程使用同样的代码和数据,从而可以有效地节省内存使用和提高程序执行的效率.另外,由于写时复制技术的存在,子进程和父进程之间的内存空间是互相独立的,这意味着它们之间的数据操作不会互相影响
execve("a.out",NULL,NULL);
execve 函数在当前进程中加载并运行包含在可执行目标文件中的程序, 用 a.out 替换当前程序
加载并运行 a.out 需要如下几个步骤