目录
Operating Systems: Three Easy Pieces 笔记
第15章 机制:地址转换
第16章 分段
第17章 空闲空间管理
1. 地址转换
我们先假设
用户的地址空间必须连续地放在物理内存中
地址空间不是很大,小于物理内存的大小
每个地址空间的大小完全一样
1.1 动态重定位
基址加界限机制(base and bound),有时又称为动态重定位(dynamic relocation)
每个 CPU 需要两个硬件寄存器:
基址(base)寄存器和界限(bound)寄存器 / 限制(limit)寄存器
基址寄存器将虚拟地址转换为物理地址
界限寄存器确保这个地址在进程地址空间的范围内
1.1.1 基址寄存器(虚拟地址 -> 物理地址)
采用这种方式,在编写和编译程序时假设地址空间从零开始。但是,当程序真正执行时,操作系统会决定其在物理内存中的实际加载地址,并将起始地址记录在基址寄存器中。
当进程运行时,该进程产生的所有内存引用,都会被处理器转换为物理地址:
physical address = virtual address + base
进程中使用的内存引用都是虚拟地址(virtual address),硬件接下来将虚拟地址加上基
址寄存器中的内容,得到物理地址(physical address),再发给内存系统。
图 15.1 从程序的角度来看,它的地址空间(address space)从 0 开始到 16KB 结束。它包含的所有内存引用都应该在这个范围内。
图 15.2 展示了一个例子,操作系统将第一块物理内存留给了自己,并将上述例子中的进程地址空间重定位到从 32KB 开始的物理内存地址。剩下的两块内存空闲(16~32KB 和 48~64KB)。
将虚拟地址转换为物理地址,即地址转换(address translation)技术。
由于这种重定位是在运行时发生的,这种技术一般被称为动态重定位(dynamic relocation)。
1.1.2 界限寄存器(提供访问保护)
上例,界限寄存器被置为 16KB。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU 将触发异常,进程最终可能被终止。
这种基址寄存器配合界限寄存器的硬件结构是芯片中的(每个 CPU 一对)。有时我们将
CPU 的这个负责地址转换的部分统称为内存管理单元(Memory Management Unit,MMU)。
1.2 操作系统的工作
说明:在上下文切换时,操作系统也必须执行一些额外的操作。
每个 CPU 只有一个基址寄存器和一个界限寄存器,对于每个运行的程序它们的值不同。因此,
在切换进程时,操作系统必须保存和恢复基础和界限寄存器。
2. 分段
2.1 分段:泛化的基址/界限
在 MMU 中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段(segment)一对。一个段只是地址空间里的一个连续定长的区域。
把图 16.1 中的地址空间放入物理内存。通过给每个段一对基址和界限寄存器,可以将每个段独立地放入物理内存。如图 16.2 所示,64KB 的物理内存中放置了 3 个段(为操作系统保留 16KB)。从图中可以看到,只有已用的内存才在物理内存中分配空间,因此可以容纳巨大的地址空间。
2.2 引用哪个段
硬件在地址转换时使用段寄存器。它如何知道段内的偏移量,以及地址引用了哪个段?
可以用虚拟地址的开头几位来标识不同的段,之前的例子有 3 个段,因此需要两位来标识。
如果用 14 位虚拟地址的前两位来标识,那么虚拟地址如下所示:
13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
段 | 偏移量 |
上述例子中,如果前两位是 00,硬件就知道这是属于代码段的地址,使用代码段的基址和界限;
如果前两位是 01,则是堆地址,使用堆的基址和界限。
虚拟地址 4200(在堆中)的二进制形式如下:
0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
段 01 -> 堆中
段内偏移 0000011 01000 -> 104(十进制)
2.3 代码和堆的地址转换举例
(1)虚拟地址100(在代码段中)
MMU 将基址值加上偏移量(100)得到实际的物理地址:100 + 32KB = 32868
检查该地址是否在界限内(100 小于 2KB),发现是的,于是发起对物理地址 32868 的引用。
(2)虚拟地址 4200(在堆中)
计算堆的偏移量:堆从虚拟地址 4K开始,4200 的偏移量是 4200 减去 4096,即 104
用这个偏移量(104)加上基址寄存器中的物理地址(34KB),得到真正的物理地址 34920。
2.4 栈的地址转换举例
在表 16.1 中,栈被重定位到物理地址 28KB。注意,它是反向增长的。
在物理内存中,它始于 28KB,增长回到 26KB,相应虚拟地址从 16KB 到 14KB。
硬件支持:除了基址和界限外,硬件还需要知道段的增长方向(用一位区分,比如 1 代表自小而大增长,0 反之)。
假设要访问虚拟地址 15KB,它应该映射到物理地址 27KB。
该虚拟地址的二进制形式是:11 1100 0000 0000。
从 3KB (1100 0000 0000) 中减去最大的段地址:例本中,段可以是 4KB(2^12B),因此正确的偏移量是 3KB 减去 4KB,即−1KB。
用这个反向偏移量(−1KB)加上基址(28KB),就得到了正确的物理地址 27KB。用户可以进行界限检查,确保反向偏移量的绝对值小于段的大小。
2.5 支持共享
要节省内存,有时候在地址空间之间共享(share)某些内存段是有用的,例如代码共享。
硬件支持:保护位(protection bit),标识程序是否能够读写该段,或执行其中的代码。
通过将代码段标记为只读和可执行,同样的代码可以被多个进程共享,因此物理内存中的一个段可以映射到多个虚拟地址空间。
2.6 分段解决和未解决的问题
分段解决了一些问题,
更好地支持稀疏地址空间
很快、地址转换的开销极小
代码共享
在内存中分配不同大小的段会导致一些问题:
由于段的大小不同,空闲内存被割裂成各种奇怪的大小,此满足内存分配请求可能会很难;
分段还是不足以支持更一般化的稀疏地址空间,例如,如果有一个很大但是稀疏的堆,都在一个逻辑段中,整个堆仍然必须完整地加载到内存中
3. 空闲空间管理
3.1 底层机制
3.1.1 分割与合并
假设我们只申请一个字节的内存。分配程序会执行所谓的分割(splitting)动作:它找到一块可以满足请求的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中。
如果应用程序调用 free(10),归还堆中间的空间,会发生什么?
问题出现了:尽管整个堆现在完全空闲,却被分割成了 3 个 10 字节的区域。
这时,如果用户请求 20 字节的空间,简单遍历空闲列表会找不到这样的空闲块,因此返回失败。
为了避免这个问题,分配程序会在释放一块内存时合并(coalescing)可用空间。
在归还一块空闲内存时,查看要归还的内存块的地址及其相邻的空闲空间块。如果新归还
的空间与一个或两个原有空闲块相邻,就将它们合并为一个较大的空闲块:
3.1.2 追踪已分配空间的大小
假设:函数void free(void *ptr)接受一个指针,释放对应的内存块。请注意该接口的隐含意义,在释放空间时,用户不需告知库这块空间的大小。因此,在只传入一个指针的情况下,库必须能够弄清楚这块内存的大小。
要完成这个任务,大多数分配程序都会在头块(header)中保存一点额外的信息,它在
内存中,通常就在返回的内存块之前。
在上图的例子中,用户调用了 malloc(),并将结果保存在ptr 中:ptr = malloc(20)。
该头块中包含所分配空间的大小、一些额外的指针来加速空间释放、一个幻数来提供完整性检查,以及其他信息。我们假定,一个简单的头块包含了分配空间的大小和一个幻数。
因此,如果用户请求 N 字节的内存,库不是寻找大小为 N 的空闲块,而是寻找 N 加上头块大小的空闲块。释放空间同理,实际释放的是头块大小加上分配给用户的空间的大小。
3.1.2 嵌入空闲列表
假设我们需要管理一个 4096 字节的内存块(即堆是 4KB)。head 指针指向这块区域的起始地址,假设是 16KB,列表的状态是它只有一个条目,记录大小为 4088。(图17.3)
现在,假设有一个 100 字节的内存请求。库首先要找到一个足够大小的块。因为只有一个 4088 字节的块,所以选中这个块。然后,这个块被分割(split)为两块:一块足够满足请求(以及头块,如前所述),一块是剩余的空闲块。假设记录头块为 8 个字节(一个整数记录大小,一个整数记录幻数),堆中的空间如图 17.4 所示。
至此,对于 100 字节的请求,库从原有的一个空闲块中分配了 108 字节,返回指向它的一个指针(在上图中用 ptr 表示),并在其之前连续的 8 字节中记录头块信息,供未来的free()函数使用。同时将列表中的空闲节点缩小为 3980 字节(4088−108)。
再继续分配两个同样大小的块,图 17.5,
在这个例子中,应用程序调用 free(16500),归还了中间的一块已分配空间(sptr 指向,内存块的起始地址 16384 加上前一块的 108,和这一块的头块的 8 字节,就得到了 16500)。
库释放空间,并将空闲块加回空闲列表。假设将它插入到空闲列表的头位置。现在的空闲列表包括一个小空闲块(100 字节,由列表的头指向)和一个大空闲块(3764字节),如图 17.6 所示。
3.2 基本策略
3.2.1 最优匹配
最优匹配(best fit)首先遍历整个空闲列表,找到和请求大小一样或更大的空闲块,然后返回这组候选者中最小的一块。这就是所谓的最优匹配(也可以称为最小匹配)。
避免空间浪费 <--> 较高的性能代价
3.2.2 首次匹配
首次匹配(first fit)找到第一个足够大的块,将请求的空间返回给用户。
速度优势 <--> 有时会让空闲列表开头的部分有很多小块
因此,分配程序如何管理空闲列表的顺序就变得很重要。一种方式是基于地址排序(address-based ordering)。通过保持空闲块按内存地址有序,合并操作会很容易,从而减少了内存碎片。
3.2.3 下次匹配
下次匹配(next fit)算法多维护一个指针,指向上一次查找结束的位置。其想法是将对空闲空间的查找操作扩散到整个列表中去,避免对列表开头频繁的分割。
3.3.4 分离空闲列表
分离空闲列表(segregated list),如果某个应用程序经常申请一种(或几种)大小的内存空间,那就用一个独立的列表,只管理这样大小的对象。其他大小的请求都一给更通用的内存分配程序。
减少碎片、分配和释放都很快 <--> 应该拿出多少内 存来专门为某种大小的请求服务?
厚块分配程序(slab allocator),很优雅地处理了这个问题。
具体来说,在内核启动时,它为可能频繁请求的内核对象创建一些对象缓存(object cache),如锁和文件系统 inode 等。这些的对象缓存每个分离了特定大小的空闲列表,因此能够很快地响应内存请求和释放。如果某个缓存中的空闲空间快耗尽时,它就向通用内存分配程序申请一些内存厚块(slab)(总量是页大小和对象大小的公倍数)。相反,如果给定厚块中对象的引用计数变为 0,通用的内存分配程序可以从专门的分配程序中回收这些空间,这通常发生在虚拟内存系统需要更多的空间的时候。
3.3.5 伙伴系统
因为合并对分配程序很关键,所以人们设计了一些方法,让合并变得简单,一个好例子就是二分伙伴分配程序(binary buddy allocator)。
在这种系统中,空闲空间首先从概念上被看成大小为 2N 的大空间。当有一个内存分配请求时,空闲空间被递归地一分为二,直到刚好可以满足请求的大小(再一分为二就无法满足)。这时,请求的块被返回给用户。在下面的例子中,一个 64KB 大小的空闲空间被切分,以便提供 7KB 的块:
这种分配策略只允许分配 2 的整数次幂大小的空闲块,因此会有内部碎片(internal fragment)的麻烦。
伙伴系统的漂亮之处在于块被释放时。如果将这个 8KB 的块归还给空闲列表,分配程序会检查“伙伴”8KB 是否空闲。如果是,就合二为一,变成 16KB 的块。然后会检查这个 16KB 块的伙伴是否空闲,如果是,就合并这两块。这个递归合并过程继续上溯,直到合并整个内存区域,或者某一个块的伙伴还没有被释放。
确定某个块的伙伴很容易 --> 每对互为伙伴的块只有一位不同,正是这一位决定了它们在整个伙伴树中的层次。