虚拟内存
物理和虚拟寻址
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。第一个字节的地址为0,接下来的字节地址为1,再下一个为2,依此类推。所以最简单的结构就是,CPU访问内存的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址。下图是一个物理寻址的示意图:
使用虚拟寻址,CPU通过生成一个寻你地址来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。将一个虚拟地址转换成物理地址的任务叫做地址翻译。CPU芯片上叫做内存管理单元(Memory Management Unit,MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表内存由操作系统管理。下图是一个虚拟寻址的示意图:
地址空间
地址空间是一个非负整数地址的有序集合:
如果地址空间中的整数是连续的,那么我们说它是一个线形地址空间。
在一个带虚拟内存的系统中,CPU从一个有N=2n个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间。
一个地址空间的大小是由表示最大地址所需要的位数来描述的。
一个系统还有一个物理地址空间,对应于系统中物理内存的M个字节:(0,1,……,M-1)
主存中的每个字节都有一个选自虚拟地址空间的虚拟地址和一个选择物理地址空间的物理地址。
虚拟内存作为缓存工具
虚拟内存系统通过将虚拟内存分割为称为虚拟页的大小固定的块来作为传输单元。每个虚拟页的大小为P=2p字节。类似的,物理内存被分割为物理页,大小也为P字节。
在任何时刻,虚拟页面的集合都分为三个不相交的子集:
- 未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
- 缓存的:当前已缓存的物理内存中的已分配页。
- 未缓存的:未缓存在物理内存中的已分配页。
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统需必须确认这个虚拟页存放在哪个物理页中。如果不命中,系统还必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。
页表就是一个页表条目(Page Table Entry,PTE)的数组。虚拟地址空间中的每个页在也表中一个固定偏移量处都有一个PTE。
我们假设每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址便是这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的位置。
地址翻译
CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(Virtual Page Offset,VPO)和一个(n-p)位的虚拟页号(Virtual Page Number,VPN)。MMU利用VPN来选择适当的PTE,将页表条目中的物理页号(Physical Page Number,PPN)和虚拟地址中的VPO串联起来,就得到了相应的物理地址。此外,因为物理和虚拟的页的大小都是P字节的,所以物理页面偏移(Physical Page Offset,PPO)和VPO是相同的。下图是地址翻译的原理图:
当页面命中时,CPU硬件执行步骤:
当页面不命中时,需要硬件和操作系统内核协作完成下面步骤:
在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(Translation Lookaside Buffer,TLB)。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。用于组选择和行匹配的索引项和标记字段是从虚拟地址中的虚拟页号中提取出来的。
TLB索引(TLBI)用于确定组,TLB标记(TLBT)用于在一个组中确定PTE条目。
最为关键的是,所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。而执行步骤如下:
当TLB不命中时,MMU必须从L1缓存中提取出相应的PTE,新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
完成地址翻译后,得到一个物理地址(Physical Address,PA),由PPN和PPO组成。然后使用这个PA访问高速缓存即可找到相应的字节。
动态内存分配
动态内存分配器维护者一个进程的虚拟内存区域,称为堆。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格:
显式分配器,要求应用显示地释放任何已分配的块。如C语言中malloc函数和free函数。
隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就自动释放这个块。隐式分配器也叫做垃圾收集器。
malloc函数和free函数
C标准库提供了一个称为malloc程序包的显示分配器。程序通过调用malloc函数来从堆中分配块。
malloc函数返回一个指针,指向大小至少为size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。如果malloc函数遇到问题,那么它就返回NULL,并设置errno。malloc函数并不会初始化它返回的内存。如果想要已初始化的动态内存,那么可以使用calloc函数,它可以将分配的内存初始化为零。想要改变一个以前已分配块的大小则可以使用realloc函数。
动态内存分配器可以通过使用mmap和munmap函数显示地分配和释放堆内存,或者还可以使用sbrk函数
sbrk函数通过将内核的brk指针增加incr来扩展和收缩堆。如果成功,它就返回brk的旧值,否则,它就返回-1。
程序是通过调用free函数来释放已分配的堆块。
ptr参数必须指向一个从malloc、calloc或者realloc函数获得的已分配块的起始位置。如果不是,那么free的行为就是未定义的。更糟糕的是,既然它什么都不返回,free函数就不会告诉应用出现了错误。
隐式空闲链表
任何实际的分配器都需要一些数据结构,允许它来区别块的边界,以及区别已分配的块和空闲块。大多数分配器将这些信息嵌入块的本身。
在上图的情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有填充),以及这个块是已分配的还是空闲的。
如果我们增加一个双字的对齐约束条件,那么块大小就是总是8的倍数,且块的大小的最低3位总是零。因此我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。
在这里我们使用最低位来指明这个块是已分配的还是空闲的。