在 x86-64 架构的世界里,内存分页机制扮演着举足轻重的角色,它就像是一座桥梁,连接着虚拟地址与物理地址。简单来说,内存分页机制就是将线性地址(也就是虚拟地址)切分成一个个固定大小的页,并把这些页映射为物理地址的机制 。
这种机制并非随时可用,它有一个前提条件,那就是必须在保护模式下才能发挥作用(CR0.PE =1 时进入保护模式)。当分页机制开启后,从应用程序的视角来看,它所看到的是一个线性地址(虚拟地址)空间,而不是实际的物理地址。这就好比我们在玩一场虚拟现实游戏,游戏中的角色看到的各种场景和物品的位置都是虚拟的,而背后对应的真实物理位置,角色是感知不到的。
为了更深入地理解分页机制,我们先来看看它的开启与关闭条件。在保护模式下,是否开启分页是由 CR0 寄存器的 PG 位(第 31 位)决定的。当 CR0.PG =0 时,分页机制未开启,此时线性地址等同于物理地址,就好像游戏里的虚拟位置和现实中的物理位置是一一对应的,没有任何转换。而当 CR0.PG =1 时,分页机制开启,线性地址需要通过分页单元的转换才能形成物理地址,这就开启了虚拟地址与物理地址之间复杂而精妙的映射关系。
内存分页还提供了许多优势,包括:
-
虚拟化:每个进程都有自己独立的地址空间,提高了安全性和隔离性。
-
内存共享:多个进程可以共享同一物理页面,节省内存资源。
-
惰性加载:只有当程序需要访问某个页面时才将其加载到内存中,减少了初始化时间和内存占用。
-
内存保护:通过将页面标记为只读或不可执行,可以提供对代码和数据的保护。
一、内存分页的作用
内存分页是一种操作系统和硬件协同工作的机制,用于将物理内存分割成固定大小的页面(通常为4KB)并将虚拟内存空间映射到这些页面上。内存分页的主要作用包括:
-
虚拟内存管理: 内存分页允许操作系统将进程的虚拟地址空间映射到物理内存中的不同页面上,从而实现了虚拟内存管理。这使得每个进程能够拥有独立的地址空间,提高了内存的利用率和安全性。
-
内存保护: 通过页表中的权限位可以对页面进行保护,例如只读、读写、执行等权限设置。这样可以保护操作系统和进程之间的内存隔离,防止非法访问或修改内存数据。
-
内存共享: 内存分页也支持不同进程之间的内存共享。多个进程可以将同一个物理页面映射到各自的虚拟地址空间中,从而实现共享内存的目的。
-
内存管理: 通过内存分页,操作系统可以更灵活地管理物理内存,如内存的分配、回收、页面置换(换出到磁盘、换入到内存)、内存压缩等操作。
-
减少外部碎片: 内存分页可以将物理内存划分为固定大小的页面,减少了外部碎片的产生,提高了内存的利用效率。
1.1一级页表
分页机制是在分段机制的基础之上的,分段机制获取的地址就是之前我们用选择子选择到的全局描述符里面的段基址+EIP中的段内偏移地址,这两个地址相加可以获得实际的物理地址,在我们没有进行内存分页之前。
如果打开了分页机制,段部件输出的线性地址就不再等同于物理地址了,我们称之为虚拟地址,它是逻辑上的,是假的,不应该被送上地址总线。CPU必须要拿到物理地址才行,此虚拟地址对应的物理地址需要在页表中查找,这项查找工作是由页部件自动完成的。
我们直接举个例子讲述一级页表的工作方式,结合我们上节讲的GDT,假设选择子选择出来的段基址为0,偏移地址为0x1234。
1.2二级页表
一级页表我们只是举个例子,用来说明页表的操作,但实际我们用的是二级页表,因为一级页表有些问题:
-
一级页表中最多可容纳1M(1048576)个页表项,每个页表项是4字节,如果页表项全满的话,便是4MB大小
-
一级页表中所有页表项必须要提前建好,原因是操作系统要占用4GB虚拟地址空间的高1GB,用户进程要占用低3GB
-
每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。
归根结底,我们要解决的是:不要一次性地将全部页表项建好,需要时动态创建页表项。
所以我们多套一层,多一个页目录项:
每个进程都有自己的页表,这样的话每个进程中相同的虚拟地址可以映射到不同的物理地址中,这样的话就实现了进程与进程之间内存的隔离,顺便也解决了碎片化的问题。
1.3页表项和也目录项
-
P,Present,意为存在位。若为1表示该页存在于物理内存中,若为0表示该表不在物理内存中。
-
RW,Read/Write,意为读写位。若为1表示可读可写,若为0表示可读不可写。
-
US,User/Supervisor,意为普通用户/超级用户位。若为1时,任意级别都可以访问。为0,只允许特权级别为0、1、2的程序访问。
-
PWT,Page-level Write-Through,意为页级通写位,也称页级写透位。若为1表示此项采用通写方式,本位用来间接决定是否用此方式改善该页的访问效率。这里直接置为0就可以。
-
PCD,Page-level Cache Disable,意为页级高速缓存禁止位,置为0。
-
A,Accessed,意为访问位。若为1表示该页被CPU访问过啦。是用来在内存不足时与将不常用的内存置换到硬盘中。
-
D,Dirty,意为脏页位。当CPU对一个页面执行写操作时,就会设置对应页表项的D位为1。
-
PAT,Page Attribute Table,意为页属性表位,置0。
-
G, Global,意为全局位,为1表示是全局页,为0表示不是全局页。若为全局页,该页将在高速缓存TLB中一直保存,无需繁琐的置换过程。
-
AV L,意为Available位,即保留位。
页表同描述符表一样,是个内存中的数据结构,处理器要使用它们,必须要知道它们的物理地址,所以页表也有个专门的寄存器来存储其地址。这就是控制寄存器cr3。控制寄存器cr3用于存储页表物理地址,所以cr3寄存器又称为页目录基址寄存器(Page Directory Base Register,PDBR)。
由于页目录表所在的地址要求在一个自然页内,即页目录的起始地址是4KB的整数倍,低12位地址全是0。所以,只要在cr3寄存器的第31~12位中写入物理地址的高20位就行了。PWT位和PCD位在介绍页表项时说过了,它们用于设置高速缓存相关的特性,在此将其置为0即可。
二、分页机制的开启与关闭
开启内存分页机制分为三步:
-
1、准备好页目录以及页表
-
2、在cr3寄存器的第31~12位中写入页目录物理地址的高20位
-
3、寄存器cr0的PG位置1。(其中cr0寄存器的各个位在进入保护模式时有讲)
分页只能在保护模式(CR0.PE = 1)下使用。在保护模式下,是否开启分页,由 CR0. PG 位(位 31)决定:
-
当 CR0.PG = 0 时,未开启分页,线性地址等同于物理地址;
-
当 CR0.PG = 1 时,开启分页。
我们可以看代码了,loader.s添加了一下代码:
; os/src/boot/loader.s
; 下面就是保护模式下的程序了
[bits 32]
p_mode_start:mov ax, SELECTOR_DATAmov ds, axmov es, axmov ss, axmov esp,LOADER_STACK_TOPmov ax, SELECTOR_VIDEOmov gs, axmov byte [gs:320], 'M'mov byte [gs:322], 'A'mov byte [gs:324], 'I'mov byte [gs:326], 'N'call setup_page ; 创建页目录及页表并初始化页内存位图;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载sgdt [gdt_ptr] ; 存储到原来gdt的位置;将gdt描述符中视频段描述符中的段基址+0xc0000000mov ebx, [gdt_ptr + 2] or dword [ebx + 0x18 + 4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故0x18。;段描述符的高4字节的最高位是段基址的31~24位;将gdt的基址加上0xc0000000使其成为内核所在的高地址add dword [gdt_ptr + 2], 0xc0000000add esp, 0xc0000000 ; 将栈指针同样映射到内核地址; 把页目录地址赋给cr3mov eax, PAGE_DIR_TABLE_POSmov cr3, eax; 打开cr0的pg位(第31位)mov eax, cr0or eax, 0x80000000mov cr0, eax;在开启分页后,用gdt新的地址重新加载lgdt [gdt_ptr] ; 重新加载mov byte [gs:320], 'V' ;视频段段基址已经被更新,用字符v表示virtual addrmov byte [gs:322], 'i' ;视频段段基址已经被更新,用字符v表示virtual addrmov byte [gs:324], 'r' ;视频段段基址已经被更新,用字符v表示virtual addrmov byte [gs:326], 't' ;视频段段基址已经被更新,用字符v表示virtual addrmov byte [gs:328], 'u' ;视频段段基址已经被更新,用字符v表示virtual addrmov byte [gs:330], 'a' ;视频段段基址已经被更新,用字符v表示virtual addrmov byte [gs:332], 'l' ;视频段段基址已经被更新,用字符v表示virtual addrjmp $setup_page: ; 创建页目录及页表mov ecx, 4096mov esi, 0
.clear_page_dir: ; 清理页目录空间mov byte [PAGE_DIR_TABLE_POS + esi], 0inc esiloop .clear_page_dir.create_pde: ; 创建页目录mov eax, PAGE_DIR_TABLE_POSadd eax, 0x1000 ; 此时eax为第一个页表的位置及属性,属性全为0mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。; 下面将页目录项0和0xc00都存为第一个页表的地址,; 一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,; 这是为将地址映射为内核地址做准备or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.sub eax, 0x1000mov [PAGE_DIR_TABLE_POS + 4092], eax ; 使最后一个目录项指向页目录表自己的地址;下面创建第一个页表PTE,其地址为0x101000,也就是1MB+4KB的位置,需要映射前1MB内存mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256mov esi, 0mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
.create_pte:mov [ebx+esi*4],edx ; 此时的ebx已经在上面成为了第一个页表的地址,edx地址为0,属性为7add edx,4096 ; edx+4KB地址inc esi ; 循环256次loop .create_pte;创建内核其它页表的PDEmov eax, PAGE_DIR_TABLE_POSadd eax, 0x2000 ; 此时eax为第二个页表的位置or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性为7mov ebx, PAGE_DIR_TABLE_POSmov ecx, 254 ; 范围为第769~1022的所有目录项数量mov esi, 769
.create_kernel_pde:mov [ebx+esi*4], eaxinc esiadd eax, 0x1000loop .create_kernel_pderet
boot.inc 添加了如下的宏定义:
PAGE_DIR_TABLE_POS equ 0x100000
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b
我们可以画个图,看一下现在的内存中的页目录和页表是怎么回事:
其实有两个页目录项指向了第一个页表,第一个页目录以及第768个页目录,第768个页目录意味着虚拟地址为 1100_0000_00 开头的地址,这一部分指向了第一个PTE,第一个PTE首先包含了1024项,但是只有前256项被用到,这个地址范围是 0000_0000_00 ~ 0100_0000_00 ,是虚拟地址的中10位,最后十二位就是在相应内存块的位置。
这部分作用就是将物理地址 0x00000~0xfffff 映射到虚拟地址 0xc0000000 ~ 0xc00fffff,样我们的内核代码就放在物理地址1MB以下的位置即可。最后看一下成果:
最后一个页目录是指向了自己,这也为修改页目录表埋下了机会,否则内存虚拟化后,无法通过直接访问物理地址来访问内存,页目录表也不在虚拟内存可以访问的空间内,那么这个表相当于直接丢失了,无法访问
可以观察到有三个奇怪的地址映射,这就是最后一个页目录指向自己导致的:
0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff
0xfff00000-0xffffefff -> 0x000000101000-0x0000001fffff
0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff
-
1、若虚拟地址的高十位为 11_1111_1111 ,那么索引为当前的页目录表,所以,当前的页目录表就被当做了页表。
-
2、若虚拟地址的中十位为 11_1111_1111 ,那么索引为当前的页目录表(被当做页表)的最后一项,指向的还是当前的页目录表,再配合虚拟地址的后12位就可以修改页目录表了,这就是 0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff 这个地址映射的由来。
-
3、若虚拟地址的中十位为 00_0000_0000, 那么索引为当前的页目录表(被当做页表)的第一项,指向的是第一项的PTE页表,再配合虚拟地址的后12位就可以修改第一个页表了,这就是 ,0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff 这个地址映射的由来。
-
4、若虚拟地址的中十位为 11_0000_0000 ~ 11_1111_1111, 那么索引为当前的页目录表(被当做页表)的第768项到1024项,指向的是第768项到1024项的PTE页表,再配合虚拟地址的后12位就可以修改这些页表了,这就是 ,0xfff00000-0xffffefff -> 0x000000101000-0x0000001fffff 这个地址映射的由来。
拿到了这个虚拟地址,我们就可以直接访问这块内存对PDE与PTE进行修改。
三、四种分页模式
Intel-64 处理器支持 4 种分页模式:
-
32-bit paging: CR4.PAE = 0
-
PAE paging: CR4.PAE = 1, IA32_EFER.LME = 0
-
4-level paging: CR4.PAE = 1, IA32_EFER.LME = 1, CR4.LA57 = 0
-
5-level paging: CR4.PAE = 1, IA32_EFER.LME = 1, CR4.LA57 = 1
处理器当前处于哪种分页模式,由 CR4.PAE, CR4.LA57 以及 IA32_EFER.LME 联合决定:
-
如果 CR4.PAE = 0, 使用的是 32位分页模式。
-
如果 CR4.PAE = 1 且 IA32_EFER.LME = 0,使用的是 PAE 分页模式
-
如果 CR4.PAE = 1, IA32_EFER.LME = 1 且 CR4.LA57 = 0,使用的是 4 级分页模式。
-
如果 CR4.PAE = 1, IA32_EFER.LME = 1 且 CR4.LA57 = 1,使用的是 5 级分页模式。
这些标志位的说明如下所示:
3.1 32-bit模式
32-bit分页模式是分页开启后的默认模式,CR0.PG被置位后默认进入32-bit的分页模式。该模式下只支持32位的线性地址作为输入,并将其转化为32位的物理地址。任意时刻只有4G的线性空间可以被访问。
(1)层级结构
32-bit分页模式通过三级索引实现线性地址到物理地址的映射,如下图:
32-bit分页模式下,CR3存放的是页目录的物理地址,地址转换时首先通过CR3查找到页目录,再从页目录中查找页表的物理地址,最后从页表中查找目标页面的物理地址。线性地址转换时定位页表项的过程为CR3 -> PDE -> PTE -> Page Physical Address。
(2)PTE/PDE
线性地址低12位的最大寻址能力是4K,通过它可以从一个页面内找到任意地址的内存;中间10位用来在一个拥有2^10=1024个条目的页表中查询目标项,由于页表存放的单位也是页,因此一个页面如果作为页表,其每个条目的长度是4K / 1024 = 4Byte = 32bit。页表项PTE(Page Table Entry)的格式如下:
因为PTE指向的是物理页的地址,物理页总是4K(或者大于4K)对齐的,所以物理页地址的低12位总是为0。因此PTE不需要将低12位记录下来,而是利用这12位做其它事情,比如描述所映射的物理页,Intel就是这么设计的,PTE的低12位存放描述物理页的元数据。
这些信息包括物理页是否被分配(bit0 = Present),页是否可写(bit1 = R/W),用户特权级是否可以访问此页(bit2 = U/S),页cache是否打开(bit4 = PCD),是否为脏页(bit6 = Dirty),用户程序是否具有对该页的访问权限等。
线性地址的高10位用来在一个拥有2^10 = 1024个条目的页目录中查询目标项,页目录的的每一项存放的是页表地址,页目录的存放单位也是页,一个页面如果作为页目录,其每个条目的长度是4K/1024 = 4Byte = 32bit。页目录项PDE(Page Directory Entry)的格式如下:
(3)CR3
32-bit分页模式下CR3存放的页目录表物理地址,格式如下:
3.2 PAE 分页
PAE(Physical Adress Extend)即物理地址扩展。顾名思义,它是对物理地址的扩展,扩展什么呢?扩展32-bit模式下只能寻址4G内存空间的限制,它可以将32位的线性地址转化位最高52位的物理地址,可以寻址大于4G的地址空间。
怎么做到的?PAE分页模式的页表结构变了,页表项长度增加变成了8字节(64bit),PAE模式的开启需要CR0.PG和CR4.PAE两个标志位同时打开。
32-bit模式
32-bit分页模式是分页开启后的默认模式,CR0.PG被置位后默认进入32-bit的分页模式。该模式下只支持32位的线性地址作为输入,并将其转化为32位的物理地址。任意时刻只有4G的线性空间可以被访问。
(1)层级结构
32-bit分页模式通过三级索引实现线性地址到物理地址的映射,如下图:
32-bit分页模式下,CR3存放的是页目录的物理地址,地址转换时首先通过CR3查找到页目录,再从页目录中查找页表的物理地址,最后从页表中查找目标页面的物理地址。线性地址转换时定位页表项的过程为CR3 -> PDE -> PTE -> Page Physical Address。
(2)PTE/PDE
线性地址低12位的最大寻址能力是4K,通过它可以从一个页面内找到任意地址的内存;中间10位用来在一个拥有2^10=1024个条目的页表中查询目标项,由于页表存放的单位也是页,因此一个页面如果作为页表,其每个条目的长度是4K / 1024 = 4Byte = 32bit。页表项PTE(Page Table Entry)的格式如下:
因为PTE指向的是物理页的地址,物理页总是4K(或者大于4K)对齐的,所以物理页地址的低12位总是为0。因此PTE不需要将低12位记录下来,而是利用这12位做其它事情,比如描述所映射的物理页,Intel就是这么设计的,PTE的低12位存放描述物理页的元数据。
这些信息包括物理页是否被分配(bit0 = Present),页是否可写(bit1 = R/W),用户特权级是否可以访问此页(bit2 = U/S),页cache是否打开(bit4 = PCD),是否为脏页(bit6 = Dirty),用户程序是否具有对该页的访问权限等。
线性地址的高10位用来在一个拥有2^10 = 1024个条目的页目录中查询目标项,页目录的的每一项存放的是页表地址,页目录的存放单位也是页,一个页面如果作为页目录,其每个条目的长度是4K/1024 = 4Byte = 32bit。页目录项PDE(Page Directory Entry)的格式如下:
(3)CR3
32-bit分页模式下CR3存放的页目录表物理地址,格式如下:
PAE分页模式下存放页目录物理地址的寄存器不再是CR3,而是该模式下特有4个PDPTE寄存器,4个寄存器从PDPT(page-directory-pointer table)中加载值,该表的物理地址存放到了CR3中。
在PAE模式下,只要CR3的值有改变,就会同步更新4个PDPTE寄存器,当发生地址转换时CPU可以直接从PDPTE寄存其中读取页目录的物理地址,4个PDPTE寄存器在地址转换中起到的作用和32-bit模式下CR3的作用相同。
对比32-bit分页模式,PAE分页模式多了一级索引,物理地址转换过程变为CR3 -> PDPTE -> PDE -> PTE -> Page Physical Address。这里要注意,CR3 -> PDPTE的过程并非发生在地址转换流程中,而是在CR3寄存器变化的时候。所以从地址转换效率上说,32-bit模式和PAE模式都只有三次查询动作,效率接近。PAE模式通过多维护4个PDPTE寄存器实现了物理地址的扩展。
(2)PTE/PDE
PAE模式页大小我们也分析4K的情况,同32-bit模式一样,通过低12位可以从一个页面内找到任意地址的内存。因此线性低12位用作查找页面内的偏移。
线性地址的12 ~ 20位,这9位用来在页表中索引目标页表项,9位地址可以索引2 ^ 9 = 512个条目,如果一张页表只存放512个条目,那么每个条目的长度为 2 ^ 12 / 2 ^ 9 = 2 ^ 3 = 8byte = 64bit。有足够的宽度来存放物理地址,而32-bit模式下,最多只有32bit来存放物理地址,这是PAE能够扩展物理地址寻址能力的关键。PTE格式如下:
PTE的低12位仍然用于描述内存页的元数据,剩下的52bit中除最高位以外,其余可以全部用作存放页的物理地址。因此PTE模式下的页表,理论上可以存放51bit的物理地址。Intel在具体实现上,页表中页的物理地址取决于最大物理地址位宽MAXPHYADDR,该值是cpu所支持的最大地址宽度。
很明显32-bit分页模式下,MAXPHYADDR为32,PAE分页模式下通常可以配置的值为36,40,52这三个,PTE中存放页面物理地址的区间可以表示为12 ~ MAXPHYADDR
线性地址的21 ~ 29位,这9位用来在页目录表中索引目标页目录表项,9位地址可以索引 2 ^ 9 = 512个条目,同PTE类似,PDE存放的是PTE的物理地址,这个物理地址也是4K对齐,存放页表物理地址的区间可以表示为12 ~ MAXPHYADDR,PDE格式如下:
(3)PDPTE
PAE模式下线性地址的最高两位用于索引4个PDPTE寄存器,每个PDPTE(page-directory-pointer table entry)寄存器都存放了一个页目录表的地址,指向一张页目录表。PDPTE寄存器的初始值来自于页目录指针表(page-directory-pointer table),在PAE模式开启之前,用户软件就需要在内存中准备这样一张表,然后将其地址取出,存放到CR3寄存器中。
存放的MOV指令会触发加载动作,将内存中表的内容加载到寄存器中。这样PDPTE寄存器就替代了CR3的作用,CPU每次进行地址转换时,不再通过CR3寻找页目录表的地址,而是通过PDPTE寄存器寻找地址。而PDPTE有4个,用哪个呢?它的索引就是线性地址的最高两位。PDPTE格式如下:
PDPTE的格式和PDE/PTE的格式类似,低12位都用于存放描述内存区域的元数据,12 ~ MAXPHYADDR
用于存放页目录表的物理地址。
(4)CR3
PAE模式下CR3存放的不再是页目录表的物理地址,而是页目录指针表的物理地址,格式如下:
由于PDPT包含4个PDPTE,所以总长度是4 * 8 = 32字节,PDPT的物理地址32字节对齐,低5位始终为0。CR3寄存器的低5位可以复用,但这里暂时没有定义,因此忽略低5位。除去低5位,CR3的剩余27用于存放页目录指针表的物理地址。
3.3 4 级分页
4-level模式,顾名思义,地址转换需要通过4级层层索引才能实现。4-level与32-bit和PAE相比,最大的不同就是它在地址转换时多了一级,32-bit和PAE可以看做是2-level的地址转换(因为内存里面存放了两张表)。4-level将48位线性地址转化成52位物理地址,因此4-level分页模式必须在64位cpu上才能支持。
4-level分页模式多出的一级索引叫做PML4(Page Map Level 4),它存放的是页目录指针表的地址,4-level模式下的地址转换过程CR3 -> PML4E -> PDPTE -> PDE -> PTE。
4-level模式开启需要CR0.PG,CR4.PAE和IA32_EFER.LME三个标志位同时打开。
(1)层级结构
4-level分页模式增加了PML4结构,层级结构如下:
(2)PTE/PDE
4-level模式下的线性地址,页面大小仍然以4K为例,低12位用作一个物理页面的内部地址索引,线性地址余下的部分被分成了4部分,每个部分都占用9位,用做对应表的索引,可以计算出,每个部分对应的表条目都是2 ^ 9 = 512个,如果页面是4K,那么每个条目的长度为2 ^ 12 / 2 ^ 9 = 2 ^ 3 = 8字节,每个条目都是64bit。
线性地址中用来索引PTE/PDE的域都是9位,格式如下:
(3)PDPTE
4-level分页模式下,页目录指针表被存放到了内存中,其内容并没有加载到4个寄存器上,这一点和PAE模式不同,线性地址中同样用9位来索引PDPTE,其格式如下:
(4)CR3
4-level分页模式下CR3存放的是PML4表的物理地址:
不同的分页模式,其支持的线性地址宽度、物理地址宽度和页大小也是同的,其对应关系如下:
不同型号的处理器,所支持的物理地址和线性地址宽度也不相同,处理器提供了cpuid
指令来查询 CPU 信息。Linux 系统下,有个同名的 shell 命令(需要单独安装),可用来查看当前处理器信息,包括所支持的地址宽度。在我的 Ubuntu 虚拟机上,使用 cpuid
命令,查看结果如下:
$ cpuid|grep addressphysical address extensions = truemaximum physical address bits = 0x27 (39)maximum linear (virtual) address bits = 0x30 (48)
可以看到,该 CPU 支持最大 39 位物理地址,以及最大 48 位虚拟地址。
32 位分页和 PAE 分页只能在 32 位保护模式(IA32_EFER.LME = 0)下使用,只能转换 32 位的线性地址。 本文不会对这两种分页模式进行讨论。
相对的,4 级和 5 级分页,只能在 IA-32e 模式(IA32_EFER.LME = 1)下使用。IA-32e 模式有两种子模式:
兼容模式。这种子模式下,只使用 32 位的线性地址;4 级和 5 级分页把线性地址中的位 63:32 全部当做 0 来看待。
64 位模式。这种子模式下,能够使用 64 位的线性地址。但由于 4 级分页只支持 48 位线性地址(5 级分页支持 57 位),所以 4 级分页线性地址的 63:47 位,5 级分页线性地址的 63:57 位,均未使用。在 64 位模式下,处理器要求线性地址必须是 canonical 的,即这些冗余位应该是一致的,要么全是 0,要么全是 1。
3.4 5 级分页
5 级分页模式是 x86-64 架构分页模式中的 “新成员”,当 CR4.PAE = 1、IA32_EFER.LME = 1 且 CR4.LA57 = 1 时,它便被启用。它支持 57 位线性地址,进一步扩大了虚拟地址空间的范围,就像给超大型大楼又增加了更多的楼层和房间,每个房间的编号变成了 57 位(线性地址) 。
在物理地址方面,它同样最大支持 52 位。页大小与 4 级分页相同,有 4KB、2MB 和 1GB。5 级分页模式的出现,主要是为了满足对内存需求极高的应用场景,比如一些大型的云计算平台、大规模的数据处理中心等。在这些场景中,大量的虚拟机同时运行,每个虚拟机都需要大量的内存资源,5 级分页模式能够更有效地管理这些内存,提高系统的整体性能和稳定性。
四、层级分页结构解析
上述 4 种分页模式,都使用了层级分页结构。每个页结构的大小为 4096 字节,由多个项组成。在 32 位分页模式下,每一项大小为 4 字节(32位),每个页结构包含 1024 项;在 4 级 或 5 级分页模式下,每一项大小为 8 字节(64位),每个页结构包含 512 项。PAE 分页模式中有个例外情况,使用了大小为 32 个字节的页结构,该页结构由 4 个 8 字节(64 位)的项组成。
从功能上来说,线性地址可分为 2 个部分。线性地址的高位部分(称为页号,page number),用来识别一系列页结构项。这些项中的最后一个,用来标识线性地址转换后的内存区域的物理地址(称为页帧,page frame)。线性地址的低位部分(称为页偏移量, page offset),标识了线性地址转换后的内存区域内的特定地址。
总的来说,线性地址的高位部分,决定了物理地址的高位部分;线性地址的低位部分,决定了物理地址的低位部分。而页的大小,决定了页号(page number)和页偏移量(page offset)的边界。
每一个页结构项都包含一个物理地址,该物理地址要么是另一个页结构项的地址,要么是一个页帧的地址。对于第一种情况,我们说该页结构项引用了另一个页结构项;对于后者,我们说该页结构项映射了一个页。
不论哪种分页模式,第一个页结构(根页结构)的物理地址都会被保存在 CR3 寄存器中。然后,使用以下迭代过程来进行线性地址转换:使用线性地址的一部分(刚开始时使用最高位部分)定位到页结构(刚开始时使用保存在 CR3 寄存器中的地址)中的一项。如果该项又引用了另一个页结构项,那么使用被引用项和线性地址的剩余部分,继续该过程。如果该项映射到了一个页,那么转换过程完成:该项包含的物理地址即为页帧,线性地址的剩余部分就是页内偏移量。
4 级 和 5 级分页模式下(以 4KB 页为例),转换过程概述如下:
-
在 4 级分页下,每个页结构由 512 (2^9)项组成,每次转换使用 48 位线性地址中的 9 位。位 47:39 标识了第一个页结构项,位 38:30 标识了第二个;位 29:21 标识了第三个;位 20:12 标识了第四个。注意,最后一个页结构项标识了页帧。
-
5 级分页跟 4 级类似,只不过 5 级分页的线性地址是 57 位的。位 56:48 标识了第一个页结构项,剩余位用于 4 级分页。
上述示例中,最后一个页结构项映射了一个 4KB 的页,线性地址的低 12 位作为页内偏移。但情况并非总是如此,因为除了 4KB 大小的页,处理器还支持其它尺寸的页。比如,在 4 级 和 5 级分页下,支持 4KB、2MB 及 1GB 大小的页。页结构项的 PS (page size)位,决定了该项是否映射到页,同时也决定了页的大小:
-
如果线性地址剩余的位数超过 12,则参考当前页结构项的位 7(PS — page size)。如果该位为 0,该项引用了另一个页结构;如果为 1,该项映射了页。
-
如果线性地址只剩余 12 位,当前页结构项总是映射到一个页(位 7 被用作其它用途)。
在转换过程中,每一层页结构被赋予不同的名称。下表提供了不同页结构的名称。同时也提供了其物理地址的来源(CR3 或 不同的页结构项)、线性地址中用来选择页结构项的位、该项是否以及如何映射到一个页。
4.1转换过程
(1)先看一张intel手册上的4-level paging4KB大小的页的转换图:
(2)cr3寄存器介绍
CR3寄存器又叫页目录基址寄存器(Page Directory Base Register, PDGR), CR3中存放着当前任务页表目录的物理地址.
(2)内核中线性地址和物理地址转换宏
下面代码使用内核中定义的宏打印物理地址,代码中的宏选自linux5.4.34arch/x86/include/asm/page.harch/x86/include/asm/page_64.h
#include <stdio.h>#define __AC(X,Y) (X##Y)
#define _AC(X,Y) __AC(X,Y)
#define __PAGE_OFFSET_BASE_L4 _AC(0xffff888000000000, UL)
#define __PAGE_OFFSET __PAGE_OFFSET_BASE_L4
#define PAGE_OFFSET ((unsigned long)__PAGE_OFFSET)
#define __START_KERNEL_map _AC(0xffffffff80000000, UL)
// __va宏是将物理地址转换成线性地址,直接等于物理地址 + 0xffff888000000000
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
static inline unsigned long __phys_addr_nodebug(unsigned long x)
{unsigned long y = x - __START_KERNEL_map;/* use the carry flag to determine if x was < __START_KERNEL_map */// 笔者电脑上phys_base为0x = y + ((x > y) ? 0 /* phys_base */ : (__START_KERNEL_map - PAGE_OFFSET));return x;
}
#define __phys_addr(x) __phys_addr_nodebug(x)
#define __phys_addr_symbol(x) \((unsigned long)(x) - __START_KERNEL_map + 0 /* phys_base */) // phys_base为0
#define __phys_reloc_hide(x) (x)
// __pa宏是将线性地址转换成物理地址
#define __pa(x) __phys_addr((unsigned long)(x))
// ___pa_symbol宏也是将线性地址转换成物理地址,转换以0xffffffff8开头的在vmlinux.lds.S中定义的符号
#define __pa_symbol(x) \__phys_addr_symbol(__phys_reloc_hide((unsigned long)(x)))int main() {printf("address: 0x%lx\n", __pa(0xffff88800220a000UL));printf("address: 0x%lx\n", __pa(0xffffffff8220a000UL));printf("address: 0x%lx\n", __pa_symbol(0xffffffff8220a000UL));
}
上面代码输出如下,所以问题1和问题2的答案为0x220a000,下面讲解怎么转换成0x220a000
address: 0x220a000
address: 0x220a000
address: 0x220a000
(4)各级页目录索引
下面代码使用内核中定义的宏和索引函数打印各级页目录索引,代码中的宏和索引函数选自linux5.4.34 arch/x86/include/asm/pgtable.h
#include <stdio.h>#define PAGE_SHIFT 12#define PGDIR_SHIFT 39
#define PTRS_PER_PGD 512#define PUD_SHIFT 30
#define PTRS_PER_PUD 512#define PMD_SHIFT 21
#define PTRS_PER_PMD 512#define PTRS_PER_PTE 512#define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1))static inline unsigned long pud_index(unsigned long address)
{return (address >> PUD_SHIFT) & (PTRS_PER_PUD - 1);
}static inline unsigned long pmd_index(unsigned long address)
{return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
}static inline unsigned long pte_index(unsigned long address)
{return (address >> PAGE_SHIFT) & (PTRS_PER_PTE - 1);
}void printIndex(unsigned long address) {printf("address: 0x%lx\n", address);printf("pgd_index: %ld\n", pgd_index(address)); // 对应第一节图中的PML4printf("pud_index: %ld\n", pud_index(address)); // 对应第一节图中的Directory Ptrprintf("pmd_index: %ld\n", pmd_index(address)); // 对应第一节图中的Directoryprintf("pte_index: %ld\n", pte_index(address)); // 对应第一节图中的Table
}int main() {printIndex(0xffffffff8220a000);printIndex(0xffff88800220a000);
}
代码输出如下所示:
address: 0xffffffff8220a000
pgd_index: 511
pud_index: 510
pmd_index: 17
pte_index: 10
address: 0xffff88800220a000
pgd_index: 273
pud_index: 0
pmd_index: 17
pte_index: 10
(5)在内核中添加代码打印页表
在内核代码文件arch/x86/mm/init.c
中添加代码
// 添加打印页表代码开始
void printPTETable(unsigned long parent) {unsigned long* pte = (unsigned long*)((parent & PTE_PFN_MASK) + PAGE_OFFSET);int i = 0;printk("------pte: 0x%lx\n", pte);while (i < PTRS_PER_PTE /* 512 */) {unsigned long entry = *(pte + i);if (entry) {printk("--------index: %d pysical address: 0x%lx\n", i, entry);}i++;}
}void printPMDTable(unsigned long parent) {unsigned long* pmd = (unsigned long*)((parent & PTE_PFN_MASK) + PAGE_OFFSET);int i = 0;printk("----pmd: 0x%lx\n", pmd);while (i < PTRS_PER_PMD /* 512 */) {unsigned long entry = *(pmd + i);if (entry) {printk("------index: %d pte entry: 0x%lx\n", i, entry);if (entry >> 7 & 1) {printk("--------pysical address: 0x%lx\n", entry);} else {printPTETable(entry);}}i++;}
}void printPUDTable(unsigned long parent) {unsigned long* pud = (unsigned long*)((parent & PTE_PFN_MASK) + PAGE_OFFSET);int i = 0;printk("--pud: 0x%lx\n", pud);while (i < PTRS_PER_PUD /* 512 */) {unsigned long entry = *(pud + i);if (entry) {printk("----index: %d pud entry: 0x%lx\n", i, entry);if (entry >> 7 & 1) {printk("----pysical address: 0x%lx\n", entry);} else {printPMDTable(entry);}}i++;}
}void printPGDTable(void) {// 读取CR3寄存器,转换成线性地址unsigned long* pgd = (unsigned long*)(native_read_cr3_pa() + PAGE_OFFSET); int i = 0;printk("cr3 pgd: 0x%lx\n", pgd);while (i < PTRS_PER_PGD /* 512 */) {unsigned long entry = *(pgd + i);if (entry) {printk("--index: %d pgd entry: 0x%lx\n", i, entry);printPUDTable(entry);}i++;}
}
// 添加打印页表代码结束void __init init_mem_mapping(void)
{// ... 省略printPGDTable();load_cr3(swapper_pg_dir); // 切换CR3,切换前后打印printPGDTable();// ... 省略
}
下面截取部分输出内容,截取部分为上一节打印出来的索引对应的物理地址
[ 0.000000] cr3 pgd: 0xffff88800269e000
[ 0.000000] --index: 273 pgd entry: 0x26a0063
[ 0.000000] --pud: 0xffff8880026a0000
[ 0.000000] ----index: 0 pud entry: 0x26a1063
[ 0.000000] ----pmd: 0xffff8880026a1000
省略
[ 0.000000] ------index: 17 pte entry: 0x80000000022000e3
[ 0.000000] --------pysical address: 0x80000000022000e3
省略
[ 0.000000] --index: 511 pgd entry: 0x220c067
[ 0.000000] --pud: 0xffff88800220c000
[ 0.000000] ----index: 510 pud entry: 0x220d063
[ 0.000000] ----pmd: 0xffff88800220d000
省略
[ 0.000000] ------index: 17 pte entry: 0x22001e3
[ 0.000000] --------pysical address: 0x22001e3
省略
切换CR3
[ 0.000000] cr3 pgd: 0xffff88800220a000
[ 0.000000] --index: 273 pgd entry: 0x2801067
[ 0.000000] --pud: 0xffff888002801000
[ 0.000000] ----index: 0 pud entry: 0x2802067
省略
[ 0.000000] ----pmd: 0xffff888002802000
[ 0.000000] ------index: 17 pte entry: 0x80000000022001e3
[ 0.000000] --------pysical address: 0x80000000022001e3
省略
[ 0.000000] --index: 511 pgd entry: 0x220c067
[ 0.000000] --pud: 0xffff88800220c000
[ 0.000000] ----index: 510 pud entry: 0x220d063
[ 0.000000] ----pmd: 0xffff88800220d000
省略
[ 0.000000] ------index: 17 pte entry: 0x22001e3
[ 0.000000] --------pysical address: 0x22001e3
省略
可以看出最后的物理地址为0x80000000022000e3, 0x22001e3, 0x80000000022001e3
其中0x80000000022000e3中的8即第63位设置为1表示这快内存是不可执行的
其中0x0e3和0x1e3即低12位表示内存的FLAG,具体含义我们先不讨论
所以通过页表找出来的物理内存地址为0x2200000,这里有个问题,只转化了3次就结束了,和第一节图中转换了4次不一样,这里我迷惑了很久,搜了很多资料没找到答案,最终在intel手册里找到了答案。
4.2异常
正常情况下,当识别出页帧时,转换过程就完成了。但是,当转换过程遇到标识了”不存在“(P 位为 0)的页结构时,或者修改了保留位,转换过程就会提前中止,并触发 page-fault 异常。
4级和5级页表中的保留位如下所示:
-
位 51:MAXPHYADDR 被保留
-
PML5E 或 PML4E 中的 PS 标志位被保留
-
如果处理器不支持 1-GByte 的页,PDPTE 中的 PS 标志位被保留
-
如果 PDPTE 中的 PS 标志为 1,该项中的 29:13 位被保留
-
如果 PDE 中的 PS 标志为 1,该项中的 20:13 位被保留
-
如果 IA32_EFER.NXE = 0,XD 标志位(第 63 位)被保留
五、实际应用与案例分析
为了更直观地感受不同处理器对分页模式的支持情况,我们可以通过实际案例来进行分析。在 Linux 系统下,有一个非常实用的工具 ——cpuid 命令(需要单独安装),它就像是一个 CPU 信息的探测器,能够帮助我们查看当前处理器的各种信息,包括所支持的地址宽度。
在我的 Ubuntu 虚拟机上,我迫不及待地使用 cpuid 命令来一探究竟。当我在终端中输入cpuid|grep address后,得到了如下结果:
physical address extensions = true
maximum physical address bits = 0x27 (39)
maximum linear (virtual) address bits = 0x30 (48)
从这个结果中,我们可以清晰地看到,该 CPU 支持最大 39 位物理地址,以及最大 48 位虚拟地址 。这就像是了解到了一台电脑的内存 “容量” 和 “规格”,让我们对它的内存管理能力有了一个基本的认识。根据前面介绍的分页模式与地址宽度的对应关系,我们可以推断出这台虚拟机的 CPU 支持 4 级分页模式,因为 4 级分页模式支持 48 位线性地址,与我们查询到的结果相匹配。这就好比我们根据一个人的身高、体重等特征,判断出他适合参加哪种体育项目一样。
再来看 Linux 内核中用户空间和内核空间的虚拟地址范围,以 4 级分页的 Linux 内核为例 ,用户空间的虚拟地址范围是 0x0000000000000000 - 0x00007fffffffffff(47 位) ,这个范围就像是一个大型商场的顾客购物区,每个顾客(应用程序)都在这个区域内活动,进行各种数据的处理和操作。
而内核使用的虚拟地址范围是 0xffff800000000000 - 0xffffffffffffffff(47 位) ,这就如同商场的管理区域,负责整个商场的运营和维护,管理着各种资源和权限。这两个范围都是满足 canonical 类型地址要求的,中间的空洞部分,就像是商场中暂时未开放的区域,不满足 canonical 地址要求,所以未被使用。这种合理的地址范围划分,保证了 Linux 内核在内存管理上的高效和稳定,就像一个井然有序的商场,每个区域都发挥着自己的作用,共同保障着整个系统的正常运转。