Jun's Blog

CSAPP第九章笔记之虚拟内存

· Jun

虚拟内存

一个系统有很多进程,本质上每个进程都与其它进程共享主存。但是如果直接让每个进程自由访问整个物理内存,将非常危险且麻烦:

  • 一个进程可能有意或无意读取或写入其它进程的内存,这将十分危险。
  • 链接会很麻烦。

为了解决这些问题,我们引入了虚拟内存这个概念。它是一层在进程与硬件间的抽象,它为每个进程都提供了一个大的,一致的和私有的地址空间。在虚拟内存中的每个进程都以为自己独占整个内存。

概念上而言,虚拟内存被组织为一个存放在硬盘上的N个连续字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。硬盘上数组的内容被缓存在主存中。VM系统将虚拟内存分割为称为虚拟页(Virtual Page)的固定大小块,将物理内存分割为同样大小的物理页(Physical Page)。

页表

页表被用来翻译进程所看到的虚拟地址到实际的物理地址。它被存放在物理内存中,与MMU中的地址翻译硬件协同工作,将虚拟页映射到物理页。操作系统负责维护页表这个数据结构,以及在硬盘和主存间来回传送页。页表本质上就是一个由页表条目(PTE)构成的数组。虚拟地址空间的每个页在页表中的一个固定偏移量处都有一个PTE。一个PTE由一个有效位,一些额外的许可位,以及一个n位地址字段组成的。

  • 有效位用于表明此虚拟页是否被放在主存中。
  • 许可位可以用于说明此页的权限,比如是否是可读可写可执行的。如果有指令违反了条件,CPU便会触发一个一般保护故障,将控制传给一个内核中的异常处理程序。Linux中一般会将这种异常报告为段错误(segmentation fault)。

缺页

如果有效位为1,那么万事大吉说明页命中(即虚拟内存中的内容被缓存在了主存)但是,如果有效位为0,即缓存不命中,则称为缺页(page fault)。缺页异常调用内核中的缺页异常处理程序,它会在物理页选择一个牺牲页,将缺失的页从磁盘写入牺牲页所在的物理页,同时更新PTE。注意,如果牺牲页已经被修改过了,那么内核会先将它写会磁盘。在磁盘和内存之间传送页的活动被称为交换(swapping)或者页面调度(paging)。所有现代操作系统都使用的是按页面调度的方式,即直到不命中发生时才换入页面(将页从磁盘传送到主存)。表面上这样做的效率似乎很低,因为磁盘比主存大概慢上100 000 多倍。但幸运的是,尽管在整个运行过程中程序引用的不同页面的总数可能超过物理内存的总大小,但局部性原则保证了在任意时刻,程序趋向于在一个较小的活动页面集合工作,除去初始开销后,也就是将这个集合(也称为工作集)调度到内存后,大部分情况下我们都是命中的。

Linux虚拟内存系统

Linux为每个进程维护了一个单独的虚拟地址空间和页表,内核虚拟内存包内核中的代码和数据结构,这些区域一般被映射到制只读的,所有进程共享的物理页面。 内核为每个进程维护了一个单独的任务结构(task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息。任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态,其中它有两个字段我们这里需要注意:

  • pgd 指向了第一级页表的基址。
  • mmap 指向了一个空闲链表。这个空闲链表就负责管理进程的虚拟内存。

内存映射

Linux通过将一个虚拟内存区域和硬盘上的一个对象关联起来,以初始化这个虚拟内存区域的内容。虚拟内存和映射到下面两种类型的对象:

  • 普通文件:一个区域可以映射到一个普通硬盘文件的连续部分,比如一个可执行文件。注意因为采取按需调度,只有在CPU第一次引用页面的时候这些虚拟页面才会进入物理内存。
  • 匿名文件:一个区域可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制0。CPU第一次引用这样的页面时,内核就在物理内存中找到一个牺牲页,将它换进去。值得注意的是,硬盘和内存并没有实际的数据传输,因为它是匿名的,刚开始本身并没有实际内容。映射到匿名文件区域的页面有时也叫请求二进制零的页面。

fork和execve的基本实现

fork函数被调用时,内核为新进程创建各种数据结构,并分配给它唯一的PID。它创建了当前进程的mm_struct,页表等的副本,所以两个进程都指向了相同的虚拟内存。当这两个进程中的任意一个在未来进行写操作时,写时复制机制(Copy on write)就会创建新页面,否则他们将一直读取同一块虚拟内存。

用一个例子简单看一下execve函数的工作流程:

1
execve("a.out", NULL, NULL);
  1. 删除已存在的用户区域。
  2. 映射私有区域。为新程序的代码,数据,栈区域创建新的区域结构,所有这些区域都是私有的,写时复制的。bss区域,栈区域和堆区域都是请求二进制零的页。
  3. 映射共享区域。对于像libc.so这种只读的公共库,会被映射到和其他进程相同的区域,而不是新创建一份独立的。
  4. 设置程序计数器。设置当前进程上下文中的程序计数器,使之指向代码入口。