X86_64 CR3控制寄存器详解


CR3寄存器目前博客主要能查找到的内容都比较简单,例如

《控制寄存器 cr0,cr2,cr3》等博客,只对CR3寄存器进行了简单的介绍:

状态和控制寄存器组除了EFLAGS、EIP ,还有四个32位的控制寄存器,它们是CR0,CR1,CR2和CR3。

CR3含有存放页目录表页面的物理地址,因此CR3也被称为PDBR。因为页目录表页面是页对齐的,所以该寄存器只有高20位是有效的。而低12位保留供更高级处理器使用,因此在往CR3中加载一个新值时低12位必须设置为0。

使用MOV指令加载CR3时具有让页高速缓冲无效的副作用。为了减少地址转换所要求的总线周期数量,最近访问的页目录和页表会被存放在处理器的页高速缓冲器件中,该缓冲器件被称为转换查找缓冲区(Translation Lookaside Buffer,TLB)。只有当TLB中不包含要求的页表项时才会使用额外的总线周期从内存中读取页表项。

即使CR0中的PG位处于复位状态(PG=0),我们也能先加载CR3。以允许对分页机制进行初始化。当切换任务时,CR3的内容也会随之改变。但是如果新任务的CR3值与原任务的一样,处理器就无需刷新页高速缓冲。这样共享页表的任务可以执行得更快。

本文深入挖掘一下CR3寄存器的相关信息,了解MMU、TLB、操作系统与CR3寄存器的交互

一、CR3寄存器

对于64位机,CR3寄存器也从32位变成了64位,它的主要功能还是用来存放页目录表物理内存基地址,每当进程切换时,Linux就会把下一个将要运行进程的页目录表物理内存基地址等信息存放到CR3寄存器中。

图片来源(http://ilinuxkernel.com/?p=606)

二、CR3寄存器与TLB

关于CR3寄存器与TLB找到了两个大佬的博文,对于TLB里面的信息描述得很清楚,可以直接移步~

1、《TLB原理》

地址:https://zhuanlan.zhihu.com/p/108425561?utm_source=wechat_timeline

这个文章对于TLB的原理描述地很清晰,TLB只能使用虚拟地址来做tag,那么是否会出现TLB别名问题以及TLB歧义问题都进行了分析。文中指出了TLB不存在别名问题,但是存在TLB歧义,解决TLB歧义最简单的方法就是进程切换之后使整个TLB无效,这会导致性能损失,所以文中提出了尽可能避免TLB flush的方法,例如在TLB中添加一项ASID(Address Space ID)的匹配之类的。这篇文章很有参考价值,内容也比较新~

2、《进程切换分析(2):TLB处理》

地址:http://www.wowotech.net/process_management/context-switch-tlb.html

这个文章也提出了《TLB原理》这个文章中的一些问题,但是没有上一篇文章那么细致,它重点关注了进程切换的时候,TLB的一些处理。

在《深入理解LINUX内核》一书中指出,Intel微处理器只提供了两种使TLB无效的技术:

  • 在向CR3寄存器写入值时所有Pentium处理器自动刷新相对于非全局页的TLB表项;
  • 在Pentium Pro及之后的处理器中,invlpg汇编语言指令使映射指定虚拟地址的单个TLB表项无效。

深入理解LINUX内核基于2.6版本内核,本文将基于4.15版本内核对相关内容进行探究,所以上面的说法可能有些已经过时,需要我们自己看内核代码来分析。对于本节给出的文章2《进程切换分析(2):TLB处理》,它指出x86平台上,在进程切换的时候,软件不需要显示的调用tlb flush函数,在switch_mm函数中会用next task中的mm->pgd加载CR3寄存器,这时候load cr3的动作会导致本cpu中的local tlb entry被全部flush掉。在x86支持PCID(X86术语,相当与ARM的ASID)的情况下会怎样呢?也会在load cr3的时候flush掉所有的本地CPU上的 local tlb entry吗?其实在linux中,由于TLB shootdown,普通的linux并不支持PCID,因此,对于x86的进程地址空间切换,它就是会有flush local tlb entry这样的side effect。

但是对于切换到内核线程时,不需要进行TLB flush,这里涉及到enter_lazy_tlb函数。

三、CR3寄存器与操作系统

CR3寄存器的改变与操作系统的关联主要是由于进程切换,每当进程切换时,CR3的内容需要被操作系统修改。

先了解一下进程切换的具体内容,从本质上说,每个进程切换由两部分组成:

1、切换页全局目录以安装一个新的地址空间

2、切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要的所有信息,包括CPU寄存器

context_switch切换到新的MM,同时修改新进程寄存器的状态。

先看代码(基于Linux-4.15 x86)。在代码中,如果next task是内核线程,我们并不会执行switch_mm(该函数会引起tlb flush的动作),而是调用enter_lazy_tlb进入lazy tlb mode。在x86架构下,代码如下:

这段代码的意思就是如果要切入的next task是一个内核线程(next->mm == NULL )的话,那么可以通过enter_lazy_tlb函数标记本cpu上的next task进入lazy TLB mode。

entry_lazy_tlb不是本文重点,修改CR3寄存器状态是本文的重点~

在context_switch函数中,switch_mm_irqs_off(进行页切换,如更新cr3寄存器)是一个重点

从上面的代码(linux-4.15版本)中,可以看出还是有ASID这个概念的(与上一节文章2有一定区别了)。

switch_mm_irqs_off函数比较重要,里面内容还是挺多的。

从这里可以看出来在x86中还是有asid这个概念,

如果是一个新进程,那么定义了新的asid:new_asid

获取new_asid,进入到该函数查看该函数内容

里面用到了next->context,先查看一下这个context到底是什么内容再做具体分析

context是mm_context_t类型,继续查看mm_context_t的内容

从上可以看到ctx_id唯一标识了一个mm_struct(内存描述符),ctx_id永不会被重复使用,如果ctx_id为0则表示这个ctx_id无效。

tlb_gen: 任何需要为此mm执行任何类型的TLB刷新的代码都将首先对其页表进行更改,然后递增tlb_gen,然后刷新。这使低层级刷新代码可以跟踪需要刷新的内容。

回到函数choose_new_asid函数,

如果next进程的进程描述符id在系统中(个人感觉就是如果ctx_id能够对应上,那么说明asid就已经在TLB中,只需要把new_asid赋予它原来的asid即可,也就是不用重新分配,有的话就直接返回)

如果没有,那么就需要分配一个asid,分配asid的代码应该如下:

其中TLB_NR_DYN_ASIDS等于6

this_cpu_add_return()函数开始就看不太懂了~接下来回到switch_mm_irqs_off函数。

这段代码应该是ASID分配完了,所以需要flush,然后把新的asid的相关内容添加进来,如果不需要flush,那么直接写cr3寄存器,将next的内容填入cr3。

上面是修改cr3的代码,write_cr3调用的是native_read_cr3函数~

继续看上面的代码,invalidate_user_asid函数如下,对于一个给定的ASID,flush它对应的user ASID

然后根据上面的代码根据pgdir和new_asid构建新的cr3的内容,下面的两个函数定义在arch、x86/include/asm/tlbflush.h中

如果支持PCID,那么cr3就由pgd和asid组成,否则就是pgd

最后写入cr3~

现在的疑问是这个asid是怎么分配的,它与pid的关系,以及它被保存在哪里?

先了解两个结构体tlb_context与tlb_state:

1、struct mm_struct *loaded_mm:每当中断打开时,cpu_tlbstate.loaded_mm应与CR3匹配。这意味着它可能与current-> active_mm不匹配,即使我们已经切换回swapper_pg_dir,当我们处于懒惰TLB模式时,它将包含先前的用户mm。

2、bool invalidate_other:如果设置将页面表更改为需要使所有上下文(也就是PCID / ASID)无效的方式。这告诉我们在下一个上下文切换上使所有未加载的ctxs []无效。当前ctx在运行时保持最新状态,不需要无效。

3、unsigned short user_pcid_flush_mask:包含TLB_NR_DYN_ASIDS + 1位的掩码,用于指示相应的用户PCID(https://blog.csdn.net/jus3ve/article/details/79544927下次切换时需要刷新; 参见SWITCH_TO_USER_CR3

4、struct tlb_context ctxs[TLB_NR_DYN_ASIDS]:

1)这是TLB中可能存在的所有上下文的列表。我们使用的每个ASID都有一个,而ASID(CPU称为PCID)是ctxt的索引(但是很遗憾TLB_NR_DYN_ASIDS等于6)。

2)对于每个上下文,ctx_id表示TLB用户条目来自哪里(来自哪个进程地址空间mm_struct)。 作为不变式,TLB永远不会包含过时的条目,例如该mm到达列表中的tlb_gen时。

3) 明确地说,这意味着TLB代码在不更新tlb_gen的情况下刷新TLB是合法的。 由于paravirt remote flushes,这种情况可能会发生(至少目前如此)。

4) 注意:context 0有点特殊,因为初始化代码的各个位也使用它。 这很好-不知道PCID的代码最终将无害地刷新context 0。

5、struct tlb_context {

u64 ctx_id;

u64 tlb_gen;

};

cpu_tlbstate暂未在内核代码中找到相关定义,根据资料来看(书籍《深入理解Linux内核》,与https://wenku.baidu.com/view/0dabe0be453610661fd9f41f.html)大概可以知道cpu_tlbstate是内核中一个由tlb_state结构组成的全局数组,数组的大小就是cpu的个数。

操作系统读写cr3接口

四、总结

从代码中可以看出,x86在linux-4.15版本还是有关于ASID的代码。

在这里面也有一个判断,如果系统支持X86_FEATURE_PCID,那么对CR3寄存器写入的时候也会将ASID写入CR3(写入CR3的低12位),也就是尽可能避免TLB的flush~ 。

但是从ASID分配来看似乎支持的最大ASID为6,这个有一定的疑问,是不是因为ASID多了之后TLB不好管理之类的,如果有大佬知晓还请评论解答一下~

TLB在判断一个虚拟地址是否命中时,根据ASID来看是否是对应进程的,ASID被分配完了之后需要flush tlb,然后新进程切换进来又重新分配ASID。cpu_tlbstate里面绑定ASID与进程mm_struct的绑定,让TLB中的一个ASID只对应一个mm_struct。

简单梳理一下,如有错误,还望各位大佬评论指正~