chikaku

且听风吟

永远是深夜有多好。
github
email

现代操作系统 - 虚拟内存

为何使用虚拟内存#

通用操作系统中一般会运行多个进程,使用同一份物理内存资源。操作系统在进行内存管理时,如果允许进程直接使用物理内存,会有一些问题:

  • 操作系统给进程分配的空间会不够用,再次申请内存时空间可能并不连续
  • 由于共享同一份物理地址空间 A 进程可能会写入到 B 进程内使用的地址

出于效率和安全性考虑,引入了虚拟内存:应用进程通过虚拟地址读写物理内存 CPU 内部的 MMU 负责将虚拟地址转换成具体的物理地址再进行对应的读写操作,操作系统负责为每个进程建立虚拟地址到物理地址的映射。对于每个进程来说,都能够认为自己拥有连续完整的地址空间。

早期大多数系统使用的虚拟内存管理方式是分段:操作系统将虚拟地址空间和物理地址空间分割成大小不同的段比如代码段数据段等等,通过段表实现虚拟地址到物理地址的映射,但是在物理段之间容易出现碎片

  • 虚拟地址包含:段号 + 段内地址 (偏移量)
  • MMU 根据 段表基址寄存器 找到段表
  • 通过段号在段表中找到对应段的起始物理地址
  • 物理段起始地址 + 偏移量即得到物理地址

虚拟内存分页#

现代操作系统通常使用分页机制管理虚拟内存。

操作系统首先将虚拟地址空间和物理地址空间分割成较小的连续等长的页,通过页表实现虚拟地址到物理地址的映射。用户进程可以使用全部的虚拟地址空间,内核为每个进程维护一个页表,这样即使两个进程使用相同的虚拟地址,如果页表中所映射的物理地址不同也不会产生冲突。

  • 虚拟地址包含:页号 + 页内偏移量
  • MMU 根据 页表基址寄存器 找到对应进程的页表
  • 在页表中找到页号对应的物理页的起始地址
  • 物理页起始地址 + 页内偏移量即得到物理地址

多级页表#

为了压缩页表项所占用的物理内存空间 (考虑 64 位虚拟地址空间每页大小 4KB 使用单页表所占用的空间), 通常会使用多级页表。多级页表将虚拟地址的页号部分分为若干份,其中每份对应一个页表项目。以 AArch64 架构为例,虚拟地址使用 48 个有效位 (即虚拟地址空间大小为 2^48) 其中低 12 位表示页内偏移量,剩下的 36 位分割成 4 级页表

  • 首先通过 TTBR0_EL1 寄存器找到第 0 级页表物理地址
  • 通过第 47-39 位找到在第 0 级页表中的项:第 1 级页表的物理地址
  • 通过第 38-30 位找到在第 1 级页表中的项:第 2 级页表的物理地址
  • 通过第 29-21 位找到在第 2 级页表中的项:第 3 级页表的物理地址
  • 通过第 20-12 位找到在第 3 级页表中的项:对应的物理页号
  • 通过第 11-0 位 (页内偏移量) 加上物理页号得到真正的物理地址

通常在页表项中除了存储物理地址外,还会存一些 flag 比如页表项对应物理地址是否存在;是否是脏页等等。在多级页表项的查找过程中如果某一个页表项对应物理地址不存在则可以直接终止查询。

多级页表中第 i 级并非所有页表项都指向一个第 i+1 级页表,假设某个进程只使用了 4KB 的内存空间,则其第 0 级页表中只有一项真正指向一个第 1 级页表,第 1 级页表中也只有一项指向一个第 2 级页表,后续同理。这样一共也就使用了 4 个页表页。如果其使用了连续的 4KB*64 的内存空间,如果这 64 个页的前三级页表地址恰好一样,最终也只会使用到 4 个页表页,非常节省空间。参考下图:

地址翻译

转址旁路缓存 TLB 和页表切换#

为了提升转址效率 MMU 内有一个 TLB 硬件用来缓存一个完整的虚拟页号到物理页号的映射。在 TLB 内部会有类似于 CPU 的多层缓存比如 L1 指令缓存 L1 数据缓存 L2 层缓存 等。一般 TLB 内能够存储的条目较少,只有几千个条目。在一次转址过程中 MMU 首先会通过 TLB 查询缓存,若缓存命中则直接返回;如果未命中则执行多级页表查询最后将结果回写到 TLB 如果 TLB 满则会根据硬件策略替换掉某一项。另外,在操作系统中一般会运行多个进程,不同进程使用的虚拟地址可能是一样的,所以需要确保 TLB 中缓存的内容和当前进程的页表一致,操作系统在执行进程切换 (页表切换时) 时主动刷新 TLB

考虑到每次切换进程刷新 (清空) TLB 会导致进程刚开始运行时产生大量的 TLB Miss 现代的 CPU 硬件提供了进程标签功能如 amd64 下的 Process Context IDentifier 操作系统可以给不同的进程分配不同的 PCID 再将其写入到页表基址寄存器和 TLB 的缓存项中,这样即使切换了进程 TLB 也能通过当前页表基址寄存器中的 PCID 和缓存条目中的 PCID 区分不同进程的缓存条目而无需清空 TLB 但是在修改页表时操作系统还是需要及时刷新 TLB 以保证一致性。这样做的坏处就是单个进程能够使用的 TLB 条目进一步减少了。

在 AArch64 架构中一般内核和用户进程使用不同的页表基址寄存器因此在切换到内核态 (执行系统调用) 时无需切换页表。而在 x86-64 架构中虽然页表基址寄存器只有一个,但是内核并不使用单独的页表,而是将自己的地址空间映射到每个进程地址空间的高位部分 (相当于多个用户进程共享同一份内核地址空间) 因此也不需要切换页表。

换页与缺页#

注意:下文中分配指的是进程向操作系统申请一定大小的空间,操作系统为进程在虚拟地址空间中分配一段空间并将其开始和结束地址返回给进程。

在应用进程执行过程中会预先申请一段内存空间 (比如在 Linux 下使用 brk 或者 mmap 系统调用) 在未对这些空间上的地址进行读写之前,操作系统并不为其映射到真实的物理页,即页表中这些地址所表示的虚拟页对应的的物理页的映射是不存在的。在首次读写时 CPU 检查页表发现对应的物理页不存在会触发缺页异常此时 CPU 会执行操作系统预先设置的 page fault handler 函数,为其找到到一个合适的物理页并将地址写入到页表中。实际情况下,为了减少缺页异常触发的次数,每次在执行缺页处理函数时可能会同时为邻近的多个虚拟页映射物理页。

进程在运行过程中可能会使用超过物理内存大小的内存资源,此时如果再触发缺页异常,操作系统可以通过将部分物理页写入到磁盘等更低级的存储设备中,让出一部分空闲的物理页给当前正在运行的进程,这个过程称之为换页 / 换出在执行换页过程中,操作系统需要将物理页内容写入到磁盘,清空对应页表项,保存物理页在磁盘上的位置。当被换出的虚拟页被重新访问时,操作系统会把之前换出到磁盘上的内容重新写入到一个物理页,修改进程页表重新将虚拟页映射道新的物理页,这个过程称为换入

文件系统中的交换分区即是虚拟内存在换页时使用的磁盘分区。考虑到磁盘 IO 效率较低,操作系统在执行换入时会预测多个可能会被访问的页,一并换入以减少 IO 次数。

当应用程序访问某个虚拟页触发缺页异常时,操作系统需要判断这个虚拟页是处于未分配状态还是已分配但未映射状态。操作系统只会给已分配的虚拟页执行到物理页的映射。在 Linux 下进程的虚拟地址空间会分为多个 Virtual Memory Area 每个 VMA 都会有一定的范围 (区域的起始地址到区域的结束地址) 比如代码,栈,堆分别对应不同的 VMA 触发缺页异常时操作系统可以通过检查虚拟页是否处于某个 VMA 下来判断是否已分配。

虚拟内存功能#

共享内存#

由于虚拟页是通过进程的页表映射到物理页,操作系统可以通过让两个进程 A 和 B 的两个虚拟页 A1 和 B1 映射到同一个物理页 P 上来实现进程间的内存共享。任意一个进程对共享内存的写入都可以被另外一个进程读取到。

写时拷贝#

通过共享内存可以实现 Copy On Write写实拷贝功能。比如在 Linux 下进程可以通过 fork 创建子进程,子进程创建之初父子进程的内存数据是完全一样的所以 Linux 仅仅为子进程复制一份页表,不修改任何映射,同时 Linux 会在页表中将这一片共享内存权限设置为只读,当任一进程对此虚拟页进行写入就会由于权限不足触发缺页异常,后续操作系统会将缺页部分的数据拷贝到新的物理页,并将新的物理页写入到触发缺页异常进程的页表项并恢复权限等。除了 fork 以外多个进程也可以通过共享内存映射到相同的动态链接库,减少内存使用。

共享内存和写时拷贝

内存去重#

基于 COW 操作系统还可以 (定期) 主动将多个具有相同内容的物理页合并成一个,然后将所有关联的页表映射修改到这个唯一的物理页上来提升内存利用效率,这项功能称为内存去重。在 Linux 下实现了此功能称之为 Kernel Same-page Merging 可以想象使用此功能会对性能造成一定的影响,另外还会产生某些安全性问题:比如攻击者可以通过枚举构造数据,然后等待内存去重,根据访问延迟 (由 COW 产生缺页中断造成) 确认是否发生了内存去重,如果发生了则攻击者可以猜测当前物理地址空间内存在与当前构造相同的数据。不过操作系统可以通过只在同一用户的进程之间去重来避免这个问题。

内存压缩#

操作系统可以通过将不太常使用的内存数据压缩的方式减少物理页的使用。在 Linux 下内存中有一个 zswap 区域用来存储被压缩的内存数据,内存数据被压缩后首先是放到 zswap 下当物理内存资源不足时 zswap 会批量换出到磁盘。这样能够减少甚至能够避免立即换出造成的磁盘 IO 提高内存使用效率。

大页#

前面提高过 TLB 的缓存条目很少,在 4KB 内存页大小的情况下可能会不够用 (经常 TLB Miss 使得效率很低) 所以很多操作系统提供了大页的功能,通过增大内存页的大小到 2MB 甚至 1GB 减少 TLB 的占用量。在 Linux 下就提供了 transparent huge page透明大页机制自动将连续的 4KB 页合并成一个 2MB 的页。

通过使用大页可以极大降低 TLB 缓存条目的占用,进而提升 TLB 命中率,但是大页也可能会降低内存的使用效率,比如一个 2MB 的大内存页实际只使用了 256KB 会造成造成很高的资源浪费。

参考与引用
《现代操作系统:原理与实现》

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。