CSAPP Note chap9

CSAPP 读书笔记系列chap9

作者 Ferris Chan 日期 2017-12-24
CSAPP Note chap9

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

使用VM的OS.png

地址空间

地址空间也就是一个非负整数地址的有序集合,例如{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 来表示缓存中的数据是否和内存中相同,因为可能在其他的时候内存中对应地址的数据已经更新,那么重复写入就会导致原有数据丢失)

如下图所示:
虚拟页和物理页.png

图中VP中的地址有三个状态:

  • 未分配的:VM还没分配的页,不与任何的数据关联.不占磁盘空间

  • 未缓存的: 未缓存的在物理内存的已分配的页,占磁盘空间

  • 缓存的:缓存的在物理内存的已分配的页,占磁盘空间

而VM和内存之间的映射是通过页表 实现的

页表(page table)

每个页表实际上是一个数组,数组中的每个元素称为页表项(PTE, page table entry),每个页表项负责把虚拟页映射到物理页上。

在 内存DRAM 中,每个进程都有自己的页表

例如下面的一个图:
页表VM和PM映射
页表VM和PM映射.png

所以这里也会像cache那样发生命中和不命中

  • 命中(Page Hit)

    • 访问到页表中蓝色条目的地址时,因为在 DRAM 中有对应的数据,可以直接访问。
    • 由于局部性原理,命中的几率会很高,虚拟内存其实是非常高效的机制.
  • 不命中(Page Fault),也就是常说的缺页

    • 访问到 灰色条目的时候,因为在 DRAM 中并没有对应的数据,所以需要执行一系列操作(从磁盘复制到 DRAM 中),具体为:

      • 触发 Page fault,也就是一个异常
      • Page fault handler 异常处理器 会选择 DRAM 中需要被置换的 page,并把数据从磁盘复制到 DRAM 中
      • 重新执行访问指令,这时候就会是 page hit

然后程序一般是在一个较小的活动页面集合(工作集)上工作的,可以很好地使用局部性.

9.3 虚拟内存作为内存管理的工具

因为在 内存DRAM 中,每个进程都有自己的页表,也就是一个独立的虚拟地址空间

例如下图中

独立的地址空间.png

多个虚拟页面可以映射到同一个共享的物理页面上,PP6是一个共享的物理页面

此外,因为有了统一的抽象,不需要纠结细节,所以VM 还可以提高以下功能:

  • 简化链接: 每个进程的内存映象都是相同的基本格式,不管实际的代码和数据存放的物理位置
  • 简化加载
  • 简化共享: 也是进程间通信的一个方式,如上图
  • 简化内存分配: 由于页表的形式,内存分配只是需要在VM上进行,而不管PM的实际分布

9.4 虚拟内存作为内存保护的工具

也就是通过设计页表中的某几个许可位来表示内存读写权限,对应与Linux中的r w x权限

页表中的每个条目的高位部分是表示权限的位,MMU 可以通过检查这些位来进行权限控制(读、写、执行)

内存权限.png

这里也有说到 段错误的定义:

  • 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 不命中

    TLB 不命中时,MMU从L1缓存读取PTE,如下图:

     TLB 不命中

  • 缺页
     第一次触发页错误会把页面载入内存/缓存,然后再以 Page Hit 的机制得到数据:

缺页

同时,虚拟内存也会使用到多级页表 Multi-Level Page Table

虽然页表是一个表,但因为往往虚拟地址的位数比物理内存的位数要大得多,所以保存页表项(PTE) 所需要的空间也是一个问题。

实际上,带多级页表的翻译不会比单级页表的慢多少

多级页表.png

一个具体的地址翻译的例子请看书上的9,6.4,或看http://wdxtub.com/2016/04/16/thin-csapp-7/

9.6 Linux 虚拟内存系统

Linux 为每个进程维护了一个单独的虚拟地址文件,如下图:

一个单独的虚拟地址文件

Linux 把虚拟内存VM组织成一些区域(也叫作段)的集合.

虚拟内核内存中的mm_struct,其描述了虚拟内存的当前状态.

其功能如下:

Linux组织虚拟内存.png

9.7 内存映射

内存映射:Linux 通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个

虚拟内存区域的内容为以下两种之一:

  • Linux文件系统中的普通文件,需要时再页面调度进入PM内存中

- 匿名文件:由内核创建的全是二进制零的文件,映射到匿名文件的也叫请求二进制零的页

一旦一个虚拟页面被初始化了,他就在交换空间之间传来传去.

9.7.1 再看共享对象(共享内存)

例如下图:

共享对象

一个对象映射在虚拟内存的一个区域,要么作为共享对象,要么作为私有对象;

私有对象会使用一种叫做写时复制的机制,
其解析了为什么两个编译器同时打开同一个文件时,会出现一个副本的情况

同时,在fork函数中,父子进程的过程也是大体相似

9.7.2 再看execve 函数

对于execve(“a.out”,NULL.NULL)函数,其加载和运行步骤为:

  • 删除已存在的用户区域

  • 映射私有区域

  • 映射共享区域

  • 设置程序计数器PC

如下图:

执行execve函数.png

9.7.3 用户级内存映射

Linux 可以 使用mmap 来创建新的内存区域,并把对象映射到这些区域中;
使用munmap函数来删除虚拟内存中的区域.

1
2
3
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
int munmap(void *addr, size_t length);

9.9 动态内存  

使用动态内存的一个重要原因是:直到程序运行时候才知道某个数据结构的大小,例如哈希函数的数组分配.

9.9.1 动态内存简介

程序员通过动态内存分配(例如 malloc)来让程序在运行时得到虚拟内存。动态内存分配器会管理一个虚拟内存区域,称为堆(heap)。

在C语音中,malloc 和free 函数搭配使用;

1
2
3
4
5
#include <stdlib.h>
void *malloc(size_t size);
void free(void *ptr);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);

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
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

目标是尽可能提高吞吐量以及内存利用率(注意,这两个目标常常是冲突的)

- 吞吐量是在单位时间内完成的请求数量。
- 假设在 10 秒中之内进行了 5000 次 malloc 和 5000 次 free 调用,那么吞吐量是 1000 operations/second

- 最大的内存利用率 Peak Memory Utilization。


影响内存利用率的主要因素就是『内存碎片』,分为内部碎片和外部碎片两种。

- 内部碎片
- 内部碎片指的是对于给定的块,如果需要存储的数据(payload)小于块大小,就会因为对齐和维护堆所需的数据结构的缘故而出现无法利用的空间
- 内部碎片只依赖于上一个请求的具体模式,所以比较容易测量。

- 外部碎片
- 指的是内存中没有足够的连续空间,内存中有足够的空间,但是空间不连续,所以成为了碎片

#### 9.9.3 动态内存分配器的实现

一个实际的动态内存分配器的实现需要注意的问题有很多,例如:

- 空闲块的组织:如何记录未分配的块?

- 放置:如何选择一个合适的空闲块,如果有多个区域满足条件,如何选择?

一些算法如下:
- First Fit: 每次都从头进行搜索,找到第一个合适的块,线性查找
- Next Fit: 每次从上次搜索结束的位置继续搜索,速度较快,但可能会有更多碎片
- Best Fit: 每次遍历列表,找到最合适的块,碎片较少,但是速度最慢

- 分割:实际需要的空间比未分配的空间要小的时候,剩下的空间怎么办?

- 合并:释放空间之后如何进行记录和处理?

这里涉及的算法和数据结构比较复杂.

动态内存分配器的实现这部分书中提到了几种方法:

1.
隐式空闲列表 Implicit List
- 书上的主要实现,如下图
![隐式空闲链表实现堆.png](http://img.blog.csdn.net/20171222154522070?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvZmVycmlzX2NoYW4=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
- 具体可以等到lab再去做这个事情

2.
显式空闲列表 Explicit List
- 使用的是双向链表

3.
分离的空闲列表 Segregated Free List
- 维持多个空闲链表
- 又分为两种
- 简单分离存储:按照大小对块进行排序 Blocks Sorted by Size
- 分离适配:分配器维持一个空闲链表的数组,是C标准库采用的方法
- 又有一个特例为伙伴系统

这些具体去看书好一点.

### 9.10 垃圾收集

所谓垃圾回收,就是不再需要显式释放所申请内存空间了,例如:

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++ 中也有类似的变种,但是需要注意的是,是不可能回收所有的垃圾的。

垃圾收集通过垃圾收集器来实现

垃圾收集器将内存视为一个有向可达图,例如下列图,

![垃圾收集器有向可达图.png](http://img.blog.csdn.net/20171222160729205?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvZmVycmlzX2NoYW4=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)

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

![C语音垃圾收集器.png](http://img.blog.csdn.net/20171222160900912?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvZmVycmlzX2NoYW4=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)


如何知道什么东西才是『垃圾』呢?只要没有任何指针指向的地方,也就是内存不可达,不管有没有用,因为都不可能被使用,当然可以直接清理掉啦。不过这其实是需要一些前提条件的:

- 需要知道哪里是指针,哪里不是指针
- 每个指针都指向 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 Memory

1
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
sizeof(int));
//
free(x);

1
2

- 引用已释放的块 Referencing Freed Blocks

x = malloc(N sizeof(int));
//
free(x);
// ….
y = malloc(M
sizeof(int));
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去