内存管理之虚拟内存
内存管理是计算机操作系统中最复杂、重要的内容之一,虽然内存的容量在不断地增大,但是还是不足以将系统和用户进程所需要的全部程序、数据装入主存,因此这时候就需要计算机操作系统对内存进行划分和动态分配,而虚拟内存则大大简化了内存管理
以下内容大部分来源于《深入理解计算机操作系统》,以32位处理器为例子,且很多东西都是为了方便理解简化了,和真正的计算机操作系统还是有所不同的
存储器
随机访问存储器(RAM)
分为两类:静态RAM(SRAM)、动态RAM(DRAM)
RAM存储单元的内容可按照需要随机取出或存入,且存取的速度与存储单元的位置无关,一般用于存储程序运行中用到的数据(包括全局变量、局部变量、堆栈段等)。
只要有供电,SRAM就会保持不变。与DRAM不同,SRAM不需要刷新,且存取比DRAM快,但是SRAM的成本和功耗也更高
非易失性存储器
如果断电,DRAM和SRAM会丢失它们的信息,而非易失性存储器则关电后仍然保存着它们的信息
非易失性存储器主要分为ROM和flash
一般ROM会用于存储固化程序,例如PC的BIOS(基本输入/输出系统)
FLASH ROM比普通的ROM读写速度快,擦写方便,一般用来存储用户程序和需要永久保存的数据
虚拟内存
内存管理的功能有:
- 内存空间的分配与回收
- 地址转换
- 内存空间的扩充
- 存储保护
- ……
为了实现这些功能,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)
引入虚拟内存可以把不同进程所使用的地址“隔离”开来,即让操作系统为每个进程分配一套独立的虚拟地址空间,彼此之间互不干涉,如果程序要访问虚拟地址,则由操作系统将其转换成不同的物理地址,这样不同的进程运行的时候,写入的则是不同的物理地址,也就不会产生冲突了
操作系统管理虚拟内存和物理内存之间的关系主要有两种方式:内存分段和内存分页
内存分段
程序是由若干个逻辑分段组成的,比如代码段、数据段、堆栈段等。不同的段有着不同的属性,所以就用分段(Segmentation)的形式把这些段分离出来。内存分段机制下,虚拟地址主要由两部分组成,段选择子(段号+特权等标志位)和段内偏移量,这里只关注段号和段内偏移量:
虚拟地址映射到物理地址则是通过段表来实现的,段表的每个条目都有段基地址和段界限。段基地址包含该段在内存中的开始物理地址,而段界限指定该段的长度。虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
内存分段使程序本身不需要去关心具体的物理内存地址,它能产生连续的内存空间,但它同时也存在内存碎片多和内存交换的效率低(内存碎片太多时就不得不重新交换内存区域,每一次内存交换,都需要把一大段连续的内存数据写到硬盘上,而硬盘的访问速度要比内存慢太多了)等问题,而内存分页机制则有效地解决了这些问题
内存分页
虚拟内存系统会将虚拟内存划分为一个个大小相同的块(32位系统的处理器一般默认块大小为4kb,即2^12),这些块也被称为虚拟页(VP),类似的,物理内存则划分为一个个大小相同的物理页,也称为页帧(PP,大小与虚拟页对应)
引入页机制后,虚拟地址则由虚拟页号+虚拟页偏移量组成,物理地址则由物理页号+物理页偏移量组成,这里虚拟页偏移量=物理页偏移量
计算机使用 SRAM 缓存来表示位于 CPU 和 主存之间的 L1, L2 和 L3 高速缓存,使用 DRAM 缓存来表示虚拟内存系统中的缓存,也就是主存。在存储器层次结构中,DRAM 比 SRAM 慢个大约 10x 倍,磁盘比 DRAM 慢大约 10000x 倍,因此 DRAM 缓存的不命中比 SRAM 缓存中的不命中的成本要昂贵的多,因为 DRAM 缓存不命中需要和磁盘传送数据,而 SRAM 缓存不命中只是和 DRAM 传送数据。
而虚拟页(VP)存储在磁盘上,物理页(PP)缓存在DRAM中,即主存会作为虚拟内存的缓存
在任意时刻,虚拟页面的集合都分为三个不相交的子集:未分配的,缓存的,未缓存的
缓存的:可找到对应的物理页
未分配的:虚拟内存系统还未创建的页,此时不占用空间
未缓存的:没有缓存在物理内存的已创建的页,例如程序中需要某些数据但数据不在物理内存中,这时候会触发缺页异常
对比之前的内存分段机制,内存分页由于内存空间都是一开始就划分好了且大小都是固定的(以页为单位,不会产生无法给进程使用的小内存),于是就不会像内存分段一样会产生间隙非常小的内存,而这正是内存分段会产生内存碎片的原因。内存分页机制下如果内存空间不够,一般操作系统会把其它正在运行的进程中的最近没被使用的页面给释放掉,如果有修改的话则还需要写回磁盘(也有其它的方法),称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。而一般一次性写入磁盘的也只有少数的一个页或者几个页,开销比较小,因此内存交换的效率就相对比较高
实际上,内存分段和内存分页并不是对立的,如段页式内存管理则是两者的一个结合,这种管理方式虽然提高了系统开销,但是也提高了内存的利用率
虚拟内存系统如何通过虚拟地址拿到对应的物理地址?这里就涉及到MMU(内存管理单元)中的地址翻译硬件和存放在物理内存中的页表(Page Table)。
MMU(内存管理单元)
当处理器没有MMU或者MMU没有启用,CPU执行单元发出的内存地址将被直接当为物理地址来使用,而如果处理器启用了MMU,CPU执行单元发出的内存地址将首先被MMU截获,MMU中的地址翻译硬件会将虚拟地址转换为物理地址
现在大多数使用MMU的机器都采用分页机制,即MMU将虚拟地址(VA)转换为物理地址(PA)实际上是以页为单位的(VP->PP),虚拟内存的哪个虚拟页映射到物理内存的哪个物理页是通过页表来描述的,MMU会查找页表来确定一个VA应该映射到什么PA
MMU除了做地址转换之外,还提供内存保护机制。处理器一般拥有用户模式和特权模式,操作系统可以在页表中设置每个页表的访问权限,有些页表不可以访问,有些页表只能在特权模式下访问,而有些页表在用户模式和特权模式下都可以访问,在这个基础之上,访问权限又分为可读、可写、可执行。例如当用户访问一个MMU检查为无权访问的虚拟地址时,MMU会产生一个异常,而内核会把这个异常解释为段错误,并把引发异常的进程终止。一般情况下,处理器在用户空间用用户模式执行用户程序,在内核空间用特权模式执行内核程序。
页表
操作系统为每个进程提供了一个独立的页表。页表是一种特殊的数据结构,它存放着各个虚拟页的状态,每次MMU将一个虚拟地址转换成物理地址都会读取页表
一般页表的每一个页表条目(PTE,一般是4字节)对应着一个虚拟页,且由两部分组成,第一部分记录该虚拟页是否在物理内存,第二部分则记录物理页的位置(物理页号或者物理地址)。PTE 按照虚拟页索引(虚拟页号)(VPN)排序,比如第 0 页位于的起始位置,第 1 页位于第 0 页后面,依此类推。另外VPN的起始地址是根据页表基地址、页大小算出来的,比如页大小为 4KB,那第 0 页的地址就是页表的起始地址,第 1 页的地址就是页表地址+页大小,即 0x00001000,位于第 0 页和第 1 页之间的地址都属于第 0 页。
首先,在虚拟内存中
缓存的:VP0、VP3、VP4、VP7
未缓存的:VP1、VP6
未分配的:VP2、VP5
从上图来看,页表确实就像是一个页表条目的数组,每一个虚拟页都会对应到一个页表条目。这里简单地假设页表条目由有效位+物理页号组成
当有效位为1时,那么根据后面的物理页号可以到 DRAM 找到相应的物理页,根据虚拟页的偏移量可以找到对应的物理地址,即这个物理页缓存了该虚拟页;而当有效位为0时,会触发缺页异常,如果物理页号为null,则表明该虚拟页未分配,否则表明该虚拟页未缓存(如果页表中是未缓存但已分配虚拟内存的话,PTE存放的是虚拟页面在磁盘上的索引,根据这个索引就能找到对应的虚拟页)
页的共享
在一般的开发过程中,经常会有多个程序需要共享同一段代码或数据的情况,在内存分页管理的存储器中,这个事情十分好办,让多个程序共享同一个页面即可。
具体的方法是:使这些相关程序的虚拟空间的虚拟页在页表中指向内存中的同一个物理页。这样,当程序运行并访问这些相关虚拟页时,就都是对同一个物理页进行访问,从而达到共享
页命中
1.首先处理器生成一个虚拟地址,并把它传送给MMU
2.MMU会从寄存器中获取到页表的基地址,根据VPN生成相应的PTE地址,并从DRAM中请求得到它
3.DRAM向MMU返回PTE
4.MMU再根据VPO构造物理地址,并把它传送给DRAM
5.DRAM返回请求的数据给处理器
缺页
前3步与页命中一样,如果PTE的有效位为0时,此时MMU会触发缺页异常,然后系统会调用内核中的缺页异常处理程序,该程序会确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘,并且修改牺牲页中的页表条目,将有效位改成0,接着缺页处理程序将缺失页面拷贝到DRAM中,并更新内存中的PTE,随后返回用户进程。当异常处理程序返回时,它会重启执行导致缺页的指令,该指令会将导致缺页的虚拟地址重新发送到MMU,后面就是页命中了
关于页表的建立
系统加载一个进程后,就需要为该进程创建属于它的页表,因为页表本身也是放在内存中的, 其本质上是由若干物理页组成的,分配物理内存本身就是耗时的,而页表的创建和销毁又是很频繁的操作,另外现在的页表多达四级甚至五级,所以整个过程的系统开销是不可小觑的。加速这一过程最常用的方法就是采用cache,具体的做法是把要提供给页表创建的物理页放到一组叫"quicklist"的链表中,以后销毁页表就往这个quicklist里送,创建页表就直接从这个quicklist里找
TLB
页表一般都很大且还是分多级的,不过这里还是简单地以1级页表为例。由于页表存放在内存中,所以处理器引入MMU后,读取指令、数据需要访问两次内存:首先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据
TLB(translation lookaside buffer,转译后备缓冲器,也称为快表)是一个小的、虚拟寻址的缓存,一般存放在 CPU 内部的高速缓冲存储器 Cache,其中每一行都保存着一个由单个PTE组成的块,简单地说,TLB就是页表的Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。当硬件存在TLB后,虚拟地址转换为物理地址的方式就发生变化了:
首先处理器生成一个虚拟地址,并把它传送给MMU,MMU首先访问TLB,如果TLB中含有相应的PTE,则直接使用该PTE进行地址转换和权限检查;否则MMU访问页表找到PTE后再进行地址转换和权限检查,并将这个PTE填入TLB中(如果TLB已满,则利用相应算法找到一个PTE,然后覆盖它,比如最近未使用的PTE),下次再使用这个虚拟地址时就可以直接使用TLB中的PTE了
TLB刷新与上下文切换
当进程地址空间进行了切换,比如现在是进程1在运行,TLB中放的是进程1相关数据的地址,这时突然切换到进程2,TLB中原有的数据不是进程2相关的,此时TLB需要刷新数据,怎么刷新数据?
目前两种方法:全部刷新或者部分刷新
全部刷新很简单,但开销大。
部分刷新是根据标志位,刷新需要刷新的数据,保留不需要刷新的数据。有一种方法是借助进程ID,linux下的进程都拥有一个独一无二的进程ID,TLB添加了一项ASID(Address Space ID,地址空间ID)的匹配,ASID就类似于进程ID,可以用来区分不同进程的TLB的页表条目。这样在进程切换的时候就不需要对TLB进行全部刷新,但是仍然需要软件管理和分配ASID。
tag类似于虚拟页号,可以用来区分同一进程的不同虚拟页
进程ID取值范围很大,但是ASID一般是8或16 bit,所以只能区分256或65536个进程。ASID的管理可以使用bitmap,操作系统每创建一个新进程,就为之分配一个新的ASID,当ASID分配完后,则刷新整个TLB,然后重新分配ASID。linux内核中的每一个进程都会有一个名为task_struct结构体,因此这里可以把为进程分配的ASID塞到task_struct结构中,而页表基地址寄存器也有空闲位可以用来存储ASID。当进程切换时,可以将页表基地址和从task_struct获得的ASID共同存储在页表基地址寄存器中。当查找TLB时,可以通过对比页表基地址寄存器存储的ASID和TLB表项存储的ASID以及TLB表项存储的tag和虚拟地址中的tag来定位到是哪个进程的哪一个虚拟页,如果两者都相等,则代表TLB 命中,否则TLB 不命中。当TLB 不命中时,需要遍历页表,查找物理页号。然后缓存到TLB中,同时缓存当前的ASID。比较特殊的是,当新建虚拟页和物理页的映射时,是需要更新TLB的,比如建立虚拟页号A->物理页号B的映射关系,此时我们并不知道TLB那边有没有缓存虚拟页A到其它物理页的映射,因此这时候需要更新一次TLB
内核空间和用户空间是分开的,并且内核空间是所有进程共享。既然内核空间是共享的,进程A切换进程B的时候,如果进程B访问的地址位于内核空间,完全可以使用进程A缓存的TLB。但是现在由于ASID是不一样的,导致TLB miss。为了解决这个问题,TLB引入了全局映射,一般针对内核空间的这种全局共享的映射关系被称之为global映射,而针对每个进程的映射称之为non-global映射。所以,我们可以在PTE中引入一个bit(non-global (nG) bit)用来代表这个虚拟页是不是global映射。当虚拟地址映射物理地址关系缓存到TLB时,将nG bit也存储下来。后面判断是否命中TLB时,先比较tag是否相等,再判断是不是global映射,如果是的话,直接为TLB 命中,这种情况下就不需要比较ASID了。而当不是global映射时,最后再通过比较ASID判断是否为TLB 命中。
多级页表
上面虚拟页与物理页通过一张页表进行地址映射,看起来好像已经解决了内存分页寻址的问题,但实际上它还有一个很大的空间上的缺陷。假设在32位4G虚拟地址空间的环境下,每个页表条目需要用4个字节存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表,这仅仅是对于一个进程来说的,每个进程都有自己独立的虚拟地址空间,即都有一张属于自己的页表,如果有100个进程,那么内存将消耗掉400M,这已经是非常大了。
而引入多级页表主要是用于节省内存:第一,如果一级页表中的一个页表条目是空的,那么相应的二级页表就根本不会存在,这代表着一种巨大的潜在节约,因为对于一般程序,4GB的虚拟地址空间的大部分都会是未分配的,而页表是一定要覆盖全部虚拟地址空间的。第二,只有一级页表才需要总是在内存中,虚拟内存系统可以在需要时再去创建、调出二级页表,这就减少了主存的压力,只有最经常使用的二级页表才需要缓存在主存中,其实这就像是把页表当成了页面,当需要用到某个页面时,将此页面从磁盘调入到内存;当内存中页面满了时,将内存中的页面调出到磁盘(利用了程序的局部性,程序执行过程中所用到的指令、数据的地址往往集中在一个很小的范围内,其中的地址、数据经常多次使用)。
这里简单地以32位处理器常用的二级页表为例:
- 全局页目录项 PGD(Page Global Directory);
- 页表项 PTE(Page Table Entry);
二级页表页命中
1.首先处理器生成一个虚拟地址,并把它传送给MMU
2.MMU会从页表基地址寄存器中获取到一级页表(PGD页表)的基地址,根据VPN1生成相应的PGD地址,并从DRAM中请求得到它
3.DRAM向MMU返回PGD
4.MMU根据PGD找到二级页表(PTE页表)的基地址,这里会有二级页表的创建或调出等操作,根据VPN2生成相应的PTE地址,并从DRAM中请求得到它
5.DRAM向MMU返回PTE
6.MMU再根据VPO构造物理地址,并把它传送给DRAM
7.DRAM返回请求的数据给处理器
多级页表虽然解决了内存空间的浪费问题,但是同时也增加了内存的访问次数,由此也就引出了TLB
总结
在多进程的环境下,为了让进程之间互不干涉,操作系统为每个进程分配一套独立的虚拟地址空间,如果程序要访问虚拟地址,则由操作系统将其转换成不同的物理地址,这样不同的进程运行的时候,写入的则是不同的物理地址,也就不会产生冲突了。有了虚拟地址空间,那么操作系统就得提供虚拟地址映射到物理地址的方法,常见的有内存分段、内存分页以及二者相结合的段页式内存管理,内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,它分配的是连续的地址空间,但同时也引来了内存碎片过多和内存交换效率低等问题。而内存分页则有效地解决了这几个问题,它把虚拟空间和物理空间分成大小固定的页,由MMU和页表来实现虚拟页到物理页的映射,同时会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入),这些操作的往往只有一个或几个页,因而内存交换效率比较高。由于页表是一定要覆盖全部虚拟地址空间的,每个进程都有自己的页表,如果把这些页表都放在主存中,这个消耗是十分巨大的,因此这里引入了多级页表,如果一级页表中的一个页表条目是空的,那么相应的二级页表就根本不会存在,虚拟内存系统可以在需要时再去创建、调出二级页表,这就减少了主存的压力。最后由于只使用页表去获取物理地址时访问内存的次数有点多,根据程序的局部性原理,CPU引入了TLB,即快表,它可以缓存最近常被访问的页表项,大大提高了地址的转换速度。
参考文章
深入理解虚拟内存机制:https://www.jianshu.com/p/13e337312651
MMU:https://www.cnblogs.com/alantu2018/p/9002309.html
TLB:https://zhuanlan.zhihu.com/p/108425561
内存管理:https://jishuin.proginn.com/p/763bfbd248c0
《深入理解计算机操作系统》