深入理解Linux内核页表映射分页机制原理
前言
操作系统用于处理内存访问异常的入口操作系统的核心任务是对系统资源的管理,而重中之重的是对CPU和内存的管理。
为了使进程摆脱系统内存的制约,用户进程运行在虚拟内存之上,每个用户进程都拥有完整的虚拟地址空间,互不干涉。
而实现虚拟内存的关键就在于建立虚拟地址(Virtual Address,VA)与物理地址(Physical Address,PA)之间的关系,因为无论如何数据终究要存储到物理内存中才能被记录下来。
如下图所示,进程1和进程2拥有完整的虚拟地址空间,虚拟地址空间分为了用户空间和内核空间,对于不同的进程面对的都是同一个内核,其内核空间的地址对于的物理地址都是一样的,因而进程1和进程2中内核空间的VA K地址都映射到了物理内存的PA K地址。
而不同的进程的用户空间是不同的,进程1和进程2相同的虚拟地址VA 1和VA 2分别映射到了不同的物理地址PA 1和PA 2上。
而虚拟地址到物理地址映射关系的实现可以称之为地址转换(Address Translation)。
为了实现上述地址转换,操作系统需要借助硬件的帮助,即内存管理单元(Memory Management Unit,MMU)的帮助。
对于MMU应当有如下功能:
要求 | 说明 | ||||
---|---|---|---|---|---|
特权模式 | 区分内核空间和用户空间,用户进程无法直接访问内核地址空间 | ||||
基址/界限寄存器 | 记录地址转换基址的寄存器,用于寻址地址转换映射表 | ||||
地址转换 | 完成地址转换过程 | ||||
异常处理特权操作指令 | 操作系统用于处理内存访问异常的入口 | ||||
MMU配合操作系统完成了诸多功能:
用户空间和内核空间,通过特权模式划分了内核空间和用户空间,用户空间无法直接访问内核空间,必须通过某些手段(系统调用,异常,中断等)切换到特权模式才能间接访问内核。
地址转换,通过基址/界限寄存器记录的转换映射表基址,结合虚拟地址,可以完成地址转换的功能,从而实现通过虚拟地址访问到物理地址。
进程独立的虚拟地址空间,通过基址/界限寄存器的访问指令,在进程切换时修改基址/界限寄存器的值,从而使MMU在做地址转换时找到各个进程对应的地址映射表,从而实现不同进程虚拟地址完全独立。
缺页异常,对于进程申请的内存,并不需要在其申请内存时即建立地址转换映射表,同时分配对应的物理空间,而是在进程真正访问内存地址时,MMU上报缺页异常再分配对应的物理空间。
当然虚拟地址到物理地址映射表中的一些标志区可以实现更多的缺页异常类型,例如读写权限错误,特权错误,越界错误等异常。
一、分页
分页即将内存划分为固定长度的单元,每个单元就是一页。对于虚拟地址空间,分页机制将地址空间分割成固定大小的单元,每个单元称为一页。
对于物理地址空间**,物理内存被抽象成固定大小的单元,每个单元称为页帧(frame)**。通过分页管理内存可以避免分段带来的内存外碎片问题。
分页管理内存的核心问题是虚拟地址页到物理地址页帧的映射关系。虚拟地址到物理地址的转换可以抽象简化成下图,假设地址是32位的。
为了将虚拟地址转换成物理地址,将虚拟地址分割成两部分:
- 虚拟页面号,高31-X位组成,VPN(virtual page number);
- 虚拟地址偏移,低X位组成,VA Offset(virtual address offset);
物理地址也抽象成两部分:
- 物理页帧号,高31-X位组成,PFN(physical frame number);
- 物理地址偏移,低X位组成,PA Offset(physical address offset);
虚拟页面号VPN用于索引物理页帧号PFN,VPN索引PFN的过程就是地址转换的核心。VA offset通常就是PA offset,即PFN + VA offset就是最终物理地址。
所以,可以说分页机制的核心就是VPN到PFN的映射。而VPN到PFN的映射关系是通过页表记录的。MMU通过页表记录的映射关系完成VPN到PFN的转换,即找到了页表就找到了物理地址。
1.1 页表存在哪里?
以32位地址空间为例,分页大小为4KB(最常用的分页大小),上述抽象例子中的X为12,那么VPN长度就是20bit,偏移量为12bit。
20bit的VPN意味着操作系统需要2^20个地址转换映射,假设每个转换映射需要4Byte空间存储,那么所有映射关系需要4MB空间。
开篇我们提到,进程的虚拟地址到物理地址的转换是不同的,所以每个进程的映射关系也是不同的,就是说每个进程都需要4MB的空间来存储页表。如果操作系统运行100个进程,则需要400MB空间。
可见页表所需要的空间是很大的,所以页表都存储在物理内存中。即MMU通将虚拟地址转换为物理地址,需要访问物理内存中对应的页表。
当然页表占用物理内存大的问题还是需要解决的,这是分页相对于分段的一个劣势,解决方案是多级页表配合缺页异常的方式,后面再详细介绍多级页表的机制。
1.2 页表长啥样?
页表是如何完成VPN到PFN的转换的,要知道这个问题就得清楚页表的基本内容,即页表记录了什么信息。
页表的作用就是通过VPN找到PFN,那么页表最基本的组成部分需要包含如下内容:
- PFN物理页帧号;
- 有效位(valid),用于标记页面是否有效;
- 存在位(present),指示该页是否存在于物理内存,用于页面换入换出(swap);
- 特权标记,指示页面访问的特权等级;
- Dirty位,写操作时设置该位,表示页面被写过,页面交换时使用;
1.3 分页机制如何完成进程地址空间切换?
每个进程都拥有自己独立的地址空间,进程切换时地址空间也会切换。
不同进程都拥有自己的一套页表,因而即使两个进程虚拟地址相同,映射的物理地址也是不同的。
切换地址空间相当于控制MMU访问不同进程拥有的页表,MMU找到了页表就找到了物理地址。
通常CPU会提供若干寄存器供操作系统使用,用于为MMU指示页表的基地址。
如下图所示,进程切换时,只需要设置页表基址寄存器即可完成页表的切换,也就完成了进程地址空间的切换。
所以CPU会为操作系统提供页表基址寄存器用于进程地址空间的切换。
-
X86体系架构提供的寄存器是CR3(Control Register 3);
-
ARM-v7体系架构提供的寄存器是协处理器CP15寄存器TTBR(Tranlation Table Base Register);
-
ARM-v8体系架构提供的寄存器是系统寄存器TTBR(Tranlation Table Base Register)。
1.4 实际使用的分页机制
考虑到分页机制占用内存过多的问题,实际的分页机制是多级分页。
以二级页表为例,如下图所示:
- MMU通过页表基址寄存器配合虚拟地址中的**PGD index(Page Global Directory)**找到一级页表,
- 通过一级页表配合虚拟地址中的**PTE index(Page Table Entry)**找到二级页表,通过二级页表配合虚拟地址中Offset找到物理地址。
多级页表要做到节省内存,还需要配合缺页异常,进程往往只需将一级页表保持到内存中,二级页表在缺页异常时再分配。
下图示例中,一级页表一共4096项(212),二级页表一共512项(29)。
因此进程页表可以只使用4096 X 4Byte空间即可。
如果使用一级页表,则需要2097152 X 4Byte空间。
因此多级页表带来的最大好处就是降低了内存空间的占用。
1.5 多级页表的缺点
多级页表带来了好处,降低了操作系统进程管理,内存管理对内存空间的占用。
当然计算机领域总是没有那么完美的方案,多级分页也逃避不了这个宿命,获得了空间的优势,也带来时间上的损失。
多级分页时间上的损失主要体现在如下几个方面:
-
用时下发的耗时:对于子进程写时复制(COW)技术大家比较熟悉,其实多级页表也利用了类似的思想。多级页表的后几级页表映射关系没有存在内存中,MMU地址转换中发现页表不存在需要向操作系统上报缺页异常,操作系统需要在缺页异常中下发页表到内存;
-
额外的内存访问:MMU进行地址转换需要通过页表基址寄存器找到一级页表,再依次找到下级页表,所有的页表都存放在内存中,访问内存是需要额外的时间消耗的,相对于CPU对寄存器的访问,Cache的访问速度而言,内存的访问速度是灾难性的,何况还是多次访问。当然额外的内存访问本身是分页机制相对分段机制的缺陷,一级页表映射也存在这样的缺陷,只是多级页表映射将这个缺点再次放大。
1.6 Translation Lookside Buffer
Translation Lookside Buffer简称TLB,按其真实作用应当翻译为地址转换缓存。
方才抨击了多级页表映射基址,提出了它可能导致系统变慢的缺点,那么如何解决这一问题呢?
如果使MMU做页表转换时不访问内存,是不是就解决问题了?
TLB就是干这个事的。
TLB之所以可以解决这个问题是因为TLB是Cache,它将CPU访问内存替换为CPU访问Cache,也就是说MMU做页表转换时不再访问内存的页表,而是访问缓存在TLB中的页表,因而降低了时间的消耗。
TLB要实现这个替换,其需要实现的基本工作原理是:
-
从虚拟地址中提取页号(VPN),检查TLB是否有该VPN的转换映射。
-
如果有,则表示TLB命中(TLB hit),意味着从TLB中找到VPN对应的物理页框号(PFN)。PFN与虚拟地址的偏移量组成成物理地址(PA)。
-
如果没有,表示TLB未命中(TLB miss),则需要处理TBL miss。
TLB miss处理有两种方法:
- 一种是硬件处理
- 一种是软件处理
硬件处理TLB miss会自动更新TLB。
软件处理则是由硬件抛出一个TLB miss异常,软件进入异常处理程序,查找物理页表中转换映射,再由指令更新TLB,并从异常中返回。
软件处理TLB miss异常与其他异常不同,异常处理返回后,应继续执行陷入异常之后的那条指令,而TLB miss异常处理返回后,从导致陷入异常的执行开始执行。这样保证TLB一定命中。
诚然,TLB是好,但是也引入了一些麻烦事(既然是Cache,就有一致性问题):
- 进程切换时TLB如何处理?
- TLB表项满了如何处理?
- mmap映射的内存被munmap解除TLB怎么处理?
- ……
针对这些话题本文不做深入探讨,可以阅读另一篇为其量身定做的博文《深入Linux内核(内存篇)—TLB》。
1.7 页表多大合适?
-
大页表的好处:
-
省内存:可以解决分页机制占用内存的问题,取得和多级页表一样节省内存的效果;
-
对TLB友好:大页表意味着地址转换时需要更少的页表映射表项,页表映射表项少了意味着TLB缓存的表项少,这样就提高了TLB的命中率;
-
-
大页表的坏处:
-
内存内碎片:操作系统申请内存时总是申请一大块内存,哪怕实际只需要很小的内存,导致大页内存得不到充分利用;而且内存很快会被这些大页侵占。
-
显然小页表的好处和坏处正好与大页表对立。
-
因此页表不是越大越好,也不是越小越好,找到折中的大小是才最适合。通常操作系统的使用的页大小是4KB。
-
各种体系架构的CPU都支持很多种页大小。因此实际页表的应用可能会更“聪明”,用户进程在请求地址空间时,可以因需求选择合适的页大小,这样既可以满足数据的存放,同时占用更少的TLB表项。
一个典型的例子,DPDK使用了1GB的大页内存,这样DPDK进程的页表映射只占用一个TLB表项,在进程执行过程中杜绝了TLB miss情况的发生,保障了性能。
DPDK (Data Plane Development Kit): DPDK 是一个开源库集合,它允许开发者通过用户空间应用直接访问网络硬件,从而实现高性能的数据包处理。这通常用于快速的数据包转发和处理,例如在路由器、交换机或防火墙中。
当DPDK使用1GB的大页内存时,这意味着DPDK进程的虚拟地址空间中的每1GB部分都映射到物理内存的一个连续区域。因此,对于1GB的虚拟地址范围,DPDK进程的页表只需要一个条目来描述这一映射。
由于页表条目减少了,TLB中需要的表项也减少了。在这个例子中,DPDK进程的整个1GB虚拟地址范围只需要一个TLB表项。这意味着,当DPDK进程尝试访问这1GB范围内的任何地址时,它都可以直接从TLB中获取虚拟到物理地址的转换,而不需要查询页表。
这杜绝了TLB miss情况的发生,因为DPDK进程在访问其虚拟地址空间时总是能够在TLB中找到所需的转换。避免了TLB miss意味着避免了查询页表的开销,从而显著提高了性能。
总之,通过使用大页内存,DPDK能够减少页表和TLB的大小,降低TLB miss率,从而提高数据包处理的性能。
二、X86中的分页
X86中定义分页即将每个线性地址转换为物理地址,并确定对于每个转换,允许对线性地址的何种访问(地址的访问权限)以及用于此类访问的缓存类型(地址的内存类型)。
X86支持如下四种分页模式:
分页模式的选择主要由control register CR0,control register CR4,IA32_EFER MSR控制。
由上表可以看出:
- CR0.PG = 0,关闭分页单元,线性地址被直接解释成物理地址;
- CR0.PG = 1 && CR4.PAE = 0,使用32-bit分页机制,线性地址大小是232,物理地址大小可以达到240,支持的页大小是4KB和4MB;
- CR0.PG = 1 && CR4.PAE = 1,使用PAE(Physical Address Extension)分页机制,线性地址大小是232,物理地址大小可以达到252,支持的页大小是4KB和2MB;
- CR0.PG = 1 && CR4.PAE = 1 && IA32_EFER.LME = 1,使用4级分页机制,线性地址大小是248,物理地址大小可以达到252,支持的页大小是4KB、2MB和1GB;
- CR0.PG = 1 && CR4.PAE = 1 && IA32_EFER.LME = 1 && CR4.LA57 = 1,使用5级分页机制,线性地址大小是257,物理地址大小可以达到252,支持的页大小是4KB、2MB和1GB;
三、ARM中的分页
直接上ARMv8
3.1 ARMv8 分页配置
ARMv8架构AArm64支持三种页大小:64KB,16KB和4KB。
页大小选择由系统寄存器TCR控制,如下图所示为TCR_EL1寄存器。
比较重要的bit位说明:
- T0SZ[5:0]:The size offset of the memory region addressed by TTBR0_EL1,TTBR0_EL1寻址的内存区域的大小偏移量,内存区域大小计算方法2(64-T0SZ);
- T1SZ[21:16]:The size offset of the memory region addressed by TTBR1_EL1,TTBR1_EL1寻址的内存区域的大小偏移量,内存区域大小计算方法2(64-T0SZ);
- IRGN0[9:8]/IRGN1[25:24]:Inner cacheability attribute for memory associated,控制内部Cache访问模式,直写和回写;
- ORGN0[11:10]/ORGN1[27:26]:Outer cacheability attribute for memory associated,控制外部Cache访问模式,直写和回写;
- TG0[15:14]:Granule size for the TTBR0_EL1,页大小,为0表示4KB,1表示64KB,2表示16KB;
- TG1[31:30]:Granule size for the TTBR1_EL1,页大小,为0表示4KB,1表示64KB,3表示16KB;
- A1[22]:ASID选择,为0选择TTBR0_EL1.ASID ,为1选择TTBR1_EL1.ASID;
- IPS[34:32]:Intermediate Physical Address Size,中间物理地址大小;
- AS[36]:ASID Size,ASID大小,为0表示8bit,为1表示16bit;
- HA[39]:Hardware Access flag update,Access使能位;
- HD[40]:Hardware management of dirty state,Dirty使能位;
说明:
- ARM架构提供了两个页表基址寄存器TTBR0和TTBR1,可以分别用于用户态和内核态。
- ASID用于标识进程,可以根据ASID划分TLB entry,避免TLB entry频繁Flush。
- 显然系统寄存器TCR控制了页表映射的参数,其中TCR.TG0/TG1决定了页大小。
- 当页大小为4KB时,分页单元每级页表的地址范围如下,其中TnSZmin和TnSZmax分别表示TCR_ELx.TnSZ的最小最大值,IA表示Input Address,即虚拟地址:
3.2 ARMv8 Paging
以页大小为4KB,虚拟地址位宽为48bit为例,符合上一节中TCR_ELx.TnSZ为最小值的情况,如下图所示。
ARMv8对IA(input address)划分成了五部分:
- Index the level 0 translation table[47:39]:最高9bit;
- Index the level 1 translation table[38:30]:中间9bit;
- Index the level 2 translation table [29:21]:中间9bit;
- Index the level 3 translation table[20:12]:中间9bit;
- OA[11:0]:output address,最低12bit。
这个划分方法与X86 4-Level Paging一样。
其地址转换过程,与前述的地址转换过程并无差别,从页表基址寄存器TTBR_ELx开始逐级查找到物理地址.
3.3 Kernel中的ARMv8分页
Linux Kernel分页为了支持不同的CPU体系架构,设计了五级分页模型,如下图所示。五级分页模型是为了兼容X86-64体系架构中的5-Level Paging分页模式,见第二节。
五级分页每级命名分别为页全局目录(PGD)、页4级目录(P4D)、页上级目录(PUD)、页中间目录(PMD)、页表(PTE)。对应的相关宏定义命名如下:
#define PGDIR_SHIFT
#define P4D_SHIFT
#define PUD_SHIFT
#define PMD_SHIFT
#define PAGE_SHIFT
这些宏定义与具体体系架构相关,如果体系架构只使用了4级,3级或者更少的分级映射,则将其中的某几个定义忽略即可。
Linux对于页表的操作主要定义了以下函数或宏。这些操作方法也是与体系架构相关的,因此需要按照体系架构的硬件定义去实现。
宏或函数 | 说明 | ||||
---|---|---|---|---|---|
pgd_offset(mm, addr) | 根据入参内存描述符mm和虚拟地址address,找到address在页全局目录中相应表项的线性地址。 | ||||
pgd_offset_k(addr) | 根据入参虚拟地址address和init_mm,找到address在页全局目录中相应表项的线性地址。仅用于内核页表。 | ||||
p4d_offset(pgd, addr) | 根据入参pgd和虚拟地址address,找到address在页四级目录中相应表项的线性地址。 | ||||
pud_offset(p4d,addr) | 根据入参p4d和虚拟地址address,找到address在页上级目录中相应表项的线性地址。 | ||||
pmd_offset(pud, address) | 根据入参pud和虚拟地址address,找到address在页中间目录中相应表项的线性地址。 | ||||
pte_index(address) | 根据入参虚拟地址address,找到address在页表中索引。 | ||||
set_pgd(pgdp, pgd) | 向PGD写入指定的值 | ||||
set_p4d(p4dp, p4d) | 向P4D写入指定的值 | ||||
set_pud(pudp, pud) | 向PUD写入指定的值 | ||||
set_pmd(pmdp, pmd) | 向PMD写入指定的值 | ||||
set_pte(ptep, pte) | 向PTE写入指定的值 | ||||
pte_dirty(pte) | 读Dirty标志 | ||||
pte_mkdirty(pte) | 写Dirty标志 | ||||
分页机制与CPU体系架构强相关,因此分析Linux Kernel分页时还是需要根据体系架构分析。
ARMv8页表支持三种粒度:4KB,16KB和64KB。
ARMv8支持48bit虚拟地址空间,实现ARMv8.2-LVA( Large Virtual Address)并使用64KB页大小时虚拟地址寻址空间可达52bit。
当使用64KB页大小时,ARMv8使用三级页表;当使用4KB和16KB页大小时,ARMv8使用四级页表。
正如下图所示。
ARMv8采用4KB页大小,使用4级页表时,内存分布如下,内核空间和用户空间大小分别为256TB。
内核空间地址范围从0xffff000000000000到0xffffffffffffffff,共256TB空间,用户空间地址范围从0x0000000000000000到0x0000ffffffffffff,共256TB空间。
AArch64 Linux memory layout with 4KB pages + 4 levels (48-bit)::
Start End Size Use
-----------------------------------------------------------------------
0000000000000000 0000ffffffffffff 256TB user
ffff000000000000 ffff7fffffffffff 128TB kernel logical memory map
ffff800000000000 ffff9fffffffffff 32TB kasan shadow region
ffffa00000000000 ffffa00007ffffff 128MB bpf jit region
ffffa00008000000 ffffa0000fffffff 128MB modules
ffffa00010000000 fffffdffbffeffff ~93TB vmalloc
fffffdffbfff0000 fffffdfffe5f8fff ~998MB [guard region]
fffffdfffe5f9000 fffffdfffe9fffff 4124KB fixed mappings
fffffdfffea00000 fffffdfffebfffff 2MB [guard region]
fffffdfffec00000 fffffdffffbfffff 16MB PCI I/O space
fffffdffffc00000 fffffdffffdfffff 2MB [guard region]
fffffdffffe00000 ffffffffffdfffff 2TB vmemmap
ffffffffffe00000 ffffffffffffffff 2MB [guard region]
ARM提供了两个页表基址寄存器TTBR0和TTBR1,在Linux中分别用于用户空间和内核空间,内核空间地址高16位全为1,用户空间地址高16位全为0。
如下图所示,TTBR1和TTBR0分别管理0xffff000000000000到0xffffffffffffffff和0x0000000000000000到0x0000ffffffffffff两部分地址空间,其余地址空间访问则会发生异常。
MMU做地址转换时选择TTBR1和TTBR0是根据虚拟地址VA[63],如果63bit为1则选择TTBR1,为0则选择TTBR0。
ARMv8采用4KB页大小,4级页表映射,其虚拟地址划分为,在3.4节中已经做过说明。
Linux上述 Index the level 0 & 1 & 2 & 3 translation table等数据的定义位于ARM体系架构目录,如下所示。
#define VA_BITS (CONFIG_ARM64_VA_BITS)
#define ARM64_HW_PGTABLE_LEVEL_SHIFT(n) ((PAGE_SHIFT - 3) * (4 - (n)) + 3)
#define PTRS_PER_PTE (1 << (PAGE_SHIFT - 3))
/*
* PMD_SHIFT determines the size a level 2 page table entry can map.
*/
#if CONFIG_PGTABLE_LEVELS > 2
#define PMD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(2)
#define PMD_SIZE (_AC(1, UL) << PMD_SHIFT)
#define PMD_MASK (~(PMD_SIZE-1))
#define PTRS_PER_PMD PTRS_PER_PTE
#endif
/*
* PUD_SHIFT determines the size a level 1 page table entry can map.
*/
#if CONFIG_PGTABLE_LEVELS > 3
#define PUD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(1)
#define PUD_SIZE (_AC(1, UL) << PUD_SHIFT)
#define PUD_MASK (~(PUD_SIZE-1))
#define PTRS_PER_PUD PTRS_PER_PTE
#endif
/*
* PGDIR_SHIFT determines the size a top-level page table entry can map
* (depending on the configuration, this level can be 0, 1 or 2).
*/
#define PGDIR_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(4 - CONFIG_PGTABLE_LEVELS)
#define PGDIR_SIZE (_AC(1, UL) << PGDIR_SHIFT)
#define PGDIR_MASK (~(PGDIR_SIZE-1))
#define PTRS_PER_PGD (1 << (VA_BITS - PGDIR_SHIFT))
PGDIR_SHIFT宏对应了 Index the level 0 translation table,当4KB页大小,4级页表映射时(PAGE_SHIFT = 12;CONFIG_PGTABLE_LEVELS = 4),通过计算可得PGDIR_SHIFT宏为39,与硬件分页定义一致。
PGDIR_SHIFT = (12-3)*(4-0)+3 = 39
CONFIG_PGTABLE_LEVELS表示使用页表级数,当前使用的是4级页表,所以CONFIG_PGTABLE_LEVELS>3。因此PUD定义为:
#define ARM64_HW_PGTABLE_LEVEL_SHIFT(n) ((PAGE_SHIFT - 3) * (4 - (n)) + 3)
#define PUD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(1)
PUD_SHIFT宏对应了 Index the level 1 translation table,带入计算可知PUD_SHIFT为30,与硬件分页定义一致。
PUD_SHIFT = (12-3)*(4-1)+3 = 30
CONFIG_PGTABLE_LEVELS表示使用页表级数,当前使用的是4级页表,所以CONFIG_PGTABLE_LEVELS>2。因此PMD定义为:
#define ARM64_HW_PGTABLE_LEVEL_SHIFT(n) ((PAGE_SHIFT - 3) * (4 - (n)) + 3)
#define PMD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(2)
PMD_SHIFT宏对应了 Index the level 3 translation table,带入计算可知PUD_SHIFT为21,与硬件分页定义一致。
PMD_SHIFT = (12-3)*(4-2)+3 = 21
至此,PGD,PUD,PMD都已获悉,对应的其他宏的值也可以顺利计算出结果。
#define PGDIR_SIZE 512GB
#define PTRS_PER_PGD 512
#define PUD_SIZE 1GB
#define PTRS_PER_PUD 512
#define PMD_SIZE 2MB
#define PTRS_PER_PMD 512
ARMv8 Linux下发PGD,PUD,PMD,PTE并没有使用汇编语言,而是使用C语言实现,对应的函数如下,其实现原理都是将对应的表项内容写入表项所在地址。
/* 向内存下发PGD页表,入参分别为pgd页表虚拟地址和pgd表项*/
static inline void set_pgd(pgd_t *pgdp, pgd_t pgd)
{
if (in_swapper_pgdir(pgdp)) {
set_swapper_pgd(pgdp, pgd); /* 将pgd写入swapper_pg_dir所指地址 */
return;
}
WRITE_ONCE(*pgdp, pgd); /* 将pgd写入pgdp所指地址 */
dsb(ishst); /* 数据内存屏障 */
isb(); /* 指令内存屏障 */
}
static inline void set_pud(pud_t *pudp, pud_t pud)
{
#ifdef __PAGETABLE_PUD_FOLDED
if (in_swapper_pgdir(pudp)) {
set_swapper_pgd((pgd_t *)pudp, __pgd(pud_val(pud)));
return;
}
#endif /* __PAGETABLE_PUD_FOLDED */
WRITE_ONCE(*pudp, pud); /* 将pud写入pudp所指地址 */
if (pud_valid(pud)) {
dsb(ishst);
isb();
}
}
static inline void set_pmd(pmd_t *pmdp, pmd_t pmd)
{
#ifdef __PAGETABLE_PMD_FOLDED
if (in_swapper_pgdir(pmdp)) {
set_swapper_pgd((pgd_t *)pmdp, __pgd(pmd_val(pmd)));
return;
}
#endif /* __PAGETABLE_PMD_FOLDED */
WRITE_ONCE(*pmdp, pmd); /* 将pmd写入pmdp所指地址 */
if (pmd_valid(pmd)) {
dsb(ishst);
isb();
}
}
static inline void set_pte(pte_t *ptep, pte_t pte)
{
WRITE_ONCE(*ptep, pte); /* 将pte写入ptep所指地址 */
/*
* Only if the new pte is valid and kernel, otherwise TLB maintenance
* or update_mmu_cache() have the necessary barriers.
*/
if (pte_valid_not_user(pte)) {
dsb(ishst);
isb();
}
}
本文内核版本为Linux5.6.4。
四、举个栗子
页全局目录(PGD),页上级目录(PUD),页中间目录(PMD),页表(PTE)
- 从CR3寄存器中读取页目录所在物理页面的基址(即所谓的页目录基址),从线性地址的第一部分获取页目录项的索引,两者相加得到页目录项的物理地址。
- 第一次读取内存得到pgd_t结构的目录项,从中取出物理页基址取出,即页上级页目录的物理基地址。
- 从线性地址的第二部分中取出页上级目录项的索引,与页上级目录基地址相加得到页上级目录项的物理地址。
- 第二次读取内存得到pud_t结构的目录项,从中取出页中间目录的物理基地址。
- 从线性地址的第三部分中取出页中间目录项的索引,与页中间目录基址相加得到页中间目录项的物理地址。
- 第三次读取内存得到pmd_t结构的目录项,从中取出页表的物理基地址。
- 从线性地址的第四部分中取出页表项的索引,与页表基址相加得到页表项的物理地址。
- 第四次读取内存得到pte_t结构的目录项,从中取出物理页的基地址。
- 从线性地址的第五部分中取出物理页内偏移量,与物理页基址相加得到最终的物理地址。
- 第五次读取内存得到最终要访问的数据。
整个过程是比较机械的,每次转换先获取物理页基地址,再从线性地址中获取索引,合成物理地址后再访问内存。
不管是页表还是要访问的数据都是以页为单位存放在主存中的,因此每次访问内存时都要先获得基址,再通过索引(或偏移)在页内访问数据,因此可以将线性地址看作是若干个索引的集合。
linux中对地址转换的实现
/*描述各级页表中的页表项*/
typedef struct { pteval_t pte; } pte_t;
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pgdval_t pgd; } pgd_t;
/* 将页表项类型转换成无符号类型 */
#define pte_val(x) ((x).pte)
#define pmd_val(x) ((x).pmd)
#define pud_val(x) ((x).pud)
#define pgd_val(x) ((x).pgd)
/* 将无符号类型转换成页表项类型 */
#define __pte(x) ((pte_t) { (x) } )
#define __pmd(x) ((pmd_t) { (x) } )
#define __pud(x) ((pud_t) { (x) } )
#define __pgd(x) ((pgd_t) { (x) } )
/* 获取页表项的索引值 */
#define pgd_index(addr) (((addr) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1))
#define pud_index(addr) (((addr) >> PUD_SHIFT) & (PTRS_PER_PUD - 1))
#define pmd_index(addr) (((addr) >> PMD_SHIFT) & (PTRS_PER_PMD - 1))
#define pte_index(addr) (((addr) >> PAGE_SHIFT) & (PTRS_PER_PTE - 1))
/* 获取页表中entry的偏移值 */
#define pgd_offset(mm, addr) (pgd_offset_raw((mm)->pgd, (addr)))
#define pgd_offset_k(addr) pgd_offset(&init_mm, addr)
#define pud_offset_phys(dir, addr) (pgd_page_paddr(*(dir)) + pud_index(addr) * sizeof(pud_t))
#define pud_offset(dir, addr) ((pud_t *)__va(pud_offset_phys((dir), (addr))))
#define pmd_offset_phys(dir, addr) (pud_page_paddr(*(dir)) + pmd_index(addr) * sizeof(pmd_t))
#define pmd_offset(dir, addr) ((pmd_t *)__va(pmd_offset_phys((dir), (addr))))
#define pte_offset_phys(dir,addr) (pmd_page_paddr(READ_ONCE(*(dir))) + pte_index(addr) * sizeof(pte_t))
#define pte_offset_kernel(dir,addr) ((pte_t *)__va(pte_offset_phys((dir), (addr))))
进程在切换的时候就是根据task_struct找到mm_struct里的PGD字段,取得新进程的页全局目录,然后填充到CR3寄存器,就完成了页的切换。
下面我们动手操作一下,通过代码来深度理解下虚拟地址是如何转化为物理地址的。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/pid.h>
#include <linux/mm.h>
#include <asm/pgtable.h>
#include <asm/page.h>
MODULE_DESCRIPTION("vitual address to physics address");
static int pid;
static unsigned long va;
module_param(pid,int,0644); //从命令行传递参数(变量,类型,权限)
module_param(va,ulong,0644); //va表示的是虚拟地址
static int find_pgd_init(void)
{
unsigned long pa = 0; //pa表示的物理地址
struct task_struct *pcb_tmp = NULL;
pgd_t *pgd_tmp = NULL;
pud_t *pud_tmp = NULL;
pmd_t *pmd_tmp = NULL;
pte_t *pte_tmp = NULL;
printk(KERN_INFO"PAGE_OFFSET = 0x%lx\n",PAGE_OFFSET); //页表中有多少个项
/*pud和pmd等等 在线性地址中占据多少位*/
printk(KERN_INFO"PGDIR_SHIFT = %d\n",PGDIR_SHIFT);
//注意:在32位系统中 PGD和PUD是相同的
printk(KERN_INFO"PUD_SHIFT = %d\n",PUD_SHIFT);
printk(KERN_INFO"PMD_SHIFT = %d\n",PMD_SHIFT);
printk(KERN_INFO"PAGE_SHIFT = %d\n",PAGE_SHIFT);
printk(KERN_INFO"PTRS_PER_PGD = %d\n",PTRS_PER_PGD); //每个PGD里面有多少个ptrs
printk(KERN_INFO"PTRS_PER_PUD = %d\n",PTRS_PER_PUD);
printk(KERN_INFO"PTRS_PER_PMD = %d\n",PTRS_PER_PMD); //PMD中有多少个项
printk(KERN_INFO"PTRS_PER_PTE = %d\n",PTRS_PER_PTE);
printk(KERN_INFO"PAGE_MASK = 0x%lx\n",PAGE_MASK); //页的掩码
struct pid *p = NULL;
p = find_vpid(pid); //通过进程的pid号数字找到struct pid的结构体
pcb_tmp = pid_task(p,PIDTYPE_PID); //通过pid的结构体找到进程的task struct
printk(KERN_INFO"pgd = 0x%p\n",pcb_tmp->mm->pgd);
// 判断给出的地址va是否合法(va<vm_end)
if(!find_vma(pcb_tmp->mm,va)){
printk(KERN_INFO"virt_addr 0x%lx not available.\n",va);
return 0;
}
pgd_tmp = pgd_offset(pcb_tmp->mm,va); //返回线性地址va,在页全局目录中对应表项的线性地址
printk(KERN_INFO"pgd_tmp = 0x%p\n",pgd_tmp);
//pgd_val获得pgd_tmp所指的页全局目录项
//pgd_val是将pgd_tmp中的值打印出来
printk(KERN_INFO"pgd_val(*pgd_tmp) = 0x%lx\n",pgd_val(*pgd_tmp));
if(pgd_none(*pgd_tmp)){ //判断pgd有没有映射
printk(KERN_INFO"Not mapped in pgd.\n");
return 0;
}
pud_tmp = pud_offset(pgd_tmp,va); //返回va对应的页上级目录项的线性地址
printk(KERN_INFO"pud_tmp = 0x%p\n",pud_tmp);
printk(KERN_INFO"pud_val(*pud_tmp) = 0x%lx\n",pud_val(*pud_tmp));
if(pud_none(*pud_tmp)){
printk(KERN_INFO"Not mapped in pud.\n");
return 0;
}
pmd_tmp = pmd_offset(pud_tmp,va); //返回va在页中间目录中对应表项的线性地址
printk(KERN_INFO"pmd_tmp = 0x%p\n",pmd_tmp);
printk(KERN_INFO"pmd_val(*pmd_tmp) = 0x%lx\n",pmd_val(*pmd_tmp));
if(pmd_none(*pmd_tmp)){
printk(KERN_INFO"Not mapped in pmd.\n");
return 0;
}
//在这里,把原来的pte_offset_map()改成了pte_offset_kernel
pte_tmp = pte_offset_kernel(pmd_tmp,va); //pte指的是 找到表
printk(KERN_INFO"pte_tmp = 0x%p\n",pte_tmp);
printk(KERN_INFO"pte_val(*pte_tmp) = 0x%lx\n",pte_val(*pte_tmp));
if(pte_none(*pte_tmp)){ //判断有没有映射
printk(KERN_INFO"Not mapped in pte.\n");
return 0;
}
if(!pte_present(*pte_tmp)){
printk(KERN_INFO"pte not in RAM.\n");
return 0;
}
pa = (pte_val(*pte_tmp) & PAGE_MASK) ;//物理地址的计算方法
printk(KERN_INFO"virt_addr 0x%lx in RAM Page is 0x%lx .\n",va,pa);
//printk(KERN_INFO"contect in 0x%lx is 0x%lx\n",pa,*(unsigned long *)((char *)pa + PAGE_OFFSET));
return 0;
}
static void __exit find_pgd_exit(void)
{
printk(KERN_INFO"Goodbye!\n");
}
module_init(find_pgd_init);
module_exit(find_pgd_exit);
MODULE_LICENSE("GPL");
运行结果如下:
可以看出虚拟地址ffff99b488d48000对应的物理地址是80000000c8d48000。这个过程也是mmu的过程。
参考资料
- https://zhuanlan.zhihu.com/p/458935522
- https://blog.csdn.net/liyuewuwunaile/article/details/108632620