虚拟内存是一个操作系统提供我们非常完美的抽象,我们无需管理物理内存,无需为我们的程序分配地址和位置,因此我们在运行程序时基本完全不用考虑这些问题,甚至进一步来说我们甚至不需要考虑虚拟内存的存在,而转而去相信我们的程序独占了整个内存地址空间。但是我们还是要了解他,以便于我们可以编出更合理以及更安全的程序,这也是现在程序员的要求(其实我觉得C之所以有不安全的因素更是因为他直接操作地址的特性)。
寻址与地址空间
我们把直接访问物理内存的方式称为物理寻址,CPU通过生成一个虚拟地址来访问内存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。
地址空间是一个非负整数地址的有序集合。
然后这里是我也不知道PPT为什么就开始介绍段(或者说PPT一直都没什么逻辑,阅读起来无疑是一种痛苦)
Intel使用段页式存储管理:
- 段式管理:逻辑地址->线性地址==虚拟地址
- 页式地址:虚拟地址->物理地址
段描述符是一种数据结构,实际上就是段表项,分两类:
- 用户的代码段和数据段描述符
- 系统控制段描述符,有分两种:1)特殊系统控制段描述符,包括:局部描述符表和任务状态段描述符;2)控制转移类描述符:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符;
描述符表实际上就是段表,由段描述符(段表项)组成,有三种类型:
- 全局描述符表:只有一个,用来存放系统内每个任务都有可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及任务状态段等
- 局部描述符表:存放某任务专用的描述符
- 中断描述符表:包括256个中断门、陷阱门和任务们描述符
不过PPT这么讲也无可厚非,因为解释虚拟内存是什么确实是一个无聊的事情,虚拟内存由我们上面的系统图就可以看出来,我们通过虚拟地址空间进行寻址,而不是直接对物理内存进行操作,不但方便了我们的管理,而且虚拟内存提供给进程的抽象无疑帮了我们大忙。
地址翻译
先看一个符号总结(我做实验的时候要是有这个表,页表管理也不会做的那么难)
然后再来看一个地址翻译的图
还是看图好,不需要动脑子。
页面命中
- 处理器生成一个虚拟地址,并把它传送给MMU
- MMU生成PTE地址,并从高速缓存/主存请求得到他
- 高速缓存/主存向MMU返回PTE
- MMU构造物理地址,并把它传送给高速缓存/主存
- 高速缓存/主存返回所请求的数据字给寄存器
页面不命中
- 处理器生成一个虚拟地址,并把它传送给MMU
- MMU生成PTE地址,并从高速缓存/主存请求得到他
- 高速缓存/主存向MMU返回PTE
- PTE有效位是零,MMU触发异常,传递给CPU中,到操作系统中调用缺页异常处理程序
- 缺页处理程序确定出物理内存中的牺牲页,如果已被修改,则把它换出到磁盘
- 缺页处理程序调入新的页面,并更新内存中的PTE
结合高速缓存和虚拟内存
看图
利用TLB加速地址翻译
看图
多级页表
因为每个进程都要维护一个页表,这也是虚拟内存要求的,我们使用一级页表会有很大的内存开销,所以我们使用多级页表去规避这个问题,因为我们使用虚拟内存的初衷就是改变物理内存太少的现状。
看图:
这要是看不明白就别看了,去工地当力工去把。
小内存系统实例
地址假设:
- 14位虚拟地址
- 12位物理地址
- 页面大小64字节
TLB:
- 16个条目
- 四路组相联
前16个PTE:
cache:
- 16组,每块四字节
- 通过物理地址中的字段寻址
- 直接映射
example:
Linux缺页处理
内存映射
Linux通过将虚拟内存区域与磁盘上的对象关联起来以初始化这个虚拟内存区域的内容,这个过程称为内存映射。
虚拟内存区域可以映射的对象:
- 磁盘上的普通文件(比如说一个可执行目标文件):文件区被分成页大小的片,对虚拟页面初始化;
- 匿名文件:第一次引用该区域内的虚拟页面时分配一个全是零的物理页;一旦该页面被修改即和其他页面一样
共享对象
一图看完共享对象:
看fork函数
为新进程创建虚拟内存:
- 创建当前进程的原样副本
- 两个进程中的每个页面都标记为只读
- 两个进程中的每个区域结构都标记私有的写时复制
再看execve函数
execve函数在当前进程中加载并运行新程序的步骤:
- 删除已存在的用户区域
- 创建新的区域结构:1)私有的,写时复制;2)代码和初始化数据映射到.text和.data区3).bss和堆栈映射到匿名文件,栈堆的初始长度为0
- 共享对象由动态链接映射到本进程共享区域
- 设置PC,指向代码区域的入口点:Linux根据需要换入代码和数据页面
用户级内存映射
mmap创建新的虚拟内存区域,并将对象映射到这些区域(可以拷贝文件)
从fd指定的磁盘文件的offset处映射len个字节到一个新创建的虚拟内存区域,该区域从地址start处开始:
- start:虚拟内存的起始地址,通常定义为NULL
- prot:虚拟内存区域的访问权限
- flags:被映射对象的类型
返回一个指向映射区域开始处的指针。
动态内存分配
在程序运行时程序员使用动态内存分配器获得虚拟内存,因为数据结构的大小只有运行时才知道。
动态内存分配器维护一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护,每个块要么是已分配的,要么是空闲的。
分配器的类型:
- 显式分配器:要求应用显式地释放任何已分配的块
- 隐式分配器:应用检测到已分配块不再被程序所使用,就释放这个块(垃圾收集)
malloc:
- 成功:返回已分配块的指针,块大小至少size字节,对其方式以来编译模式(32位8字节,64位16字节)
- 出错:返回NULL,同时设置errno
free:
- 将p指向的块返回到可用内存池
- p必须malloc、realloc或calloc已分配块的起止地址
calloc:malloc的另一个版本,将已分配块初始化为0;
realloc:改变之前分配块的大小
sbrk:分配器隐含地扩展或收缩堆
可以处理任意的分配和释放序列,只能释放已分配的块;
分配器:
- 无法控制分配块的数量或大小
- 立即响应malloc请求
- 必须从空闲内存分配块
- 必须对齐块
- 只能操作或改变空闲块
- 一旦块被分配就不允许修改或移动
因此会产生很多外部碎片
隐式空闲链表
隐式空闲链表通过头部中的大小字段隐含地连接空闲块,对于每个块我们都需要知道块的大小和分配状态;
找到一个空闲块
- 首次适配:从头开始搜索链表,选择第一个合适空闲块
- 下一次次适配:从上一次查询结束的地方开始
- 最佳适配:查询链表,选择一个最好的空闲块
释放合并
放置策略:首次适配、下一次适配、最佳适配
分割策略:我们什么时候开始分割空闲块、我们能够容忍多少内部碎片
合并策略:立即合并、延迟合并
显式空闲链表
显式空闲链表在空闲块中使用指针连接空闲块。
保留空闲块链表,而不是所有块:
- 下一个空闲块可以在任何地方:因此我们要存储前/后指针,而不仅仅是大小
- 还要合并边界标记
- 我们之跟踪空闲块就可以使用有效区域
逻辑上是按照链表顺序排列,但是物理上很可能不是按序排列
插入新释放的块:
- LIFO(后进先出):将新释放的块放置在链表的开始处(简单,易于实现;碎片较多)
- 地址顺序法:按照地址顺序维护链表(需要搜索;碎片比较少)
与隐式链表比较,显式链表分配时间从块总数的线性时间减少到空闲块数量的线性时间,当大量内存被占用时快得多。
分离的空闲链表
每个大小类的空闲链表包含大小相等的块,每个大小类中的块构成了一个空闲链表;
小块有单独的大小类,大块通常按照2的幂分类。
当分配器需要一个大小为n的块时:
- 搜索相应的空闲链表,其大小要满足m > n
- 如果找到合适的块:拆分块,并将剩余部分插入到适当的可选列表中
- 如果找不到合适的块,就搜索下一个更大的大小类的空闲链表
- 直到找到为止
如果空闲链表中没有合适的块:
- 向操作系统请求额外的堆内存
- 从这个新的堆内存中分配出n字节
- 将剩余部分放置在适当的大小类中
释放块:合并,并将结果放置到相应的空闲链表中。
分离适配的优势:
- 更高的吞吐量;
- 更高的内存使用率:对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内存利用率。
隐式内存管理
垃圾收集:自动回收堆存储的过程-应用从不显式释放;
内存管理器如何知道何时可以释放内存?
- 一般我们不知道下一步会用到什么,因为这取决于条件
- 但是我们知道如果没有指针,某些块就不能被使用
必须做些关于指针的假设:
- 内存管理器可以区分指针和非指针
- 所有指针都指向一个块的起始地址
- 无法隐藏指针
把内存看作一张有向图:
- 每个块石图中的一个节点
- 每个指针是图中的一个边
- 根节点的位置一定不在某些堆中,这些堆中包含指向堆的指针
可达节点:存在一条从任意根节点触发并到达该节点的有向路径;
不可达节点是垃圾;
标记&清除垃圾收集器
可以建立在已存在的malloc包的基础上:使用malloc分配直到你用完了了空间
当空间被用完:
- 使用块头部中的mark bit标记位
- 标记:从根节点开始标记所有的可达块
- 清除:扫描所有块并释放没有被标记的块
简单实现的假设:
- 应用
- new(n): 返回指向所有位置已被清除的新块的指针
- read(b,i): 读取 b 块位置 i 的内容到寄存器
- write(b,i,v): 将内容 v 写入到 b 块位置 I
- 每个块都会有一个包含一个字的头部
- 对于块b,标记为 b[-1]
- 用在不同的收集器中,可以起到不同的作用
- 垃圾收集器使用函数的说明
- is_ptr(p): 判断p是不是指针
- length(b): 返回块b以字为单位的长度(不包括头部)
- get_roots(): 返回所有根节点
C程序的保守的Mark&Sweep
C程序的保守的垃圾收集器:
- is_ptr()通过检查某个字是否指向已分配的内存块来确定该字是否为指针
- 但是在C语言中指针可以指向一个块的中间位置
C程序中常见的与内存有关的错误
先放张图:
- ->, (), [] 有高优先级, * 和 & 次之
- 一元 +, -, * 比二进制形式有更高的优先级(than binary forms)
间接引用坏指针
scanf读入了一个错误的指针
读未初始化的内存
假设堆内存被初始化为零(只有.bss才会初始化为0)
允许栈缓冲区溢出
如果一个程序不检查串的大小就写入缓冲区的话,就会导致缓冲区溢出;
void bufoverflow(){
char buf[64];
gets(buf);
return;
}
覆盖内存
分配可能错误大小的对象,这里假设了指向对象的指针和它们所指向的对象是相同大小的。
错位错误
越界对其他的数据进行了写。
误解指针运算
将指针也加一了;
引用指针,而不是他所指向的对象
如果不太注意C操作符的优先级和结合性,就会错误操作指针。