物理和虚拟内存
计算机系统中主存被组织为一个由M个连续字节大小的单元组成数组。每字节都有一个唯一的物理地址。
虚拟地址是虚拟的,CPU通过MMU(内存管理单元——利用主存中的查询表动态翻译虚拟地址 )硬件将虚拟地址——>物理地址,流程如图:
地址空间
主存中的每个字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。
虚拟内存作为缓存的工具
虚拟内存被组织为一个由存放在磁盘上的N个连续字节大小的单元组成数组。
虚拟内存系统通过将虚拟内存分割为虚拟页的大小固定的块来处理这个问题。物理内存和虚拟内存被分割为物理页和虚拟页,大小都是P字节,物理页也称之为页帧。
虚拟页面的集合都分为三个不相交的子集:
- 未分配的:VM系统还没有分配(创建)的页。未分配块没有它们的任何数据和它们管理,也不占磁盘
- 缓存的:当前已经缓存在物理内存的已分配页
- 未缓存的:未缓存在物理内存的已分配页
如图:
DRAM缓存的组织结构
- DRAM中如果不命中,需要从磁盘中读取数据,往往时间消耗巨大
- 对磁盘访问时间长,DRAM缓存总是写回,而不是直写。
页表
虚拟内存机制的完成,是由软硬件联合提供的,包括OS软件、MMU中的地址翻译硬件和一个存放在物理内存中的页表的数据结构。
页表是一个页表条目的数组:
页命中
CPU根据虚拟地址,通过MMU把虚拟地址定位页表,如果页表对应项中有效位为一,页命中
缺页、分配页面次数也是如此
幸好有局部性
程序倾向于在一个较小的活动页面集合上工作,这个集合叫做工作集或常驻集合。
抖动:如果工作集超过了物理内存大小,就会产生抖动现象。
- 虚拟内存作为内存管理的工具
- 可以多个虚拟内存同时映射同一个共享的物理内存
- 按需页面调度和独立的虚拟地址空间集合对内存管理极其重要,VM简化链接、加载、代码和数据共享以及应用程序的内存分配。
地址翻译
地址翻译符号:
使用页表的地址翻译:
CPU的一个控制寄存器页表基址寄存器
指向当前页表。其中虚拟地址分为虚拟页号和虚拟页偏移量,MMU利用虚拟页号选择适当的页表条目,从页表条目中得到物理页号,利用物理页号再进行拼接虚拟页偏移量即可得到响应的物理地址。
当页面命中时,CPU执行硬件操作如下:
- CPU生成一虚拟地址,传给MMU
- MMU生成PTE地址,从高速缓存/主存请求得到它
- 高速缓存/主存向MMU返回PTE(页表条目)
- 构造物理地址,并把它传给高速缓存/主存
- 高速缓存/主存返回所请求的数据字给处理器
页面命中完全由硬件来处理,如果缺页的话,由硬件和OS内核协作完成
第一步到第三步:和页命中一样
第四步发现PTE有效位为0,MMU触发一个异常,传递CPU中的控制到OS内核中的缺页异常处理程序
第五步,缺页异常处理程序确定物理内存中的牺牲页,如果这个页面已经被修改了,把它换出磁盘
第六步,缺页处理程序页面调入新的页面,并更新内存的PTE
第七步,缺页返回程序返回到原来的进程,再次执行导致缺页的指令。之后就是再走一遍页命中的老路
结合高速缓存和虚拟内存
在PTE中,我们之前总是在主存中获取,并且经常访问PTE。其具有较高的局部性,如果设置为缓存那不是美滋滋,如图:
利用TLB加速地址翻译
之前说的如果把PTE放在L1缓存中,可以把开销减小到1或2个周期。然而很多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小缓存,称之为TLB(Translation Lookaside Buffer)
TLB示意图如图:
TLB通常有高度的相联度,用于组选择和行匹配的索引和标记字段是从虚拟地址的虚拟页号提取出来。如果TLB有 2 t 2^t 2t个组,则标记为为VPN中剩余位数构成。
当TLB命中的流程如何:
- CPU生成一个虚拟地址
- MMU根据虚拟地址直接在TLB取出响应的PLE
- MMU将这个PLE翻译成物理地址,然后把他发送到高速缓存/主存中
- 高速缓存/主存中将所请求的数据字返回给CPU
当TLB不命中时,MMU必须从L1缓存中清楚PLE
多级页表
到目前为止,我们一直都是使用单独的页表来进行地址翻译。如果我们在32位地址空间、4KB的页面和一个4字节的PTE,那么即使应用所引用的只是虚拟地址空间中很小的一部分,也需要4MB的页表驻留在主存中。
4MB来源于
虚拟地址有32位,页内偏移为 4 K = 2 12 4K=2^{12} 4K=212。一共有20位地址记载页面大小。即页表存储一共需要 2 20 2^{20} 220 乘 2 2 2^2 22 ,需要4MB大小。
二级页表从两方面减少了内存要求:
- 若一级页表的PTE为空,则相应的二级表不会被分配。对于一个典型的程序,一般来说4GB的内存大小大部分都是未分配的
- 只有一级页表才需要总是在主存中,虚拟地址系统在系统在需要时创建页面调入或调入二级页表,减少主存压力
使用K级页表层次结构的地址翻译,VA被划分成为K个VPN和一个VPO:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LsbsiFWo-1633835098745)(E:\研究生\工作\阅读书籍\csapp\第九章\image-20211009200848151.png)]
端到端的地址翻译
这里参考课本的示例,可以对高速缓存、TLB、虚拟地址和物理地址
Intel Core i7/Linux内存系统
如图是Intel Corei7内存系统重要组成部分,处理器封装包括四个核、一个大的所有核共享的L3高速缓存,以及一个DDR3内存控制器。
类似与多级页表读取PLE的过程,其中CR3寄存器包含了L1页表的物理地址,VPN1提供了VPN2的地址,以此类推。
Linux 虚拟内存系统
还是老图:
在这里内核虚拟内存包含内核中的代码和数据接哦古。内核虚拟内存的某个区域被映射到所有进程共享的物理页面。比如每一个进程共享内核的代码和全局数据结构。
有趣的是,Linux也将一组连续的虚拟页面(大小等于系统中的DRAM的总量)映射到相应的一组连续的物理页面。这为内核提供一种便利的方式访问物理内存的任意位置。
内核虚拟内存的其他区域包含每个进程不一样的数据。比如页表、内核在进程上下文执行代码时使用的栈,及其记录虚拟地址空间当前组织的各种数据结构。
-
Linux 虚拟地址区域
Linux将虚拟内存组织成一些区域的集合。一个区域就是已经存在的(已分配)的虚拟内存的连续片,这些页是以某种方式相关联的。
每个存在的虚拟页面都保留在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。
区域的概念很重要,因为它允许虚拟地址有间隙。内核不用记录哪些不存在的虚拟页,而这样的页不用占用内存、磁盘、还有内核本身的任何额外资源。
如图,强调了记录一个进程中虚拟内存区域的内核数据结构:
任务结构元素包括或者指向内核运行该进程所需要的所有信息(例如,PID,指向用户栈的指针,可执行目标文件名字,及其程序计数器)
任务结构种一个条目指向
mm_struct
,描述虚拟内存的当前状态。我们感兴趣的是pgd(指向第一级页表[页全局目录]的基址),mmap(指向一个vm_area_struct
[区域结构])的链表,其中vm_area_struct
都描述了当前虚拟地址空间的一个区域。当内核运行这个进程时,就把pgd放入到CR3控制寄存器。 -
Linux 缺页异常处理
-
段错误
-
保护异常
-
正常缺页
-
内存映射
Linux 通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程叫做内存映射
。
虚拟内存区域可以映射到两种类型的对象中的一种:
- Linux文件相同中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。这些虚拟页面并没有加入物理内存,知道CPU第一次引用。
- 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的都是二进制零。
再看共享对象
私有对象使用一种叫做写时复制
的技术巧妙地映射到内存中。一个私有对象开始生命周期的方式和共享对象的一样,在物理内存中只保留私有对象的一份副本。
对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制
。如果有一个进程试图修改私有区域的某个页面,那这个写操作会触发一个保护故障,进行如下图的操作:
再看fork()函数
父进程创建子进程时使用fork函数,如果父子进程中有一个进程进行写操作,写时复制机制就会创建新的页面,即为每一个进程保持了私有地址空间的抽象概念
再看execve()函数
execve("a.out", NULL, NULL)
execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效代替当前程序。
加载并运行a.out需要以下步骤:
- 删除已存在的用户区域
- 映射私有区域 其中bss区域是请求二进制零的,映射到匿名文件
- 映射共享区域
- 设置程序计数器,execve最后一件事就是设置进程上下文的PC,使它指向代码区域的入口点。
映射如图:
使用mmap函数的用户级内存映射
mmap函数要求内存创建一个新的虚拟内存区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片映射到这个新区域。
void * mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)
int munmap(void *start, size_t length)
munmap函数删除从虚拟地址start开始的,由接下来length字节组成的区域。接下来对已删除的区域的引用会导致段错误
动态内存分配
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。紧接未初始化的数据区域后面开始向上(高地址增长)。对于每一个进程,内核维护者一个变量brk,它指向堆的顶部。
分配器有两种基本的风格。两种风格都要求应用显式分配块。不同之处在于那个实体负责释放已分配块
- 显式分配器, C malloc 与 free
- 隐式分配器, 要求分配器检测一个已分配块何时不被程序使用时释放块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。Java语言做的比较出色
为什么要使用动态内存分配
最重要的原因:经常直到程序运行时,才知道某些数据结构大小。当然正确高效使用分配器十分重要。
分配器要求与目标:
- 处理任意请求序列
- 立即响应请求
- 只使用堆
- 对齐块
- 不修改已分配块,分配器只能操作或改变(空闲块)
分配器设计始终是在最大化吞吐率与最大化内存利用率之间平衡
碎片
造成堆利用率低的原因是碎片:
- 内部碎片
- 外部碎片
- 量化内部碎片难,难以量化或不可预测,通常常用启发式策略试图维持少量大空闲块
实现问题
- 空闲块组织
- 放置
- 分割
- 合并
隐式空闲链表
显式空闲链表
垃圾收集
垃圾收集器是种动态内存分配器,它自动释放程序不再需要的已分配块。
垃圾收集器将内存视为一张有向可达图。
编程语言方面,像ML、Java这样的编程语言的垃圾收集器,对于创建指针比较有严格的规定,能够维护可达图的精准表达
Mark & Sweep 垃圾收集器
由标记阶段和清除阶段组成
C语言使用Mark & Sweep 垃圾收集器来处理的时候是必须保守的,其根本原因是因为C语言不会使用类型信息来标记内存位置
C程序中常见的与内存有关错误
-
间接引用坏指针
scanf("%d", val)
-
读为初始化内存
虽然.bss内存位置准备加载初始化为零,但堆内存不同
int *y = (int *) malloc(n * sizeof(int)) // 这里的y指向的内容不一定为0,eg: y[0]不一定为零
-
允许栈缓冲区溢出
-
假设指针和它们指向对象都是相同的
-
造成错位错误
-
引用指针,而不是它所指向对象 *size – (
*
size)– -
误解指针运算
-
引用不存在变量
-
引用空闲块中的数据
-
引起内存泄漏