有时候人们会说,操作系统有两种方法,来解决大多数空间管理问题。
第一种是将空间分割成不同长度的分片,就像虚拟内存管理中的分段。遗憾的是,这个解决方法存在固有的问题。具体来说,将空间切成不同长度的分片以后,空间本身会碎片化(fragmented),随着时间推移,分配内存会变得比较困难。
因此,值得考虑第二种方法:将空间分割成固定长度的分片。在虚拟内存中,我们称这种思想为分页,可以追溯到一个早期的重要系统,Atlas[KE+62, L78]。分页不是将一个进程的地址空间分割成几个不同长度的逻辑段(即代码、堆、段),而是分割成固定大小的单元,每个单元称为一页。相应地,我们把物理内存看成是定长槽块的阵列,叫作页帧(pageframe)。每个这样的页帧包含一个虚拟内存页。我们的挑战是:
关键问题:如何通过页来实现虚拟内存 如何通过页来实现虚拟内存,从而避免分段的问题?基本技术是什么?如何让这些技术运行良好,并尽可能减少空间和时间开销?
一个简单的例子 🌰
在这个例子中,有 8 个页帧(由 128 字节物理内存构成,也是极小的)

为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常为每个进程保存一个数据结构,称为页表(page table)。页表的主要作用是为地址空间的每个虚拟页面保存地址转换(address translation),从而让我们知道每个页在物理内存中的位置。
页表是一个每进程的数据结构(如果在上面的示例中运行另一个进程,操作系统将不得不为它管理不同的页表,因为它的虚拟页显然映射到不同的物理页面(除了共享之外)。
现在,我们了解了足够的信息,可以完成一个地址转换的例子。设想拥有这个小地址空间(64 字节)的进程正在访问内存:
movl <virtual address>, %eax具体来说,注意从地址<virtual address>到寄存器 eax 的数据显式加载(因此忽略之前肯定会发生的指令获取)。
为了转换(translate)该过程生成的虚拟地址,我们必须首先将它分成两个组件:虚拟页面号(virtual page number,VPN)和页内的偏移量(offset)。

物理帧号(PFN)(有时也称为物理页号,physical page number 或 PPN)我们可以通过用 PFN 替换 VPN 来转换此虚拟地址,然后将载入发送给物理内存。
偏移量保持不变(即未翻译),因为偏移量只是告诉我们页面中的哪个字节是我们想要的。

页表存在哪里
页表可以变得非常大,比我们之前讨论过的小段表或基址/界限对要大得多,我们没有在 MMU 中利用任何特殊的片上硬件,来存储当前正在运行的进程的页表,而是将每个进程的页表存储在内存中。现在让我们假设页表存在于操作系统管理的物理内存中,很多操作系统内存本身都可以虚拟化,因此页表可以存储在操作系统的虚拟内存中(甚至可以交换到磁盘上)。
因此,页表一般放在内存中。

列表中究竟有什么
页表就是一种数据结构,用于将虚拟地址(或者实际上,是虚拟页号)映射到物理地址(物理帧号)。因此,任何数据结构都可以采用。最简单的形式称为线性页表(linear page table),就是一个数组。操作系统通过虚拟页号(VPN)检索该数组,并在该索引处查找页表项(PTE),以便找到期望的物理帧号(PFN)。现在,我们将假设采用这个简单的线性结构。
至于每个 PTE 的内容:
有效位(valid bit)通常用于指示特定地址转换是否有效。保护位(protection bit),表明页是否可以读取、写入或执行。同样,以这些位不允许的方式访问页,会陷入操作系统。存在位(present bit)表示该页是在物理存储器还是在磁盘上(即它已被换出,swapped out)。脏位(dirty bit)也很常见,表明页面被带入内存后是否被修改过。参考位(reference bit,也被称为访问位,accessed bit)有时用于追踪页是否被访问,也用于确定哪些页很受欢迎,因此应该保留在内存中。

分页:也很慢
要获取所需数据,系统必须首先将虚拟地址转换为正确的物理地址。因此,在从地址 117 获取数据之前,系统必须首先从进程的页表中提取适当的页表项,执行转换,然后从物理内存中加载数据。
为此,硬件必须知道当前正在运行的进程的页表的位置。现在让我们假设一个页表基址寄存器(page-table base register)包含页表的起始位置的物理地址。为了找到想要的 PTE的位置,硬件将执行以下功能:
VPN = (VirtualAddress & VPN_MASK) >> SHIFT
PTEAddr = PageTableBaseRegister + (VPN * sizeof(PTE))一旦知道了这个物理地址,硬件就可以从内存中获取 PTE,提取 PFN,并将它与来自虚拟地址的偏移量连接起来,形成所需的物理地址。具体来说,你可以想象 PFN 被 SHIFT左移,然后与偏移量进行逻辑或运算,以形成最终地址。
offset = VirtualAddress & OFFSET_MASK
PhysAddr = (PFN << SHIFT) | offset
1 // Extract the VPN from the virtual address
2 VPN = (VirtualAddress & VPN_MASK) >> SHIFT
3
4 // Form the address of the page-table entry (PTE)
5 PTEAddr = PTBR + (VPN * sizeof(PTE))
6
7 // Fetch the PTE
8 PTE = AccessMemory(PTEAddr)
9
10 // Check if process can access the page
11 if (PTE.Valid == False)
12 RaiseException(SEGMENTATION_FAULT)
13 else if (CanAccess(PTE.ProtectBits) == False)
14 RaiseException(PROTECTION_FAULT)
15 else
16 // Access is OK: form physical address and fetch it
17 offset = VirtualAddress & OFFSET_MASK
18 PhysAddr = (PTE.PFN << PFN_SHIFT) | offset //拼接
19 Register = AccessMemory(PhysAddr)对于每个内存引用(无论是取指令还是显式加载或存储),分页都需要我们执行一个额外的内存引用,以便首先从页表中获取地址转换。工作量很大!额外的内存引用开销很大,在这种情况下,可能会使进程减慢两倍或更多。
如果不仔细设计硬件和软件,页表会导致系统运行速度过慢,并占用太多内存。
内存追踪
一段循环赋值 c 代码:
int array[1000];
...
for (i = 0; i < 1000; i++)
array[i] = 0;编译:
prompt> gcc -o array array.c -Wall -O
prompt> ./array反编译后的汇编:
0x1024 movl $0x0,(%edi,%eax,4)
0x1028 incl %eax
0x102c cmpl $0x03e8,%eax
0x1030 jne 0x1024第一条指令将零值(显示为$0x0)移动到数组位置的虚拟内存地址,这个地址是通过取%edi的内容并将其 加上%eax乘以4 来计算的。%edi 保存数组的基址,而%eax 保存数组索引(i)。
第二条指令增加保存在%eax中的数组索引(i++)
第三条指令将该寄存器的内容与十六进制值 0x03e8 或十进制数 1000 进行比较(i<1000)。如果比较结果显示两个值不相等(这就是 jne 指令测试)
第四条指令跳回到循环的顶部。
假设一个大小为 64KB 的虚拟地址空间。我们还假定页面大小为 1KB。
- 页表:物理地址 1KB(1024)
- 代码段:虚拟地址 1KB,大小 1KB,VPN=1,映射到物理页 4(VPN 1->PFN 4)
- 数组:4000 字节(1000X
4),int占4字节,我们假设它驻留在虚拟地址 40000 到 44000(不包括最后一个字节)。(VPN 39 → PFN 7), (VPN 40 → PFN 8), (VPN 41 → PFN 9), (VPN 42 → PFN 10)
当它运行时,每个指令将产生两个内存引用:一个访问页表以查找指令所在的物理框架,另一个访问指令本身将其提取到 CPU 进行处理
另外,在 mov 指令的形式中,有一个显式的内存引用,这会首先增加另一个页表访问(将数组虚拟地址转换为正确的物理地址),然后时数组访问本身。
图 18.7 展示了前 5 次循环迭代的整个过程。左边虚拟地址和右边实际物理地址。

小结
- 我们引入了分页(paging)的概念
- 优点:
- 两个可能的问题: