Linux 内存管理窥探(6):分页机制

参考文档:https://blog.csdn.net/gatieme/article/details/52402861 感谢作者的无私分享。

在前面的内容了解到硬件为了支持虚拟地址和物理地址,引入了硬件的 MMU 管理

分页机制其实是硬件和操作系统软件共同作用的产物,硬件需要支持虚拟地址到物理地址的转换,软件需要设计一种方式来兼容所有的处理器的这种机制,所以就诞生了这种 VA -> PA 的机制。

 

1. 为什么使用多级页表来完成映射

软件层面,需要提供一张表,这个表中表明了虚拟地址到物理地址的映射关系。

用来将虚拟地址映射到物理地址的数据结构称为 页表 , 实现两个地址空间的关联最容易的方式是使用数组, 对虚拟地址空间中的每一页, 都分配一个数组项. 该数组指向与之关联的页帧, 但这会引发一个问题, 例如, IA-32体系结构使用4KB大小的页, 在虚拟地址空间为4GB的前提下, 则需要包含100万项的页表. 这个问题在64位体系结构下, 情况会更加糟糕. 而每个进程都需要自身的页表, 这回导致系统中大量的所有内存都用来保存页表.

设想一个典型的32位的X86系统,它的虚拟内存用户空间(user space)大小为3G, 并且典型的一个页表项(page table entry, pte)大小为4 bytes,每一个页(page)大小为4k bytes。那么这3G空间一共有(3G/4k=)786432个页面,每个页面需要一个pte来保存映射信息,这样一共需要786432个 pte !

如何存储这些信息呢?一个直观的做法是用数组来存储,这样每个页能存储(4k/4=)1K个,这样一共需要(786432/1k=)768个连续的物理页面(phsical page)。而且,这只是一个进程,如果要存放所有N个进程,这个数目还要乘上N! 这是个巨大的数目,哪怕内存能提供这样数量的空间,要找到连续768个连续的物理页面在系统运行一段时间后碎片化的情况下,也是不现实的。

为减少页表的大小并容许忽略不需要的区域, 计算机体系结构的涉及会将虚拟地址分成多个部分. 同时虚拟地址空间的大部分们区域都没有使用, 因而页没有关联到页帧, 那么就可以使用功能相同但内存用量少的多的模型: 多级页表

好了,这个时候引入了多级页表的概念了,那么使用多少级页表来表示最为合适呢 ?

 

2. 硬件层面的分页选择

2.1 在 32 位系统的硬件分页选择

在 32 位系统中,最大寻址空间为 4G (2 的 32 次幂)。

从 80386 开始, intel处理器的分页单元是4KB的页, 32位的地址空间被分为3部分(注意这里是 X86):

单元 描述
页目录表 Directory 最高10位
页中间表Table 中间10位
页内偏移 最低12位

即页表被划分为页目录表Directory和页中间表 Table 两个部分

此种情况下, 线性地址的转换分为两步完成.

第一步, 基于两级转换表(页目录表和页中间表), 最终查找到地址所在的页帧

第二步, 基于偏移, 在所在的页帧中查找到对应偏移的物理地址

使用这种二级页表可以有效的减少每个进程页表所需的RAM的数量. 如果使用简单的一级页表, 那将需要高达220220个页表, 假设每项4B, 则共需要占用220∗4B=4MB220∗4B=4MB的RAM来表示每个进程的页表. 当然我们并不需要映射所有的线性地址空间(32位机器上线性地址空间为4GB), 内核通常只为进程实际使用的那些虚拟内存区请求页表来减少内存使用量。

举个例子:

Linux 内存管理窥探(6):分页机制

每个活动的进程必须有一个页目录,但是却没有必要马上为所有进程的所有页表都分配 RAM,只有在实际需要一个页表时候才给该页表分配 RAM。

这个例子是在 TLB miss 的时候,不得已系统必须去进行 table walk:

1. 首先准备好这个进程的 PGD 页表,并且将其首地址放置到 cr3 接触器

2. 当进行 table walk 的时候,首先硬件去以虚拟地址的高 10 bit取出来,作为 PGD 的 index,去索引一个 PGD 出来

3. 这个 PGD 记录了其对应的 PTE 的首地址,此刻硬件会以这个首地址来作为下一个阶段的首地址

4. 去取虚拟地址的中间 10 bit 作为 index,以第 3 步的首地址为起点,索引到对应页面(4K)的首地址

5. 硬件以第4步获取到的首地址作为实际页面(4K为单位对其的)的起始地址,以虚拟地址的最后的 12bit 作为 4K 页面的页面内偏移量来索引到实际的那个内存单元。

这个过程,操作系统软件需要准备好相关的页表,但是整个搜索过程由硬件自动完成,访问到实际的物理地址。

X86 上整体的过程如图:

Linux 内存管理窥探(6):分页机制

 

2.2 在 64 位系统的硬件分页选择

在 64 bit 系统上的具体分页和处理器相关。

正常来说, 对于32位的系统两级页表已经足够了, 但是对于64位系统的计算机, 这远远不够.

首先假设一个大小为4KB的标准页. 因为1KB覆盖210210个地址的范围, 4KB覆盖212212个地址, 所以offset字段需要12位.

这样线性地址空间就剩下64-12=52位分配给页中间表Table和页目录表Directory. 如果我们现在决定仅仅使用64位中的48位来寻址(这个限制其实已经足够了, 2^48=256TB, 即可达到256TB的寻址空间). 剩下的48-12=36位被分配给Table和Directory字段. 即使我们现在决定位两个字段各预留18位, 那么每个进程的页目录和页表都包含218218个项, 即超过256000个项.

基于这个原因, 所有64位处理器的硬件分页系统都使用了额外的分页级别. 使用的级别取决于处理器的类型

平台名称 页大小 寻址所使用的位数 分页级别数 线性地址分级
alpha 8KB 43 3 10 + 10 + 10 + 13
ia64 4KB 39 3 9 + 9 + 9 + 12
ppc64 4KB 41 3 10 + 10 + 9 + 12
sh64 4KB 41 3 10 + 10 + 9 + 12
x86_64 4KB 48 4 9 + 9 + 9 + 9 + 12

具体的 table walk 的方式与 32 位系统的类似。

 

3. Linux 操作系统层面的分页

由于程序存在局部化特征, 这意味着在特定的时间内只有部分内存会被频繁访问,具体点,进程空间中的text段(即程序代码), 堆, 共享库,栈都是固定在进程空间的某个特定部分,这样导致进程空间其实是非常稀疏的, 于是,从硬件层面开始,页表的实现就是采用分级页表的方式,Linux内核当然也这么做。所谓分级简单说就是,把整个进程空间分成区块,区块下面可以再细分,这样在内存中只要常驻某个区块的页表即可,这样可以大量节省内存。

 

3.1 Linux最初的二级页表

Linux最初是在一台i386机器上开发的,这种机器是典型的32位X86架构,支持两级页表

一个32位虚拟地址如上图划分。当在进行地址转换时,

结合在CR3寄存器中存放的页目录(page directory, PGD)的这一页的物理地址,再加上从虚拟地址中抽出高10位叫做页目录表项(内核也称这为pgd)的部分作为偏移, 即定位到可以描述该地址的pgd;

从该pgd中可以获取可以描述该地址的页表的物理地址,再加上从虚拟地址中抽取中间10位作为偏移, 即定位到可以描述该地址的pte;

在这个pte中即可获取该地址对应的页的物理地址, 加上从虚拟地址中抽取的最后12位,即形成该页的页内偏移, 即可最终完成从虚拟地址到物理地址的转换。 
从上述过程中,可以看出,对虚拟地址的分级解析过程,实际上就是不断深入页表层次,逐渐定位到最终地址的过程,所以这一过程被叫做page talbe walk。

至于这种做法为什么能节省内存,举个更简单的例子更容易明白。比如要记录16个球场的使用情况,每张纸能记录4个场地的情况。采用4+4+4+4,共4张纸即可记录,但问题是球场使用得很少,有时候一整张纸记录的4个球场都没人使用。于是,采用4 x 4方案,即把16个球场分为4组,同样每张纸刚好能记录4组情况。这样,使用一张纸A来记录4个分组球场情况,当某个球场在使用时,只要额外使用多一张纸B来记录该球场,同时,在A上记录”某球场由纸B在记录”即可。这样在大部分球场使用很少的情况下,只要很少的纸即困记录,当有球场被使用,有需要再用额外的纸来记录,当不用就擦除。这里一个很重要的前提就是:局部性。


3.2 Linux的三级页表

当X86引入物理地址扩展(Pisycal Addrress Extension, PAE)后,可以支持大于4G的物理内存(36位),但虚拟地址依然是32位,原先的页表项不适用,它实际多4 bytes被扩充到8 bytes,这意味着,每一页现在能存放的pte数目从1024变成512了(4k/8)。相应地,页表层级发生了变化,Linus新增加了一个层级,叫做页中间目录(page middle directory, PMD), 变成:

字段 描述 位数
cr3 指向一个PDPT crs寄存器存储
PGD 指向PDPT中4个项中的一个 位31~30
PMD 指向页目录中512项中的一个 位29~21
PTE 指向页表中512项中的一个 位20~12
page offset 4KB页中的偏移 位11~0

实际的page table walk依然类似,只不过多了一级

现在就同时存在2级页表和3级页表,在代码管理上肯定不方便。巧妙的是,Linux采取了一种抽象方法:所有架构全部使用3级页表: 即PGD -> PMD -> PTE。那只使用2级页表(如非PAE的X86)怎么办?

办法是针对使用2级页表的架构,把PMD抽象掉,即虚设一个PMD表项。这样在page table walk过程中,PGD本直接指向PTE的,现在不了,指向一个虚拟的PMD,然后再由PMD指向PTE。这种抽象保持了代码结构的统一。

 

3.3 Linux的四级页表

硬件在发展,3级页表很快又捉襟见肘了,原因是64位CPU出现了, 比如X86_64, 它的硬件是实实在在支持4级页表的。它支持48位的虚拟地址空间1。如下:

字段 描述 位数
PML4 指向一个PDPT 位47~39
PGD 指向PDPT中4个项中的一个 位38~30
PMD 指向页目录中512项中的一个 位29~21
PTE 指向页表中512项中的一个 位20~12

Linux内核针为使用原来的3级列表(PGD->PMD->PTE),做了折衷。即采用一个唯一的,共享的*层次,叫PML4[2]。这个PML4没有编码在地址中,这样就能套用原来的3级列表方案了。不过代价就是,由于只有唯一的PML4, 寻址空间被局限在(239=)512G, 而本来PML4段有9位, 可以支持512个PML4表项的。现在为了使用3级列表方案,只能限制使用一个, 512G的空间很快就又不够用了,解决方案呼之欲出。

在2004年10月,当时的X86_64架构代码的维护者Andi Kleen提交了一个叫做4level page tables for Linux的PATCH系列,为Linux内核带来了4级页表的支持。在他的解决方案中,不出意料地,按照X86_64规范,新增了一个PML4的层级, 在这种解决方案中,X86_64拥一个有512条目的PML4, 512条目的PGD, 512条目的PMD, 512条目的PTE。对于仍使用3级目录的架构来说,它们依然拥有一个虚拟的PML4,相关的代码会在编译时被优化掉。 这样,就把Linux内核的3级列表扩充为4级列表。这系列PATCH工作得不错,不久被纳入Andrew Morton的-mm树接受测试。

不出意外的话,它将在v2.6.11版本中释出。但是,另一个知名开发者Nick Piggin提出了一些看法,他认为Andi的Patch很不错,不过他认为最好还是把PGD作为第一级目录,把新增加的层次放在中间,并给出了他自己的Patch:alternate 4-level page tables patches。Andi更想保持自己的PATCH, 他认为Nick不过是玩了改名的游戏,而且他的PATCH经过测试很稳定,快被合并到主线了,不宜再折腾。

不过Linus却表达了对Nick Piggin的支持,理由是Nick的做法conceptually least intrusive。毕竟作为Linux的扛把子,稳定对于Linus来说意义重大。

最终,不意外地,最后Nick Piggin的PATCH在v2.6.11版本中被合并入主线。在这种方案中,4级页表分别是:PGD -> PUD -> PMD -> PTE。

字段 描述
PGD 页全局目录(Page Global Directory)
PUD 页上级目录(Page Upper Directory)
PMD 页中间目录(Page Middle Directory)
PTE 页表(Page Table)

四级页表模式下将虚拟地址转化为逻辑地址,基本过程如下:

1.从CR3寄存器中读取页目录所在物理页面的基址(即所谓的页目录基址),从线性地址的第一部分获取页目录项的索引,两者相加得到页目录项的物理地址。

2.第一次读取内存得到pgd_t结构的目录项,从中取出物理页基址取出(具体位数与平台相关,如果是32系统,则为20位),即页上级页目录的物理基地址。

3.从线性地址的第二部分中取出页上级目录项的索引,与页上级目录基地址相加得到页上级目录项的物理地址。

4.第二次读取内存得到pud_t结构的目录项,从中取出页中间目录的物理基地址。

5.从线性地址的第三部分中取出页中间目录项的索引,与页中间目录基址相加得到页中间目录项的物理地址。

6.第三次读取内存得到pmd_t结构的目录项,从中取出页表的物理基地址。

7.从线性地址的第四部分中取出页表项的索引,与页表基址相加得到页表项的物理地址。

8.第四次读取内存得到pte_t结构的目录项,从中取出物理页的基地址。

9.从线性地址的第五部分中取出物理页内偏移量,与物理页基址相加得到最终的物理地址。

10.第五次读取内存得到最终要访问的数据。

整个过程是比较机械的,每次转换先获取物理页基地址,再从线性地址中获取索引,合成物理地址后再访问内存。不管是页表还是要访问的数据都是以页为单 位存放在主存中的,因此每次访问内存时都要先获得基址,再通过索引(或偏移)在页内访问数据,因此可以将线性地址看作是若干个索引的集合。

Linux 内存管理窥探(6):分页机制

Linux 内存管理窥探(6):分页机制

 

linux中每个进程有它自己的PGD( Page Global Directory),它是一个物理页,并包含一个pgd_t数组。

进程的pgd_t数据见 task_struct -> mm_struct -> pgd_t * pgd;

PTEs, PMDs和PGDs分别由pte_t, pmd_t 和pgd_t来描述。为了存储保护位,pgprot_t被定义,它拥有相关的flags并经常被存储在page table entry低位(lower bits),其具体的存储方式依赖于CPU架构。

前面我们讲了页表处理的大多数函数信息,在上面我们又讲了线性地址如何转换为物理地址,其实就是不断索引的过程。

通过如下几个函数,不断向下索引,就可以从进程的页表中搜索特定地址对应的页面对象

宏函数 说明
pgd_offset

根据当前虚拟地址和当前进程的mm_struct获取pgd项

pud_offset

参数为指向页全局目录项的指针 pgd 和线性地址 addr 。

这个宏产生页上级目录中目录项 addr 对应的线性地址。在两级或三级分页系统中,该宏产生 pgd ,即一个页全局目录项的地址

pmd_offset 根据通过pgd_offset获取的pgd 项和虚拟地址,获取相关的pmd项(即pte表的起始地址)
pte_offset 根据通过pmd_offset获取的pmd项和虚拟地址,获取相关的pte项(即物理页的起始地址)

 

后记:

关于 ARMV7-A 的 MMU 在后面会根据 ARM 官方的 DataSheet 进行分析,以及如何在软件层面进行对应。

入口:

https://blog.csdn.net/zhoutaopower/article/details/88049623

 

参考文档:

https://blog.csdn.net/gatieme/article/details/52402861

https://blog.csdn.net/gatieme/article/details/50651561

https://blog.csdn.net/gatieme/article/details/50756050

https://blog.csdn.net/xiaojsj111/article/details/11065717

https://blog.csdn.net/yrj/article/details/2508785

http://www.cnblogs.com/fozu/p/4601291.html