虚拟内存是一种对主存的抽象概念。
(1)将主存看作一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式高效地使用内存
(2)为每个进程提供一致的地址空间,从而简化内存管理。
(3)保护每个进程的地址空间不被其它进程破坏。
1、物理寻址
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。第一个字节的地址为0,接下来的字节地址为1,再下一个为2,依此类推CPU访问内存的最自然的方式就是使用物理地址。这种方式称为物理寻址。
早期的PC使用物理寻址,而且诸如数字信号处理器、嵌人式微控制器以及Cray超级计算机这样的系统仍然继续使用这种寻址方式。然而,现代处理器使用的是一种称为虚拟寻址(virtual addressing)的寻址形式。
2、虚拟寻址
虚拟内存地址是指令用到的内存地址;物理内存地址是实际在内存硬件中的空间地址。
CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译。就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片上叫做内存管理单元(Memory Management Unit,MMU)的专用硬件,利用**存放在主存中的查询表(页表)**来动态翻译虚拟地址,该表的内容由操作系统管理。
3、虚拟页和物理页
虚拟内存被组织成一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为数组索引。磁盘上数组的内容被缓存在主存中。
虚拟内存被分割为虚拟页VP,物理内存被分割为物理页PP。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
4、页表
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。
这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表(page table)的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
页表就是一个页表条目(Page Table Entry,PTE)的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。
PTE中的有效位为0表示未分配,地址字段为null;或者已经分配了但是没有缓存到物理内存中,地址字段指向磁盘地址;为1时表示已经缓存到物理内存中,地址字段指向物理页号。
页命中:
CPU读取包含在虚拟内存中某虚拟页的一个字,该虚拟页已经缓存在主存中,使用PTE中的物理内存地址构造出这个字的物理地址。
缺页:
物理内存不命中即缺页。地址翻译硬件从内存中读取PTE,从有效位推断出该虚拟页未被缓存(未分配的PTE没有联系任何数据,不会被读取),并且触发一个缺页异常。缺页异常调用内核中的处理程序,该程序
(1)判断虚拟地址是否合法,是否是某个区域结构定义的区域,访问空白区域会触发段错误。
(2)内存访问是否合法,是否有读取该地址空间的权限。
(3)对合法的虚拟地址进行合法的操作:选择物理内存中的一个物理页作为牺牲页,从磁盘上用虚拟页的副本取代该牺牲页。程序返回之后CPU重新启动导致缺页的指令,正常读取不会产生异常。
页面分配:
调用malloc的结果,就是在磁盘上创建空间(创建新的虚拟页)并更新某个未分配的页表条目PTE,使它指向磁盘上这个新创建的页面。
5、内存映射
linux将一个虚拟内存区域和一个磁盘上的对象关联起来,初始化这个虚拟内存区域的内容,这个过程称为内存映射,映射为文件系统中的普通文件或者内核创建的匿名文件。
一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。文件区被分为页大小的片,每一片包含一个虚拟页面的初始内容。
共享对象和私有对象:
一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。
共享对象:如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。
私有对象:另一方面,对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。
一个映射到共享对象的虚拟内存区域叫做共享区城。类似地,也有私有区城。
写时复制:
私有对象使用写时复制的技术映射到虚拟内存中。一个私有对象开始生命周期的方式和共享对象一样,在物理内存中只保存私有对象的一份副本。当一个进程试图写私有区域的某个界面,这个写操作会触发一个保护故障。处理程序会在物理内存中创建这个页面的一个新副本,并更新页表条目。程序返回时CPU重新执行写操作即正常执行。
fork函数:
用于创建一个进程,所创建的进程复制父进程的代码段/数据段/BSS段/堆/栈等所有用户空间信息;在内核中操作系统重新为其申请了一个PCB,并使用父进程的PCB进行初始化;
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当 fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
execve函数:
在当前进程中加载并运行包含在可执行目标文件中的程序,用该程序替代当前程序。
(1)删除已存在的用户区域
(2)映射私有区域
(3)映射共享区域
(4)设置程序计数器
mmap函数:
要求内核创建一个新的虚拟内存区域,并将指定对象的一个连续的片映射到这个新的区域。munmap用来删除虚拟内存区域。
6、动态内存分配
运行时需要额外虚拟内存时,用动态内存分配器更加方便,移植性更好。动态内存分配器维护堆,将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。
C标准库提供一个称为malloc程序包的显式分配器,程序通过调用malloc函数来从堆中分配块。
动态内存分配器,如malloc,可以通过使用mmap和munmap函数,显式地分配和释放堆内存。
内存碎片:
造成堆利用率很低的主要原因是一种称为碎片(fragmentation)的现象,当虽然有未使用的内存但不能用来满足分配请求时,就发生这种现象。有两种形式的碎片:内部碎片(internal fragmentation)和外部碎片(external fragmentation)。
内部碎片是在一个已分配块比有效载荷大时发生的。很多原因都可能造成这个问题。例如,一个分配器的实现可能对已分配块强加一个最小的大小值,而这个大小要比某个请求的有效载荷大。或者分配器可能增加块大小以满足对齐约束条件。
内部碎片的量化是简单明了的。它就是已分配块大小和它们的有效载荷大小之差的和。因此,在任意时刻,内部碎片的数量只取决于以前请求的模式和分配器的实现方式。
外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。那么如果不向内核请求额外的虚拟内存就无法满足这个请求,即使在堆中仍然有足够多空闲的字。问题的产生是由于这些空闲字是分在多个空闲块中的。