为何linux内核的leave_mm中最后有一个load_cr3,这样的话岂不是又加载了cr3,这样岂不是违背了懒惰模式懒惰刷新tlb的初衷吗?这是邮件列表中很多人问的一个问题,要回答这一个问题,还要从2.6.18内核谈起。在2.6.18以及以前的内核中,leave_mm中是没有laod_cr3的,可是后来就有了,到底为什么?这一个要从cpu硬件的预取谈起。
每个cpu为了效率,几乎都会提前好几个时钟周期预取指令,叫做prefetch,到底会提前多少对于不同的cpu就不一定了,Intel的P4cpu的预取队列十分的长,这就说明P4的cpu会提前很久预取指令,而且P4由于拥有很长的流水线,那么它的分支预测机制就必须十分强大,于是这种cpu就给 2.4.19以前的linux内核带来了bug,在那些内核的实现中,在解除掉内存映射的时候,都是先释放掉页表在刷新TLB,这就在页表被释放和TLB 被刷新之间留下了一个无人区,这里所谓的无人区就是不被管理的区域,如果恰巧cpu预取逻辑预取到了这个将要释放的页表中映射的一个地址,预取逻辑会先查 cacahe(注意这里的cacahe就是tlb),恰好此时cacaheline缺失,那么预取逻辑等待cacaheline被填充,等待期间,这个对应的存有预取指令的页面被释放,然后被别的cpu分配走并且写入了数据,此时cacaheline被填充,那么预取到的指令就不再有效了,因为它已经不再是原来的数据了,预取逻辑将此页面作为了一个页表,更巧的是,如果某“页表项”的“全局位”是1的话,那么即使后面马上的刷新tlb也不会刷掉它,当然预取的指令也不会无效,这就为系统埋下了一颗×××,一旦预取指令被执行,那么百分之九十九的会出现非法指令错误,虽然预取的很多指令可能都是废的,这里的这种情况,可能根本轮不到预取指令执行别的逻辑就将之剔出了,但是绝对不能排除指令被执行的可能,更严重的,由于“全局位”,这个×××将一直在那里,直到出错,并且最终肯定会出错。
问题在哪里?问题就是那个间隙,就是那个无人区!于是很容易修正这个很鲜见的bug,就是将页表释放放到flush tlb之后,这也是一个常规做法,可是不知道为什么2.4.19之前的内核会相反地实现,是开发人员的疏忽没有注意到那个无人区还是bug没有被发现而采取的真正的懒惰措施,本质上,本着数据一致性的原则,只有刷新了tlb之后,页表才可以被释放,于是我看了2.4.19的代码,并没有发现有什么不同啊。和2.4.18的相比,我们分析可能要释放页表的exit_mmap函数:
void exit_mmap(struct mm_struct * mm)
{
struct vm_area_struct * mpnt;
...
flush_cache_mm(mm);
while (mpnt) {
struct vm_area_struct * next = mpnt->vm_next;
...
zap_page_range(mm, start, size);
...
mpnt = next;
}
flush_tlb_mm(mm);
clear_page_tables(mm, FIRST_USER_PGD_NR, USER_PTRS_PER_PGD);
}
以上这个函数,两个版本几乎一致,没有什么区别,我们注意其中比较重要的zap_page_range和flush_tlb_mm以及 clear_page_tables三个函数调用,前两个函数两个版本一模一样,不同的是第三个函数,2.4.19版本的内核在其中的 check_pgt_cache的前面调用了flush_tlb_pgtables,这个函数事实上就刷新了TLB,那么如果2.4.19真的修正了那个 bug的话,就只有在check_pgt_cache里面最终释放掉页表了,但是前面的zap_page_range中不是也有释放的吗?
#define tlb_remove_page(ctxp, pte, addr) do {/
if ((ctxp)->nr == ~0UL) {/ //说明就一个用户
pte_t __pte = *(pte);/
pte_clear(pte);/
__free_pte(__pte);/
break;/
}/
...
这个宏释放的是页表指向的页面而不是页表本身,顾名思义,真正释放页表的是clear_page_tables函数,这个函数也是调用一个循环来释放的,最终调用的是一个free_pte来释放,它在2.4.18种会释放掉页表,两个版本到了这里唯一可能的不同就是这个free_pte的实现了,还真是,2.4.19的实现就是和2.4.18的不同,19的实现是fast,而18的实现是fast,现在就真相大白了,2.4.19的实现中并不将页表真的释放放给伙伴系统或者slab,而是将之链接如一个链表,叫做quick list,这个链表负责缓存页表或者二级页表或者页目录,因为这些东西在内核总是频繁被使用,因此就缓存一部分,内核保持两个值,当该缓存数目高于高值的时候就真正释放一部分,一直到其达到低值,2.4.19的free_pte就是将页表放到缓存,而2.4.18以及以前的内核实现就是直接释放了,这样在 2.4.19种一直到了flush tlb过后调用check_pgt_cache的时候,才有可能释放掉这些页表,于是做到了将刷新tlb放到释放页表之前,从而最终弥补住了那个无人区的漏洞,另外2.4.19还有一个改动就是在leave_mm的最后加上了load_cr3的调用,在处于lazy模式的cpu收到刷新tlb的请求时将 cr3加载成swapper_pg_dir,原因也是怕由于预取队列太长而取到了将来执行的用户进程的用户指令,恰恰这个进程的mm就是lazy模式正在用的active_mm,而且更巧的是,在TLB中预取到的这个mm的页面地址本来应该是一个PTE的地址,但是它被释放然后又被重分配给了别的cpu,别的cpu污染了这个页面,并且将其作为PTE理解时的global位置为1,如果错过了这次刷新,那么这个×××就开始倒计时了,因此必须刷新 tlb,作为一种良策,加载swapper_pg_dir就可以了,一来它更快,二来以后不用担心预取实效等烦心事了,可以安心的进入lazy而不用接受刷新tlb的ipi了。
至于说为何在内核中运行的内核线程会预取到用户空间的指令,Linus的一段解释不错:Since the BTB can (and does) contain mostly user ddresses (from previous execution in user land), it's apparently quite common to speculatively fetch user TLB entries even when you're in kernel mode.另外的问题就是预取出错的指令能躲过tlb的刷新吗?答案能。因为这里的这个bug是个鲜见的bug,出错很容易重现的,而是将脏页面的一个项作为页表理解时其global位正好是1,而且这个pte所在的页面原来是一个用户空间指令的地址对应的pte,这个pte作为地址在tlb中被预取得到。以上只要那个global位出现了,那么这个假的pte就可以躲过任何的tlb刷新直到出错了。Intel规定,只有刷新tlb才可以同步cpu的 tlb。
前面我写过一篇讨论tlb懒惰模式的文章,那篇文章举个例子其实是不可能实现的,那里说,leave_mm中的load_cr3是因为害怕这个页目录在别的cpu被释放掉,其实那是不可能的,因为在进入lazy模式前,内核线程借用前面的用户线程mm的时候就递增了那个mm的引用计数,因此在这个内核线程使用完这个mm之前是不能被释放的。有人认为完全可以在leave_mm的时候将引用计数递减然后释放掉这个借来的mm,因为已经不再需要了,可是不能那么做,因为这个leave_mm是在ipi中断上下文,而释放操作是会引起阻塞的,因此不能。
对于lazy模式下的那个load_cr3(swapper_pg_dir),linus恶心透了intel的那个给它带来噩梦的超长预取,于是在 lazy时收到刷新tlb的时候干脆加载一个init_mm的pgd,一了百了,要不然每次刷新都要采取措施防止预取出错,这样一下子载入init_mm 的pgd,很稳定而且不会刷新,反正是没有问题了。至于cpu预取会出现什么问题,请考虑以下的场景:1.cpu0预取逻辑预取用户空间的一个逻辑地址的数据;2.cpu0肯定要按照mmu的逻辑找到这个用户地址的物理地址;3.由于这时tlb没有失效,那么恰好tlb中有这个用户逻辑地址的物理地址;4.cpu0访问这个tlb的物理地址想取得它的数据;5.cpu0查找tlb中是否有这个数据的虚拟地址对应的物理地址的缓存;6.很不幸,没有;7.于是cpu0不是等待数据被载入tlb,而是访问当前的cr3,以标准的方式获得物理地址(当然在x86下,mips下会有所不同);8.此时正常的执行逻辑将用户要预取的这个地址的页表释放;9.cpu1重新分配了这个页面,并且将相应的Global位置为1;10.这时预取逻辑等待的地址被加入tlb cache;11.此时加入tlb的就是那个已经有了global位为1的物理地址;12.flush tlb,可是已经晚了。问题发生了,如果在释放页表之前刷新了tlb,那么就会是一番不同的景象,如果在释放页表之前就刷新了页表,那么cpu预取到一个逻辑地址时,还是会查tlb,如果tlb还没有被刷新并且命中,那么这次命中将在不久以后的刷新tlb中失效,如果不命中,那么就会去查页表,此时页表如果在内存,那么这次预取也将在不久后的刷新中失效,如果不在内存,那么预取不再进行,待到刷新发生时,页表还没有释放,不会出现global为1等异常情况,此时已经不会命中,因为tlb被刷新,已经不再会有效了,必须从页表获取,而页表已经标志位页面不在内存了,故不再预取。还是那句话,问题就出在页表已经被释放和tlb被刷新之间的无人区。
转载于:https://blog.51cto.com/dog250/1273502