5.4. Linux内核页表

本文分析Linux内核页表的概念空间。

页表的原理是分级描述页到物理地址的对应关系。

如果一个va对应一个pa,那么页表就需要地址空间那么多项,每项至少包含一个va和一个pa,这空间本身内存还大,这肯定不现实,所以页表(严格来说是地址映射表)的核心设计逻辑是怎么省空间。

第一个方法当然是页,一个va对4K个pa,这就省了不少了,如果对64K,就更少了。这是最基本的方法。

第二个方法是目录,这其实没有省空间,但它解决了稀疏化的问题:如果某些空间的va地址我没有用,我能不能不留映射表在那个区域上?所以,把va地址分成多段,分段来描述,不在段中的地址就不用分配空间了。

比如RISCV的Sv39,VA地址分配::

63           38          29          20          11        0
+-----------+-----------+-----------+-----------+-----------+
|    NA     |    VPN2   |   VPN1    |   VPN0    |   offset  |
+-----------+-----------+-----------+-----------+-----------+

这个地址分配不使用整个可达的64位地址空间,放弃掉63-39位,仅使用低端512G的范围。把这512G分成512份,每份1G,VPN2就可以寻址任何一个1G,如果这个1G的空间没有人用,就不用分配下一级的页表了,这就省下了一大片的空间。反过来说,如果这个页表有人用,其实一点空间都没有省,还因为分了多段,浪费更多的空间。

所以,本质上分多少级目录,是稀疏管理的需要,是个根据运行数据经验的选择,很多平台支持不同的页大小,不同的页表分级,都是为了解决这个“不同应用不用配置”的问题。

va->pa映射需要一一对应,但pa空间和va空间不需要一一对应的。还是用这个Sv39为例,它的pa是这样定义的::

63           55          29          20          11        0
+-----------+-----------+-----------+-----------+-----------+
|    NA     |    PPN2   |   PPN1    |   PPN0    |   offset  |
+-----------+-----------+-----------+-----------+-----------+

PPN2比VPN2大得多,这一点问题没有,因为你又不是只有一个进程,进程最多用512G内存,很多的进程仍可以用完这64T的物理内存啊,只要我最后的PTE里面,每个VPN都能找到对应的PPN就可以了。

现在看Linux对这个的支持。

每个平台有不同的页表配置,但原理都是基于VPN的数组,Linux内核就把这个抽象为一组数据。比如,如果我们有4级页表,我们应该有4级的数组,像这样:

pt_l2 = pt_l3[VPN3].ppn
pt_l1 = pt_l2[VPN2].ppn
pt_l0 = pt_l1[VPN1].ppn
pte   = pt_l0[VPN0].ppn

最后的pte的ppn拼上offset就是确切的物理地址了。

这种东西,其实直接用1,2,3,4来表示是最简洁的,只是Linux最早是在x86上实现的,用的都是x86的语言习惯,所以它把这些名字换了,L3, L2, L1, L0的页表项分别叫做pgd, pud, pmd和pte。换句话说,L3页表是pgd的数组,L2是pud的数组,……如此类推。

如果有5级,名字就又不够用了,这时就只好用数字了,这个叫p4d,插到pgd和pud之间(因为pgd名字是global,必然就是第一级)。所以,Linux内核中的页表项的表示是pgd, p4d, pud, pmd和pte。一些平台(比如x86),页目录和pte的内容是不同的,部分平台(比如riscv),两者其实是一样的。

为了让4级页表和5级页表的代码可以通用,Linux的代码假设总共就是5级,在4级的平台上,它使能一个逻辑,叫__PAGETABLE_P4D_FOLDED,这种模式下,从pgd就p4d,得到的就是pgd本身,这样,后面引用p4d求后面的页表,就是从pgd开始的了。

然后我们看看基于这些表建立的概念:

我们用XXX表示一级页表项的名字,比如p4d, pgd等,YYY表示它上一级的页表项的名字,XXX[]表示这个页表项组成的页表。

其中:

XXX_val(XXX)/__XXX(val)

把XXX当作值使用和把值当作XXX来使用。

XXX_none(XXX);

检查XXX项是否有效,也就是它的下一级页表是否被分配了。

XXX_present/bad(XXX);

检查XXX指向的页是否存在(有没有被交换出去),present和bad互相取反。none和bad很相似,但none表示这个值压根就没有填,下一级页表就没有分配,而bad表示这个值无效,但下一级页表是存在的。

XXX_leaf(XXX);

检查XXX是否是最后一级页表的页表项

XXX_offset(YYY, va);

求va在这个页表上的那个项目的指针。注意了,不是求偏移,而是求XXX自身。

set_XXX(XXX*, XXX)

在一个XXX[n]上设置一个XXX。

clear_XXX(XXX*)

在一个XXX[n]上清除一个XXX,但不会释放对应的内存。

XXX_alloc(mm, YYY, va);

分配XXX[]这个函数会考虑在核间同步的问题,所以你不要直接用他们操作和IO相关的页表。这也意味这,这组函数不是给IO或者IOMMU等子系统用的。

pfn_XXX(XXX, prot)

从pfn求XXX

XXX_pfn(pfn)

从XXX求pfn

XXX_page_vaddr(XXX)

从XXX求下一级页表的虚拟地址

pfn_pte(pte)/pfn_pte(pfn, prot)

从pte求pfn(包括所有层级连在一起的结果),或者反过来。

pte_read/write/exec/hugh/dirty...(pte)

检查pte属性。

pte_mkread/mkwrite/mkexec/mkhugh/mkdirty...(pte)

设置pte属性。