Skip to content

Lec 3 虚拟内存

引言: 用虚拟化加强模块化。

阅读参考书 §5.1, §5.3, §5.4

思考题

  • 什么是虚拟化?
  • 当我们说内存时候,意味着什么? 内存和存储区别是什么?
  • 为什么我们将页表用在虚拟内存? 他们解决了什么问题?
  • 当使用页表时,如何将虚拟地址转换为物理地址?
  • 如果我们没有足够的内存来存储我们的程序指令和数据时会发生什么?
  • 内核的工作是什么?
  • 页表项的其他比特作用什么?如何 P, U/K 和R/W 比特。他们每个都解决什么问题?
  • 如何用多级页表实现虚拟地址到物理地址的转换?
  • 为什么多级页表要比“一般”的页表要省空间?

客户端/服务端组织的目标是将客户端和服务之间的交互限制为消息。为了确保没有隐藏的交互,计算机之间的网络强制执行模块化。这种实现减少了编程错误从一个模块传播到另一个模块的机会,但它也有助于实现安全性(因为服务模块只能通过发送消息来渗透)和容错性(服务模块可以地理上分开,从而减少灾难性故障)

使用每个模块一个计算机的主要缺点是需要与模块数量相等的计算机。由于系统及其应用程序的模块化不应由可用计算机的数量来决定,因此这种要求是不可取的。设计者需要一种方法,在同一台计算机上运行多个模块,而不需要依赖软模块化。虚拟化作为实现这一目标的主要方法,并提出了三种新的抽象(带界限缓冲区的发送和接收、虚拟内存和线程),它们对应于三种主要抽象(通信链路、内存和处理器)的虚拟化版本。我们使用一台物理计算机创建多个虚拟计算机,并在每个虚拟计算机中执行一个模块。这个想法可以通过一种叫做虚拟化的技术来实现。虚拟化程序模拟物理对象的接口,但它通过多路复用一个物理实例来创建多个虚拟对象,或者通过聚合多个物理实例提供一个大的虚拟对象,或者使用仿真从不同种类的物理对象实现虚拟对象。

在内存强化模块化

内核

在内核模式下运行的一组模块通常被称为内核程序,或简称内核。在内核模式下,如果发生错误(例如除零错误),计算机会崩溃并停止运行,因为这些错误通常是由于内核程序中的编程错误引起的,而从这些错误中恢复通常非常困难

NOTE

操作系统的引导

当用户打开计算机电源时,处理器的所有寄存器都会被初始化为零,因此用户模式位被关闭。处理器执行的第一条指令是地址 0 处的指令(即程序计数器 pc 寄存器的初始值)。因此,在复位后,处理器会从地址 0 处获取第一条指令。

地址 0 通常对应于只读存储器(ROM)。这块存储器包含一些初始代码,即引导代码(boot code),它是一个简单的内核程序,用于从磁盘加载完整的内核程序。计算机制造商会将引导程序写入只读存储器,一旦写入,引导程序就无法更改。引导程序包含一个简单的文件系统,它会在磁盘上的预定位置找到内核程序(该程序可能由软件制造商提供)。引导代码将内核加载到物理内存中,并跳转到内核的第一条指令执行。

通过一个小型的引导程序来引导内核,有助于提高系统的模块化。硬件和软件制造商可以独立开发各自的产品,用户也可以更换内核,例如升级到新版本或使用不同厂商的内核,而无需修改硬件。

有时,为了应对额外的约束,可能会有多个引导阶段。例如,第一阶段的引导加载程序可能只能加载一个磁盘块,而一个磁盘块可能不足以容纳完整的引导程序。在这种情况下,引导代码可能会先加载一个更简单的内核程序,然后由该程序加载基本内核程序,再由基本内核程序加载完整的内核程序。

内核启动后,会为自身分配一个线程。这一线程的分配涉及为其分配一个用于栈的域(domain),这样它就可以进行过程调用,使得内核的其余部分可以用高级语言编写。内核还可能会分配其他一些域,例如用于存储域表的域。

当内核初始化完成后,通常会创建一个或多个线程来运行非内核服务。内核会为每个服务分配一个或多个域(例如,一个用于程序代码,一个用于栈,一个用于数据)。通常,内核会预加载某些域,填充非内核服务的程序代码和数据。常见的解决方案是将第一个非内核程序(类似于内核程序)存放在磁盘的预定地址,或者将其作为内核程序的数据的一部分。

一旦线程在用户模式下运行,它可以通过内核过程的入口点(gate)重新进入内核。使用内核过程,用户级线程可以创建更多线程,为这些线程分配域,并在完成后退出。

如果用户模式下的线程发生错误(例如除零错误,访问不属于该线程的域的地址,或者违反权限),处理器会触发异常,切换到内核模式。异常处理程序随后可以清理该线程。

虚拟内存

实际上内存和地址空间是有限的,程序员通过ALLOCATE_DOMAIN需要指定合理内存。为了能够增长域大小,我们提供一个新的源语GROW_DOMAIN。随着域的增长,内存管理上存在问题,比如下图。进程1分配了域1,进程2分配了域2, 现在域1要增长,因为不能跨地址,只能重新再空闲区域分配一块更大的域,并把域1复制到新域,这样效率会很低,并且内存管理会很复杂

image-20250303121423478

大多数计算系统虚拟化内存,这步提供了两个特性:

  1. 虚拟地址。程序更改虚拟地址来使用内存,并且内存管理器会翻译成物理内存。
  2. 虚拟地址空间。一个地址空间可能不足同时以支撑所有的应用,虚拟地址空间这种扩展允许每个程序有自己一套地址空间。

我们分成两部分来介绍, 先介绍虚拟地址高效翻译成物理地址的方法;然后在介绍虚拟地址空间。

虚拟化地址

当读写内存时候,线程负责发射虚拟地址;虚拟地址管理器负责将其翻译成物理地址——内存的某个总线地址或者是设备的某个控制器。虚拟化地址是通过间接的方式实现模块解耦。得益于翻译,虚拟内存管理器可以在不更改应用程序的情况下实现内存系统数据的重新组织。

image-20250303122312096

从命名的角度来看,虚拟内存管理器在物理地址的命名空间之上创建了一个虚拟地址的命名空间。虚拟内存管理器的命名方案将虚拟地址转换为物理地址。假设有一个虚拟地空间非常大264字节,但是物理地址较小。假设某个线程分配了两个大小为100字节的区域。内存的物理地址连续分配了两个区域,但在虚拟地址空间中,两个区域相距甚远。现在线程希望将域1从8KB扩展到16KB。如果使用虚拟内存,内存管理器可以在虚拟地址空间扩展该域,在物理内存中分配所需的额外空间,并将域1的内容复制到新配物理内存,然后更新域1的地址映射。由于有了虚拟地址,应用程序无需关心内存管理器是否移动了其数据,只需继续使用相同的虚拟地址即可。

即便不考虑内存复制带来的开销,引入虚拟地址嗲来复杂性和性能的成本,因为还需要管理它——分配释放、建立转换关系。地址转换在访存时动态进行,这会导致内存访问变慢。

image-20250303131340376

一种朴素的方式是维护映射表。每个虚拟地址空间都记录对应的物理地址,但是这种方式内存开销非常大。每个物理地址空间占一个8 byte-word,有264个虚拟地址。一个更高效的方式是使用页表,页表是一个数组,每个PE用于转换固定大小的连续虚拟地址(称为页),将其映射到一块物理地址(称为块),该块存储该页的内容。内存管理器维护全局页表,所有线程共享同一个虚拟地址空间。

image-20250303131354870

页表转换不赘述了。非常简单。

如果页大小为212字节,虚拟地址为8字节宽,那么线性页表会非常大(25252bit)因此实际应用中,通常采用更加高效的页表表示方式,例如

  • 二级页表
  • 反向页表(即物理地址索引而不是按虚拟地址索引)

页表的的存储

通常页表存储在物理内存中,与普通数据页共享同一片内存。为了让处理器找到页表的起始地址,处理器会使用一个特殊的寄存器,页表地址寄存器(Page-map address register);为了确保用户级线程无法直接修改页表,用户级线程不能直接写入,只有内核可以修改。

图 5.20 展示了一个内核如何使用页表的示例。内核在物理地址 0 处分配了一张页表, 用于管理虚拟地址到物理地址的映射页表提供了一个连续的虚拟地址空间,但无需在物理内存中分配连续的块。当线程请求新的内存域或者扩展现有内存域时:内核可以从空闲块中分配新的物理块,并将该块插入到页表,建立映射关系。由于页表提供了间接寻址机制,这一切都对运行中的线程透明——线程无需知道数据在物理内存中的实际位置。

image-20250303143210261

虚拟地址空间

目前设计假设所有线程共享同一个虚拟地址空间,该地址空间足够大,可以容纳所有的活跃的模块机器数据。然而许多处理器的虚拟地址并没这么大。

原语、或基本操作

虚拟内存管理器可以为每个地址空间创建一张独立的页表,从而支持多个虚拟地址空间。为支持多个虚拟地址空间可能需要如下接口

  • id <- CREATE_ADDR_SPACE():创建新的地址空间,地址空间为空,即没有任何虚拟页映射到物理内存。返回该地址空间的标识符id
  • block <- allocate_block(): 申请一个物理内存块。内存管理器尝试分配一个未使用的物理块,如果没有空闲块,则申请失败。返回物理块的地址
  • map(id, block, page_number, permission): 将物理块映射到地址空间id。内存管理器在id的页表中插入一个映射,将物理块block关联到虚拟页page_num,并设置访问权限
  • unmap(id, page_number): 取消地址空间id的虚拟页page_number的映射
  • free_block(block): 释放物理块block,将其归还到空闲内存列表中
  • delete_address_space(id):销毁地址空间id,内存管理器释放该物理地址空间的页表机器所有已分配的物理块。

上述的接口,线程可以独立创建自己的地址空间,或者与其他线程共享地址空间。程序员创建线程时需要指定该线程运行在哪个地址空间中。许多OS,进程通常指一个虚拟地址空间,由一个或多个线程共享。虚拟地址空间就是线程的作用域(domain),页表定义了线程如何访问物理内存。内核不需要维护额外的域表和域寄存器,如果一个物理块未出现在某个地址空间的页表中,则该地址的中的线程无法访问该物理块;如果一个物理块出现在多个地址空间中,则多个空间的线程可以共享该物理块。

图 5.21 展示了多个地址空间的示例

image-20250303152054382

两个线程(A 和 B),各自拥有一个独立的虚拟地址空间,物理块 800 被两个线程共享。除了共享块,每个线程还拥有两个私有的物理块:

  1. 一个映射为可读可执行的块,例如存放程序代码
  2. 映射为可读写的块, 存放线程的栈

为了支持虚拟地址空间,处理器的“页表地址寄存器(page-map address register, PMAR)” 存储当前运行线程的页表所在的物理地址。虚拟地址的翻译过程如下:

1  procedure translate (integer virtual, perm_needed) returns physical_address
2      page ← virtual[0:41]         // 提取虚拟地址的页号(前 42 位)
3      offset ← virtual[42:63]      // 提取偏移量(后 12 位)
4      page_table ← pmar            // 读取当前页表地址
5      perm_page ← page_table[page].permissions  // 查询该页的权限
6      if permitted (perm_needed, perm_page) then
7          block ← page_table[page].address  // 通过页号查询物理块号
8          physical ← block + offset         // 计算物理地址(拼接物理块号和偏移量)
9          return physical                   // 返回物理地址
10     else return error                      // 权限不足,返回错误

内核与地址空间

为内核设置页表时,有两种可选方案:

方案1:每个用户地址空间都会映射内核地址空间。高地址部分存放内核,低地址部分存放用户程序。这样的设计,用户程序与内核程序共享同一个地址空间,因此,切换用户和内核态时不需要更改页表地址寄存器(PMAP),只需要切换用户模式。内核设置内核页的权限为“仅内核可访问”,当用户态尝试写入内核页时,会触发“非法指令”异常。优势是上下文切换更快,内核可以直接访问用户程序的数据结构,减少数据拷贝操作。劣势:用户可用的地址空间变少了。

方案2:内核和用户程序使用完全独立的地址空间。内核程序和用户程序拥有各自独立的地址空间,用户态线程无法访问内核地址空间。为了实现这种隔离,需要扩展 SVC(supervisor call)指令,以确保进入内核模式时,切换 PMAR 到内核页表。进入内核模式(例如通过系统调用或异常),PMAR 切换到内核页表。返回用户模式,PMAR 切换回原来的用户线程页表。内核仍然需要管理用户地址空间,解决办法就是,让内核地址空间包含所有用户地址空间的页表,这样依赖, 内核可以通过修改用户页表调整用户地址空间。 好处是:1 页表比地址空间本身小很多,因此该方案浪费的地址空间比第一种方案少;2 用户态程序无法直接访问内核地址空间,提高安全性。劣势是:1 内核无法直接访问用户态数据结构; 2 用户程序必须通过值传递来向内核传递参数,或者内核需要更复杂的方法来访问用户数据。

现代操作系统通常采用方案二,以确保安全性,并通过内核映射部分用户地址空间等优化方法减少访问开销

分析

NOTE

位置无关程序可以加载到任意内存地址,而不会影响其运行。编译器生成相对地址,而非绝对地址。

在多虚拟地址空间的设计下,虚拟地址是相对于特定地址空间的。优点是无需编译成位置无关代码,因为每个程序都可以存储在虚拟地址0。缺点是共享数据时可能产生混淆,灵活性较差——同一个物理块在不同地址空间可能有不同的虚拟地址,导致线程在共享数据时难以协调;灵活性较差,受限于“页”粒度,共享对象必须对齐到页边界,意味着如果共享对象小于1页,未使用的部分就会浪费。

软件 vs. 硬件 以及 TLB

硬件设计师和软件设计师之间的持续争论:

  • 哪些部分的虚拟内存管理器应该由处理器的硬件实现?
  • 哪些部分应该由操作系统的软件实现?
  • 硬件和软件模块之间的接口应该如何设计?

设计上的权衡: 性能 与 灵活性

性能上说,地址转换影响几乎所有指令执行,如果完全由硬件实现,速度最快;处理器内部的数字电路与CPU频率同步运行,减少额外的延迟

灵活性上说,完全由硬件实现会降低操作系统的灵活性,因为操作系统无法自由调整虚拟地址和物理地址的映射方式

缓存页表项:TLB

为减少频繁的内存访问,处理器内部维护一个页表缓存(TLB)。 TLB 的设计依据:局部性原理(Locality of Reference

TLB 设计原理:

  1. 当处理器访问某个虚拟地址时,先在 TLB 中查找对应的页表项。
  2. 如果 TLB 命中(TLB hit),直接返回物理地址,避免额外的内存访问。
  3. 如果 TLB 未命中(TLB miss),处理器必须访问内存中的页表,并将结果存入 TLB 以备后续使用

TLB 的实现方式:全相联(Associative) vs. 直接索引(Indexed)

  • 全相联缓存(Associative Memory)
    • 任意页表项可以存储在 TLB 的任意位置,不需要固定索引。
    • 灵活性更高,但实现复杂。
    • 由于 TLB 远小于物理内存,全相联方法在 TLB 设计中是可行的。
  • 直接索引缓存(Indexed Memory)
    • 采用哈希索引,直接映射到固定位置,访问速度快,但容易发生冲突。
    • 适用于较大规模的页表缓存。
  • TLB 在硬件中的作用:
    • 许多 RISC 处理器仅在硬件中实现 TLB,而不规定具体的页表格式。
    • 当发生 TLB miss 时,处理器会触发异常,由软件(操作系统)进行页表查找,并更新 TLB。

软件管理页表的优势

软件可以灵活选择页表的数据结构:

  • 小型程序:页表可采用链表或者树结构,节省内存
  • 大规模地址空间,可以采用倒排页表,按照物理内存存储页表项,减少空间浪费
  • 软件控制页表映射的好处:减少碎片化;支持将页存储在外部设备,实现虚拟内存。