4.2 四级页表
在 32 位系统中,内核主要采用二级页表体系来进行虚拟内存寻址,但是到了 64 位系统中,二级页表明显就不够用了,因为二级页表最多只能映射 4G 的物理内存空间,而 64 位系统中,进程的虚拟寻址空间是巨大的,进程的用户态需要寻址 128T 的虚拟内存空间,内核态也有 128T 的虚拟内存空间。
64位系统中虚拟内存空间整体布局.png为了能够寻址这么大的虚拟内存空间,内核在 64 位系统中引入了四级页表体系,当我们清楚了二级页表的虚拟寻址过程,四级页表就很简单了,不就是多引入了两级页目录么,前面小节介绍的多级页表的本质还是不变的。
image.png64 位系统中的四级页表相比 32 位系统中的二级页表来说,多引入了两个层级的页目录,分别是四级页表和三级页表,四级页表体系完整的映射关系如下图所示:
image.png但是在内核中一般不这么叫,内核中称上图中的四级页表为全局页目录 PGD(Page Global Directory),PGD 中的页目录项叫做 pgd_t,PGD 是四级页表体系下的顶级页表,保存在进程 struct mm_struct 结构中的 pgd 属性中,在进程调度上下文切换的时候,由内核通过 load_new_mm_cr3 方法将 pgd 中保存的顶级页表虚拟内存地址转换物理内存地址,随后加载到 cr3 寄存器中,从而完成进程虚拟内存空间的切换。
上图中的三级页表在内核中称之为上层页目录 PUD(Page Upper Directory),PUD 中的页目录项叫做 pud_t 。
二级页表在这里也改了一个名字叫做中间页目录 PMD(Page Middle Directory),PMD 中的页目录项叫做 pmd_t,最底层的用来直接映射物理内存页面的一级页表,名字不变还叫做页表(Page Table)
由于在四级页表体系下,又多引入了两层页目录(PGD,PUD),所以导致其通过虚拟内存地址定位 PTE 的步骤又增加了两步,首先需要定位顶级页表 PGD 中的页目录项 pgd_t,pgd_t 指向的 PUD 的起始内存地址,然后在定位 PUD 中的页目录项 pud_t,后面的流程就和二级页表一样了。
因此 64 位的虚拟内存地址格式也就随着发生了变化:
image.png32 位系统中的页目录表,页表和 64 位系统中的页目录表,页表在内核中都是使用一个普通 4K 的物理内存页存储映射关系的,不同的是 64 位系统中的页表中的 PTE 以及页目录表(PGD,PUD,PMD)中的 PDE 都是占用 8 个字节,在内核中都是使用 unsigned long
类型描述:
// 定义在内核文件:/arch/x86/include/asm/pgtable_64_types.h
typedef unsigned long pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long pgdval_t;
typedef struct { pteval_t pte; } pte_t;
// 定义在内核文件:/arch/x86/include/asm/pgtable_types.h
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pgdval_t pgd; } pgd_t;
内核这里使用 struct 结构来包裹 unsigned long 类型的目的是要确保这些页目录项以及页表项只能被专门的辅助函数访问,不能直接访问。
一张页表 4K 大小,页表中的一个 PTE 占用 8 个字节,所以在 64 位系统中一张页表只能包含 512 个 PTE,在内核中使用 PTRS_PER_PTE
常量来表示一张页表中可以容纳的 PTE 个数,用 PAGE_SHIFT
常量表示一个物理内存页的大小:2^PAGE_SHIFT。
/*
* entries per page directory level
*/
#define PTRS_PER_PTE 512
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
要寻址页表中这 512 个 PTE,我们用 9 个 bit 就可以了,因此上图虚拟内存地址中的 一级页表中的 PTE 偏移
占用 9 个 bit 位。而一个 PTE 可以映射 4K 大小的物理内存(一个物理内存页),所以在 64 位的四级页表体系下,一张一级页表可以映射的物理内存空间大小为 2M 大小。
一张中间页目录 PMD 也是 4K 大小,PMD 中的页目录项 pmd_t 也是占用 8 个字节,所以一张 PMD 中只能容纳 512 个 pmd_t,内核中使用 PTRS_PER_PMD
常量来表示 PMD 中可以容纳多少个页目录项 pmd_t。因次 64 位虚拟内存地址中的 PMD中的页目录偏移
使用 9 个 bit 就可以表示了。
/*
* PMD_SHIFT determines the size of the area a middle-level
* page table can map
*/
#define PMD_SHIFT 21
#define PTRS_PER_PMD 512
而一个 pmd_t 指向一张一级页表,所以一个 pmd_t 可以映射的物理内存为 2M,内核中使用 PMD_SHIFT
常量来表示一个 pmd_t 可以映射的物理内存范围:2^PMD_SHIFT。一张 PMD 可以映射 1G 的物理内存。
同理我们知道,一张上层页目录 PUD 中可以容纳 512 个页目录项 pud_t,内核中使用 PTRS_PER_PUD
常量来表示 PUD 中可以容纳多少个页目录项 pud_t。 64 位虚拟内存地址中的 PUD中的页目录偏移
也是使用 9 个 bit 就可以表示了。
/*
* 3rd level page
*/
#define PUD_SHIFT 30
#define PTRS_PER_PUD 512
内核中使用 PUD_SHIFT
常量来表示一个 pud_t 可以映射的物理内存范围:2^PUD_SHIFT,一个 pud_t 指向一张 PMD,因此可以映射 1G 的物理内存。一张 PUD 可以映射 512G 的物理内存。
一样的道理,顶级页目录 PGD 中可以容纳的页目录 pgd_t 个数 PTRS_PER_PGD = 512
, 64 位虚拟内存地址中的 PGD中的页目录偏移
也是使用 9 个 bit 就可以表示了,一个 pgd_t 可以映射的物理内存为 2^PGDIR_SHIFT = 512 G
。一张 PGD 可以映射的物理内存为 256 T,可以说是非常非常巨大了。
/*
* 4th level page in 5-level paging case
*/
#define PGDIR_SHIFT 39
#define PTRS_PER_PGD 512
通过以上内容介绍,我们就得到了 64 位虚拟内存地址中的比特位布局情况:
image.png-
PAGE_SHIFT 用来表示页表中的一个 PTE 可以映射的物理内存大小(4K)。
-
PMD_SHIFT 用来表示 PMD 中的一个页目录项 pmd_t 可以映射的物理内存大小(2M)。
-
PUD_SHIFT 用来表示 PUD 中的一个页目录项 pud_t 可以映射的物理内存大小(1G)。
-
PGD_SHIFT 用来表示 PGD 中的一个页目录项 pgd_t 可以映射的物理内存大小(512G)。
这些 XXX_SHIFT 常量在内核中除了可以表示对应页目录项映射的物理内存大小之外,还可以从一个 64 位虚拟内存地址中获取其在对应页目录中的偏移。
比如我们现在需要从一个 64 位虚拟内存地址中获取它在 PGD 中的偏移,可以讲虚拟内存地址右移 PGD_SHIFT 位来得到:
#define pgd_index(address) ( address >> PGDIR_SHIFT)
然后我们可以通过 PGD 的起始内存地址加上 pgd_index
就可以得到虚拟内存地址在 PGD 中的页目录项 pgd_t 了。
#define pgd_offset_pgd(pgd, address) (pgd + pgd_index((address)))
同样的道理,我们可以将虚拟内存地址右移 PUD_SHIFT 位,并用掩码 PTRS_PER_PUD - 1
掩掉高 9 位 , 只保留低 9 位,就可以得到虚拟内存地址在 PUD 中的偏移了:
PTRS_PER_PUD - 1
转换为二进制是 9 个 1,用来截取最低的 9 个比特位。
static inline unsigned long pud_index(unsigned long address)
{
return (address >> PUD_SHIFT) & (PTRS_PER_PUD - 1);
}
我们通过 pgd_t 获取 PUD 的起始内存地址 + pud_index
得到虚拟内存地址对应的 pud_t:
/* Find an entry in the third-level page table.. */
static inline pud_t *pud_offset(pgd_t *pgd, unsigned long address)
{
return (pud_t *)pgd_page_vaddr(*pgd) + pud_index(address);
}
根据相同的计算逻辑,我们可以通过 pmd_offset 函数获取虚拟内存地址在 PMD 中的页目录项 pmd_t:
/* Find an entry in the second-level page table.. */
static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address)
{
return (pmd_t *)pud_page_vaddr(*pud) + pmd_index(address);
}
static inline unsigned long pmd_index(unsigned long address)
{
return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
}
通过 pte_offset_kernel 函数可以获取虚拟内存地址在一级页表中的 PTE:
static inline pte_t *pte_offset_kernel(pmd_t *pmd, unsigned long address)
{
return (pte_t *)pmd_page_vaddr(*pmd) + pte_index(address);
}
static inline unsigned long pte_index(unsigned long address)
{
return (address >> PAGE_SHIFT) & (PTRS_PER_PTE - 1);
}
现在我们已经清楚了内核如何通过一系列的 XXX_offset 方法从虚拟内存地址中提取对应页目录以及页表中的偏移了,有了这些基础之后,接下来我们就来看一下四级页表体系的寻址过程:
image.png-
首先 MMU 会从 cr3 寄存器中获取顶级页目录 PGD 的起始内存地址,然后通过 pgd_index 从虚拟内存地址中截取
PGD 中的页目录项偏移
,这样就定位到了具体的一个 pgd_t。 -
pgd_t 中保存的是 PMD 的起始内存地址,通过 pud_index 可以从虚拟内存地址中截取
PUD 中的页目录项偏移
,从而确定虚拟内存地址在 PUD 中的页目录项 pud_t。 -
同样的道理,根据 pud_t 中保存的 PMD 其实内存地址,在加上通过 pmd_index 获取到的
PMD 中的页目录项偏移
,定位到虚拟内存地址在 PMD 中的页目录项 pmd_t。 -
后面的寻址流程就和二级页表一样了,pmd_t 指向具体页表的起始内存地址,通过 pte_index 截取虚拟内存地址在
一级页表中的 PTE 偏移
,最终定位到一个具体的 PTE 中,PTE 指向的正是虚拟内存地址映射的物理内存页面,然后通过虚拟内存地址中的低 12 位(物理内存页内偏移),最终确定到一个具体的物理字节上。
4.2.1. 64 位页表项
在 64 位系统中,页表中 PTE 在内核中使用 unsigned long
类型描述,占用 8 个字节:
typedef unsigned long pteval_t;
typedef struct { pteval_t pte; } pte_t;
这 64 位的 PTE 布局如下:
image.png这里我们以 36 位物理内存地址(最多 52 位)为例进行说明,首先物理内存页的起始内存地址都是按照 4K 对齐的,所以 36 位物理内存地址的低 12 位全部为 0 ,和 32 位的 PTE 一样,内核可以用这低 12 位来描述 PTE 的权限位,其中 0 到 8 之间的比特位,在 32 位 PTE 和 64 位 PTE 中含义都是一样的,这里笔者不在赘述。
R(11)
这里的 R 表示 restart,该比特位主要用于 HLAT paging,当遍历到 R 位是 1 的 PTE 时,MMU 会重新从 CR3 寄存器开始遍历页表,这里我们只做简单了解。
本例中的物理内存地址是 36 位的,由于物理内存页都是 4K 对齐的,低 12 位全都是 0 ,因此我们只需要在 PTE 中存储物理内存地址的高 24 位即可,这部分存储在 PTE 中的第 12 到 35 比特位。
Reserved(51:36)
这些是预留位,全部设置为 0 。Protection(62:59)
这 4 个比特位用于对物理内存页的访问进行控制。
XD(63)
该比特位是 64 位 PTE 中新增的,32 位 PTE 中是没有的,值为 1 表示该 PTE 所映射的物理内存页面中的数据是可以被执行的。
4.2.2. 64 位页目录项
在 64 位系统中使用的四级页表体系中一共包含了三个层级的页目录,它们分别为:全局页目录 PGD(Page Global Directory),上层页目录 PUD(Page Upper Directory),PMD(Page Middle Directory)。
这三种类型的页目录中的页目录项 PDE 在内核中也是使用 unsigned long
类型来描述的,在 64 位系统中占用 8 个字节:
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long pgdval_t;
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pgdval_t pgd; } pgd_t;
64 位 PDE 中的比特位布局如下图所示:
image.png当 64 位 PDE 的 PS(7)
比特位为 0 时,该 PDE 指向的是其下一级页目录或者页表的起始内存地址。
当 64 位 PDE 的 PS(7) 比特位为 1 时,该 PDE 指向的就是一个内存大页,对于 PMD 中的页目录项 pmd_t 而言,它指向的是一张 2M 大小的物理内存大页。
image.png对于 PUD 中的页目录项 pud_t 而言,它指向的是一张 1G 大小的物理内存大页。
image.png当 64 位 PDE 的 PS(7) 比特位为 1 时,这些页目录项 PDE 就被当做了一个特殊的 ”PTE“ 对待了,因此 PDE 中的比特位布局又就变成了 64 位 PTE 中的样子了。
image.png为了表述严谨,这里笔者需要特殊说明的一点是,方便让大家容易理解,笔者将第 12 到 35 比特位直接标注为了存储大页内存的地址,但事实上,大页内存的地址并不需要这么多位来存储,因为大页中的内存容量比较大,所以大页个数相对较少,它们的起始内存地址不会特别高,使用小于 24 位的比特就可以存放了,多出来的比特位被用作其他目的,但是这些都和本文主旨无关,笔者就直接忽略掉了。
内核当然也会提供一系列的辅助函数来对页目录进行操作:
-
pgd_alloc,pud_alloc,pmd_alloc 负责创建初始化对应的页目录。
-
mk_pgd,mk_pud,mk_pmd,mk_pte 用于创建相应页目录项和页表项,并初始化上述比特位。
-
以及提供相关 pgd_xxx,pud_xxx,pmd_xxx 等形式的辅助函数,用于对相关比特位的增删改查操作。
5. CPU 的整个寻址过程
本文的重点是要为大家完整清晰地构建出内核中的页表体系,给大家解释清楚虚拟内存是如何与物理内存映射起来的,当我们理解了这些之后,在本小节中,笔者准备带大家一起探秘下,当 CPU 访问一个进程虚拟内存空间中的某个虚拟内存地址之后,操作系统背后到底发生了什么。
image.png经过本文前边内容的介绍,上图中的这个四级页表的遍历过程,我们已经非常的清楚了,我们可以明显的体会到整个地址翻译的过程需要的步骤还是比较多的,而 CPU 访问内存的操作是非常非常频繁的,如果我们采用内核这种软件的方式对页表进行遍历,效率会非常的差。
而采用一种专门的硬件来对软件进行加速,无疑是一种最简单,最直接有效的优化手段,于是在 CPU 中引入了一个专门对页表进行遍历的地址翻译硬件 MMU(Memory Management Unit),有了 MMU 硬件的加持整个地址翻译的过程就非常的快了。
事实上,上图中展示的四级页表的整个遍历操作均是在 MMU 中进行的:
image.png经过前边的内容我们知道,这些页目录,页表的本质其实在内核看来都是一张普通的 4K 大小的物理内存页,而物理内存页中经常被访问到的内存数据都是缓存在 CPU 的高速缓存 L1 ,L2,L3 CACHE 中的,这样可以利用局部性原理加速 CPU 对内存的访问。
CPU缓存结构.png所以页目录表和页表中那些经常被 MMU 遍历到的页目录项 PDE,页表项 PTE 均会缓存在 CPU 的 CACHE 中,这样 MMU 就可以直接从 CPU 高速缓存中获取 PDE , PTE 了,近一步加速了整个地址翻译的过程。
当 MMU 拿到一个 CPU 正在访问的虚拟内存地址之后, MMU 首先会从 CR3 寄存器中获取顶级页目录表 PGD 的起始内存地址,然后从虚拟内存地址中提取出 pgd_index,从而定位到 PGD 中的一个页目录项 pdg_t,MMU 首先会从 CPU 的高速缓存中去获取这个 pgd_t,如果 pgd_t 经常被访问到,那么此时它已经存在于高速缓存中了,MMU 直接可以进行下一级页目录的地址翻译操作,避免了慢速的内存访问。
同样的道理,在 MMU 经过层层的页目录遍历之后,终于定位到了一级页表中的 PTE,MMU 也是先会从 CPU 高速缓存中去获取 PTE,如果 PTE 不在高速缓存中,MMU 才会去内存中去获取。获取到 PTE 之后,MMU 就得到了虚拟内存地址背后映射的物理内存地址了。
image.png在我们引入 MMU 之后,虽然加快了整个页表遍历的过程,但是 CPU 每访问一个虚拟内存地址,MMU 还是需要查找一次 PTE,即便是最好的情况,MMU 也还是需要到 CPU 高速缓存中去找一下的,即便这样开销已经很小了,但是我们还是想近一步降低这个访问 CPU CACHE 的开销,让 CPU 访存性能达到极致,那么该怎么办呢?
既然 MMU 每次都需要查找一次 PTE,那么我们能不能在 MMU 中引入一层硬件缓存,这样 MMU 可以把查找到的 PTE 缓存在硬件中,下次再需要的时候直接到硬件缓存中拿现成的 PTE 就可以了,这样一来,CPU 的访存效率又被近一步加快了。
这个 MMU 中的硬件缓存就叫做 TLB(Translation Lookaside Buffer),TLB 是一个非常小的,虚拟寻址的硬件缓存,专门用来缓存被 MMU 翻译之后的热点 PTE。当我们引入 TLB 之后,整个寻址过程就又有了一些新的变化:
image.png当 CPU 将要访问的虚拟内存地址送到 MMU 中翻译时,MMU 首先会在 TLB 中通过虚拟内存寻址查找其对应的 PTE 是否缓存在 TLB 中,如果 cache hit ,那么 MMU 就可以直接获得现成的 PTE,避免了漫长的地址翻译过程。
如果 cache miss,那么 MMU 就需要重新遍历页表,然后获取 PTE 的内存地址,从 CPU 高速缓存或者内存中去查找 PTE,慢速路径下获取到 PTE 之后,MMU 会将 PTE 缓存到 TLB 中,加快下一次获取 PTE 的速度。
当 MMU 获取到 PTE 之后,就可以从 PTE 中拿到物理内存页的起始地址了,在加上虚拟内存地址的低 12 位(物理内存页内偏移)这样就获取到了虚拟内存地址映射的物理内存地址了。
image.png那么当 MMU 拿到我们最终要访问的物理内存地址之后,又该怎么办呢?
image.png-
当 MMU 获取到最终的物理内存地址,首先会根据物理内存地址到 CPU 高速缓存中去查找数据,如果 cache hit,整个访存操作快速结束。
-
如果 cache miss,那么 MMU 会将物理内存地址放到系统总线上传输,随后 IO bridge 会将系统总线上传输的地址信号传递到存储总线上。
-
内存中的存储控制器感受到存储总线上的地址信号之后,会将物理内存地址从存储总线上读取出来。并根据物理内存地址定位到具体的存储器模块,随后解析物理内存地址从 DRAM 芯片中取出对应物理内存地址里的数据。
关于 DRAM 芯片的具体访问细节,感兴趣的读者朋友可以回看下笔者之前文章 《一步一图带你深入理解 Linux 虚拟内存管理》 的 ”8. 到底什么是物理内存地址“ 小节。
-
存储控制器将读取到的数据放到存储总线传输上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。
-
CPU 芯片感受到系统总线上的数据信号之后,将数据从系统总线上读取出来并拷贝到寄存器中,随后通过 ALU 完成计算。
总结
本文笔者通过页表体系这条主线脉络,为大家串讲了一下之前介绍的虚拟内存管理以及物理内存管理的相关内容,在我们回顾完虚拟内存管理和物理内存管理之后,随后我们引出了虚拟内存如何与物理内存进行映射这个问题,并在这个过程中为大家揭露了页表的本质。
在我们清楚了页表的本质之后,笔者又沿着页表体系的演进这条主线,对单级页表,二级页表,四级页表展开了介绍,其中花了一定的篇幅为大家详细的介绍了 32 位和 64 位页表项以及页目录想的比特位布局,让大家真真实实的看到了页表项和页目录项到底长什么样子。
在这个基础之上,笔者又对虚拟内存地址格式的组成进行了详细的剖析,并深入到内核中,带着大家梳理了内核是如何从虚拟内存地址中提取对应页目录以及页表中的偏移的,在这些基础之上详细介绍了页表的整个遍历过程。
在本文的最后,笔者带大家又梳理了一遍 CPU 寻址的完整过程,对前边的知识内容做一个串联回顾。
到这里页表相关的知识内容,笔者就为大家介绍完了,感谢大家的收看,我们下篇文章见~~~~~~
网友评论