虚拟内存(上)
概述
在一个系统中,各个进程间是共享CPU和主存资源的,然而主存共享伴随着很多挑战和问题,为了更加有效的管理主存,就有了虚拟内存这个概念。文件是对IO设备的抽象,虚拟内存是对主存和文件的抽象,进程是对处理器、内存和文件的抽象。
虚拟内存是硬件异常,硬件地址翻译,主存,磁盘文件和内核软件的完美交互,为每个进程提供一个大的,一致的和 私有的地址空间,它主要有三个主要能力:
- 将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保留了活动区域,并根据需要在磁盘和主存间来回传送数据,从而高效使用主存。
- 为每个进程提供一致的地址空间。内存的每个字节由一个唯一的数字表示,称为它的地址,所有可能地址的集合称为虚拟地址空间。
- 保护了每个进程的地址空间不被其他进程破坏,即地址空间私有。
一、物理和虚拟寻址
计算机系统的主存被组织为M
个连续的字节大小的单元组成的数组,每个字节都有一个唯一的物理地址,CPU通过物理地址寻址的方式称为物理寻址。
如下图,示例上下文是一条加载指令,它从物理地址4
处开始读取4个字节
的数据字。当CPU执行这条指令的时,会生成一个有效物理地址,通过内存总线传递给主存。主存取出从物理地址4
处开始的4个字节
的数据字,并将其返回给CPU,CPU会将它放在一个寄存器里。
虚拟寻址,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址被送到内存之前先转成相应的物理地址。将一个虚拟地址转成物理地址的任务叫做地址翻译。
二、地址空间
地址空间(address space)是一个非负整数地址的有序集合。如果地址空间中整数是连续的,我们说它是线性地址空间(linear address space)。
-
在一个带虚拟内存的系统中,CPU从一个有
N = 2^n
个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space)。一个地址空间大小是由表示最大地址所需要的位数来描述的,如N = 2^n
个地址的虚拟地址空间叫做n
位地址空间,现在操作系统支持32
位或64
位。 -
一个系统还有物理地址空间,它与系统中物理内存的
M = 2^m
(M
不要求是2
的幂,这里假设为2
的幂)个字节相对应。
【注】地址空间区分了数据对象(字节)和它们的属性(地址),每个数据对象有多个独立的地址(如上面提到的一条加载指令有四个字节的数据字),其中每个地址都选自一个不同的地址空间,这就是虚拟内存的基本思想。主存中的每个字节都有一个选自虚拟地址空间的虚拟地址和选自物理地址空间的物理地址,且虚拟地址和物理地址互相对应。
三、虚拟内存作为缓存的工具
虚拟内存
是计算机系统内存管理的一种技术。它使得进程认为
它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。——虚拟内存与物理内存的联系与区别
虚拟内存被组织为一个由存放在磁盘上的N
个连续的字节大小的单元组成的数组,每个字节都有一个唯一的虚拟地址,作为到数组的索引,磁盘上数组的内容被缓存到主存。
磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页(virtual page,VP)的大小固定的块来处理这个问题,每个虚拟页大小为P = 2^p
个字节。类似的物理内存被分割为物理页(physical page,PP),大小也为P
字节(物理页也被称为页帧)。
虚拟页面的集合都被分为三个不相交的子集:
- 未分配的:未分配的页没有任何数据与他们相关联,所以不占用物理内存;
- 缓存的:已缓存在物理内存的已分配页;
- 未缓存的:未缓存在物理内存中的已分配页;
3.1 页表
SRAM
缓存表示位于CPU和主存之间L1
、L2
和L3
高速缓存,DRAM
缓存表示虚拟内存系统的缓存,它在主存中缓存虚拟页。
当计算机使用到一个虚拟页时,虚拟内存系统(VM)首先判定虚拟页在DRAM
是是否存在,如果命中,VM再确定这个虚拟页放在哪个物理页中;如果不命中,VM会判断这个虚拟页放在磁盘的哪个位置,找到它并在DRAM
中选择一个牺牲页,将虚拟页复制到该牺牲页中(牺牲页中可能缓存着其他虚拟页,所以原来的内容会被替换),如果牺牲页的内容被修改,内核就会将其中的内容复制到磁盘。
页表是一个存放在内存中的页表条目(page table entry,PTE
)的数组,负责将虚拟页映射到物理页。虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE
,PTE
由有效位
和n位地址字段
组成:
- 有效位表明该虚拟页是否被缓存在
DRAM
中; - 如果在,地址字段就表示
DRAM
中相应物理页的起始位置; - 如果不在,地址字段就指向该虚拟页在磁盘上的起始位置;
3.2 页命中与缺页
-
页命中:一个页命中的过程,就是一个虚拟地址转换为物理地址的过程。
-
缺页:
DRAM
缓存不命中称为缺页(page fault),缺页会触发一个缺页异常,异常调用内核中的缺页异常处理程序,在DRAM
中选择一个牺牲页。
3.3 分配页面
操作系统分配一个新的虚拟内存时,如调用malloc
,首先在磁盘上面创建空间并更新页表的PTE
,使其中某个PTE
从原来指向null
,变成指向磁盘上新创建的页面,此时虚拟页从未分配状态变为未缓存。
四、虚拟内存作为内存管理的工具
页表的功能是将虚拟地址空间映射到物理地址空间,操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间,且每个进程的虚拟地址空间结构都一样。多个虚拟页可以映射到同一个共享物理页面上,如下图,进程i
的VP2
和进程j
的VP1
映射到物理内存的共享页面PP7
。
简化连接。独立地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。
简化加载。加载可执行文件和共享文件,要把目标文件中的.text
和.data
节加载到一个新创建的进程中,加载器为代码段和数据段分配虚拟页,并将其标记为无效(未缓存),将页表条目指向目标文件中适当的位置,加载器不会将任何数据从磁盘复制到内存。
简化内存分配。当一个运行在用户进程中的程序要求额外的堆内存时(调用malloc
),操作系统会分配k
个连续的虚拟内存页面,并将它们映射到物理内存中任意位置的k
个物理页面。即只需虚拟页面连续,而物理页面随机。
五、虚拟内存作为内存保护的工具
每次CPU生成一个地址时,地址翻译硬件都会读一个页表条目PTE
,为了防止内存被非法访问,会在PTE
上添加一些额外的许可位来控制对一个虚拟页面内容的访问。
sup
,进程是否运行在内核模式下才能访问。read
、write
,读写控制访问。
六、地址翻译
地址翻译就是把一个N
元素虚拟地址空间(VAS)中的元素映射到M
元素物理地址空间(PAS)中的元素上。
- CPU中有一个
页表基地址寄存器
(Page Table Base Register, PTBR)指向当前页表,用于快速定位。 n
位的虚拟地址
划分为p
位的虚拟地址偏移
VPO和(n - p)
位的虚拟页号
VPN。m
位物理地址
划分为p
位的物理地址偏移
PPO和(m - p)
位的物理页号
PPN。- 因为虚拟页和物理页大小都是
P
字节,所以VPO
和PPO
是相同的。MMU利用VPN
来选择PTE
,进而得到PPN
,由于VPO
与PPO
相同,因此将PPN
与VPO
串联起来就得到了物理地址。
当页面命中时,CPU硬件的执行步骤:
- 1)处理器生成一个虚拟地址,并把它传送给
MMU
- 2)
MMU
生成PTE
地址,并从高速缓存/主存请求得到它 - 3)高速缓存/主存向
MMU
返回PTE
- 4)
MMU
构造物理地址,并把它传送给高速缓存/主存 - 5)高速缓存/主存返回所请求的数据字给处理器
处理缺页则要求硬件和操作系统内核协作完成,具体步骤:
- 1-3)前三步与处理页命中的步骤相同
- 4)
PTE
中的有效位为0
,所以MMU
触发了一次异常,传递CPU中的控制到操系统内核中的缺页异常处理程序 - 5)缺页处理程序确定出物理存储器中的牺牲页,如果这个页面被修改了,则把它换出到磁盘。
- 6)缺页处理程序页面调入新的页面,并更新存储器中的
PTE
- 7)缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给
MMU
6.1 结合高速缓存和虚拟内存
结合高速缓存和虚拟内存,大多数系统都是选择物理地址来访问高速缓存。下图显示物理寻址的高速缓存如何和虚拟内存结合,主要思路是地址翻译发生在高速缓存查找之前,注意页表条目可以缓存。
6.2 利用TLB加速地址翻译
每次CPU产生一个虚拟地址,MMU
就必须查阅一个PTE
,以便将虚拟地址翻译为物理地址。在糟糕时会要求从内存取一次数据,这样的代价是几十到几百个周期。如果PTE
碰巧在L1
中,那么开销就下降到1个或2个周期。为了消除这种开销,提高地址翻译的效率,许多系统在MMU
中还引入了一个关于PTE
的小的缓存,称为翻译后备缓冲区TLB
(translation lookaside buffer)。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE
组成的块。
使用TLB
后在页命中时,所有地址翻译步骤都在芯片上的MMU
中执行,因此非常块。TLB
页命中流程:
- 1)CPU产生一个虚拟地址
- 2)
MMU
从TLB
中取出相应的PTE
- 3)
MMU
将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存 - 4)高速缓存/主存将所请求的数据字返回给CPU
当TLB
不命中时,MMU
必须从L1
缓存中取出相应的PTE
,新取出的PTE
存放在TLB
中,可能覆盖一个已经存在的条目。
6.3 多级页表
如果有一个32
位的地址空间,页面大小为4KB
,PTE
大小为4
字节,则一共有232/4K = 220= 1M个页面,也就需要1M
个PTE
,也就需要一个(4 * 1)MB
的页表,那么即使应用所引用的只是虚拟地址空间中的很小的一部分,也总是需要一个4MB
的页表驻留在存储器中。
用来压缩页表的常用方法是使用层次结构的页表。以上述情况为例,还假设在这一时刻,虚拟地址空间有如下形式:虚拟内存的前2K
个页面分配给了代码和数据,接下来的6K
个页面还未分配,再接下来的1023
个页面页未分配,接来下的1
个页面分配给了用户栈。
为这个虚拟地址构造一个两级的页表层次结构:
- 一级页表的每个
PTE
负责映射虚拟地址空间中一个4MB
的片,这里每一片都是由1024
个连续的页面组成的。这样一来,在32
位的地址空间中,1024
个PTE
足以覆盖整个空间。(232/4K) / 210 = 210 = 1K。
如果片i
中的每个页面都未被分配,那么一级PTE i
就为空,例如图中片2~7
。如果在片i
中至少有一个页是分配了的,那么一级PTEi
就指向一个二级页表的基址。例如图中片0、1、8
。 - 二级页表中的每个
PTE
都负责映射一个4KB
的虚拟页面。注意,使用4
字节的PTE
,每个一级和二级页表都是4KB
字节,这刚好和一个页面的大小一样。
使用多级页表的方式从两个方面减少了对内存是需求:
- 1)如果一级页表中的一个
PTE
是空的,那么相应的二级页表就根本不会存在,这代表这一种巨大的潜在节约; - 2)只有一级页表才需要总是存在主存中,虚拟内存系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存的压力,只有最经常使用的二级页表才需要缓存在主存中。
使用 k
级页表层次结构的地址翻译如下图所示。虚拟地址被划分为 k
个VPN
和 1
个VPO
,每个VPN i
都是一个到第 i
级页表的索引,其中1 <= i <= k
。第k
级页表中的每个PTE
包含某个物理页面的PPN
或一个磁盘块的地址。在能够确定PPN
之前,MMU
必须访问K
个PTE
。对于只有一级的页表结构,PPO
和VPO
是相同的。
虽然要访问 k
个PTE
,但由于TLB
的作用,不同层次上页表的PTE
被缓存起来。因此多级页表的地址翻译并不比单级页表的慢很多。
七、Linux 内存系统
Linux为每个进程维护了一个单独的虚拟地址空间,结构如下图所示:
内核虚拟内存:
- 内核虚拟内存的某些区域被映射到所有进程共享的物理页面。例如,每个进程共享内核的代码和全局数据结构。Linux也将一组连续的虚拟页面(大小等于
DRAM
的总量)映射到相应的一组连续的物理页面,为内核提供了一种便利的方法来访问物理内存中任何特定的位置。 - 内核虚拟内存的其他区域包含每个进程都不相同的数据。例如,页表、内核在进程上下文中执行代码时使用的栈、以及记录虚拟地址空间当前组织的各种数据结构。
Linux虚拟内存区域:Linux将虚拟内存组织成一些区域(也叫做段)的集合,一个区域就是已经存在的(已分配的)虚拟内存的连续片,这些页是以某种方式相关联的。如代码段、数据段、堆、共享库段和用户区都是不同是区域。
- 只要是存在的虚拟页就保存在某个区域中,不属于某个区域的虚拟页面是不存在的,并且不能被进程所引用。
- 区域的存在允许虚拟地址空间有间隙。
- 内核不记录不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源,由此节省空间。
下图强调了记录一个进程中虚拟内存区域的内核数据结构。内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct
)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID
、指向用户栈的指针、可执行目标文件的名字以及程序计数器)。
任务结构task_struct
的mm
指针指向了mm_struct
,该结构描述了虚拟内存的当前状态。mm_struct
的pgd
指针指向该进程的第一级页表(页全局目录)的基址。mmap
指针指向了vm_area_struct
(区域结构)链表,每个vm_area_struct
都描绘了当前虚拟地址空间的一个区域。当内核运行这个进程时,它就将pgd
存放在CR3
控制寄存器中。区域结构包含以下几个部分:
vm_start
:指向这个区域的起始处vm_end
:指向这个区域的结束处vm_port
:描述这个区域内包含的所有页的读写许可权限vm_flags
:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的vm_next
:指向链表中下一个区域结构
7.1 Linux 缺页异常处理
当MMU
翻译一个虚拟地址A
时发生发生缺页异常,该异常使控制转移到内核的缺页处理程序,程序执行如下步骤:
- 1)判断
A
是否合法,即A
是否在某个区域内。缺页处理程序搜索区域结构的链表,将虚拟地址与每个区域结构的vm_start
和vm_end
进行比较,由此判断虚拟地址是否合法,即是否在某个区域结构定义的区域内。若不合法,则缺页处理程序触发段错误,终止进程。 - 2)判断进程是否有读、写、执行这个区域内页面的权限,即内存访问是否合法。若不合法,则触发一个保护异常,终止进程。
- 3)若是通过了上述两步,则说明该缺页是对一个合法地址的合法操作导致的,由此则可以处理缺页。选择一个牺牲页,如果牺牲页被修改过,那么把它交换出去,换入新的页面并更新页表。缺页处理程序返回时,
CPU
重新启动引起缺页的指令,这条指令将再次发送A
到MMU
。
八、内存映射
虚拟内存区域是和磁盘中的文件对应的,Linux通过将一个虚拟内存区域与一个磁盘上的对象(也就是文件)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。虚拟内存区域可以映射到以下两类对象:
- 1)Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,比如一个可执行文件。文件区被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页没有实际交换进入物理内存,直到CPU第一次引用虚拟页面(发射一个虚拟地址,落在这个页面的地址空间范围之内)。
- 2)匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建,内容全是二进制零。CPU第一次引用这样一个区域的虚拟内存时,内核就在物理内存中找一个合适的牺牲页面,如果页面被修改过就将页面换出来,用二进制零覆盖牺牲页面并更新页表,并将这个页面标记为留在内存中。在磁盘和内存间没有实际的数据传输,因此映射到匿名文件中的页叫做请求二进制零的页(demand-zero page)。
无论哪种情况,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫交换空间、交换区域。注意该空间限制当前运行的进程能够分配的虚拟页面的总数。
8.1 共享对象
操作系统为每个进程提供私有的虚拟地址空间,可以免受其他进程读写的干扰。但对于每个进程都要访问的相同的只读代码区域,如果每个进程在物理内存中保存一份副本,那就是极大的浪费。因此还是希望进程能够共享某些对象,即多个进程共享内存中的同一份资源。
一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。一个映射到共享对象的虚拟内存区域叫做共享区域,类似的,也有私有区域。
- 若进程将一个共享对象映射到虚拟内存的一个区域,则该进程对这个区域的任何写操作,对那些也把该共享对象映射到虚拟内存的其他进程而言是可见的。并且,对象的变化会反映到磁盘上的原始对象上。对于下图有三个说明:1)共享文件映射到进程
1
和进程2
的地址空间不一定相同;2)共享对象被映射到多个共享区域,但内存中只需存放一份共享对象的副本即可;3)物理页面一般不连续,但是虚拟页面连续。
- 反之,进程对映射到私有对象的区域所做的改变对其他进程不可见,且进程对该区域所做的任何写操作也不会反应在磁盘上的对象上,私有对象使用一种叫做写时复制的巧妙技术被映射到虚拟内存中。对于下图有两个说明:1)在物理内存中保存私有对象的一个副本,只要没有进程试图写私有区域,进程就可以一直共享物理内存中对象的一个单独副本,且每个进程私有区域的页表条目都被标记为只读,区域结构被标记为私有写时复制。2)若有进程试图写私有区域的某个页面,会触发一个保护故障,它会在内存中创建这个被写页面的新副本,然后更新页表条目指向新副本,并恢复这个页面的可写权限。
8.2 fork 函数
Linux下可以使用fork
函数创建新的进程,显然创建的新进程带有自己独立的虚拟地址空间。在当前进程调用fork
函数时,内核为新进程创建各种数据结构,并为其分配唯一的进程ID
。为给新进程创建虚拟内存,创建当前进程的mm_struct
、区域结构和页表的副本。两个进程中的每个页面都标记为只读,两个进程中每个区域结构都标记为私有写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中某个进行写操作时,写时复制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
8.3 execve 函数
execve
函数在当前进程中加载、运行包含在可执行文件a.out
中的程序,用a.out
有效代替当前程序,步骤如下:
- 1)删除当前进程虚拟地址的用户部分已存在的区域结构。
- 2)映射私有区域,如代码区、数据区分别映射
a.out
文件的.text
、.data
区等。 - 3)映射共享区域,如
a.out
与libc.so
库链接,则将库映射到用户空间的共享区域。 - 4)设置当前进程上下文的程序计数器,使之指向代码区域的入口。