Linux系统学习笔记:虚拟存储器

系统中的进程共享CPU和主存资源,但存储器空间是有限的,而且还容易被破坏。现代系统提供了一种对主存的抽象,称为虚拟存储器,以更有效地管理存储器。虚拟存储器将主存看作磁盘上的地址空间的高速缓存,为每个进程提供了一致的地址空间,并保护进程的地址空间不被其他进程破坏。

Contents

  • 地址空间
  • 虚拟存储器
  • 地址翻译
  • 存储器映射
  • 动态存储器分配
    • 显式分配器
      • 隐式空闲链表
      • 显式空闲链表
      • 分离的空闲链表
    • 垃圾收集
  • 和存储器有关的错误

地址空间

计算机主存可看作一个由M个连续的字节大小的单元组成的数组,每个字节有一个唯一的物理地址(PA)。早期的PC使用物理寻址,直接访问存储器。现代的计算机使用虚拟寻址,CPU生成一个虚拟地址(VA)来访问主存,虚拟地址在送到存储器之前会先转换为物理地址,这种转换称为地址翻译。

地址空间是一个非负整数地址的有序集合,一般假设整数是连续的,它的大小由表示最大地址所需的位数来描述。有虚拟存储器的系统中,CPU从N = 2^n个地址的地址空间中生成虚拟地址,该地址空间称为虚拟地址空间。现代系统通常支持32位或64位虚拟地址空间。系统还有一个物理地址空间,和物理存储器的M个字节对应,为简化,这里假定M = 2^m。

主存中的每个字节都有一个虚拟地址空间的虚拟地址,和一个物理地址空间的物理地址。

虚拟存储器

虚拟存储器(VM)为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组,每个字节有唯一的虚拟地址。磁盘上数组的内容以块为单位被缓存在主存中,虚拟存储器分割的块称为虚拟页(VP),物理存储器分割的块称为物理页(PP,页帧),它们的大小都为P = 2^p字节。

虚拟页面的集合可以分为:

  • 未分配的:VM系统还未分配的页,它们没有关联的数据,不占用磁盘空间。
  • 未缓存的:没有缓存在物理存储器中的已分配页。
  • 缓存的:缓存在物理存储器中的已分配页。

将虚拟存储器系统的缓存称为DRAM缓存。磁盘比DRAM慢得多,它的不命中处罚和访问第一字节的开销都很大。因此虚拟页较大(一般为4 ~ 8KB),而且DRAM缓存是全相联的(虚拟页可以放在任何物理页),替换策略也很重要,并总是使用写回。

页表将虚拟页映射到物理页,它是存放在物理存储器中的一个数据结构,地址翻译硬件转换地址时都会读取页表。操作系统负责维护页表的内容和在磁盘与DRAM之间传送页。

页表是一个PTE(页表条目)的数组,为了简化,设PTE由一个有效位和一个n位地址字段组成。设置了有效位表明虚拟页为已缓存的,地址指向DRAM中相应物理页的起始位置;未设置有效位且地址为空表明虚拟页未分配,否则地址指向磁盘上虚拟页的起始位置。

Linux系统学习笔记:虚拟存储器_第1张图片

页表

当CPU读取的字在某一虚拟页,且该页已缓存,则称为页命中。DRAM缓存不命中称为缺页,它触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序选择一个牺牲页,如果该页已经被修改,内核将它写回磁盘,然后内核更新页并返回,异常处理程序返回时会重新启动导致缺页的指令。

在磁盘和存储器之间传送页称为交换或页面调度。现代系统采用按需页面调度的策略,即当有不命中发生时才换入页面。

局部性原理使程序引用的页面趋向于在一个较小的活动页面集合上工作,该集合称为工作集(常驻集合)。通常虚拟存储器系统工作得很好,但如果工作集的大小超出了物理存储器的大小,那么程序会产生颠簸,即页面不断换进换出。

操作系统为每个进程都提供了一个独立的页表,即一个独立的虚拟地址空间。多个虚拟页面可以映射到同一个共享物理页面上。这带来了很多好处:

  • 简化链接。独立的地址空间使进程可以为它的存储器映像使用相同的基本格式,而不管代码和数据在物理存储器的实际存放位置。Linux进程的存储器映像可以见前面章节。文本区、栈、共享库、操作系统代码和数据总是从固定的位置开始。
  • 简化共享。独立的地址空间提供了一个管理用户进程和操作系统自身之间共享的一致机制。操作系统将不同进程中的一些虚拟页映射到相同的物理页来使它们共享这部分代码。
  • 简化存储器分配。虚拟存储器是一个向用户进程分配额外存储器的机制。页面可以随机地分散在物理存储器中。
  • 简化加载。虚拟存储器简化了加载可执行文件和已共享目标文件,只需将虚拟页标识为无效的(未缓存)并将PTE指向目标文件的适当位置。实际中加载器并不从磁盘复制数据到存储器,而是在页面被引用时再按需调度。

虚拟存储器也可以提供对存储器的访问保护,可以通过在PTE上添加一些额外的许可位来控制对一个虚拟页面内容的访问。指令违反许可条件时,CPU触发一个一般保护故障的异常,Linux将它报告为段错误。

Linux系统学习笔记:虚拟存储器_第2张图片

VM为进程提供独立地址空间和页面级的存储器保护

地址翻译

地址翻译是N元素虚拟地址空间(VAS)的元素和M元素物理地址空间(PAS)的元素之间的映射 MAP:VAS->PASU/o。

  • MAP(A) = A',若虚拟地址A处的数据在物理地址A'处。
  • MAP(A) = /o,若虚拟地址A处的数据不在物理存储器中。

CPU中的页表基址寄存器(PTBR)指向当前页表。n位的虚拟地址包含p位的VPO(虚拟页面偏移)和n-p位的VPN(虚拟页号)两部分,MMU利用VPN选择PTE,将PTE中的PPN(物理页号)和虚拟地址中的VPO串联,得到相应的物理地址。PPO(物理页面偏移)和VPO是相同的。

页面命中时,完全由硬件处理:

  1. CPU生成虚拟地址,传送给MMU。
  2. MMU生成PTE地址,向高速缓存/主存请求它。
  3. 高速缓存/主存向MMU返回PTE。
  4. MMU构造物理地址,传送给高速缓存/主存。
  5. 高速缓存/主存返回所请求的数据。

缺页时,硬件和系统内核协作:

1. 和页面命中时前三步相同。 4. PTE有效位为0,MMU触发异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。 5. 缺页处理程序确定物理存储器中的牺牲页,若该页已被修改,把它页面换出到磁盘。 6. 缺页处理程序调入新的页面,更新存储器中的PTE。 7. 缺页处理程序返回到原来的进程,重新启动导致缺页的指令。重新发送给MMU,页面命中。

高速缓存通常是物理寻址的,地址翻译发生在高速缓存查找之前。

为消除查找PTE的开销,在MMU中包含一个PTE的小缓存,称为TLB(翻译后备缓冲器),它是一个小的虚拟寻址的缓存,每行保存一个单个PTE组成的块。TLB有高度的相联性。

若TLB有T = 2^t个组,TLB索引(TLBI)由VPN的t个最低位组成,TLB标记(TLBT)由VPN的剩余的位组成。

Linux系统学习笔记:虚拟存储器_第3张图片

页面命中和缺页

对于32位地址空间、4KB的页面,若PTE为4字节,则页表会达到4MB,64位的地址空间的页表就更加庞大。实际中的系统使用多级页表来压缩页表。这样只有一级页表才需要总在主存中。

k级页表的地址翻译中,虚拟地址被划分为k个VPN和1个VPO。第k级页表中的PTE指向物理页面的PPN。

Linux系统学习笔记:虚拟存储器_第4张图片

k级页表的地址翻译

在Pentium/Linux存储器系统,有32位地址空间、4KB的页大小、TLB、L1、L2为4路组相联:

  • 指令TLB:32个条目、8组。
  • 数据TLB:64个条目、16组。
  • L1 i-cache和d-cache:16KB、128组、32B块大小。
  • L2高速缓存:128KB ~ 2MB、32B块大小。

Pentium系统使用两级页表,第一级页表称为页面目录,包含1024个32位的PDE(页面目录条目),PDE指向二级页表,每个二级页表包含1024个32位的PTE,PTE指向物理存储器或磁盘上的页面。每个进程有唯一的页面目录和页表集合,页表可以换进换出,页表目录和已分配页面的相关页表常驻存储器。PDBR(页面目录基址寄存器)指向页表目录的起始位置。

Linux系统学习笔记:虚拟存储器_第5张图片

Pentium系统地址翻译

虚拟存储器中位于 0xc0000000 之上的部分为内核虚拟存储器,它包含内核的代码和数据,部分区域映射到所有进程共享的物理页面,其他区域包含每个进程不同的数据。

Linux系统学习笔记:虚拟存储器_第6张图片

Linux进程的虚拟存储器

虚拟存储器被组织成区域(段)的集合,区域是已分配的连续组块,以某种方式相关联。不属于某区域的虚拟页是不存在的,不能被引用。区域允许了虚拟地址空间有间隙。

内核为每个进程维护一个任务结构 task_struct ,结构中包含运行进程所需的信息。其中一个条目指向mm_struct ,它描述了虚拟存储器的当前状态。其中两个字段, pgd 指向页面目录表的基址, mmap 指向vm_area_struct 链表。每个 vm_area_struct 描述当前虚拟地址空间的一个区域。

/**  */
struct vm_area_struct {
    unsigned long vm_start;         /* 区域开始处 */
    unsigned long vm_end;           /* 区域结束处 */
    struct vm_area_struct *vm_next; /* 链表中下一个区域结构 */
    pgprot_t vm_page_prot;          /* 区域内所有页面的读写权限 */
    unsigned long vm_flags;         /* 区域内的页面是共享的还是私有的, ... */
    /* ... */
};

存储器映射

将虚拟存储器区域和磁盘上的对象关联起来,来初始化这个区域的内容,称为存储器映射。

虚拟存储器区域可以映射两种类型的对象:文件系统中的普通文件和匿名文件。匿名文件是内核创建的全0(二进制)文件。映射时磁盘和存储器之间并没有数据传送,对于普通文件,会按需进行页面调度。

虚拟页面被初始化后,就在交换空间(交换文件)中换来换去,交换空间限制着当前运行的进程能分配的虚拟页面的总数。

对象被映射到虚拟存储器的一个区域,作为共享对象或私有对象,对应的区域为共享区域和私有区域。私有对象使用写时复制的技术,开始时像共享对象一样,但PTE标记为只读,且区域结构标记为私有的写时复制,只有进程试图写私有区域的页面时,在物理存储器创建这个页面的新副本,更新PTE,恢复页面的可写权限。

fork 函数创建新进程时,内核创建当前进程的 mm_struct 、 vm_area_struct 和页表的原样副本,标记两个进程的每个页面为只读的,标记两个进程的每个区域结构为私有的写时复制的。

execve 函数加载和执行程序时,替代当前程序。具体地,删除已存在的用户区域,映射私有区域(文本和数据区映射为程序文件的文本和数据区,bss区映射到匿名文件,栈和堆初始长度为0),映射共享区域,设置程序计数器(PC)。

mmap 函数提供了用户级的存储器映射:

#include 

/** 要求内核创建新的虚拟存储器区域,最好从addr开始,将fd指定的对象的一个连续的组块映射到该新区域
 * @return      返回指向映射区域的指针,出错返回-1
 * @addr        建议起始地址,一般用NULL
 * @length      组块字节大小
 * @prot        访问权限位
 *              PROT_EXEC:区域内页面由可执行指令组成
 *              PROT_READ:区域内页面可读
 *              PROT_WRITE:区域内页面可写
 *              PROT_NONE:区域内页面不能访问
 * @flags       描述映射对象类型
 *              MAP_ANON:且fd为NULL,匿名对象
 *              MAP_PRIVATE:私有的写时复制对象
 *              MAP_SHARED:共享对象
 * @fd          文件描述符
 * offset       距文件开始处的字节偏移 */
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);
/** 删除从虚拟地址addr开始的length字节的区域
 * @return      返回0,出错返回-1 */
int munmap(void *addr, size_t length);

动态存储器分配

C程序运行时在需要额外的存储空间时,一般会使用动态存储器分配器,它维护着一个进程的虚拟存储器区域,称为堆。堆是一个请求二进制零的区域,内核为每个进程维护一个变量 brk ,指向堆的顶部。

分配器将堆视为一组不同大小的块,每个块为虚拟存储器的一个连续组块,是已分配的或空闲的。

有两种分配器。显式分配器要求应用显式地释放任何已分配的块,如C的 malloc/free 和C++的new/delete 。隐式分配器则由分配器检测不再被使用的已分配块,并释放块,也称为垃圾收集器,Lisp、ML和Java等高级语言使用垃圾收集。

显式分配器

C标准库提供了 malloc 程序包作为显式分配器,包括 malloc 、 calloc 、 realloc 、 free 函数。

动态存储分配器可以使用 mmap 和 munmap 函数显式地分配和释放堆,还有 sbrk 函数:

#include 

/** 将内核的brk指针增加increment来扩展和收缩堆,increment为0时返回brk当前值
 * @return      返回brk的旧值,出错返回-1,并设errno为ENOMEM */
void *sbrk(intptr_t increment);

显式分配器有一些约束条件:

  • 能够处理任意(分配和释放)请求的序列,释放请求必须对应以前分配请求分配的块。
  • 立即响应请求。
  • 对齐块,使可以保存任何类型的数据对象,因此大多数系统中分配器返回的块为8字节对齐的。
  • 不修改已分配的块。

分配器力图做到吞吐量最大化和存储器利用率最大化,在两者之间平衡。吞吐量指单位时间内完成的请求数,一般要求分配请求的最差运行时间和空闲块的数量成线性关系,释放请求的运行时间为常数。描述存储器利用率常用峰值利用率,即请求序列的某个时刻时已分配的总有效载荷和堆的当前大小(为整个请求序列时间的最大值)的比值。

碎片会造成堆的利用率低,产生于未使用的存储器不能满足分配请求的情况。有内部碎片和外部碎片。内部碎片在已分配块比有效载荷大时发生,比如由于对齐要求。外部碎片在没有单独的空闲块足够满足请求时发生,尽管它们合起来足够大。

分配器需要处理空闲块的组织,放置、分隔和合并块。实际的分配器会使用一些数据结构来区别块边界,已分配块和空闲块。

隐式空闲链表

下图中示意了用隐式空闲链表来组织堆的方式。

Linux系统学习笔记:虚拟存储器_第7张图片

简单的堆块的格式和隐式空闲链表的组织

放置块时,分配器搜索空闲链表,常见有首次适配、下一次适配和最佳适配的放置策略。首次适配从头开始搜索空闲链表,下一次适配从链表的上一次查询结束的地方开始搜索,最佳适配检查所有空闲块,选择最小满足的。下一次适配运行最快,但利用率低得多;最佳适配最慢,利用率最高。

分配器找到匹配的空闲块后,根据情况可能分割它。如果没有合适的空闲块,合并空闲块来创建更大的空闲块。如果还是不能满足需要,分配器向内核请求额外的堆存储器,转成空闲块加入到空闲链表中。

分配器可以选择立即合并或推迟合并,一般为防止抖动,会采用某种形式的推迟合并。

合并需要在常数时间内完成,对于空闲链表来说,它是单链表,可以方便地查看后面的块是否空闲块,但前面的块则不行,一个好办法是在块的脚部使用边界标记,它是头部的副本,这样就可以在常数时间查看前后块的类型了。为了避免边界标记占用空间,可以只在空闲块中加边界标记。

显式空闲链表

对于通用的分配器,隐式空闲链表并不适合,因为它的块分配和堆块的总数呈线性关系。可以在空闲块中增加一种显式的数据结构。下面是双向空闲链表的堆块的格式。双向链表使首次适配时间从块总数的线性时间减少到了空闲块数的线性时间。

Linux系统学习笔记:虚拟存储器_第8张图片

双向空闲链表的堆块的格式

显式链表的缺点是空闲块必须足够大来包含结构,这增大了最小块的大小,也潜在提高了内部碎片的程度。

分离的空闲链表

分离的空闲链表利用分离存储来减少分配时间。分配器维护一个空闲链表数组,每个空闲链表为一个大小类。大小类的定义方式有很多,如2的幂。有简单分离存储和分离适配方法。

简单分离存储的大小类的空闲链表包含大小相等的块,块大小为大小类中最大元素的大小。分配和释放块都是常数时间,不分割,不合并,已分配块不需要头部和脚部,空闲链表只需是单向的,因此最小块为单字大小。缺点是很容易造成内部和外部碎片。

分离适配的分配器维护一个空闲链表的数组,每个链表和一个大小类相关联,包含大小不同的块。分配块时,确定请求的大小类,对适当的空闲链表做首次适配。如果找到合适的块,可以分割它,将剩余的部分插入适当的空闲链表中;如果没找到合适的块,查找更大的大小类的空闲链表。分离适配方法比较常见,如GNU malloc包。这种方法既快、利用率也高。

垃圾收集

垃圾收集器是一种动态存储分配器,自动释放程序不再需要的已分配块(垃圾)。支持垃圾收集的系统中,应用显式分配堆块,但从不显式释放它们。

垃圾收集器将存储器视为一个有向可达图,节点分为根节点和堆节点,堆节点对应堆中的已分配块,根节点对应包含指向堆中的指针但不在堆中的位置,如寄存器、栈里的变量、虚拟存储器中读写数据区域内的全局变量。当存在根节点到p的有向路径时,称p是可达的,不可达节点无法被应用再次使用,即为垃圾。

Java等语言对于创建和使用指针有严格的控制,能够回收所有垃圾。C/C++语言的垃圾收集器通常不能维护可达图的精确表示,称为保守的垃圾收集器,它不能回收所有垃圾。

和存储器有关的错误

在使用C语言和虚拟存储器打交道时,很容易犯一些错误,而且它们常常是致命的。

  • 间接引用坏指针。间接引用指向空洞或只读区域的指针,会造成段异常或保护异常而终止。
  • 读未初始化的存储器。.bss存储器位置总是被加载器初始化为0,但堆存储器不是这样,假定它为0会造成不可预料的结果。
  • 允许栈缓冲区溢出。不检查串的大小就写入栈中的目标缓冲区可能会有缓冲区溢出错误。
  • 假设指针和指向的对象大小相同。这可能会导致分配器的合并代码失败,但没有明显的原因。
  • 造成错位错误。如超出循环造成覆盖错误。
  • 引用指针,而不是指向的对象。
  • 误解指针运算。指针的算术操作是以指向的对象的大小为单位进行的,而不是字节。
  • 引用不存在的变量。比如栈中的局部变量,栈弹出后它就不再合法了。
  • 引用空闲堆块中的数据。和上一个类似,这回发生在被释放的堆中。
  • 引起存储器泄漏。忘记释放已分配块,产生垃圾,对于不终止的程序(守护进程、服务器),存储器泄漏的错误非常严重。
链接:  http://www.yeolar.com/note/2012/03/29/virtual-memory/

你可能感兴趣的:(Linux系统学习笔记:虚拟存储器)