chap9 虚拟内存 virtual memory
CSAPP 读书笔记系列chap9 虚拟内存
这一次说的是虚拟内存VM,是计算机系统提供的一个对内存的抽象,目的是为了更有效地管理内存和少出错,也是一个计算机上很成功的抽象.
9.1 物理和虚拟寻址
对于计算机(以及其他智能设备)来说,虚拟地址通过 MMU(Memory management unit)把虚拟地址(Virtual Address, VA)转换为物理地址(Physical Address, PA),再由此进行实际的数据传输。
可以使用vmstat 命令来查看Linux中虚拟内存的情况,见http://www.cnblogs.com/qingmingsang/articles/6832157.html
地址空间
地址空间也就是一个非负整数地址的有序集合,例如{0, 1, 2, 3 … }
计算机中的虚拟地址为:{0, 1, 2, 3, …, N-1}
- 这里 N = 2^n
而物理空间地址(内存)为{0, 1, 2, 3, …, M-1}
- 这里 M = 2 ^ m-1
注意 M < N ,所以内存为VM的缓存
VM 的具体能力为:
使用 DRAM内存(主存) 当做一个存储在磁盘上的地址空间的缓存,在内存中只保存活动的区域,根据需要再在内存和磁盘中传输数据,可以更有效率的使用内存.
为每个进程提供统一的线性地址空间,简化内存管理
保护进程之间地址空间不会相互影响,保护内存管理;例如用户程序不能访问内核信息和代码
下面具体再说
9.2 虚拟内存作为缓存工具
概念上来说,虚拟内存就是存储在磁盘上的 N 个连续字节的数组。磁盘上的数据被分割成块block,一些block,会缓存在 DRAM 中,在 DRAM 中的每个缓存块(cache block)就称为页(page)
- 虚拟页VP(virtual page): VM分配
- 物理页PP(Physical page): 内存分配
其中 VP 和 PP 的大小是一样的,都为P字节 P=2^p,
因为其HIT MISS 的惩罚很高,页一般都很大;通常是 4KB,有的时候可以达到 4MB
另外,DRAM是全相联(Fully associative)的:每一个虚拟页(virual page)可以放在任意的物理页(physical page)中,没有限制。
同时,其替换算法要求也是很高的;且通常使用写回 Write-back 而非 直写Write-through 机制
- Write-through: 命中后更新缓存,同时写入到内存中
- Write-back: 直到这个缓存需要被置换出去,才写入到内存中(需要额外的 dirty bit 来表示缓存中的数据是否和内存中相同,因为可能在其他的时候内存中对应地址的数据已经更新,那么重复写入就会导致原有数据丢失)
如下图所示:
图中VP中的地址有三个状态:
未分配的:VM还没分配的页,不与任何的数据关联.不占磁盘空间
未缓存的: 未缓存的在物理内存的已分配的页,占磁盘空间
缓存的:缓存的在物理内存的已分配的页,占磁盘空间
而VM和内存之间的映射是通过页表 实现的
页表(page table)
每个页表实际上是一个数组,数组中的每个元素称为页表项(PTE, page table entry),每个页表项负责把虚拟页映射到物理页上。
在 内存DRAM 中,每个进程都有自己的页表
例如下面的一个图:
页表VM和PM映射
所以这里也会像cache那样发生命中和不命中
命中(Page Hit)
- 访问到页表中蓝色条目的地址时,因为在 DRAM 中有对应的数据,可以直接访问。
- 由于局部性原理,命中的几率会很高,虚拟内存其实是非常高效的机制.
不命中(Page Fault),也就是常说的缺页
访问到 灰色条目的时候,因为在 DRAM 中并没有对应的数据,所以需要执行一系列操作(从磁盘复制到 DRAM 中),具体为:
- 触发 Page fault,也就是一个异常
- Page fault handler 异常处理器 会选择 DRAM 中需要被置换的 page,并把数据从磁盘复制到 DRAM 中
- 重新执行访问指令,这时候就会是 page hit
然后程序一般是在一个较小的活动页面集合(工作集)上工作的,可以很好地使用局部性.
9.3 虚拟内存作为内存管理的工具
因为在 内存DRAM 中,每个进程都有自己的页表,也就是一个独立的虚拟地址空间
例如下图中
多个虚拟页面可以映射到同一个共享的物理页面上,PP6是一个共享的物理页面
此外,因为有了统一的抽象,不需要纠结细节,所以VM 还可以提高以下功能:
- 简化链接: 每个进程的内存映象都是相同的基本格式,不管实际的代码和数据存放的物理位置
- 简化加载
- 简化共享: 也是进程间通信的一个方式,如上图
- 简化内存分配: 由于页表的形式,内存分配只是需要在VM上进行,而不管PM的实际分布
9.4 虚拟内存作为内存保护的工具
也就是通过设计页表中的某几个许可位来表示内存读写权限,对应与Linux中的r w x权限
页表中的每个条目的高位部分是表示权限的位,MMU 可以通过检查这些位来进行权限控制(读、写、执行)
这里也有说到 段错误的定义:
- segmentation fault: 一个违反了访问权限的指令触发的异常报告
9.5 地址翻译
地址翻译是 一个N元素的虚拟地址空间(Virtual Address Station,VAS)的元素和一个M元素的物理地址空间(Physical Address Station,PAS)的映射
也就是 MAP : VAS -> PAS U ∅
若为空集,则表明数据不在物理内存中;
使用页表的地址翻译如下图:
这一节是比较绕的一节,建议看书
实际的操作为:
- MMU 利用通过虚拟地址中的VPN(virtual page number)找到页表(page table)中对应的条目
- 检查有效位(valid bit),是否需要触发页错误(page fault)
- 然后根据页表中的物理页编号(physical page number)找到内存中的对应地址
- 最后把虚拟页偏移(virtual page offset,VPO和PPO 一样)和前面的实际地址拼起来,就是最终的物理地址了
其中第二步的结果分为页面命中 和 缺页,分别又有不同的流程
但其实为了访问速度更快,这里还会有一个TLB(Translation Lookaside Buffer)
TLB翻译后备缓冲器,缓存的是一个地址翻译(PPN),速度是L0级别,第二步的结果为:
- 页面命中(大多数)
- TLB 命中
CPU 首先把虚拟地址发送给 MMU,MMU 检查缓存,并把从TLB中得到对应的物理地址PTE,接着 MMU 把物理地址发送给缓存/内存,最后从缓存/内存中得到数据。
-
TLB 不命中
TLB 不命中时,MMU从L1缓存读取PTE,如下图:
缺页
第一次触发页错误会把页面载入内存/缓存,然后再以 Page Hit 的机制得到数据:
同时,虚拟内存也会使用到多级页表 Multi-Level Page Table
虽然页表是一个表,但因为往往虚拟地址的位数比物理内存的位数要大得多,所以保存页表项(PTE) 所需要的空间也是一个问题。
实际上,带多级页表的翻译不会比单级页表的慢多少
一个具体的地址翻译的例子请看书上的9,6.4,或看http://wdxtub.com/2016/04/16/thin-csapp-7/
9.6 Linux 虚拟内存系统
Linux 为每个进程维护了一个单独的虚拟地址文件,如下图:
Linux 把虚拟内存VM组织成一些区域(也叫作段)的集合.
虚拟内核内存中的mm_struct,其描述了虚拟内存的当前状态.
其功能如下:
9.7 内存映射
内存映射:Linux 通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个
虚拟内存区域的内容为以下两种之一:
- Linux文件系统中的普通文件,需要时再页面调度进入PM内存中
- 匿名文件:由内核创建的全是二进制零的文件,映射到匿名文件的也叫请求二进制零的页
一旦一个虚拟页面被初始化了,他就在交换空间之间传来传去.
9.7.1 再看共享对象(共享内存)
例如下图:
一个对象映射在虚拟内存的一个区域,要么作为共享对象,要么作为私有对象;
私有对象会使用一种叫做写时复制的机制,
其解析了为什么两个编译器同时打开同一个文件时,会出现一个副本的情况
同时,在fork函数中,父子进程的过程也是大体相似
9.7.2 再看execve 函数
对于execve(“a.out”,NULL.NULL)函数,其加载和运行步骤为:
删除已存在的用户区域
映射私有区域
映射共享区域
设置程序计数器PC
如下图:
9.7.3 用户级内存映射
Linux 可以 使用mmap 来创建新的内存区域,并把对象映射到这些区域中;
使用munmap函数来删除虚拟内存中的区域.
1 | #include <sys/mman.h> |
9.9 动态内存
使用动态内存的一个重要原因是:直到程序运行时候才知道某个数据结构的大小,例如哈希函数的数组分配.
9.9.1 动态内存简介
程序员通过动态内存分配(例如 malloc)来让程序在运行时得到虚拟内存。动态内存分配器会管理一个虚拟内存区域,称为堆(heap)。
在C语音中,malloc 和free 函数搭配使用;
1 | #include <stdlib.h> |
C++中,操作符new 和 delete搭配使用;
new T[] 和 delete[] T 搭配使用,也可以查看我之前的笔记
C++中的delete和delete[]的区别
另外,new 除了继续分配内存malloc以外,也会执行构造函数
9.9.2 动态内存分配器
回到课本,动态内存分配器以块为单位来维护堆,可以进行分配或释放。有两种类型的分配器:
- 显式分配器explicit allocator:应用分配并且回收空间(C 语言中的 malloc 和 free),书上讲的是显示分配器
- 隐式分配器implicit allocator:应用只负责分配,但是不负责回收(Java 中的垃圾收集)
分配器一般有以下限制:
- 不能控制已分配块的数量和大小,处理的程序任意请求
- 必须立即响应 malloc 请求(不能缓存或者给请求重新排序)
- 只使用堆,必须在未分配的内存中分配
- 不同的块需要对齐(32 位中 8 byte,64 位中 16 byte)
- 只能操作和修改未分配的内存
- 不能修改或移动已分配的块
而分配器也有一些衡量其性能的指标:
假设给定一个 malloc 和 free 的请求的序列:
1 |
|
void foo() {
int p = malloc(128);
return; / p block is now garbage*/
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42这种机制在许多动态语言中都有实现:Python, Ruby, Java, Perl, ML, Lisp, Mathematica。C 和 C++ 中也有类似的变种,但是需要注意的是,是不可能回收所有的垃圾的。
垃圾收集通过垃圾收集器来实现
垃圾收集器将内存视为一个有向可达图,例如下列图,

C语音可以把垃圾收集器加入malloc包中

如何知道什么东西才是『垃圾』呢?只要没有任何指针指向的地方,也就是内存不可达,不管有没有用,因为都不可能被使用,当然可以直接清理掉啦。不过这其实是需要一些前提条件的:
- 需要知道哪里是指针,哪里不是指针
- 每个指针都指向 block 的开头
- 指针不能被隐藏(by coercing them to an int, and then back again)
垃圾收集器使用的一些算法:
- Mark-and-sweep collection (McCarthy, 1960)
- 书上有提这个,LISP创始人发明
- Reference counting (Collins, 1960)
- 引用计数,C++智能指针有用到
- Copying collection (Minsky, 1963)
- Generational Collectors(Lieberman and Hewitt, 1983)
### 9.11 内存陷阱-内存的使用的一些问题
- 解引用错误指针
- 读取未初始化的内存
- 覆盖内存
- 引用不存在的变量
- 多次释放同一个块
- 引用已释放的块
- 释放块失败
#### 具体例子
- 解引用错误指针 Dereferencing Bad Pointers
这是非常常见的例子,scanf没有引用对应的地址,少了 &
int val;
…
scanf(“%d”, val); // 注意%d%d 之间也不能有逗号
Reading Uninitialized Memory1
2
3
- 读取未初始化的内存
不能假设堆中的数据会自动初始化为 0,下面的代码就会出现奇怪的问题
/ return y = Ax /
int matvec(int **A, int x) {
int y = malloc(N sizeof(int));
int i, j;
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
y[i] += A[i][j] * x[j];
return y;
}1
2
3
- 覆盖内存 Overwriting Memory
- 第一种是分配了错误的大小,下面的例子中,一开始不能用 sizeof(int),因为指针的长度不一定和 int 一样。
int *p;
p = malloc(N sizeof(int));
for (i = 0; i < N; i++)
p[i] = malloc(M * sizeof(int));
1
2
- 第二个问题是超出了分配的空间,下面代码的 for 循环中,因为使用了 <=,会写入到其他位置
int *p;
p = malloc(N sizeof (int ));
for (i = 0; i <= N; i++)
p[i] = malloc(M sizeof(int));1
- 第三种是因为没有检查字符串的长度,超出部分就写到其他地方去了(经典的缓冲区溢出攻击也是利用相同的机制)
char s[8];
int i;
gets(s); / reads “123456789” from stdin /1
- 第四种是没有正确理解指针的大小以及对应的操作,应该使用 sizeof(int *)
int search(int p, int val) {
while (p && p != null)
p += sizeof(int);
return p;
}1
2- 第五种是引用了指针,而不是其指向的对象
下面的例子中,*size-- 一句因为 后置-- 和 *的优先级一样但是使用的是右结合律,所以实际上是对指针进行了操作,正确的应该是加括号 (*size)--
int BinheapDelete(int **binheap, int size) {
int packet;
packet = binheap[0];
binheap[0] = binheap[size - 1];
size–;
Heapify(binheap, size, 0);
return (packet);
}1
2
3- 引用不存在的变量Referencing Nonexistent Variables
没有注意到局部变量会在函数返回的时候失效(所以对应的指针也会无效),这是传引用和返回引用需要注意的
int *foo() {
int val;
return &val;
}1
2
3- 多次释放同一个块Freeing Blocks Multiple Times
不能重复free一个指针两次,未定义
x = malloc(N sizeof(int));
//
free(x);
y = malloc(M
//
free(x);1
2
- 引用已释放的块 Referencing Freed Blocks
x = malloc(N sizeof(int));
//
free(x);
// ….
y = malloc(M
for (i = 0; i < M; i++)
y[i] = x[i]++;1
- 释放块失败 Memory Leaks
foo() {
int x = malloc(N sizeof(int));
// …
return ;
}
// 或者只释放了数据结构的一部分:
struct list {
int val;
struct list next;
};
foo() {
struct list head = malloc(sizeof(struct list));
head->val = 0;
head->next = NULL;
//…
free(head);
return;
}`
看完做lab去