前面学习了蛮久的CPU Cache内容,CPU Cache用来做CPU与主存间的数据缓存,CPU Cache的地址与主存间的地址再用映射策略来映射起来,的映射策略有直接映射,组相连映射,全相连映射。当CPU想要查找某个地址的数据时,会携带TAG、Index、Offset三样数据来去CPU Cache中查找是否有缓存。那么,这里有个疑问,TAG的作用我们知道,当根据Index查找到对应的Cache Line后,根据Cache Line中的Cache来判断是不是要找的那一块主存地址,那么TAG是怎么取的呢,他是怎么代表物理地址的唯一性的,如果他能代表唯一性,为什么不直接使用TAG来查找地址呢?
Q: TAG是根据什么算出来的,为什么使用TAG作为标识而不作为地址直接使用?
A: 其实只要知道TAG + Index + Offset = 主存地址,就OK了,那么根据TAG + Index则一定可以取得唯一的 Main Memory Block
,而为什么不是直接使用地址进行标识呢,也很简单,使用Index可以更快的查找到Cache Line的地址,就根数组类似Cache Line[Index]如此。
Q: 再说下直接映射、组相连映射、全相连映射的优缺点。
A: 直接映射逻辑简单,对应的硬件设计也会简单轻松。直接映射就是取模,模相同的主存地址就使用同一块Cache Line
。缺点是当同意时间内访问的都是模相同的不同地址时,那么此时都是从同一Cache Line访问的,这时就会导致缓存失效,都从主存上读取数据,这种现象被称为Cache颠簸。为了解决Cache颠簸问题,引入了组相连映射的方式,与直接映射相比呢,最大的区别就是多了组的概念,以两组相连映射举例,即一个Index下,有两个地方都能存放Cache,比如0x80与0x40都可以存放到Index为0x20的位置,那么就可以把0x80放到组1、0x40放到组2、那么此时来回读取0x80和0x40也不会发生Cache颠簸的问题了,两组相连映射时,查找一个物理地址时,根据Index找到两个组的地址,再分别比较两个组的TAG,多组相连映射即对比多个组的TAG。全相连可以认为全是组,没有Index只有TAG,查找数据时需要便利所有TAG来找,直接没有了Cache颠簸问题,但相对的硬件成本更高。
我们知道有虚拟内存的概念,那么CPU Cache里面存放的是虚拟地址还是物理地址呢?
CPU Cache存放的是虚拟地址还是物理地址呢?
如何看CPU Cache里面存放的是虚拟地址还是物理地址呢?那就需要看MMU是什么时机起作用的了。如果介于CPU Cache与Main Memory之间,那么就是VIVT(虚拟高速缓存),即CPU Cache中存放的是虚拟地址,CPU直接用虚拟地址在CPU Cache中查找数据,当Cache Miss时,则使用MMU把虚拟地址转换成物理地址,再去主存中查找。VIVT引入了两个问题:
别名
何为别名,就是同一物理地址被映射到不同的虚拟地址,这在不同进程间很常见,那么此时由于Virtual Index和Virtual Tag,CPU Cache判断这俩虚拟地址不是同一物理地址,那么当这两处虚拟地址的修改则是相互不可见的,就会引发数据错乱的问题。
歧义
何为歧义,就是相同的虚拟地址映射到的是不同的物理地址,因为Tag与Virtual都是虚拟的,它想要与物理地址联系起来必须通过MMU、在没有经过MMU翻译前、是会存在不同的物理地址被相同的Tag+Virtual表达的,那么就会出现明明想要地址A的数据,却读到了地址B的数据,这是很危险的。
怎么解决上述两个问题呢?我们先来看下PIPT
PIPT 物理高速缓存
当MMU作用于CPU于CPU Cache之间时,那么CPU Cache里面所使用的地址就是物理地址,其所使用的Tag + Index是可以与主存一一对应起来的,那么此时还存在上面的别名和歧义问题吗,自然不会有。看起来这样很好,有什么缺点呢?时间上,当MMU作用于CPU与CPU Cache之间,那么意味着,每次CPU想要取些数据,都需要经过MMU翻译才可以,只有翻译完成后,使用翻译后的物理数据再与CPU Cache交互,经过了一层MMU,性能下降了。
怎么解决性能下降问题呢?
VIPT 物理标记的虚拟高速缓存
如果可以将地址翻译与寻找一起做的话,使用并行的思想去搞, 速度不就上来了吗,那么怎么在没翻译前,还能寻找Cache Line呢,那么就使用虚拟Index,整个过程就是,使用虚拟Index查找CPU Cache的同时,使用MMU翻译地址得到物理Tag,再与通过Index寻找到的Cache Line的Tag比较,相同则hit,不同则miss。
那么此时还存在别名或歧义问题吗?
别名
何为别名,就是同一物理地址被映射到不同的虚拟地址,那么存在这种情况吗。由于Index是虚拟的,假设系统使用的是直接映射高速缓存,cache大小是8KB,cacheline大小是256字节。这种情况下的VIPT就存在别名问题。因为index来自虚拟地址位<12…8>,虚拟地址和物理地址的位<11…8>是一样的,但是bit12却不一定相等。 假设虚拟地址0x0000和虚拟地址0x1000都映射相同的物理地址0x4000。那么程序读取0x0000时,系统将会从物理地址0x4000的数据加载到第0x00行cacheline。然后程序读取0x1000数据,再次把物理地址0x4000的数据加载到第0x10行cacheline。这不,别名出现了。相同物理地址的数据被加载到不同cacheline中。
歧义
何为歧义,就是相同的虚拟地址映射到的是不同的物理地址,存在这种情况吗,显然因为Tag对于物理内存可以认为是唯一的,就不存在会有相同的P Tag + V Index可以指向不同的 P Tag + P Index的。
如何解决VIPT Cache别名问题
我们接着上面的例子说明。首先出现问题的场景是共享映射,也就是多个虚拟地址映射同一个物理地址才可能出现问题。我们需要想办法避免相同的物理地址数据加载到不同的cacheline中。如何做到呢?那我们就避免上个例子中0x1000映射0x4000的情况发生。我们可以将虚拟地址0x2000映射到物理地址0x4000,而不是用虚拟地址0x1000。0x2000对应第0x00行cacheline,这样就避免了别名现象出现。因此,在建立共享映射的时候,返回的虚拟地址都是按照cache大小对齐的地址,这样就没问题了。如果是多路组相连高速缓存的话,返回的虚拟地址必须是满足一路cache大小对齐。在Linux的实现中,就是通过这种方法解决别名问题。
总结
VIVT Cache问题太多,软件维护成本过高,是最难管理的高速缓存。所以现在基本只存在历史的文章中。现在我们基本看不到硬件还在使用这种方式的cache。现在使用的方式是PIPT或者VIPT。如果多路组相连高速缓存的一路的大小小于等于4KB,一般硬件采用VIPT方式,因为这样相当于PIPT,岂不美哉。当然,如果一路大小大于4KB,一般采用PIPT方式,也不排除VIPT方式,这就需要操作系统多操点心了。
TLB
TLB是translation lookaside buffer的简称。首先,我们知道MMU的作用是把虚拟地址转换成物理地址。虚拟地址和物理地址的映射关系存储在页表中,而现在页表又是分级的。64位系统一般都是3~5级。常见的配置是4级页表,就以4级页表为例说明。分别是PGD、PUD、PMD、PTE四级页表。在硬件上会有一个叫做页表基地址寄存器,它存储PGD页表的首地址。MMU就是根据页表基地址寄存器从PGD页表一路查到PTE,最终找到物理地址(PTE页表中存储物理地址)。四级页表查找过程需要四次内存访问。延时可想而知,非常影响性能。由此引入了TLB,TLB其实就是一块高速缓存。数据cache缓存地址(虚拟地址或者物理地址)和数据。TLB缓存虚拟地址和其映射的物理地址。TLB根据虚拟地址查找cache,它没得选,只能根据虚拟地址查找。所以TLB是一个虚拟高速缓存。硬件存在TLB后,虚拟地址到物理地址的转换过程发生了变化。虚拟地址首先发往TLB确认是否命中cache,如果cache hit直接可以得到物理地址。
TLB的别名问题
我先来思考第一个问题,别名是否存在。我们知道PIPT的数据cache不存在别名问题。物理地址是唯一的,一个物理地址一定对应一个数据。但是不同的物理地址可能存储相同的数据。也就是说,物理地址对应数据是一对一关系,反过来是多对一关系。由于TLB的特殊性,存储的是虚拟地址和物理地址的对应关系。因此,对于单个进程来说,同一时间一个虚拟地址对应一个物理地址,一个物理地址可以被多个虚拟地址映射。将PIPT数据cache类比TLB,我们可以知道TLB不存在别名问题。而VIVT Cache存在别名问题,原因是VA需要转换成PA,PA里面才存储着数据。中间多经传一手,所以引入了些问题。
TLB的歧义问题
我们知道不同的进程之间看到的虚拟地址范围是一样的,所以多个进程下,不同进程的相同的虚拟地址可以映射不同的物理地址。这就会造成歧义问题。例如,进程A将地址0x2000映射物理地址0x4000。进程B将地址0x2000映射物理地址0x5000。当进程A执行的时候将0x2000对应0x4000的映射关系缓存到TLB中。当切换B进程的时候,B进程访问0x2000的数据,会由于命中TLB从物理地址0x4000取数据。这就造成了歧义。如何消除这种歧义,我们可以借鉴VIVT数据cache的处理方式,在进程切换时将整个TLB无效。切换后的进程都不会命中TLB,但是会导致性能损失。
如何尽可能的避免flush TLB
在TLB中引入ASID的概念用来标记进程,在TLB比较Tag的同时,也去比较ASID就可以了。ASID是怎么管理的呢?我们知道进程都拥有自己的PID,但是PID的范围太大了,不适合在TLB中存储,为了尽量减少空间占用,以32位为例,ASID仅使用8bit,也就是只能区分256个进程,可是往往我们的进程并不止256个,那该怎么办呢,其实不用对所有进程都进行标记,只需要标记最近使用的256个进程就OK了,TLB本身缓存的内容也是最近使用的页表映射,那么当256个标记使用完毕后,此时就会清除其他进程标记,flush TLB,重新对进程标记。可以看到,使用了ASID后,就不需要切换进程时就flush TLB了,减少了性能损耗。
更上一层楼
我们知道内核空间和用户空间是分开的,并且内核空间是所有进程共享。既然内核空间是共享的,进程A切换进程B的时候,如果进程B访问的地址位于内核空间,完全可以使用进程A缓存的TLB。但是现在由于ASID不一样,导致TLB miss。我们针对内核空间这种全局共享的映射关系称之为global映射。针对每个进程的映射称之为non-global映射。所以,我们在最后一级页表中引入一个bit(non-global (nG) bit)代表是不是global映射。当虚拟地址映射物理地址关系缓存到TLB时,将nG bit也存储下来。当判断是否命中TLB时,当比较tag相等时,再判断是不是global映射,如果是的话,直接判断TLB hit,无需比较ASID。当不是global映射时,最后比较ASID判断是否TLB hit。
什么时候应该flush TLB
我们再来最后的总结,什么时候应该flush TLB。
- 当ASID分配完的时候,需要flush全部TLB。ASID的管理可以使用bitmap管理,flush TLB后clear整个bitmap。
- 当我们建立页表映射的时候,就需要flush虚拟地址对应的TLB表项。第一印象可能是修改页表映射的时候才需要flush TLB,但是实际情
况是只要建立映射就需要flush TLB。原因是,建立映射时你并不知道之前是否存在映射。例如,建立虚拟地址A到物理地址B的映射,我们并不知道之前是否存在虚拟地址A到物理地址C的映射情况。所以就统一在建立映射关系的时候flush TLB。