文章目录
前言
主要描述的是Linux 中 x86_64平台下的页表管理,页表用于建立虚拟地址空间和系统物理内存之间的关联。
程序使用的是虚拟地址,而实际的数据存储在物理内存中,这就需要一种机制将虚拟地址转换为物理地址,才能准确地访问到数据。
虚拟地址到物理地址的映射过程是通过页表完成的。在使用虚拟内存时,操作系统将物理内存划分成固定大小的页面(通常为4KB),并将每个进程的虚拟地址空间划分成相同大小的页面。
关于ARM64页表相关知识请参考:
https://zhuanlan.zhihu.com/p/373960777
https://blog.csdn.net/liyuewuwunaile/article/details/108632620
https://blog.csdn.net/u011649400/article/details/105984564
由于页表也会占用物理内存,Linux使用多级页表来完成地址转化,多级页表是一种时间换空间的思想,多级页表会增加物理地址查询时间,但会节约页表地址转换所占用的存放空间。
一、简介
1.1 x86_64的分页
AMD64的long mode:long mode页面转换需要使用物理地址扩展 (PAE)。 在激活long mode之前,必须通过将 CR4.PAE 设置为 1 来启用 PAE。在启用 PAE 之前激活long mode,导致发生一般保护异常 (#GP)。
PAE 分页数据结构支持将 64 位虚拟地址映射到 52 位物理地址。 PAE 将传统page-directory entries (PDE) 和page-table entries (PTE) 的大小从 32 位扩展到 64 位,允许物理地址大小大于 32 位。
AMD64 架构通过定义先前为访问和保护控制保留的位来增强page-directory-pointer entry (PDPE)。 一个新的转换表被添加到 PAE 分页中,称为 page-map level-4 (PML4)。 PML4 表在页面转换层次结构中位于 PDP 表之前。
long mode下,物理页的大小可以是4 KB 、2 MB和1GB。
在long mode下,CR3 寄存器用于指向 PML4 基地址。 CR3 在long mode下扩展为 64 位,允许 PML4 表位于 52 位物理地址空间中的任何位置。
Table Base Address Field:位 51:12, 这40 位字段指向 PML4 基地址。 PML4 表在 4 KB 边界上对齐,低 12 位地址位 (11:0) 假定为 0。这产生了 52 位的总基地址大小。 在支持少于完整 52 位物理地址空间的处理器实现上运行的系统软件必须将未实现的高基地址位清除为 0。
物理页为4 KB:
通过将虚拟地址分成六个字段来执行 4 KB 物理页面转换。 其中四个字段用作级别页面翻译层次结构的索引:
位63:48是位47的符号扩展。
位47:39索引到512个条目page-map level-4 table。
位38:30索引到512个条目page-directory pointer table。
位29:21索引到512个条目page-directory table。
位20:12索引到512个条目page table。
位11:0提供到物理页的字节偏移量。
物理页为2MB:
物理页为 1GB:
1.2 地址映射流程
(1)步骤 1:虚拟地址拆分
虚拟地址(如 64 位系统的 48 位有效地址)被划分为:
页号(Virtual Page Number, VPN):高位部分,用于索引页表。
页内偏移(Offset):低位部分(12 位对应 4KB 页),直接映射到物理页内偏移。
(2)步骤 2:MMU 查询页表
MMU 通过 页表基址寄存器(PTBR/CR3) 找到当前进程的页表,并逐级查询:
多级页表:现代系统使用多级页表(如 x86_64 的 4 级页表:PML4 → PDPE → PDE → PTE)。
每级页表用 VPN 的一部分作为索引。
最后一级页表项(PTE)包含物理页框号(PFN)。
(3)步骤 3:检查页表项(PTE)
PTE 的字段包括:
有效位(Present Bit):1 表示页面在物理内存中,0 触发缺页异常。
物理页框号(PFN):目标物理页的基址。
权限位(RWX):控制读/写/执行权限。
其他标志:如 Dirty、Accessed、Cache 策略等。
(4)步骤 4:生成物理地址
若 PTE 有效,MMU 将 PFN 与偏移量组合:
(5)步骤 5:访问物理内存
CPU 通过物理地址从内存总线读取数据。
1.3 TLB
在计算机系统中,CPU 运行程序时产生的是虚拟地址,而数据实际存储在物理内存中,这中间就需要页表来完成虚拟地址到物理地址的映射。然而,页表存放在内存里,每次查询页表都要访问内存,内存访问速度相对较慢。
TLB(转译后备缓冲器)是 CPU 内存管理单元(MMU)中的一个高速缓存,用于加速虚拟地址到物理地址的转换。它存储最近使用过的页表项(PTE, Page Table Entry),避免每次地址转换都要查询多级页表,从而显著提高内存访问性能。
TLB 是内存管理单元(MMU)的一部分,本质是页表的高速缓存,存储最近被频繁访问的页表项(虚拟地址到物理地址的映射关系)的副本,是集成在 CPU 内部的 高速缓存硬件,用于加速虚拟地址到物理地址转换的专用缓存,通过专用电路实现高速地址转换。
加速机制:TLB(Translation Lookaside Buffer),一种用于提高虚拟地址到物理地址转换速度的硬件缓存机制。
作用:缓存近期翻译过的 VA → PA 映射,避免每次访问页表。
TLB 查询流程
(1)CPU 发出虚拟地址(VA),MMU 首先检查 TLB。
(2)TLB 命中(TLB Hit):直接返回对应的物理地址(PA)。
(3)TLB 未命中(TLB Miss):MMU 必须遍历页表(Page Walk),从内存中加载正确的 PTE。更新 TLB 缓存该映射,供后续访问使用。
1.4 缺页异常
当 CPU 访问虚拟内存地址时,若该地址对应的内存页未被加载到物理内存中(或未建立映射关系),CPU 会触发一个 缺页异常(Page Fault),由操作系统内核的 缺页异常处理程序 介入处理。
缺页异常的类型:
1.4.1 Minor Fault
常见场景:
首次访问堆(Heap)或栈(Stack)内存。
共享内存(如 mmap 映射文件)首次被访问。
处理流程:
内核在物理内存中分配空闲页。
建立虚拟地址与物理页的映射(更新页表)。
当进程缺页事件发生在第一次访问虚拟内存时,虚拟内存已分配但未映射(如首次访问、写时复制、共享内存同步)物理地址,内核会产生一个 minor page fualt,并分配新的物理内存页。minor page fault 产生的开销比较小。
虚拟内存对应在进程页表体系中的相关各级页目录或者页表是空的,也就是说这段虚拟内存完全没有被映射过。
新申请的堆内存(malloc等),由于lazy机制,只建立页表而没有真实物理内存的映射,因此页表里的权限是R,发生Page Fault,在Page Fault回调中,linux会去申请一页内存,此时把页表权限设置为R+W。
minor page fualt 典型场景:
首次访问:进程申请内存后,内核延迟分配物理页(Demand Paging),首次访问时触发。
写时复制(COW):fork()创建子进程时共享父进程内存,子进程写操作前触发
共享库加载:动态链接库被多个进程共享,首次加载到物理内存时触发,即会共享页表
1.4.2 Major Fault
常见场景:
物理内存不足,内存页被交换到磁盘(Swap)。
mmap 映射的文件数据未加载到内存。
处理流程:
内核从磁盘读取页数据到物理内存。
建立虚拟地址与物理页的映射。
若物理内存不足,可能先将其他页换出到磁盘(触发 交换(Swap))。
当物理页未分配且需从磁盘(Swap分区或文件)加载数据,内核就会产生一个 majorpage fault,比如内核通过Swap分区,将内存中的数据交换出去放到了硬盘,需要时从硬盘中重新加载程序或库文件的代码到内存。涉及到磁盘I/O,因此一个major fault对性能影响比较大.
虚拟内存之前被映射过,其在进程页表的各级页目录以及页表中均有对应的页目录项和页表项,但是其对应的物理内存被内核 swap out 到磁盘上了。
在代码段区域运行执行操作时发生缺页,说明该段代码数据未从磁盘加载,则Linux申请一页内存,并从硬盘读取出代码段,此时产生了IO操作,为major主缺页。
典型场景有
Swap In:物理内存不足时,内核将内存页换出到 Swap 分区,再次访问需换回。
文件映射(mmap):通过 mmap 映射文件到内存,首次访问文件内容需从磁盘读取。
1.4.3 Invalid Fault
非法访问(如 NULL 指针解引用、写只读页)
虚拟内存虽然背后映射着物理内存,但是由于对物理内存的访问权限不够而导致的保护类型的缺页中断。比如,尝试去写一个只读的物理内存页。
用户访问了非法的内存(例如引用野指针等),MMU就会触发Page Fault中断,回调中检查进程并没有对应这段内存的VMA,给用户进程发送SIGSEGV信号报段错误并终止。
代码段在VMA中权限为R+X,如果程序中有野指针飞到此区域去写,则也会由MMU触发Page Fault中断,导致用户进程收到SIGSEGV信号。(另,malloc堆区在VMA中权限为R+W,如果程序的PC指针飞到此区域去执行,同样发生段错误。)
二、Linux内核中的分页
主要讨论Linux中的四级页表,物理页为4KB的情况。
可以用getconf命令查看当前系统物理页大小情况:
getconf - Query system configuration variables
x86_64中, cr3 寄存器 里面存放当前进程的顶级 pgd,cr3 里面需要存放 pgd 在物理内存的地址,不能是虚拟地址。因为在任务调度context_switch 进行上下文切换 ,内存切换加载cr3寄存器时,里面会使用 __pa,将 mm_struct 里面的成员变量 pgd(mm_struct 里面存的都是虚拟地址)变为物理地址,才能加载到 cr3 里面去。
用户进程在运行的过程中,访问虚拟内存中的数据,会被 cr3 里面指向的页表转换为物理地址后,才在物理内存中访问数据,这个过程都是在用户态运行的,地址转换的过程无需进入内核态。
每个进程都有独立的地址空间,为了这个进程独立完成映射,每个进程都有独立的进程页表,这个页表的最顶级的 pgd 存放在 task_struct 中的 mm_struct 的 pgd 变量里面:
// linux-4.18/include/linux/sched.h
struct task_struct {
......
struct mm_struct *mm;
......
}
// linux-4.18/include/linux/mm_types.h
struct mm_struct {
......
pgd_t * pgd;
......
}
// linux-4.18/arch/x86/include/asm/pgtable_64_types.h
typedef unsigned long pgdval_t;
// linux-4.18/arch/x86/include/asm/pgtable_types.h
typedef struct { pgdval_t pgd; } pgd_t;
pgd_t 使用struct而不是unsigned long基本数据类型表示,以确保页表项的内容只能由相关的辅助函数处理,而不能直接访问pgd_t。使用struct结构体也方便扩展,可以让表项由几个基本类型变量构成。
三、CR3加载PGD
3.1 低版本内核
首先来看一下3.10.1内核版本cr3 寄存器加载 pgd过程,比较简单:
// linux-3.10.1/kernel/sched/core.c
context_switch()
-->switch_mm()
-->//linux-3.10.1/arch/x86/include/asm/mmu_context.h
/* Re-load page tables */
load_cr3(next->pgd);
调用__pa函数将struct mm_struct next->pgd的虚拟地址转化为物理地址:
// linux-3.10.1/arch/x86/include/asm/processor.h
static inline void load_cr3(pgd_t *pgdir)
{
write_cr3(__pa(pgdir));
}
其中__pa函数,其中phys_base = 0(3.10.0的内核版本没有引入kaslr):
#define __START_KERNEL_map _AC(0xffffffff80000000, UL)
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 */
x = y + ((x > y) ? phys_base : (__START_KERNEL_map - PAGE_OFFSET));
return x;
}
#define __phys_addr(x) __phys_addr_nodebug(x)
#define __pa(x) __phys_addr((unsigned long)(x))
对于x86_64,3.10.0的内核版本没有引入kaslr:
内核代码段物理地址 = 内核代码段虚拟地址 - __START_KERNEL_map。
关于__pa函数可以参考:Linux物理内存映射
3.2 高版本内核
接下来看4.18.0内核版本,相比较就复杂许多:
(1)其中TLB引入了ASID(Address Space ID),ASID就类似进程ID一样,用来区分不同进程的TLB表项。
这样在进程切换的时候根据ASID的分配情况来flush TLB,Linux内核需要软件管理和分配ASID。
对于x86_64: PCID(进程上下文标识符)
Intel的process context identifier 了解决进程切换时TLB flush导致的性能下降问题。
PCID,Process Context IDentifiers, 即针对每个进程分配专用的ID标识,用于区分TLB中不同进程对应的entry。
(2)引入了kaslr
// linux-4.18/kernel/sched/core.c
context_switch()
-->switch_mm_irqs_off()
-->// linux-4.18/arch/x86/mm/tlb.c
switch_mm_irqs_off()
-->// linux-4.18/arch/x86/mm/tlb.c
load_new_mm_cr3()
-->// linux-4.18/arch/x86/include/asm/mem_encrypt.h
__sme_pa(x)
--> /*
* Caution: many callers of this function expect
* that load_cr3() is serializing and orders TLB
* fills with respect to the mm_cpumask writes.
*/
write_cr3(new_mm_cr3);
其中:
pgd_t *pgd;
unsigned long new_mm_cr3 = __sme_pa(pgd);
/*
* The __sme_pa() and __sme_pa_nodebug() macros are meant for use when
* writing to or comparing values from the cr3 register. Having the
* encryption mask set in cr3 enables the PGD entry to be encrypted and
* avoid special case handling of PGD allocations.
*/
#define __sme_pa(x) (__pa(x) | sme_me_mask)
#define __sme_pa_nodebug(x) (__pa_nodebug(x) | sme_me_mask)
3.3 小结
cr3寄存器,专门用于保存页全局目录的基地址,内核的主内核页全局目录的基地址保存在swapper_pg_dir全局变量中,但需要使用主内核页表时系统会把这个变量的值放入cr3寄存器,进程们自己的页全局目录基地址保存在自己的进程描述符的pgd中(struct task_struct->mm->pgd),当进程切换时,进程的页表也是需要切换的,就是把新的进程的进程描述符的pgd存入cr3中。
3.10.1内核中调用__pa将pgd虚拟地址转化为物理地址加载到cr3中。
4.18.0内核中调用_sme_pa将pgd虚拟地址转化为物理地址加载到cr3中。
参考资料
Linux内核3.10.1
Linux内核4.18.0
AMD官方手册
极客时间:趣谈Linux操作系统
https://fanlv.wiki/2021/07/25/linux-mem/
https://www.cnblogs.com/tolimit/p/4585803.html
https://blog.csdn.net/weixin_45030965/article/details/126995123
https://www.jianshu.com/p/6533f55c4401
https://www.cnblogs.com/binlovetech/p/17918733.html
https://lrita.github.io/2019/03/07/linux-page-fault/