-
前言
虚拟存储器提供了三个重要的能力
(1) 将主存当成是存储在磁盘上的地址空间的高速缓存。在主存中只保存活动区域,高效使用主存
(2) 为每个进程提供了一个一致的地址空间
(3) 保护了每个进程的地址空间不被其他进程破坏
-
物理和虚拟寻址
-
物理寻址
CPU ----物理地址---- 主存
应用场景:嵌入式微控制器、超级计算机
-
虚拟寻址
CPU ----虚拟地址---- MMU ----物理地址---- 主存
地址翻译:将虚拟地址转换为物理地址。需要CPU上的MMU硬件,和存放在主存的查询表(页表)来动态翻译虚拟地址,这个表由操作系统管理。
应用场景:现代计算机
-
-
地址空间
-
虚拟地址空间:包含
N = 2^n {0, 1, 2, ... , N - 1}
个地址,称为n位地址空间(一般n=32或64)
-
物理地址空间:与系统中物理存储器的M个字节相对应
M = 2^m {0, 1, 2, ... , M - 1}
-
主存中的每个字节,都有一个选自虚拟地址空间的虚拟地址,和一个选自物理地址空间的物理地址
-
-
虚拟存储器作为缓存的工具
-
虚拟存储器分割为多个虚拟页,每个虚拟页大小固定 P=2^p;物理存储器也被分割为多个物理页,每个页的大小也为P
-
虚拟页可以分为3种
(1) 未分配的
没有数据关联,不占磁盘空间
(2) 分配但未缓存的
没有缓存在物理存储器中的页
(3) 分配且缓存的
缓存在物理存储器中的页
-
由于不命中处罚很大,且访问第一字节的开销很高 —> 虚拟页倾向于很大(典型值为4-8KB)
-
页表
(1) 示例
名称 有效位 物理存储器地址 PTE0 0 null PTE1 1 VP1 … … … PTE7 1 VP7 (2) 磁盘上创建空间时,使用的是页表中的虚拟地址,也就是PTEx。所以虚拟页可以分为三种,有一种是未分配的
(3) 物理器存储器地址存的是映射的内存块的首地址。VPx可能是在物理存储器(DRAM)上,也可能在虚拟存储器(磁盘)上
(4) 页表的本质是一个数组,而且这个数组往往非常大,例如32位虚拟地址,页大小为4K,数组内将有
2^32 / 2^12 = 2^20
个元素
-
三种情况
(1) 页命中
当CPU读取虚拟存储器的一个字时,地址翻译硬件将地址作为索引,找到页表中的元素,发现有效位为1(说明已分配),并且指向的物理存储器地址在内存中,则命中
(2) 缺页
有效位为1(说明已分配),但是指向的物理存储器地址不在内存中,此时触发缺页异常;
内核中的缺页异常处理程序会选择内存中的一个牺牲页,把它拷贝回磁盘;然后把那个需要的页从磁盘拷贝到内存;
最后,更新页表
注:只有命中不发生时,才进行页面调度,称为按需页面调度
(3) 分配页面
在磁盘上创建空间时(例如malloc),内核会在磁盘上分配页,然后更新页表
-
局部性
虽然页表很大,换页调度很耗时,但是绝大部分用到的页都是在一个较小的活动页面集合中,所以一般虚拟存储器系统都能工作得很好。
经常发生换页叫做颠簸,应该可以用 getruusage 检测缺页的数量
-
-
虚拟存储器作为存储器管理的工具
-
事实上操作系统为每一个进程提供了一个独立的页表,所以os中页表其实有好几个
作为存储器管理的工具,好处主要有4个
-
简化链接
独立的地址空间允许每个进程为它的存储器映像使用相同的基本格式
文本区总是从 0x08048000 处开始向上增长 栈总是从 0xbfffffff 向下扩展 共享库代码总是从 0x40000000 处向上增长 操作系统代码和数据总是从 0xc0000000 向上增长
—> 简化了链接器的设计和实现
-
简化共享
进程私有的代码和数据,操作系统为每个进程创建页表,映射到不同的物理页面;
共享的代码和数据(例如系统函数),操作系统将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的一个拷贝
-
简化存储器分配
只需要在虚拟存储器层面连续分配页面,实际映射到的物理存储器页面可以不连续
-
简化加载
Linux加载程序分配一个从地址 0x08048000 处开始连续的虚拟页面区域,将它们标识为分配但未缓存的,需要用到的时候再调入存储器
-
-
虚拟存储器作为存储器保护的工具
-
页表中添加许可位
示例
进程 i 的页表
虚拟地址 SUP READ WRITE 物理地址 VP0 0 1 0 PP6 VP1 0 1 1 PP4 VP2 1 1 1 PP2 进程 j 的页表
虚拟地址 SUP READ WRITE 物理地址 VP0 0 1 0 PP9 VP1 1 1 1 PP6 VP2 0 1 1 PP11 其中 SUP 代表是否必须内核模式可访问,READ代表是否可读,WRITE代表是否可写
其中进程i的VP0和进程j的VP1映射到的物理地址是相同的,代表这个页它们是共享代码或数据
-
-
存储器映射
-
定义
将虚拟存储器区域与磁盘上的一个对象关联起来
-
磁盘上的对象包括2类
(1) Unix普通文件
(2) 匿名文件
由内核创建,包含的都是二进制0
-
共享对象
(1) 每个进程都有私有的虚拟地址空间,可以免受其他进程的错误读写
(2) 磁盘上的对象可以被映射到虚拟存储器的一个区域,要么是共享对象,要么是私有对象
(3) 如果是共享对象,那么物理存储器中只放共享对象的一份拷贝,这显而易见
(4) 如果是私有对象,则采取写时拷贝技术(copy-on-write)
(5) 写时拷贝技术:
1° 一开始,一个私有对象和共享对象一样,物理存储器中只有它的一份拷贝。这个对象可能占据了几个页,所有页的页表许可位都初始标记为只读,并且整个区域标记为私有的写时拷贝
2° 当某个进程想要对私有对象修改时(写操作),此时由于初始标记为只读,因此会触发异常;异常处理程序会将修改的部分涉及的页,在物理存储器中创建这些页的新拷贝,然后恢复这些页面的可写权限
写时拷贝技术保证了只有在不得已的时刻才对私有对象进行拷贝,延迟到最后时刻,节省了物理存储器这个稀缺资源。
—> fork函数就用到了写时拷贝,一开始子进程和父进程的私有对象是放在一起的,需要写的时候再拷贝涉及到的页
-
用户级存储器映射
(1) mmap 函数
void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset); // 成功则返回映射区起始地址, 失败则返回MAP_FAILED(-1).
要求内核创建一个新的虚拟存储器区域,并将文件描述符fd指定的对象的一个连续的组块映射到这个新的区域
进程之间通过这个方法映射同一个普通文件实现共享内存
(2) munmap 函数
int munmap(void* start, size_t length);
删除从虚拟地址start开始,长为length字节组成的区域
-
-
动态存储器分配
-
堆
用户栈 | | V ... 共享库的存储器映射区域 ... ^ | | 堆顶 <--- brk 指针 ^ | | 堆 未初始化的数据 .bss 已初始化的数据 .data 程序文本 .text
对于每个进程,内核维持一个brk指针指向堆
顶 -
(1) 显式分配器
要求应用显式释放任何已分配的块
C: malloc / free C++: new / delete
(2) 隐式分配器 --> 又叫垃圾收集器
分配器检测何时一个块不再被程序使用,然后释放这个块
例如 Java
-
malloc
#include <stdlib.h> void* malloc(size_t size); // 返回一个指针,指向大小为至少size的存储器块,至少的意思是可能因为对齐而扩大
-
free
#include <stdlib.h> void free(void* ptr); // 释放已分配的块,ptr必须是已分配块的起始位置
free以后,ptr指针指向的还是原有位置,所以手动置为null比较好
10.9.3——10.9.14没看
-
-
垃圾收集
-
一种经典的方法是 Mark&Sweep 标记——清除
Java中可以通过可达性分析,准确进行GC,因为Java中指针的创建和使用有严格的控制,但是C是不行的,因此只能叫保守的垃圾收集器
-
C中无法精确控制GC的原因
C语言不会用类型信息来标记存储器位置,所以int这样的标量可以伪装成指针
例如一个int值恰好等于某个不可达块的地址,那么垃圾收集器无法分清传入的是个标量值还是一个真正的指针
-
-
C程序中常见的与存储器有关的错误
-
间接引用坏指针
错误示例
scanf("%d", val); // 应该传入变量val的地址而非值,但是C语言却无法判断到底是指针还是标量
-
读未初始化的存储器
.bss 存储器(例如未初始化的全局C变量)总是被初始化为0;
但是堆存储器不是被初始化为0。
-
允许栈缓冲区溢出
不检查输入串的大小,就写入栈中的目标缓冲区,就可能发生缓冲区溢出错误
-
假设指针和它们指向的对象是相同大小的
sizeof(int) 未必等于 sizeof(int*)
-
错位错误
int** A = (int**)malloc(n*sizeof(int)); // 这里分配的是n for (int i = 0; i <=n; i++) // 这里用的是n+1 A[i] = (int*)malloc(m * sizeof(int));
-
引用指针,而不是它所指向的对象
*size-- 代表的是 *(size--),不是(*size)--
-
误解指针运算
指针的算术操作以它们指向的对象的大小为单位进行
int* p = (int*)malloc(sizeof(int) * 4); p++; // 代表每次移动 sizeof(int)这么多地址,不需要 p += sizeof(int);
-
引用不存在的变量
例如返回一个栈空间的变量的地址
int* stackRef() { int val; return &val; }
-
引用空闲堆块中的数据
一个空间已经被释放,但是指针没有置为null
int* x = (int*)malloc(n * sizeof(int)); free(x); (*x) = 6;
-
引起存储器泄漏
只 malloc/new,不 free/delete
-
-
应用程序可以使用mmap函数来手工创建和删除虚拟地址空间的区域;
大多数程序依赖动态存储器分配器malloc/free