第九章:虚拟内存

看完这章但是直到现在才去总结,确实是因为对虚拟内存的理解不够,可当我看了《程序员的自我修养》,有种豁然开朗的感觉,至少对虚拟内存的由来有了了解


概述

早期计算机的程序是直接运行在物理内存上的,可以理解为编译好的程序直接写入了内存条,但是思考一个问题,运行在计算机中的程序肯定不止一个吧,每个程序占用一部分内存,自然就会产生内存不足的现象,这样直接使用物理内存的第一个问题就是内存使用效率不高,例如假设总共有50MB的内存,程序A使用10MB,这里需要明确一点的是程序内存的使用都是连续的,程序B使用30MB,程序C需要20M,显然要把程序A和B的数据都存入磁盘才可以去运行程序C,但是其实将程序A移除后,内存中就已经有20M空闲的内存,但却得不到利用,而且大量的数据在内存和磁盘中移动显然是低效率的

第二个问题就是安全问题,程序内存地址是不隔离的,程序之间可以很容易的访问相互之间的数据,这对于恶意程序显然是个好机会,通过轻易访问其他程序的内存,修改内存内容,执行破坏计算机的程序,想想都有点激动

第三个问题时地址空间的不确定性,每次运行程序对应的物理地址都是不确定的,这次可能是0x0000-0x1111,下次就可能是0x2222-0x3333,这种不确定性会给程序编写造成麻烦,例如如果了解汇编的话,程序跳转都是跳转到特定的地址,也就是说编译之后就会给出特定的内存地址,这就是虚拟地址带来的好处,但是如果是物理地址就不能带来这种便利

为了解决上述三个问题,就是在物理内存和程序之间引入一个抽象层,也就是虚拟内存,这就引出了这章要讲解的内容。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互

  • 虚拟内存是核心,遍及计算机系统的所有层面,在硬件异常、汇编器、链接器、加载器、共享对象、文件和进程都是重要核心
  • 虚拟内存可以创建和销毁内存片(chunk),将内存片映射到磁盘文件的某个部分,以及与其他进程共享内存
  • 经常遇到Segment fault,理解虚拟内存和例如malloc之类管理虚拟内存的分配程序,可以避免这些错误

这章先讲解虚拟内存的实现原理,之后结合Linux讲解,然后还会有一个有一个malloc实验,号称是CSAPP Lab中最难的一个,当然我还没有完成

虚拟内存

物理地址:计算机的主存被组成一个由M个连续字节大小的单元组成的数组,每个字节都有唯一的物理地址。CPU使用这种地址就叫做物理寻址

现代操作系统使用虚拟寻址,CPU生成的是虚拟地址(Virtual Address,VM),由MMU翻译成物理地址。地址翻译需要硬件和操作系统紧密合作。CPU上有内存管理单元(MMU)利用存放在主存上的查询表来动态翻译虚拟地址,该表的内容有操作系统内核管理

地址空间

在一个带虚拟地址的系统中,CPU在一个$N=2^n$个地址的地址空间生成虚拟地址,每个地址空间称为虚拟地址空间。这里的$n$就是地址空间大小,也就是常说的系统是32位还是64位

地址空间的概念成功区分了数据对象(字节)和它们的属性(地址)。虚拟内存的基本思想就是允许每个数据对象有多个独立的地址,每个地址都选自不同的地址空间。因此对于主存中的字节都有一个来自虚拟地址空间的虚拟地址和一个来自物理地址空间的物理地址

缓存

逻辑上讲,虚拟内存被组成一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。磁盘上的数据被缓存到主存中。因为磁盘和主存的传输单元为块,VM系统通过将虚拟内存分为虚拟页(VP)大小的固定块来做统一,同样物理页(PP)也分割为同样大小的块,称为页帧

为了讨论清晰,用SRAM缓存表示位于CPU和主存之间的L1、L2和L3高速缓存,用DRAM表示虚拟内存系统的缓存,它在主存中缓存虚拟页

页表

虚拟内存系统需要判断虚拟页是否缓存在DRAM中,并确定是在哪个物理页上,如果没有缓存,系统要判断存放在磁盘的位置,并将其复制到DRAM中

这些功能需要操作系统软件、MMU中的地址翻译软件和一个存放在物理内存中的叫做页表(Page table)的数据结构联合提供。下图展示了页表的组织结构

CPU输出一个VA到MMU的地址翻译硬件,其将VM作为一个索引来定位在页表中的位置(如定位到了PTE 2),由于PTE 2的有效位为1,地址翻译硬件就知道要找的数据就存放在DRAM中,也就是命中,之后就会使用有效位接下来的物理内存地址,构造数据的物理地址

如果没有命中,也就是虚拟地址索引的PTE有效位为0,就发生缺页异常,这会调用内核中的缺页异常调用程序。程序会在DRAM中选择一个牺牲页,将其复制回磁盘,并修改页表。接下来就是根据磁盘地址从磁盘中把缺页复制到内存中。当异常处理程序返回时,就会重启导致缺页的指令。这种直到不命中时,才将页从DRAM换入或换出磁盘的策略就叫按需页面调度

虽然页面调度的处罚代价非常高,但是虚拟内存工作的非常好,因为局部性。因此只要程序有良好的时间局部性,虚拟内存系统就能工作的相当好

内存管理

虚拟内存不仅仅是提供比DRAM更大的页面,而是能简化内存管理,提供内存保护

实际上,操作系统为每个进程都提供了一个独立的页表,也就是独立的虚拟地址空间。如下图

按需调度和独立的虚拟地址空间简化了很多方面,如

  • 简化链接:独立的地址空间使得每个进程的内存映象都有相同的基本格式。这简化了链接器的设计与实现,允许链接器生成完全链接的可执行文件,这些可执行文件是独立于物理内存的最终位置的
  • 简化加载:要把目标文件中的.text等节加载到新创建的进程中,Linux加载器为代码和数据段分配虚拟页,即在页表中添加一个PTE,并标记为无效,将页表条目指向目标文件位置
  • 简化共享:操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而实现代码共享
  • 简化内存分配:当调用例如malloc时,操作系统分配适当大小的虚拟内存页面,并映射到物理内存中相同大小的任意位置上的物理页面

现代计算机系统不允许用户进程修改只读代码段,不允许修改内核代码,不允许读写其他进程的私有内存,不允许修改与其他进程共享的页面,除非通过调用显式的进程间通信系统调用

在一条PTE中,除了有效位外,还有许多标志位,控制读写许可。如果指令违反了这些许可条件,CPU就会触发一个一般保护故障,将控制传给内核中的异常处理程序,Linux shell将这种异常报告为Segmentation fault

地址翻译

地址翻译是一个$N$元素的虚拟地址空间(Virtual Area Space,VAS)中的元素和一个$M$元素的物理地址空间(PAS)中元素的映射

如下图,CPU中有一个页表寄存器(PTBR),指向当前页表,虚拟页号根据当前页表找到PTE,MMU根据得到的PPN和PPO组合出新的物理地址

如下图,当页表命中时,完全是由硬件在处理。MMU根据虚拟地址和当前页表寄存器值得出PTE的地址,并送给Cache/主存,返回PTE之后,如上图所示组合得到物理地址,再一次向主存/Cache请求,得到数据

当发生缺页时,就要硬件和操作系统内核协作完成。MMU首先检测到得到的PTE的有效位为0,就会触发异常,CPU检测到这个异常,就会根据异常表,跳转到内核的缺页异常处理程序,由内核来将缺页读入主存,最后处理程序返回,CPU重新发送缺页指令

为了消除MMU每次都要向内存查询PTE,通常在MMU中设置一个关于PTE的小缓冲(Translation Lookaside Buffer,TLB),这样读取PTE的周期从几百个降到了几个

多级页表

这种方式减少了内存要求,第一如果第一级页表中的PTE为空,相应的二级页表就不会存在,第二只有一级页表总是存在主存中,二级页表只在需要时创建,并缓存在主存中

内存映射

Linux通过一个虚拟内存区域与一个磁盘对象关联起来,以初始化这个虚拟内存区域的内容

进程使用该mmap函数创建新的虚拟内存区域,并将对象映射到这些区域中

1
2
void *mmap(void *start,size_t length,int prot,int flags,
int fd,off_t offset);

动态内存分配

使用动态内存分配器具有更好的移植性。显式分配器就是类似于C中的malloc,隐式分配器就类似于Java中的垃圾回收9

动态内存分配器维护着一个进程的虚拟内存区域,称为

垃圾回收

也是一种动态内存分配器,它自动释放程序不再需要的已分配块

内存错误

内存出错总是让人恐惧的,因为它在时间和空间上,都距离错误出现的源头有一段距离