导语|本文主要整理了计算机中的内存结构,以及CPU是如何读写内存中的数据的,如何维护CPU缓存中的数据一致性。什么是虚拟内存,以及它存在的必要性。如有不当之处请多多指教。
目前在计算机中,主要有两大存储器SRAM和DRAM。主存储器是由DRAM 实现的,也就是我们常说的内存,在CPU里通常会有L1、L2、L3这样三层高速缓存是用SRAM实现的。
SRAM被称为“静态”存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。
目前SRAM主要集成在CPU里面,每个CPU核心都有一块属于自己的L1高速缓存,通常分成指令缓存和数据缓存,分开存放CPU使用的指令和数据。L2的Cache同样是每个CPU核心都有的,不过它往往不在CPU核心的内部。所以,L2 cache的访问速度会比L1稍微慢一些。而L3 cache,则通常是多个CPU核心共用的。
在DRAM中存储单元使用电容保存电荷的方式来存储数据,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失,这也是被称为“动态”存储器的原因。由于存储DRAM一个bit只需要一个晶体管,所以存储的数据也大很多。
我们来看一些他们的速度:
上面我们说了,对于CPU来说,SRAM被称为CPU的cache,CPU每次获取数据都会先访问cache,如果获取不到数据则把数据加载到cache中进行访问。因为cache的大小是远远小于主存的,所以还需要在cache和主存之间维护一个映射关系,这样才能正确找到数据。
一种简单的方式是直接映射,有点像我们把数据找出来,直接放入到map中进行存储一样,映射通过地址和cache的数量进行取模后获取到cache中主存的地址去获取数据。
(地址)mod(cache 中的块数)
上图中画了一个地址去找cache的情况。对于cache来说可以划分为:
比如对于这个cache有1024个字,即4KB。假设有一个32位的地址要去cache中查找数据,那么会取地址10位进行取模找到对应的cache行,然后取出20位与cahce标记位进行比较,如果相等,并且有效位开启,那么对应请求地址在cache中命中。否则,发生缺失。
由于CPU在读取数据的时候,并不是要读取一整个Block,而是读取一个他需要的数据片段,cache中命中之后会根据低两位的偏移去数据里面索引到对应的字。
除了上面说的直接映射以外还有组相联和全相联。
组相联就是使用组索引代替了原来的索引,下图中表示每组有2行数据,通过组索引找到对应的数据行之后通过有效位和标记对组中每一行进行检索,如果能匹配上就说明命中。
先来看看读操作,cache在初始状态的时候是空的,这个时候去读数据会产生cache缺失(cache miss)。cache控制器会检测到缺失的发生,然后从主存中(或低一级cache)中取回所需数据。如果命中,那么就会直接使用。
当cache缺失时,对于乱序执行处理器而言依然能执行一些其他指令,但是对于顺序执行处理器,当cache缺失时会被阻塞,临时寄存器和程序员可见的寄存器中的内容基本被冻结。
再来看看写操作,因为cache是由多级组成,所以写策略一般而言有两种:写直达(write-through)和写回(write-back)。通过这两种策略使在cache中写入的数据和主存中的数据保持一致。
写直达就是在将数据写入cache之后同时将这个数据立马写入到主存中,但是由于主存和cache本身性能差异,那么每次在写入主存的时候都将花费大量的时间。解决办法就是加一层写缓冲(write buffer),这样CPU在将数据写入cache和缓冲之后可以继续执行,等到缓冲写入到主存中再释放。
但是如果写入速度大于缓冲释放速度,那么还是会阻塞CPU执行。那么可以考虑一下写回策略,这种机制会在每次写入的时候仅将新值写入cache中,只有当修改过的块被替换时才需要写到较低层存储结构中。
由于现在都是多核CPU,并且cache分了多级,并且数据存在共享的情况,所以需要一种机制保证在不同的核中看到的cache数据必须是一致的。最常用来处理多核CPU之间的缓存一致性协议就是MESI协议。
MESI协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个CPU核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个CPU核心写入cache之后,它会去广播一个“失效”请求告诉所有其他的CPU核心。
MESI协议对应的四个不同的标记,分别是:
“已修改”和“已失效”比较容易理解,我们来看看 独占”和“共享” 两个状态。
在独占状态下,对应的cache Line只加载到了当前CPU核所拥有的cache 里。其他的CPU核,并没有加载对应的数据到自己的cache里。这个时候,如果要向独占的cache Block写入数据,我们可以自由地写入数据,而不需要告知其他CPU核。
那么对应的,共享状态就是在多核中同时加载了同一份数据。所以在共享状态下想要修改数据要先向所有的其他CPU核心广播一个请求,要求先把其他CPU核心里面的cache,都变成无效的状态,然后再更新当前cache里面的数据。
在我们日常使用的Linux或者Windows操作系统下,程序并不能直接访问物理内存。程序都是通过虚拟地址VA(virtual address)用地址转换翻译成PA物理地址(physical address)才能获取到数据。也就是说CPU操作的实际上是一个虚拟地址VA。
如上图,CPU访问主存的时候会将一个虚拟地址(virtual address)被内存管理单元(Memory Management Unint, MMU)进行翻译成物理地址PA(physical address) 才能访问。
想要把虚拟内存地址,映射到物理内存地址,最直观的办法,就是来建一张映射表。这个映射表在计算机中叫页表(Page Table)。
在查找页表的时候,会将虚拟地址分成页号(Directory)和偏移量(Offset)两个部分。前面的高位,表示内存地址的页号。后面的低位,表示内存地址里面的偏移量。
查找方式和上面说的组相联类似,首先使用虚拟页号作为索引去页表中找到对应的物理页号,页表中还会有1位表示有效位,如果该位无效就不在主存中,发生一次缺页;如果有效,那么就可以拿到对应的物理页号获取到对应的物理页位置,再根据偏移量得到物理内存地址。
如果有效位关闭,那么该页就只存在磁盘上的某个指定的磁盘地址。缺页会触发缺页异常,然后在闪存或磁盘中找到该页,将其放入到主存DRAM中。
如果主存满了,那么会选择一个牺牲页,大多数操作系统会使用LRU替换策略来进行页的替换。操作系统会查找最少使用的页,被替换的页会写入磁盘的交换区(swap分区)。swap分区通常被称为交换分区,这是一块特殊的硬盘空间,即当实际内存不够用的时候,操作系统会从内存中取出一部分暂时不用的数据,放在交换分区中,从而为当前运行的程序腾出足够的内存空间。
在下图中假设选择将存放在主存中的VP6进行替换,将VP6替换为VP3。如果被替代的VP6已经被修改了,那么内核会将它复制回磁盘。
由于局部性(locality)的存在,程序一般而言会在一个较小的活动页面集合上工作,页的切换开销只存在于程序启动时将页面调度到内存中,接下来的程序都会页命中。但是如果代码的工作集太大,超过了物理内存大小,那么页面就会不停地换进换出,产生抖动。
假设我们现在是一个32位的地址空间、4KB的页面和一个4字节的PTE,那么需要一个4MB的页表常驻在内存中,并且这个页表是每个进程都独占一份,所以会造成很大的内存浪费,我们需要一种方式来优化我们的页表空间存储。
想一下虚拟内存空间结构,整个4GB的空间,操作系统用了1GB,从地址 0XC0000000到0XFFFFFFFF, 剩余3GB留给用户空间,其实很多程序根本用不到这么大的空间,对于64位系统,每个进程都会拥有256 TiB的内存空间,那就更加用不上了。
那么对于用不上的空间,我们可以不可以不把它加载到页表里面,等到用这块空间的时候才在页表里面给它分配一个页表项,是不是就可以节省大量空间。
在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。所以,在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而多级页表,就特别适合这样的内存地址分布。
假设32位虚拟地址空间被划分为4KB每页,每个条目都是4字节,那么我们可以让第一级页表中的每个PTE(页表项 page table entry)负责映射虚拟地址空间中一个4MB的片,这个片由1024个连续页面组成,表示二级页表。如果地址空间是4GB,那么1024个一级页表项就可以覆盖整个空间。
如下图所示,内存前2K个页面给代码和数据,接下来6K个页面未分配,再接下来1023个页面也未分配,接下来一个页面分配给用户栈。
这种方法从两个方面减少了内存占用。第一,如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在。由于很多程序占用内存实际远小于页表所能表示的大小,所以可以节约很大空间的页表项资源;第二,只有一级页表才需要总是在主存中,二级页表会在需要的时候创建或销毁,只有最经常使用的二级页表才需要缓存在主存中,这就减少了主存的压力。
Linux在2.6.10中引入了四层的页表辅助虚拟地址的转换:
首先会找到4级页表里面对应的条目(Entry)。这个条目里存放的是一张3级页表所在的位置。4级页面里面的每一个条目,都对应着一张3级页表,所以我们可能有多张3级页表。
找到对应这张3级页表之后,我们用3级索引去找到对应的3级索引的条目。3级索引的条目再会指向一个2级页表。依次拿到1级页表里面存储的物理页号,我们同样可以用“页号+偏移量”的方式,来获取最终的物理内存地址。
对于一个页命中的数据获取过程通常来说,如果没有TLB加速是这样的:
一次简单的数据获取需要多次经过多次与内存的交互,如果是4级页表,那么就需要访问4次内存才能获取到对应的物理页号。如果是缺页,还需要有一个PTE的置换或加载过程。在开头也讲了,访问内存的性能其实很低的,实际上这严重影响了CPU处理性能。
程序所需要使用的指令,都顺序存放在虚拟内存里面。我们执行的指令,也是一条条顺序执行下去的。也就是说,我们对于指令地址的访问,存在前面几个章节里所说的“空间局部性”和“时间局部性”,而需要访问的数据也是一样的。我们连续执行了5条指令。因为内存地址都是连续的,所以我们可以通过加缓存的方法,把之前内存转换的地址缓存下来,减少与内存的交互。
加的这一层就是缓存芯片TLB(Translation Lookaside Buffer),它里面每一行保存着一个由单个PTE组成的块。
那么查询数据的过程就变成了:
讲完了什么是虚拟内存,我们最后讲讲虚拟内存的必要性。
由于操作虚拟内存实际上就是操作页表,从上面讲解我们知道,页表的大小其实和物理内存没有关系,当物理内存不够用时可以通过页缺失来将需要的数据置换到内存中,内存中只需要存放众多程序中活跃的那部分,不需要将整个程序加载到内存里面,这可以让小内存的机器也可以运行程序。
虚拟内存可以为正在运行的进程提供独立的内存空间,制造一种每个进程的内存都是独立的假象。虚拟内存空间只是操作系统中的逻辑结构,通过多层的页表结构来转换虚拟地址,可以让多个进程可以通过虚拟内存共享物理内存。
并且独立的虚拟内存空间也会简化内存的分配过程,当用户程序向操作系统申请堆内存时,操作系统可以分配几个连续的虚拟页,但是这些虚拟页可以对应到物理内存中不连续的页中。
再来就是提供了内存保护机制。任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。虚拟内存中页表中页存放了读权限、写权限和执行权限。内存管理单元可以决定当前进程是否有权限访问目标的物理内存,这样我们就最终将权限管理的功能全部收敛到虚拟内存系统中,减少了可能出现风险的代码路径。
从上面我们可以知道CPU的缓存结构一般由L1、L2、L3三层缓存结构组成,CPU读取数据只与缓存交互,不会直接访问主存,所以CPU缓存和主存之间维护了一套映射关系。当被查找的数据发生缺失时,需要等待数据从主存加载到缓存中,如果缓存满了,那么还需要进行淘汰。如果被淘汰的数据是脏数据,那么还需要写回到主存中,写的策略有写直达(write-through)和写回(write-back)。
由于现在计算机中的CPU都是多核的,并且缓存数据是由多核共享的,所以就有了类似MESI这样的协议来维护一个状态机保证数据在多核之间是一致的。
为了访问数据安全,便捷,迅速所以加了一层虚拟内存,每个程序在启动的时候都会维护一个页表,这个页表维护了一套映射关系。CPU操作的实际上是虚拟地址,每次需要MMU将虚拟地址在页表上映射成物理地址后查找数据。并且为了节省内存所以设计了多级页表,为了从页表中查找数据更快加了一个缓存芯片TLB。