2017-2018-1 20155227 《信息安全系统设计基础》第十三周学习总结
找出全书你认为最重要的一章,深入重新学习一下,要求(期末占10分):
完成这一章所有习题
详细总结本章要点
给你的结对学习搭档讲解你的总结并获取反馈
我选择教材第九章的内容来深入学习,一方面,虚拟内存这一部分内容本身就十分重要,另一方面,我在操作系统课上对这一部分的知识理解的很浅,在十一周自学教材这一部分内容时也因为知识点太多而没有学得很深入,正好借此机会来重新学习这一章的内容,加深理解。
教材学习内容总结
第九章 虚拟存储器
为了更加有效地管理存储器且少出错,现代系统提供了对主存的抽象概念,叫做虚拟存储器(VM)
。
虚拟存储器
是硬件异常,硬件地址翻译,主存,磁盘文件和内核软件的完美交互。
为每个进程提供一个大的,一致的和 私有的地址空间。
- 提供了
3个重要能力
。
-
将主存看成磁盘地址空间的高速缓存
。
- 只保留了活动区域,并根据需要在磁盘和主存间来回传送数据,高效使用主存。
- 为每个进程提供一致的地址空间
- 保护了每个进程的地址空间不被其他进程破坏。
-
虚拟内存在幕后工作,程序员为什么要理解它呢?
遍布在计算机系统所有层次,硬件异常,汇编器,连接器,加载器,共享对象,文件和进程中扮演重要角色。
9.1 物理与虚拟寻址
-
物理地址(Physical Address,PA)
:计算机系统的主存被组织为M个连续的字节大小的单元组成的数组。每个字节的地址叫物理地址
.
- CPU访问存储器的最自然的方式使用物理地址,这种方式称为
物理寻址
。
早期的PC,数字信号处理器,嵌入式微控制器以及Cray超级计算机使用物理寻址
。
- 现代处理器使用的是
虚拟寻址(virtual addressing)
的寻址形式。
- CPU通过生成一个
虚拟地址(Virtual address,VA)
来访问主存。
- 将
虚拟地址
转换为物理地址
叫做地址翻译(address translation)
。
-
地址翻译
也需要CPU硬件和操作系统之间的紧密结合。
9.2 地址空间
地址空间(address space)
是一个非负整数地址
的有序集合。
地址空间
的概念很重要,因为它区分了数据对象(字节)
和 它们的属性(地址)
。
9.3 虚拟存储器作为缓存的工具
虚拟存储器(VM)
被组织为一个存放在磁盘上的N个连续字节大小
的单元组成的数组
。
9.3.1 DRAM缓存的组织结构
DRAM表示虚拟存储器系统的缓存,在主存中缓存虚拟页
,有两个特点。
-
DRAM缓存
不命中处罚十分严重。
- 访问一字节开销
- 从一个磁盘的一个扇区读取
第一个字节的时间
开销要比从该扇区中读连续的字节
慢大约100000倍
DRAM缓存的组织结构由这种巨大的不命中开销
驱动。因此有以下特点。
-
虚拟页
往往很大。
-
DRAM
缓存是全相联
- 任何虚拟页都能放在任何物理页中。
- 原因在于大的不命中惩罚
- 更
精密
的替换算法
-
DRAM
缓存总是写回
9.3.2 页表
判断命中和替换
由多种软硬件联合
提供。
- 操作系统软件,MMU中的地址翻译
硬件和页表(page table)
。
- 页表是存放在
物理存储器
的数据结构。
- 页表将
虚拟页
映射到物理页
。
-
地址翻译硬件
将虚拟地址转换为物理地址都会读取页表
。
- 操作系统负责
维护页表的内容
,以及磁盘及DRAM之间来回传送页
。
-
页表
就是一个页表条目(Page Table Entry,PTE)
的数组.
9.3.3 页命中
- 一个页命中的过程。
- 一个虚拟地址转换为物理地址的过程。
9.3.4 缺页
DRAM缓存不命中称为缺页
。
处理过程如下:
- 读取虚拟地址所指向的
PT
。
- 读取
PTE有效位
,发现未被缓存,触发缺页异常
。
- 调用
缺页异常处理程序
- 选择
牺牲页
。
- 如果牺牲页发生了改变,将其拷贝回磁盘(因为是
写回
)
- 需要读取的页
代替
了牺牲页的位置。
- 结果:牺牲也不被缓存,需要读取的页被缓存。
中断结束,重新执行最开始的指令。
在DRAM
中读取成功。
块被称为页。
磁盘和DRAM之间传送页的活动叫做交换(swapping)或者页面调度(paging)。
有不命中发生时,才换入页面,这种策略叫做按需页面调度(demand paging)。
9.3.5 分配页面
比如某个页面所指向地址为NULL
,将这个地址指向磁盘某处
,那么这就叫分配页面
。
此时虚拟页从未分配状态
变为 未缓存
。
9.4 虚拟存储器作为存储器的管理工具
操作系统为每个进程提供一个独立的页表
。
因此,VM
简化了链接和加载
,代码和数据共享
,以及应用程序的存储器分配
。
- 简化链接
- 独立的空间地址意味着每个进程的存储器映像使用
相同的格式
。
- 文本节总是从
0x08048000(32位)
处或0x400000(64位)
处开始。
- 然后是
数据,bss节,栈
。
- 一致性极大简化了链接器的
设计和实现
。
- 简化加载
- 加载器可以
从不实际拷贝任何数据从磁盘到存储器
。
- 基本都是
虚拟存储系统
完成。
简化共享
简化存储器分配
即虚拟页连续
(虚拟页还是单独的),物理页可以不连续
。使得分配更加容易。
9.5 虚拟存储器作为存储器保护的工具
任何现代操作系统必须为操作系统提供手段来控制对存储器系统的访问。
- 不应该允许用户进程修改它的只读文本段。
- 不允许它读或修改任何内核的代码和数据结构
- 不允许读写其他进程的私有存储器。
- 不允许修改共享的虚拟页,除非所有共享者显示允许这么做(通过调用明确的进程间通信)
-
SUP: 是否只有在内核模式下才能访问?
-
READ: 读权限。
-
WRITE: 写权限。
如果指令违反了许可条件,触发一般保护性异常
,然后交给异常处理程序
,Shell
一般会报告为段错误(segmentaion fault)
。
9.6 地址翻译
- 形式上来说,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)元素之间的映射,
- 以下展示了MMU(Memory Management Unit,存储器管理单元)如何利用页表实现这样的功能
- 页表
基址寄存器(Page Table Base Register,PTBR)
指向当前页表
。
-
n位的虚拟地址
包含两个部分
- 一个
p
位的虚拟页面偏移(Virtual Page Offset,VPO)
- 一个
n-p
位的虚拟页号(Virtual Page Number,VPN)
-
页面条目 (PTE)
中物理页号(PPN)
和虚拟地址
中的VPO
串联起啦,即是物理地址
-
PPO和VPO是相同的
- 记
VPN,PPN都是块
,都是首地址
而已,所以需要偏移地址PPO,VPO
图(a)展示页面命中,CPU硬件执行过程:
- 第一步:处理器生成
虚拟地址
,把它传送给MMU。
- 第二步: MMU生成
PTE地址(PTEA)
,并从高速缓存/主存请求中得到它。
- 第三步: 高速缓存/主存向MMU
返回PTE
。
- 第四步: MMU构造
物理地址(PA)
,并把它传送给高速缓存/主存。
- 第五步: 高速缓存/主存返回所请求的数据字给
处理器
。
页面命中完全由硬件处理,与之不同的是,处理缺页需要 硬件和操作系统内核协作完成。
- 第一到三步: 与命中时的一样
- 第四步:
PTE有效位是零
,所以MMU触发异常
,传递CPU中的控制到操作系统内核中的 缺页异常处理程序。
- 第五步:
缺页异常处理程序
确定出物理存储页中的牺牲页,如果这个页面已经被修改,则把它换出到磁盘。
- 第六步:
缺页异常处理程序
调入新的页面,并更新存储器中的PTE。
- 第七部:
缺页异常处理程序
返回到原来的进程,再次执行导致缺页的指令,之后就是页面命中一样的步骤。
9.6.1 结合高速缓存和虚拟存储器
在任何使用虚拟存储器又使用SRAM高速缓存的系统中,都存在应该使用虚拟地址 还是 使用 物理地址 来访问SRAM高速缓存的问题。
大多数系统是选择物理寻址。
- 使用物理寻址,多个进程同时在高速缓存中有
存储块
和共享来自相同虚拟页面
的块成为简单的事。
- 而且还无需处理保护问题,因为 访问权限的检查在地址翻译中
(PTE)
的一部分。
- 以下是一个例子(将PTE进行高速缓存)。
9.6.2 利用TLB加速地址翻译
每次CPU产生一个虚拟地址,MMU
就必须查阅一个PTE
,以便将虚拟地址翻译为 物理地址。
- 在最糟糕的情况下,会从内存中取数据,代价是几十 到几百个周期
- 如果
PTE
碰巧缓存在L1中,那么开销就下降到一到两个周期
许多系统都试图消除这样的开销,他们在MMU
中包含了一个关于PTE
的小缓存,称为翻译后备缓冲器(Translation Lookaside Buffer,TLB)
。
-
TLB
是一个小的,虚拟寻址的缓存。
- 每一行都保存着一个由
单个PTE
组成的块。
-
TLB
通常用于高度的相连性
- 如图所示
- 用于
组选择和行匹配
的索引和标记字段是从虚拟地址中的虚拟页号
中提取出来的。
- 如果
TLB
有T=2^t
个组
- 那么
TLB索引
(TLBI)是由VPN
的t个最低位组成
。(对应于VPO)
-
TLB标记
(TLBT)是由VPN
中剩余位
组成(对应于VPN)
- 下图展示了
TLB命中
步骤
- 关键点:所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快
-
TLB
命中
- 第一步:CPU产生
虚拟地址
。
- 第二步和第三部:
MMU
从TLB
取出对应的PTE
。
- 第四步:
MMU
将这个虚拟地址翻译成一个物理地址
,发送到高速缓存/主存
- 第五步:高速缓存/主存所请求的数据字返回给CPU
- 当
TLB
不命中的时候,MMU
必须从L1缓存或内存中取出相应的PTE
,并进行类似缺页处理过程。
9.6.3 多级页表
如果我们有一个32位
地址空间,4KB
大小的页面(p=2^12
)和一个4B的PTE
,即使应用所引用的只是虚拟地址空间中很小的一部分,也总是需要一个4MB
的页表驻留在存储器中。
所以多级页表
的诞生用于解决在很少使用时有一个很大的页表常驻于内存。
用来压缩页表的常用方式是使用层次结构的页表。
以下用上图的两层作为例子。
- 总共有
9KB
个页面,PTE
为4个字节。
- 前
2KB
个页面分配给代码和数据。
- 接下来
6KB
个页面未分配
- 再接下来
1023
个页面也未分配
- 接下一个页面分配给
用户栈
- 一级页表中的每个
PTE
负责映射虚拟地址空间中一个4MB
大小的片(chunk)
.
- 每一个片都是由
1024个连续的页面
组成。
-
4MB=1024个
页面*PTE大小4字节。
- 如果
片i
中每个页面都没有分配,那么一级PTE i
就为空。
- 例如图中的
PTE 2~PTE
7
- 但是如果
片i
中有一个被分配了,那么PTE i
就不能为空。
- 这种方法从两个方面减少了存储器要求。
- 如果一级页表
PTE
为空,那么相应的二级页表就根本不会存在。
- 只有一级页表才需要总是在主存中。
- 虚拟存储器系统可以在需要时创建,页面调入,调出二级页面,减少主存压力。
k级页表层次结构的地址翻译。
- 虚拟地址被分为
k个VPN
和一个VPO
。每个VPN i
都是i-1
级页表到i级页表的索引。
-
PPN
存于k级页表
。
-
PPO
依旧与VPO
相同。
此时TLB
能发挥作用,因为层次更细,更利于缓存。使得多级页表的地址翻译不比单级页表慢很多。
9.6.4 综合:端到端的地址翻译
一个在有一个TLB
和L1 d-cache
的小系统上。作出如下假设:
- 存储器都是按
字节寻址
的。
- 存储器访问是针对
一字节
的字的。
- 虚拟地址是
14位长(n=14)
- 物理地址是
12位长(m=12)
- 页面大小是
64字节(P=2^6)
- TLB是四路组相连的,总共有
16个
条目
-
L1 d-cache
是物理寻址,高速缓存,直接映射(E=1)的,行大小为4字节
,而总共有16个组
。
存储结构快照
:
-
TLB: TLB利用VPN的位进行缓存。
-
页表: 这个页表是一个单级设计。一个有256个,但是这里只列出16个。
-
高速缓存:直接映射的缓存通过
物理地址
的字段来寻址。
- 因为是直接映射,通过索引就能直接找到。且
E=1
。
- 直接能判定是否
命中
。
9.7 案例研究: Intel Core i7/Linux 存储器系统
处理器包(processor package)
- 四个核
- 层次结构的
TLB
- 层次结构的数据和指令高速缓存。
- 物理寻址
- L1,L2 八路组相连
- L3 十六路组相连
-
块
大小64字节
- 快速的点到点链接。
- 基于
Intel QuickPath
技术
- 为了让核与其他核和
外部I/O桥
直接通信
L3高速缓存
DDR3存储器控制器
9.7.1 Core i7地址翻译
上图完整总结了Core i7
地址翻译过程,从虚拟地址到找到数据传入CPU。
-
Core i7
采用四级页表
层次结构。
-
CR3
控制寄存器指向第一级页表(L1)
的起始位置
-
CR3
也是每个进程上下文的一部分。
- 上下文切换的时候,
CR3
也要被重置。
一级,二级,三级页表PTE
的格式:
四级页表的PTE
格式:
- PTE有三个权限位,控制对页的访问
- R/W位确定页的内容是可以 读写还是 只读。
- U/S位确定用户模式是否能够访问,从而保护操作系统内核代码不被用户程序访问。
- XD (禁止执行) 位是在64位系统引入,禁止某些存储器页取指令。
- 这是一个重要的新特性,限制只能执行只读文本段,降低缓冲区溢出的风险。
- 当
MMU
翻译虚拟地址时,还会更新两个内核缺页处理程序会用到的位。
-
A位
- 每次访问一个页,
MMU
都会设置A位,称为引用位(reference bit)
.
- 可以利用这个引用位来实现它的页替换算法。
-
D位
- 每次对一个页进行了写 就会设置D位,又称
脏位(dirty bit)
.
- 脏位告诉内核在拷贝替换页前是否要写回。
- 内核通过调用一条特殊的内核模式指令来清除引用位或脏位。
四级页表如何将VPN
翻译成物理地址
- 每个
VPN
被用作页表的偏移量
。
-
CR3
寄存器包含L1页的物理地址
9.7.2 Linux 虚拟存储系统
内核虚拟存储器
-
内核虚拟存储器
包含内核中的代码
和数据
。
-
内核虚拟存储器
的某些区域被映射到所有进程共享的物理页面
-
Linux
也将一组连续的虚拟页面(大小等同于系统DRAM总量)映射到相应的一组物理页面
。
-
内核虚拟存储器
包含每个进程不相同的数据。
Linux 虚拟存储器区域
Linux将虚拟存储器组织成一些区域
(也叫做段)的集合。
- 一个区域就是已经存在着的(已分配的) 虚拟存储器的
连续片
,这些片/页已某种形式相关联。
-
代码段
,数据段
,堆
,共享库
段,用户栈
。
- 所有存在的
虚拟页
都保存在某个区域
。
一个进程中虚拟存储器
的内核数据结构。
内核
为系统中每个进程维护了一个单独的任务结构
。任务结构
中的元素包含或指向内核运行该进程所需要的全部信息。
-
task_struct
-
mm_struct
- 描述了虚拟存储器的当前状态。
-
pgd
- 指向第一级
页表
的基址。
- 当进程运行时,内核
将pgd
存放在CR3
控制寄存器
-
mmap
-
vm_area_structs
(区域结构)
- 每个
vm_area_structs
都描述了当前虚拟地址空间的一个区域(area
).
-
vm_start
:指向这个区域的起始处。
-
vm_end
:指向这个区域的结束处。
-
vm_port
:描述这个区域内包含的所有页的读写许可权限。
-
vm_flags
:描述这个区域页面是否与其他进程共享,还是私有。
-
vm_next
: 指向链表的下一个区域。
Linux 缺页异常处理
MMU
在试图翻译虚拟地址A时,触发缺页
。这个异常导致控制转移到缺页处理程序,执行一下步骤。
-
虚拟地址A是合法的吗?
- A在某个区域结构定义的区域内吗?
-
解决方法:
-
缺页处理程序
搜索区域结构链表。
- 把A和每个区域的
vm_start
和vm_end
做比较。
如果不合法
,触发段错误
。
-
试图访问的
存储器
是否合法?
- 即是否有
读,写,执行
这个页面的权限
?
- 如果
不合法
,触发保护异常
,终止
进程。
•
-
一切
正常
的话
9.8 存储器映射
存储器映
射: Linux通过将一个虚拟存储器区域与一个磁盘上的对象关联起来,以初始化这个虚拟存储器区域
的内容,这个过程叫做存储器映射
。
虚拟存储器区域可以映射到以下两种类型文件。
- Unix文件系统中的
普通文件
:一个区域可以映射到一个普通磁盘文件
的连续部分。
- 例如,一个
可执行文件
。
-
文件区(section)
被分成页大小的片,每一片包含一个虚拟页面
的初始化内容。
- 仅仅是
初始化
,虚拟页面此时还并未进入物理存储器
。
-
匿名文件
: 一个区域可以映射到一个匿名文件。
- 匿名文件由内核创建,包含的全是二进制零。
-
CPU
第一次引用这样区域(匿名文件)的虚拟页面时。
- 将存储器中牺牲页面全部用
二进制零覆盖
。
- 并将
虚拟页面
标记为驻留在存储器中。
- 注意: 实际上,虚拟页面并没有跟存储器进行数据传送。
又叫请求二进制零的页(demand-zero page)
。
交换文
件,交换空间
。(win下叫做paging file)
- 一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的
交换文件(swap file)
之间换来换去。交换文件
也叫交换空间
或者交换区域
。
- 需要意识到,在任何时刻,
交换空间
都限制着当前运行着的进程分配的虚拟页面
总数。
9.8.1 再看共享对象
共享对象的由来
- 许多进程有同样的
只读文本区域
。
printf
- 运行
Uinx shell的
tcsh```
- 如果每个进程都加载进内存一次,极其浪费。
-
存储器
映射提供一种机制,来共享对象
。
一个对象
被映射到虚拟存储器的一个区域,一定属于以下两种。
-
共有对象
- 一个进程将一个共有对象映射到它的
虚拟地址空间
的一个区域。
- 进程对这个区域的写操作,对于那些也把这个共享对象映射它的
虚拟存储器
的进程是可见的。
- 这些变化也会反映到
磁盘上的原始对
象。
- 映射到的虚拟存储器那个区域叫做
共享区
域。
-
私有对象
- 对一个
映射到私有对象
的区域做出的改变,对于其他进程不可见.
- 并且进行的
写操作
不会反映到磁盘上。
- 映射到的
虚拟存储器
那个区域叫做私有区域
。
9.8.1.1 共享对象
9.8.1.2 私有对象
私有对象使用一种叫做写时拷贝(conpy-on-write
)的巧妙技术。
-
私有对象
开始生命周期的方式基本与共享对象
一样。
- 对于每个映射
私有对象
的进程,相应私有区域的页表条目都被标记为只读
。
- 并且区域结构(
vm_area_structs
)被标记为私有的写时拷贝。
-
过程
:只要有进程试图写私有区域内的某个页面,那么这个写操作触发保护异常。
- 故障处理程序会在物理存储器中创建被修改页面的一个新拷贝。
- 更新页表条目(
PTE
)指向这个新的拷贝,恢复被修改页面的可写权限。
- 故障处理程序返回,
CPU
重新执行这个写操作。
- 通过延迟
私有对象
中的拷贝直到最后可能的时刻,写时拷贝充分使用了稀缺的物理存储器
9.8.2 再看fork函数
了解fork函数如何创建一个带有自己独立虚拟地址空间的新进程。
- 当
fork函数
被当前进程调用时。
- 内核为新进程创建内核数据结构,并分配给它唯一一个
PID
。
- 为了给新进程创建
虚拟存储器
。
- 创建了当前进程
的mm_struct
,区域结构和页表的原样拷贝。
- 将两个进程的每个页面都标记为
只读
。并给两个区域进程的每个区域结构都标记为私有的写时拷贝。
- 注意:并没有对物理存储器进行拷贝哦,利用的是私有对象的写时拷贝技术。
- 当
fork函数
在新进程返回时。
- 新进程现在的虚拟存储器刚好和调用fork时存在的
虚拟存储器
相同。
- 当两个进程中任一个需要被写时,触发写时拷贝机制。
9.8.3 再看execve函数
理解execve函数实际上如何加载和运行程序。
- 假设运行在当前的进程中的程序执行了如下的调用:
- Execve("a.out",NULL,NULL);
-
execve函数
在当前进程加载并执行目标文件a.out中的程序,用a.out
代替当前程序。
- 加载并运行需要以下几个步骤。
-
删除已存在的用户区域。
- 删除当前进程虚拟地址的用户部分中已存在的区域结构。
-
映射私有区域。
- 为新程序的
文本
,数据
,bss
和栈区域
创建新的区域结构。
- 所有新的区域结构都是私有的,写时拷贝的。
- 文本和数据区域被映射到
a.out
文件中的文件和数据区。
-
bss
区域是请求二进制零,映射到匿名文件。
- 堆,栈区域也是请求二进制零。
-
映射共享区域
-
a.out
程序与共享对象链接。
- 这些对象都是
动态链接
到这个程序。
- 然后映射到用户
虚拟地址
的共享区域。
-
设置程序计数器(PC)
-
execve
最后一件事设置PC
指向文本区域的入口点。
9.8.4 使用mmap函数的用户级存储器映射
Unix进程可以使用mmap
函数来创建新的虚拟存储器区域
,并将对象映射到这些区域中。
#include <unistd.h>
#include <sys/mman.h>
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset);
返回:若成功时则为指向映射区域的指正,若出错则为MAP_FAILED(-1).
参数解释:
fd,start,length,offset:
mmap函数要求内核创建一个新的虚拟存储器区域,最好是从地址start
开始的一个区域,并将文件描述符fd
指定的对象的一个连续的片chunk
映射到这个新的区域。
- 连续对象片大小为
length
字节
- 从据文件开始处偏移量为
offset
字节的地方开始。
-
statr
地址仅仅是个暗示
prot
参数prot
包含描述新映射的虚拟存储器区域的访问权限位。(对应区域结构中的vm_prot
位)
-
PROT_EXEC:
这个区域内的页面由可以被CPU
执行的指令组成。
-
PROT_READ:
这个区域内的页面可读。
-PROT_WRITE:
这个区域内的页面可写。
-
PROT_NONE:
这个区域内的页面不能被访问。
flag
参数flag
由描述被映射对象类型的位组成。
-
MAP_ANON标记位
:映射对象是一个匿名对象。
-
MAP_PRIVATE标记位
:被映射对象是一个私有的,写时拷贝的对象。
-
MAP_SHARED标记位
:被映射对象是一个共享对象。
9.9 动态存储器分配
虽然可以使用更低级的mmap
和munmap函数
来创建和删除虚拟存储器的区域。
但是C程序员
还是觉得用动态存储器分配器(dynamic memory allocator)
更方便。
-
动态存储器
分配器维护着一个进程的虚拟存储区域,称为堆(heap)
。
- 系统之间细节不同,但是不失通用型。
- 假设
- 堆是一个请求
二进制零
的区域。
- 紧接着未初始化的
bss
区域,并向上生长(向更高的地址)。
- 对于每个进程,内核维护一个变量
brk(break)
,指向堆顶。
- 分配器将堆视为一组不同大小的
块block
的集合来维护。
- 每个块就是一个连续的
虚拟存储器片
,即页面大小
。
- 要么是
已分配
,要么是空闲
。
-
已分配
-
已分配
的块显式地保留供应用程序使用。
-
已分配
的块保持已分配状态,直到它被释放。
- 这种释放要么是
应用程序
显示执行。
- 要么是存储器
分配器
自身隐式执行(JAVA)。
-
空闲
-
空闲块
可用于分配。
-
空闲块
保持空闲,直到显式地被应用分配。
-
分配器
有两种基本分格。
- 都要求应用显式分配
- 不同之处在于那个实体负责释放已分配的块
-
显式分配器(explict allocator)
- 要求应用程序显式地释放
- C语言中提供一种叫
malloc
程序显示分配器
- C++
- 隐式分配器(
implicit allocator
)
- 要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。
- 隐式分配器又叫做
垃圾收集器(garbage collector)
.
- 自动释放未使用的已分配的块的过程叫做
垃圾收集(garbage collection)
.
-Lisp,ML以及Java
等依赖这种分配器。
9.9.1 malloc和free 函数
malloc
C标准库提供了一个称为malloc程序包的显示分配器。
#include<stdlib.h>
void* malloc(size_t size);
返回:成功则为指针,失败为NULL
#include<unistd.h>
void *sbrk(intptr_t incr);
返回:若成功则为旧的brk指针,若出错则为-1,并设置errno为ENOMEML.
free
程序通过调用free函数来释放已分配的堆块。
#include<stdlib.h>
void free(void *ptr);
返回:无
-
ptr
参数必须指向一个从malloc
,calloc
,realloc
获得的已分配块的起始位置。
- 如果不是,那么
free
行为未定义。
- 更糟糕的是,
free
没有返回值,不知道是否错了。
9.9.2 为什么要使用动态存储器分配
程序使用动态存储器分配的最重要原因是:
- 经常直到程序实际运行时,它们才知道某些数据结构的大小。
9.9.3 分配器的要求和目标
显式分配器有如下约束条件
- 处理任意请求序列。
- 立即响应请求。
- 只使用
堆
。
-
对齐块
。
-
不修改
已分配的块。
目标
吞吐率最大化和存储器
使用率最大化。这两个性能要求通常是相互冲突的
。
-
目标1:最大化吞吐率
- 假定
n
个分配和释放请求的某种序列R1,R2,R3.....Rn
- 通过使分配和释放请求的平均时间
最小化
来最大化吞吐率
-
目标2:最大化存储器利用率
- 设计
优秀的
分配算法。
- 需要增加分配和释放请求的时间。
- 评估使用堆的效率,最有效的标准是
峰值利用率(peak utilization)
-
吞吐率
和存储器
利用率是相互牵制
的,分配器设计的一个有趣的挑战就是在两者之间找到一个平衡
。
9.9.4 碎片
造成堆利用率很低的主要原因是一种称为碎片(fragmentation)的现象。
9.9.5 实现问题
一个实际的分配器要在吞吐率
和利用率
把握平衡,必须考虑一下几个问题。
-
空闲块组织: 如何记录空闲块? ( 9.9.6)
-
放置: 如何选择一个合适的空闲快来放置一个新分配的块? (9.9.7)
-
分割: 将一个新分配的块放入某个空闲块后,如何处理这个空闲快中的剩余部分?(9.9.8)
9.9.6 隐式空闲链表
将堆
组织为一个连续的
已分配块和空闲块
的序列。
这种结构就叫做隐式空闲链表
-
隐式
:
- 为什么叫
隐式链表
。
- 因为不是通过
指针(next)
来链接起来。
- 而是通过
头部的长度
隐含地链接起来。
-
终止头部(类似与普通链表的NULL)
-
优缺点:
-
优点
:简单
-
缺点1
:任何操作的开销都与已分配块和空闲块的总数呈线性关系O(N)
.
-
缺点2
: 即使申请一个字节,也会分配2个字的块。空间浪费。
9.9.7 放置已分配的块
有以下几种搜索放置策略
:
-
首次适配
-
下一次适配
- 和首次适配很类似,但不是从头开始,而是从上一次查询的地方开始。
-
最佳适配
- 检查每个
空闲块
,找一个满足条件的最小的空闲块(贪心)
。
优缺点
9.9.8 分割空闲块
两种策略:
-
占用所有空闲块
-
缺点:
产生更多的内部碎片
(但是如果内部碎片很少,可以接受)
-
优点:
能使得 空闲块+已分配块
的数量减少
- 能加快搜索速度。
- 有的外部碎片(几个字节,很有可能是外部碎片)可能根本放置不了东西,但是却占用了搜索时间,还不如当内部碎片算了
-
放置策略
趋向于产生好的匹配中使用。
-
分割空闲块
-
缺点:
更多的空闲块和已分配块,搜索速度降低。
-
优点:
空间利用率更高。
9.9.9 获取额外的堆存储器
如果分配器
不能为请求块找到合适的空闲块
将发生什么?
-
合并相邻的空闲块
。
-
sbrk函数
- 在
最大化合并
还不行的情况。
- 向内核请求额外的堆存储器。
9.9.10 合并空闲块
假碎片:
因为释放,使得某些时候会出现相邻的空闲块
。
- 单独的放不下请求
(碎片)
,合并却可以(假性),所以叫假碎片
。
何时合并?
-
立即合并
-
定义:
块被释放时,合并所有相邻的块。
-
缺点:
对于某些请求模式,会产生抖动
。
-
推迟合并
9.10 GC_垃圾收集
垃圾收集器(garbage collector)
是一种动态存储分配器
。
-
垃圾:
它自动释放不再需要的已分配块,这些块称为垃圾(garbage)
.
-
垃圾收集(garbage collection)
:自动回收堆存储
的过程叫做垃圾收集
。
-
应用显式分配堆块
,但从不显式释放堆块。
-
垃圾收集器
定期识别垃圾快,并调用相应地free
,将这些快放回空闲链表。
垃圾收集可以追溯到John McCarthy
在20世纪60年代
早期在MIT
开发的Lisp系统
。
- 它是
Java,ML,Perl和Mathematic等
现代语言系统的一个重要部分。
- 有关文献描述了大量的垃圾收集方法,数量令人吃惊。
- 我们讨论局限于
McCarthy
自创的Mark&Sweep(标记&清除)算法
。
- 它可以建立已存在的
malloc包
的基础上,为C和C++
提供垃圾收集。
9.11 C程序中常见的与存储器有关的错误
9.11.1 间接引用坏指正
scanf("%d",&val);
scanf("%d",val);
-
最好的情况 :
以异常中止。
- 有可能
覆盖
某个合法的读/写区域,造成奇怪的困惑的结果。
9.11.2 读未初始化的存储器
堆存储器并不会初始化。
9.11.3 允许栈缓冲区溢出
程序不检查输入串的大小就写入栈中的目标缓冲区
- 那么就有
缓冲区溢出错误(buffer overflow bug)
。
-
gets()
容易引起这样的错误
- 用fgets()
限制大小。
9.11.4 假设指针和它们所指向对象是相同大小
有的系统里,int
和 int *
都是四字节,有的则不同。
9.11.5 越界
9.11.6 引用指针,而不是它所指向的对象
对指针的优先级用错。
9.11.7 误解指针的运算
忘记了指针的算术操作是以它们指向的对象的大小为单位来进行的,这种大小不一定是字节
。
9.11.8 引用不存在的变量
返回一个指针
,指向栈
里面一个变量的地址。但是这个变量在返回的时候已经从栈里被弹出。
- 地址是正确的,指向了
栈
。
- 但是却没有指向想指向的变量。
9.11.9 引用空闲堆块的数据
引用了某个已经free
掉的块。在C++
多态中经常容易犯这个错误。
9.11.10 引起存储器泄露
- 即是没有
回收垃圾
。导致内存中垃圾越来越多。
- 对于守护进程和服务器这样的程序,存储器泄露是十分严重的事。
9.12 小结
虚拟存储器是对主存的一个抽象。
- 使用一种叫
虚拟寻址
的间接形式来引用主存。
- 处理器产生
虚拟地址
,通过一种地址翻译硬件来转换为物理地址
。
- 通过使用页表来完成翻译。
- 又涉及到各级缓存的应用。
-
页表
的内容由操作系统提供
虚拟存储器提供三个功能
- 它在主存中自动缓存最近使用的存放在磁盘上的
虚拟地址空间内容
。
- 简化了
存储器管理
,
- 进而
简化了链接
- 进程间
共享数据
。
- 进程的存储器分配以及程序加载。
- 每条
页表
条目里添加保护位,从而简化了存储器保护
。
地址翻译的过程必须和系统中所有的硬件缓存的操作集合。
- 大多数条目位于
L1
高速缓存中。
- 但是又通过一个
TLB
的页表条目的片上高速缓存L1
。
现代系统通过将虚拟存储器片和磁盘上的文件片关联起来,以初始化虚拟存储器片
,这个过程叫做存储器映射
。
GC是通过不断递归访问指针来标记已分配块,在需要的时刻进行Sweep
。
-
C,C++
无法辨认指针导致无法实现完全的GC
。
- 只有保守的
GC
。
- 需要配合平衡树进行查找
p
所指向的块
第九章课后家庭作业
书上课后练习题都有答案,就不在博客里详细写了。
9.11
A.虚拟地址0x027c
13 |
12 |
11 |
10 |
9 |
8 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
0 |
0 |
0 |
0 |
1 |
0 |
0 |
1 |
1 |
1 |
1 |
1 |
0 |
0 |
B.地址翻译
参数 |
值 |
VPN |
0x09 |
TLB索引 |
0x01 |
TLB标记 |
0x02 |
TLB命中 |
No |
缺页 |
No |
PPN |
0x17 |
C.物理地址格式
11 |
10 |
9 |
8 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
0 |
1 |
0 |
1 |
1 |
1 |
1 |
1 |
1 |
1 |
0 |
0 |
D.物理地址引用
参数 |
值 |
字节偏移 |
0x0 |
缓存索引 |
0xF |
缓存标记 |
0x17 |
缓存命中 |
No |
返回缓存字节 |
- |
9.12
A.虚拟地址0x03a9
13 |
12 |
11 |
10 |
9 |
8 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
0 |
0 |
0 |
0 |
1 |
1 |
1 |
0 |
1 |
0 |
1 |
0 |
0 |
1 |
B.地址翻译
参数 |
值 |
VPN |
0x0E |
TLB索引 |
0x02 |
TLB标记 |
0x03 |
TLB命中 |
No |
缺页 |
No |
PPN |
0x11 |
C.物理地址格式
11 |
10 |
9 |
8 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
0 |
1 |
0 |
0 |
0 |
1 |
1 |
0 |
1 |
0 |
0 |
1 |
D.物理地址引用
参数 |
值 |
字节偏移 |
0x1 |
缓存索引 |
0xA |
缓存标记 |
0x11 |
缓存命中 |
No |
返回缓存字节 |
- |
9.13
A.虚拟地址0x0040
13 |
12 |
11 |
10 |
9 |
8 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
0 |
0 |
0 |
0 |
0 |
0 |
0 |
1 |
0 |
0 |
0 |
0 |
0 |
0 |
B.地址翻译
参数 |
值 |
VPN |
0x01 |
TLB索引 |
0x01 |
TLB标记 |
0x00 |
TLB命中 |
No |
缺页 |
YES |
PPN |
- |
9.14
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
int main()
{
int fd;
char *start;
fd = open("hello.txt", O_RDWR, 0); //打开文件
start = mmap(NULL, 1, PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
if(start == MAP_FAILED) return -1;//判断是否映射成功
(*start) = 'J';
munmap(start, 1);
return 0;
}
9.15
请求 |
块大小 |
块头部 |
malloc(3) |
8 |
0x9 |
malloc(11) |
16 |
0x11 |
malloc(20) |
24 |
0x19 |
malloc(21) |
32 |
0x21 |
9.16
对齐要求 | 已分配块|空闲块|最小块大小|
---|---|---|---|
单字 | 头部和脚部|头部和脚部|16字节|
单字 | 头部,但是没有脚部|头部和脚部|16字节|
双字 | 头部和脚部|头部和脚部|16字节|
双字 | 头部,但是没有脚部|头部和脚部|16字节|
空闲块至少是16
字节。已分配块不需要pred
和succ
,所以这八个字节可以装数据,加上头和脚,就是16字节
,而且满足单字双字对齐。
9.17
我们需要修改find_fit
函数(之前的版本在practise
习题中写过,书后有答案)。
代码可以参考9.18
。
为此需要先定义一个全局的cur_point指针
,表示上次搜索之后指向哪个块(有效载荷首地址)。
static void *find_fit(size_t asize)
{
void *bp = cur_point;
do{
if( !GET_ALLOC(HDRP(bp)) && (asize <= GET_SIZE(HDRP(bp))) )
return cur_point = bp;
bp = (GET_SIZE(HDRP(bp)) == 0) ? heap_listp : NEXT_BLKP(bp);
}while(bp != cur_point);
return NULL;
}
另外,需要释放cur_point
指向的指针时,它可能和前面的空闲块合并,我们应该将cur_point
指向前一个空闲块首地址。在coalesce()
函数中需要做如下修改:
else if (!prev_alloc && next_alloc) { /* Case 3 */
size += GET_SIZE(HDRP(PREV_BLKP(bp)));
PUT(FTRP(bp), PACK(size, 0));
PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));
if(cur_point == bp) cur_point = PREV_BLKP(bp);
bp = PREV_BLKP(bp);
}
else { /* Case 4 */
size += GET_SIZE(HDRP(PREV_BLKP(bp))) +
GET_SIZE(FTRP(NEXT_BLKP(bp)));
PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));
PUT(FTRP(NEXT_BLKP(bp)), PACK(size, 0));
if(cur_point == bp) cur_point = PREV_BLKP(bp);
bp = PREV_BLKP(bp);
}
9.18
需要考虑四个问题
:
-
-
初始化
的时候,序言块
和结尾块
是怎么样的。
序言块八字节8|0x11。
结尾块四字节0|0x11.
-
- 为
堆
申请更多空间的时候(sbrk),如何更改结尾块
记录最后一个块的alloc
。
结尾块向后延伸申请的空间,并将刚多出的空间作为一个空闲块。设置为size|(alloc<<1)
。再合并该空闲块。这里如何合并呢?需要判断最后一块是否已分配,可通过epilogue
来判断。
-
- 某个
空闲块
匹配的时候,如何设置头和下一块的头。
我们基于以下假设:某个空闲块匹配,上一个和下一个一定不是空闲块(否则可以合并)。
所以头部就设置为(asize|0x011
)。
如果要分割,则下一块的头部设置为(size-asize|0x010
),不用合并,因为再下一块肯定是已经分配。
-
- 释放某个块时,如何合并。
检查头部,alloc_prev
= 上一块是否分配
检查下一个块的头部,alloc_next
= 下一个块是否分配。
再根据那四种情况分别设置。
最后,如果下一块已分配,则需要将下一块头部设置为(原头部&(~0x010))。
另外,在mm_malloc
中,分配多大的块也要发生相应的变化,因为现在最小块大小可以是DSIZE
,而不是2*DSIZE
。
9.19
1) a
; 对于伙伴系统,如果要申请大小为33
的空间,那么需要分配64
个空间。如果申请大小为65
的空间,那么块大小就需要128
,所以最多可能有约50%
的空间被浪费。b
中,最佳适配要搜索所有空间,所以肯定比首次适配要慢一些。c
,边界标记主要功能是释放一个块时,能立即和前后空闲块合并。如果空闲块不按顺序排列的话,其实也能够和前一个或者后一个空闲块进行合并,但如果要和前后一起合并,可能会有些困难,那需要搜索前后块在空闲链表中的位置,并且删除一个再进行合并。可以参考P576,LIFO
方法。d
,其实任何分配器都可能有外部碎片,只要剩余的空闲块大小和足够但是单个都不够,就会产生外部碎片
。
2) d
; 块大小递增,那么最佳适配法找到的块和首次适配找到的块是同一个,因为最佳适配总是想找一个刚好大于请求块大小的空闲块。a
,块大小递减,首次适配很容易找到,所以分配性能会很高。b
,最佳适配方法无论怎样,都要搜索所有的链表(除非维护成块大小递增的链表)。c
,是匹配的最小的。
3) c
; 保守的意思就是所有可能被引用的堆都会被标记,int
像指针,所以可能认为它表示的地址是正在被引用的(实际上它只是个int
)。
9.20
不会……
教材学习中的问题及解决
在深入学习之前我和同伴是带着这些疑问的,学习之后通过讨论,对这些问题有了一点理解。
-
问题1:
Linux
虚拟地址空间如何分布?
-
问题1解决:
Linux
使用虚拟地址空间,大大增加了进程的寻址空间,由低地址到高地址
分别为:
- 1、只读段:该部分空间只能读,不可写;(包括:代码段、
rodata 段(C常量字符串和
#define```定义的常量) )
- 2、
数据段
:保存全局变量、静态变量的空间;
- 3、
堆
:就是平时所说的动态内存, malloc/new
大部分都来源于此。其中堆顶的位置可通过函数 brk 和 sbrk
进行动态调整。
- 4、
文件映射区域
:如动态库、共享内存等映射物理空间的内存,一般是 mmap
函数所分配的虚拟地址空间。
- 5、
栈
:用于维护函数调用的上下文空间,一般为 8M
,可通过 ulimit –s
查看。
- 6、
内核虚拟空间
:用户代码不可见的内存区域,由内核管理(页表
就存放在内核虚拟空间)。
-
问题2:
64
位系统拥有2^64
的地址空间吗?
-
问题2解决:
事实上, 64
位系统的虚拟地址空间划分发生了改变:
- 1、地址空间大小不是
2^32
,也不是2^64
,而一般是2^48
。因为并不需要 2^64
这么大的寻址空间,过大空间只会导致资源的浪费。64
位Linux
一般使用48
位来表示虚拟地址空间,40
位表示物理地址,这可通过 /proc/cpuinfo
来查看
- 2、其中,
0x0000000000000000~0x00007fffffffffff
表示用户空间, 0xFFFF800000000000~ 0xFFFFFFFFFFFFFFFF
表示内核空间,共提供 256TB(2^48)
的寻址空间。这两个区间的特点是,第 47
位与 48~63
位相同,若这些位为 0
表示用户空间,否则表示内核空间。
- 3、用户空间
由低地址到高地址
仍然是只读段
、数据段
、堆
、文件映射区域
和栈
;
-
问题3:如何查看进程发生缺页中断的次数?
-
问题3解决:
用ps -o majflt,minflt -C program
命令查看。
majflt
代表major fault
,中文名叫大错误,minflt
代表minor fault
,中文名叫小错误。
这两个数值表示一个进程自启动以来所发生的缺页中断的次数。
-
问题4:发成缺页中断后,执行了那些操作?
-
问题4解决:
当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:
- 1、检查要访问的
虚拟地址
是否合法
- 2、
查找/分配
一个物理页
- 3、
填充
物理页内容(读取磁盘,或者直接置0,或者啥也不干)
- 4、建立
映射关系
(虚拟地址到物理地址)
重新执行发生缺页中断的那条指令
-
问题5:
堆内碎片
不能直接释放,导致疑似“内存泄露”问题,为什么 malloc
不全部使用 mmap
来实现呢(mmap
分配的内存可以会通过 munmap
进行 free
,实现真正释放)?而是仅仅对于大于 128k
的大块内存才使用 mmap
?
-
问题5解决:
进程向 OS
申请和释放地址空间的接口 sbrk/mmap/munmap
都是系统调用,频繁调用系统调用都比较消耗系统资源的。并且, mmap
申请的内存被 munmap
后,重新申请会产生更多的缺页中断。例如使用 mmap
分配 1M
空间,第一次调用产生了大量缺页中断 (1M/4K 次
) ,当munmap
后再次分配 1M
空间,会再次产生大量缺页中断。缺页中断是内核行为,会导致内核态CPU
消耗较大。另外,如果使用 mmap
分配小内存,会导致地址空间的分片更多,内核的管理负担更大
。
同时堆是一个连续空间,并且堆内碎片由于没有归还 OS
,如果可重用碎片,再次访问该内存很可能不需产生任何系统调用和缺页中断,这将大大降低 CPU
的消耗。 因此, glibc
的 malloc
实现中,充分考虑了 sbrk
和 mmap
行为上的差异及优缺点,默认分配大块内存 (128k
) 才使用 mmap
获得地址空间,也可通过 mallopt(M_MMAP_THRESHOLD, <SIZE>)
来修改这个临界值。
上周考试错题总结
无
结对及互评
点评模板:
- 博客中值得学习的或问题:
- 代码中值得学习的或问题:
- 其他
本周结对学习情况
-[20155318](http://www.cnblogs.com/lxy1997/)
- 结对照片
- 结对学习内容
- 教材第九章内容
- 跟着同伴回顾了第十二章的内容
- ...
其他(感悟、思考等,可选)
通过这一章的学习,进一步加深了对虚拟存储器的了解。
(statistics.sh脚本的运行结果截图)
学习进度条
|
代码行数(新增/累积) |
博客量(新增/累积) |
学习时间(新增/累积) |
重要成长 |
目标 |
5000行 |
30篇 |
400小时 |
|
第一周 |
133/133 |
1/1 |
8/8 |
|
第三周 |
159/292 |
1/3 |
10/18 |
|
第五周 |
121/413 |
1/5 |
10/28 |
|
第七周 |
835/3005 |
2/7 |
10/38 |
|
第八周 |
1702/4777 |
1/8 |
10/48 |
|
第九周 |
1664/6441 |
3/11 |
10/58 |
|
第十一周 |
300/6741 |
3/14 |
10/68 |
|
第十三周 |
743/7484 |
2/16 |
10/78 |
|
尝试一下记录「计划学习时间」和「实际学习时间」,到期末看看能不能改进自己的计划能力。这个工作学习中很重要,也很有用。
耗时估计的公式
:Y=X+X/N ,Y=X-X/N,训练次数多了,X、Y就接近了。
参考:软件工程软件的估计为什么这么难,软件工程 估计方法
计划学习时间:15小时
实际学习时间:10小时
改进情况:
(有空多看看现代软件工程 课件
软件工程师能力自我评价表)
参考资料