《Modern Operating System》、《Operating Systems:Three easy pieces》阅读笔记
1. 地址空间?
是对内存的抽象【abstraction】
是一个进程可以用于寻址内存的一套地址的集合
每个进程都有自己的独立的地址空间
为什么引入?
保护:避免用户进程直接访问内存的物理地址,避免对操作系统的破坏
它是站在运行程序的视角去看待系统的内存
2. 虚拟(逻辑)地址?
所有的用户程序所看见的都是虚拟地址,而真正的物理地址是由OS管理的
3. 动态重定位?
真正的物理地址:虚拟地址 + 基地址
由 硬件【hardware】实现的(MMU内存管理单元进行地址翻译【address translation】)
同时,如果虚拟地址 + 基地址 > bound register ,那么说明当前转换出的物理地址超出了最大范围,被视为非法操作(即重定位失败)
4. 静态重定位?
利用 软件【software】实现的
使用 装载器【loader】
但是,静态重定位【static relocation】有许多的问题
其中,最重要的问题是————不能提供保护(当进程产生错误地址因此非法访问了别的进程的内存空间或者操作系统内存空间)
另一个问题是————一旦装入后,就很难再修改了
5. 内存管理单元MMU?
主要功能有两个:
- 是保护权限,进程A不能访问进程B的地址空间;
- 是负责地址重定位(地址翻译),CPU发出的寻址指令被MMU截获,CPU发出的是虚拟地址,MMU将其转换为物理地址(地址翻译),再到内存中实际寻址
操作系统将内存分为 系统区【kernel mode】 和用户区【user mode】
- 系统区:可以使用所有的硬件设备
- 用户区:应用程序仅能运行在用户区
程序状态字【PSW】就是用来区分当前CPU是运行在哪个模式下
6. OS/硬件交互的时间线?
从【boot time】开始后,到一个进程A的运行,再到切换另一个进程B,再到出错处理
7. 内存分配算法?
1. 首次适应算法【first-fit】
每次从低地址开始顺序查找
低地址不够了才开始用高地址,顺序使用地址空间
如果找到满足当前进程大小的内存块,分配内存
剩余空闲分区仍然留空闲队列中
特点:
- 该算法倾向于使用内存中低地址部分的空闲区,在高地址部分的空闲区很少被利用
- 从而保留了高地址部分的大空闲区。显然为以后到达的大作业分配大的内存空间创造了条件。
缺点:
- 低地址部分不断被划分,会留下内存碎片(外部碎片)
- 每次查找都会重新从低地址开始,增加了查找的开销
2. 最佳适应算法【best-fit】
会将当前的空闲区按照大小顺序排序
每次总是把能满足当前进程的最小的空闲区分配给内存
虽然该算法看上去是最优的,
但是每次分配的空闲区必然会留下极小的内存剩余,形成碎片【外部碎片】(往后进程难以利用)
3. 最坏适应算法【worst-fit】
将空闲区按照大小顺序排序
每次都会将当前进程申请的内存大小差距最大的空闲区进行分配
优点:
- 尽可能利用内存中大的空闲区
缺点:
- 会使内存中缺乏大的空闲区
4. 伙伴系统算法【buddy algorithm】
Linux采用的内存管理就是伙伴系统【buddy system】
例如,有一块64KB的空闲内存空间
当我们要申请一块7KB的内存时,它会不断向下分裂,最后将一块8KB的区域分配出去。
注意!伙伴系统分配的单元大小都是2的幂次
当内存被回收的时候,需要进行合并操作:
- 首先观察它的伙伴是否为空闲
- 若伙伴也为空闲区域,那么两者合并,并向上重复该操作(直到不能合并为止)
- 若伙伴已分配,则当前区域为空闲
优点:
- 实现简单
- 容易合并相邻的块区
- 能够快速分配和合并
缺点:
- 所有块的大小都是2的幂次
- 无法避免内部碎片【internal fragmentation】
5. 分离队列【Segregated Lists 】
可以看出,每个队列都存储这不同大小的内存块
看起来可以减少内部碎片,但是问题随之而来,每个Freelist都存储固定大小的内存块,如果申请9字节数据,可能就要分配16字节,带来的内存碎片反而更多了!因此,虽然按照2的幂级数去分配是一种很简单的策略,但是它并不高效。解决方案也有不少,例如分配 2^N 和 3*2^N 的内存块。
至于外部碎片的问题,Seglist也同样存在,不过不是那么明显。因为在分配Freelist的时候,通常按照内存 Page为单位,如果块大小不是 Page 的约数,就会有外部碎片了。
Segregated-Freelist 还有一个变种,称之为 Segregated-Fit。每个Freelist 不再是存储固定大小的内存块,而是存储一定范围的内存块。大名鼎鼎的 Doug Lea内存分配其(dlmalloc)就使用了这种策略。
6. 内部碎片和外部碎片?
内部碎片【internal fragmentation】:
是已经被分配出去的内存空间大于请求所需的内存空间。
外部碎片【external fragmentation】:
是指还没有分配出去,但是由于大小太小而无法分配给申请空间的新进程的内存空间空闲块。
7. 内存分配细节?
- 切分与合并【splitting and coalescing】
假设当前堆空间大小为30B,已经分配了10B
那么,空闲队列中会有两个10B的节点:
如果,这时候我们想要申请一个15B空间的内存,会申请失败(因为空闲队列中没有大于等于15B的节点)
切分的概念为:
当我们申请到一块大于作业大小的内存,会将空闲区切分为两部分,
前半部分返回给调用者【caller】,后半部分则重新成为一块空闲区放入空闲队列中
释放使用内存的时候则需要考虑是否要和相邻的空闲区合并(而不能简单地将使用资源释放,直接添加到空闲队列)
(释放内存的时候要保证,总能得到最大的空闲内存)
- 跟踪分配内存的大小
我们知道C语言中的 free()操作并没有指定需要释放的内存大小,那么系统怎么确定要释放多少字节的内存呢?
假设我们使用:ptr = malloc(20)分配了一个20字节的内存空间,这时候操作系统会在前面在加上一个头部【header】来维护当前ptr指向的内存的大小和一些额外校验信息
typedef struct __header_t { int size; int magic;} header_t;
但用户调用 free(ptr) 后,库会进行一个简单的数学计算:
void free(void *ptr) { header_t *hptr = (void *)ptr - sizeof(header_t); ......
基于该机制下,当用户使用 malloc(N)后,实际分配到的内存并不是N,而是:N+头部【header】大小的字节数
- 建立空闲队列
空闲队列链表的数据结构:
typedef struct __node_t { int size; struct __node_t *next;} node_t;
假设有一个4KB的空闲堆区,系统调用 mmap()来申请一大块内存区
// mmap() returns a pointer to a chunk of free
spacenode_t *head = mmap(NULL, 4096, PROT_READ|PROT_WRITE,MAP_ANON|MAP_PRIVATE, -1, 0);
head->size = 4096 - sizeof(node_t);
head->next = NULL;
注意:第4行的代码
当我们申请一块4096B的内存时,header是需要驻入该区域的,所以实际上可用的内存空间 = 4096 - sizeof(node_t)
当我们申请一块100字节的内存后,系统分配内存后的结果,如图17.4所示
如图17.6所示,当我们释放了中间的这块内存,给内存块会加入空闲队列,并且下一块空闲内存会指向size:3764的地址
【注】
- 虽然说free了第二块内存区,但是sptr指针变量却没有变动,只是它指向了一块自己无法预估内容的内存区
当分配内存全部释放后的结果,如图17.7所示
这时候空闲队列中已经有四个节点了(而不再是最初的一块完整的空闲区)
**但是!**有没有发现一些问题?
**对!**我们没有进行合并【coalesce】操作,我们需要将相邻的空闲区合并成一整块
8. 分页【paging】?
之前介绍的内存分段【segmentation】方法是将内存分成不同大小【variable size】的块区
而接下来我们学习的内存分页【paging】方法是将内存分成固定大小【fixed size】的页区,每个固定的内存单元称为:页
分页的好处是更加灵活、实现简单,相比于分段,避免了外部碎片【external fragmentation】的产生,然而不可避免内部碎片
从上图我们可以看到,一个64字节的内存区域分成了4个页面(每个页面16字节)
另外,我们需要一种数据结构————页表【page table】
页表用来进行地址翻译的,也就是从虚拟页面转换到物理页面
需要注意的是:
每个进程都有一个页表
9. 地址翻译的过程?
现在,看看虚拟地址是如何翻译成为物理地址的
假设我们的虚拟地址空间为64字节—————虚拟地址一共需要6位(26 = 64)
64字节分成了4个页面,每个页面大小16字节
而虚拟地址由两部分组成:
- 虚拟页号【virtual page number】
- 偏移量【offset】
从上图看出,6位的虚拟地址由2位的虚拟页号和4位的偏移量组成
简单的一个CPU执行页面翻译的步骤如下:
- CPU生成一个虚拟地址,并传送给MMU
- MMU生成PTE地址,并从高速缓存/内存中请求得到
- 高速缓存/内存向MMU返回PTE
- MMU构造物理地址,并把返回给高速缓存/内存
- 高速缓存/内存返回所请求的数据字给CPU
10. 页表的存储位置?
由于每个进程都有一个自己的页表
页表是存储在内存中的,并且是随机地存放
页表基址寄存器【page-table base register】用来定位每个页表在内存中的位置的
11. 页表中有哪些内容?
页表就是一个页表条目【page table entry】的 数组,它负责完成虚拟页到物理页的映射
页表项包括:
- 有效位【valid bit】:未使用的页表项会被标记位fasle
- 保护位【protection bit】:保护数据权限,是否是可读、可写
- 已使用区【dirty bit】:在页面进入内存以来,是否被修改
- 参考位【reference bit】:观察某个页面是或否被使用过(这关系到后续页面置换算法的执行)
12. 段页式
段页式的产生很好的弥补了页式分区和段式分区的不足,并吸收了两者的长处
如图20.1所示,16K的虚拟地址空间被分成了16个页,但是目前仅仅用了4页,过于浪费空间!
因此,我们的策略是:
将地址空间分成若干段,每个段再分成若干页
基于这种思想,可以将上图中的地址空间分成三个段区:代码段、堆段和栈段
每个段再由不同分页组成
这样的话,虚拟地址就要作出改动,不再是原来的页号+偏移量了
而是段号+页号+偏移量(每个段区都保存一个页表【page table】)