目录
PTDUMP 机制使用
PTDUMP 应用场景
初识 PTDUMP 机制
PTDUMP 机制: Linux 提供了一个调试机制,用于在用户空间打印页表内容. 开发者可以利用该机制在用户空间获得当前进程、内核空间 和 EFI 区域 的页表内容,通过获得页表内容,开发者更好的立即内存映射机制、分页策略和内存使用情况. 开发者可以参考下面命令使用该机制:
# 目录
cd /sys/kernel/debug/page_tables
# 获得内核空间对应的页表内容
cat kernel
page_table 目录下一共提供了 4 个伪文件节点,分别是 current_kernel(当前进程对应内核空间的页表)、current_user(当前进程用户空间对应的页表)、kernel(内核空间对应的页表)、efi(EFI Region 对应的页表).
例如当查看 /sys/kernel/debug/page_tables/current_user 文件时,可以看到获得了用户空间所有虚拟内存对应的页表,其信息包括下面几个部分(e.g. “0x0000000000405000-0x0000000000411000 48K USR ro x pte”):
- 虚拟内存范围: e.g. 0x00000405000-0x00000411000 指明某段虚拟内存的范围
- 虚拟内存长度: e.g. 48K 指明这段虚拟内存大小为 48K
- 页表属性值: e.g “USR ro x” 指明特定字符串表明页表属性值
- 最后一级页表: e.g. pte 指明这段虚拟内存映射的最后一级页表信息
PTDUMP 通识知识
PTDUMP 机制是为开发者提供的一种调试机制,其可以在用户空间打印当前进程用户空间或者内核空间虚拟内存对应的页表内容. 启用 PTDUMP 机制需要在内核编译阶段使能 CONFIG_PTDUMP_DEBUGFS、CONFIG_PTDUMP_CORE、CONFIG_GENERIC_PTDUMP 宏。在使能 PTDUMP 机制之后,可以在 /sys/kernel/debug/page_tables/ 目录下获得 4 个伪文件,通过读取文件可以获得如下内容:
- current_user: 获得当前进程虚拟地址空间所有页表内容
- current_kernel: 获得当前进程切换到内核态虚拟地址空间所有页表内容
- kernel: 获得内核空间所有页表内容
- efi: 获得 EFI Region 对应的页表内容
当读取其中某个文件,那么 PTDUMP 机制将输出上图的信息,这些信息按一定格式进行输出,格式定义如下:
- Region Name: 虚拟内存会划分成几大区域(Region),每个区域都有一个名字,属于该区域的所有虚拟内存都会放在其下面. 常见的区域如用户空间(User Space)、内核空间(Kernel Space)、线性映射区(Low Kernel Mapping) 等.
- Region Entry: 将页表内容相同的虚拟内存合并在一起的区域,其属于指定 Region 的子集, 其包含了该区域的范围和页表内容信息.
- Region Entry Range: 该字段表示一段页表相同区域的起始虚拟地址和结束虚拟地址
- Region Entry Size: 该字段表示一段页表相同区域的长度,单位可以是 KMG
- PageTable Attribute: 该字段将页表属性的每个位用字符串表示出来,其中 USR 表示页表 U/S 标志位置位, 那么虚拟内存是用户空间; 反之如果没有 USR 字符串则表示 U/S 没有置位,那么虚拟内存是内核空间.
- Last Pagetable Entry: 该字段表示 Region Entry 对应的最后一级页表信息,e.g. pte 表示区域最后一级页表是 PTE Entry, 同理 pmd 表示区域最后一级页表是 PMD Entry.
当 PTDUMP 机制输出页表内容时,其首先将虚拟内存划分成不同的 Region,然后再将每个 Region 换成更小粒度的区域进行解析. PTDUMP 机制支持的 Region 与具体的硬件架构有关,因为不同的架构其虚拟内存空间划分不同,因此需要根据实际的架构进行讨论,对于 X86 架构,Region 字段支持如下:
- User Space: 用户空间虚拟内存,每个进程独自一份
- Kernel Space: 内核空间虚拟内存
- LDT Remap: LDT 重映射区
- Low Kernel Mapping: 线性映射区
- vmalloc() Area: VMALLOC 映射区
- Vmemmap: SPARSE VMEMMAP 映射区
- CPU entry Area: CPU 独立且预分配区域
- ESPfix Are: 内核 ESP 堆栈映射区
- EFI Runtime Services: EFI 映射区
- High Kernel Mapping: 内核镜像映射区
- Modules: 内核模块映射区
- Fixmap Area: 固定映射区
当 PTDUMP 机制输出页表内容时,每个 Region Entry 的 PageTable Attribute 字段会将该区域页表属性转换成特定字符串,以便让开发者可以方便知道页表的具体内容,那么这些字符串的具体含义如下:
- USR: 指明 U/S 标志位置位,那么该区域是用户空间虚拟内存; 反之不包含指明该区域为内核空间虚拟内存.
- RW: 指明 R/W 标志位置位,那么该区域是可读可写的; 反之不包含指明该区域为只读区域.
- ro: 指明 R/W 标志位清零,那么该区域是只读区域; 反之不包含指明该区域为可读可惜区域.
- PWT: 指明 PWT 标志位置位; 反之不包含表示 PWT 标志位清零
- PCD: 指明 PCD 标志位置位; 反之不包含表示 PCD 标志位清零
- PSE: 指明 PSE 标志为置位,那么该区域是映射大页; 反之不包含表示该区域为更小粒度页
- PAT: 指明 PAT 标志位置位; 反之不包含表示 PAT 标志位清零
- GLB: 指明 G 标志位置位,那么该区域是全局可见; 反之该区域全局不可见
- NX: 指明 NX 标志位置位,那么该区域不具有执行权限
- X: 指明 NX 标志位清零,那么该区域具有执行权限
当 PTDUMP 机制输出页表内容时,每个 Region Entry 的最后一个字段描述了这个区域最后一级页表信息,在 Linux 里一共支持 5 级页表,分别是 PGD(Page Global Directory Table)、P4D(Page 4th Directory Table)、PUD(Page Upper Directory Table)、PMD(Page Middle Directory Table) 以及 PTE(Page Table), 那么该字段含义如下:
- pgd: 该区域最后一级页表是 PGD Entry,那么该区域没有实际映射到物理区域上.
- pud: 该区域最后一级页表是 P4D Entry, 那么该区域没有实际映射到物理区域上.
- pud: 该区域最后一级页表是 PUD Entry,如果存在 PSE 那么映射到 1Gig 大页上,否则该区域没有映射到实际的物理区域上.
- pmd: 该区域最后一级页表是 PMD Entry, 如果存在 PSE 那么映射到 2MiB 大页上,否则该区域没有映射到实际的物理区域上.
- pte: 该区域最后一级页表是 PTE Entry,那么如果 PageTable Attribute 不为空,则映射到 4KiB 物理区域上; 反之 PageTable Attribute 为空,那么该区域没有映射到实际的物理区域上.
PTDUMP 实现原理
PTDUMP 机制原理如上图,由于每个架构的虚拟地址空间布局不一样,因此每个架构都实现了各自的 PTDUMP 接口,但逻辑上基本一致, 接下来以 X86 为例进行讲解:
- 提供虚拟地址范围: PTDUMP 机制使用 address_marker 数据结构维护虚拟地址空间各 Region 布局,当调用者需要查看内核空间页表时,PTDUMP 从 address_marker 获得内核空间所有 Region 的信息
- PageWalk 遍历页表: 将虚拟内存区域信息传递给 PageWalk 机制 进行页表遍历,PTDUMP 机制提供了 tdump_ops 回调函数合集,其实现了 pte_entry、pmd_entry、pud_entry、p4d_entry、pgd_entry 以及 pte_hole 回调函数,每个回调函数都实现了两个函数,也就是 note_page() 和 effective_prot() 函数,如果遍历到的页表是最后一级页表,那么 note_page() 函数会被调用记录页表内容, 而 effective_prot() 函数在遍历每一级页表的时候都会被调用,其用于获得有效的页表内容.
- 输出内容: PageWalk 机制遍历完所有页表之后,最后就是将其通过 Dmesg 或者 Seq 机制显示到用户空间.
PageWalk 机制 已经分析过,那么要理解 PTDUMP 机制首先要了解不同架构的虚拟内存布局,这样才能理解虚拟内存区域如果划分,而遍历页表过程中涉及页表内容解析和记录,可以通过源码分析了解该过程,最后就是信息输出,与 PTDUMP 机制没有必然连续,会用就行,那么接下来先了解不同架构虚拟内存布局:
I386 32bit Virtual Memory Map
i386 虚拟地址空间布局(右键新标签页查看大图): 当 i386 架构支持 32-bit 页表,其支持 32 位线性地址空间和 32 位物理地址空间,因此 i386 架构虚拟地址空间长度为 4G. 其中用户空间长度 3Gig,内核空间占据末端 1Gig,并且支持 4MiB 和 4KiB 两种尺寸的物理页. 虚拟地址空间各区域的范围:
- 用户空间(Userspace): 每个进程 [0, 3G) 的虚拟内存区域,其中 STACK_TOP 是用户空间堆栈的栈底. 每个进程都只能看到自己的 3G 线性地址空间,无法看到其他进程的 3G 用户空间.
- 内核空间(Kernel Space): 内核线程使用的虚拟地址空间,其占据末端 1G 空间,起始虚拟地址为 0xC0000000, 内核空间又被划分成不同功能区域.
- 线性映射区(Direct Mapping Area): 线性映射区是内核将 0xC0000000 开始的 896M 虚拟内存映射到物理起始地址 0 的 896M 物理内存上,形成了虚拟连续且物理也连续的空间。
- 内核镜像区(Kernel Text Mapping): 该区域用于映射内核镜像,其范围是 [PAGE_OFFSET, PAGE_OFFSET + KERNEL_IMAGE_SIZE).
- 高端内存(high_memroy): 高端内存是没有被线性区映射的物理内存,内核访问高端内存需要先建立页表再访问,其范围 [high_memory, 4G).
- VMALLOC 区域: VMALLOC 分配器管理的区域,区域特点是虚拟地址连续但物理地址不连续的区域,其位于高端内存起始地址 VMALLOC_OFFSET 之后的地方,那么其范围: [VMALLOC_START, VMALLOC_END)
- MODULE 区域: 内核模块自身使用虚拟内存区域,该区域与 VMALLOC 区域重叠,因此内核也是为 MODULE 动态创建页表并映射到随机物理内存上.
- PKMAP: 该区域预先分配一些物理页,以备在内核运行期间特殊情况使用,如异常处理、中断处理和其他紧急内核任务, 其范围是 [PKMAP_BASE, PKMAP_BASE + PAGE_SIZE).
- LDT Remap for PTI Area: 该区域是用于重新映射 LDT 的一个特殊区域,并用于实现页表隔离机制(Page Table Isolation), 用于减轻特定 CPU 漏洞(Spectre 和 Meltdown)的攻击.
- CPU Entry Area: 该区域为每个 CPU 提供了一个独立的、预分配的内存段,其中包含了 CPU 启动和初始化所需的代码,以确保每个 CPU 可以正确进入操作系统的执行环境,其范围是:
- FIXADDR Area: 固定映射区域,区域的虚拟内存用作特殊的目的,例如 Permanent mapping Area 用于永久(持久)映射使用, 又如 KMAP 区域用于临时映射,满足特定需求,其范围是 [FIXADDR_START, FIXADDR_TOP)
- Tail Hole: 最后 4K 区域预留作为 Hole.
X86-64 4-level PageTable Virtual Memory Map
X86 虚拟地址空间布局(右键新标签页查看大图): 当 X86-64 架构支持 4 级页表,其支持 48 位线性地址空间和 52 位物理地址空间,用户空间长度达 128TiB,内核空间占据末端 128TiB,并且支持 1Gig、2MiB 和 4KiB 三种尺寸的物理页. 下图虚拟地址空间各区域的范围:
- 用户空间(Userspace): 每个进程 [0, 128TB) 的虚拟内存区域,其中 STACK_TOP 是用户空间堆栈的栈底. 每个进程都只能看到自己的 128TB 线性地址空间,无法看到其他进程的 128TB 用户空间.
- 内核空间(Kernel Space): 内核线程使用的虚拟地址空间,其占据末端 128T 空间,起始虚拟地址为 0xffff800000000000, 内核空间又被划分成不同功能区域.
- Guard Hole: 内核空间第一个区域,与 Non-Canonical 区域相连,大小为 8TiB。其作用是为内核空间创建一个保护边界,以便检测内存越界访问和栈溢出等错误。当程序访问 Guard Hole 区域时就会触发硬件异常,其范围是: [GUARD_HOLE_BASE_ADDR, LDT_BASE_ADDR)
- LDT Remap for PTI Area: 该区域是用于重新映射 LDT 的一个特殊区域,并用于实现页表隔离机制(Page Table Isolation), 用于减轻特定 CPU 漏洞(Spectre 和 Meltdown)的攻击. 其范围是: [LDT_BASE_ADDR, LDT_END_ADDR)
- 线性映射区(Direct Mapping Area): 线性映射区是内核将 PAGE_OFFSET 起始的虚拟内存连续建立页表,映射到连续的物理内存上,形成了虚拟连续且物理也连续的空间。其大小为 64TiB,对应 [PAGE_OFFSET, PAGE_OFFSET + 64TB).
- VMALLOC 区域: VMALLOC 分配器管理的区域,区域特点是虚拟地址连续但物理地址不连续的区域,长度为 32TiB 且范围: [VMALLOC_START, VMALLOC_END)
- SPARSE VMEMMAP Area: 该区域是 SPARSE 内存映射区域,用于存储物理页的 struct page 数据结构. 其范围是: [VMEMMAP_START, VMEMMAP_START + 1TB)
- KSAN Shadow Memory: 该区域是在内核运行时跟踪每个分配的内存块,并为每个字节分配一个影子字节,该字节的值表示分配的内存是否被访问过以及如何访问的信息,以此 KSAN 可以在内核中检查到访问越界或使用未初始化内存的问题. 其范围是: [KASAN_SHADOW_START, KASAN_SHADOW_END)
- CPU Entry Area: 该区域为每个 CPU 提供了一个独立的、预分配的内存段,其中包含了 CPU 启动和初始化所需的代码,以确保每个 CPU 可以正确进入操作系统的执行环境,其范围是: [CPU_ENTRY_AREA_BASE, CPU_ENTRY_AREA_BASE + 0.5T)
- Fixup Stacks: ESP fixup 堆栈固定区域,其范围是: [0xffffff0000000000, 0xffffff0000000000 + 0.5T)
- EFI Region 映射区域: 区域的作用就是在内核中映射这些 EFI 内存映射寄存器,以便内核能够访问和操作这些信息. EFI 内存映射寄存器是一些用于访问系统内存映射信息的寄存器。它们包含了有关物理内存、I/O 端口、PCI 设备等映射的信息,操作系统和引导加载程序可以通过这些寄存器获得硬件映射信息. 区域的范围是: [EFI_VA_END, EFI_VA_START)
- 内核镜像区(Kernel Text Mapping): 该区域用于映射内核镜像,其范围是 [__START_KERNEL_map, __START_KERNEL_map + KERNEL_IMAGE_SIZE).
- MODULE 区域: 内核模块自身使用虚拟内存区域,该区域的范围是: [MODULES_VADDR, MODULES_END)
- FIXADDR Area: 固定映射区域,区域的虚拟内存用作特殊的目的,例如 Permanent mapping Area 用于永久(持久)映射使用, 又如 KMAP 区域用于临时映射,满足特定需求,其范围是 [FIXADDR_START, VSYSCALL_ADDR)
- Leacy SYSCALL: 特殊的系统调用区域,已提供给所有进程共同使用,其范围是: [VSYSCALL_ADDR, VSYSCALL_ADDR + PAGE_SIZE)
- Tail Hole: 最后 2M 区域预留作为 Hole.
X86-64 5-level PageTable Virtual Memory Map
X86 虚拟地址空间布局(右键新标签页查看大图): 当 X86-64 架构支持 5 级页表,其支持 57 位线性地址空间和 52 位物理地址空间,用户空间长达 64PB,内核空间占据末端 64PB,并且支持 1Gig、2MiB 和 4KiB 三种尺寸的物理页. 下图虚拟地址空间各区域的范围:
- 用户空间(Userspace): 每个进程 [0, 64PB) 的虚拟内存区域,其中 STACK_TOP 是用户空间堆栈的栈底. 每个进程都只能看到自己的 64PB 线性地址空间,无法看到其他进程的 64PB 用户空间.
- 内核空间(Kernel Space): 内核线程使用的虚拟地址空间,其占据末端 64PB 空间,起始虚拟地址为 0xff00000000000000, 内核空间又被划分成不同功能区域.
- Guard Hole: 内核空间第一个区域,与 Non-Canonical 区域相连,大小为 4PB。其作用是为内核空间创建一个保护边界,以便检测内存越界访问和栈溢出等错误。当程序访问 Guard Hole 区域时就会触发硬件异常,其范围是: [GUARD_HOLE_BASE_ADDR, LDT_BASE_ADDR)
- LDT Remap for PTI Area: 该区域是用于重新映射 LDT 的一个特殊区域,并用于实现页表隔离机制(Page Table Isolation), 用于减轻特定 CPU 漏洞(Spectre 和 Meltdown)的攻击. 其范围是: [LDT_BASE_ADDR, LDT_END_ADDR)
- 线性映射区(Direct Mapping Area): 线性映射区是内核将 PAGE_OFFSET 起始的虚拟内存连续建立页表,映射到连续的物理内存上,形成了虚拟连续且物理也连续的空间。其大小为 32PB,对应 [PAGE_OFFSET, PAGE_OFFSET + 32PB).
- VMALLOC 区域: VMALLOC 分配器管理的区域,区域特点是虚拟地址连续但物理地址不连续的区域,长度为 12.5B 且范围: [VMALLOC_START, VMALLOC_END)
- SPARSE VMEMMAP Area: 该区域是 SPARSE 内存映射区域,用于存储物理页的 struct page 数据结构. 其范围是: [VMEMMAP_START, VMEMMAP_START + 0.5PB)
- KSAN Shadow Memory: 该区域是在内核运行时跟踪每个分配的内存块,并为每个字节分配一个影子字节,该字节的值表示分配的内存是否被访问过以及如何访问的信息,以此 KSAN 可以在内核中检查到访问越界或使用未初始化内存的问题. 其范围是: [KASAN_SHADOW_START, KASAN_SHADOW_END)
- CPU Entry Area: 该区域为每个 CPU 提供了一个独立的、预分配的内存段,其中包含了 CPU 启动和初始化所需的代码,以确保每个 CPU 可以正确进入操作系统的执行环境,其范围是: [CPU_ENTRY_AREA_BASE, CPU_ENTRY_AREA_BASE + 0.5T)
- Fixup Stacks: ESP fixup 堆栈固定区域,其范围是: [0xffffff0000000000, 0xffffff0000000000 + 0.5T)
- EFI Region 映射区域: 区域的作用就是在内核中映射这些 EFI 内存映射寄存器,以便内核能够访问和操作这些信息. EFI 内存映射寄存器是一些用于访问系统内存映射信息的寄存器。它们包含了有关物理内存、I/O 端口、PCI 设备等映射的信息,操作系统和引导加载程序可以通过这些寄存器获得硬件映射信息. 区域的范围是: [EFI_VA_END, EFI_VA_START)
- 内核镜像区(Kernel Text Mapping): 该区域用于映射内核镜像,其范围是 [__START_KERNEL_map, __START_KERNEL_map + KERNEL_IMAGE_SIZE).
- MODULE 区域: 内核模块自身使用虚拟内存区域,该区域的范围是: [MODULES_VADDR, MODULES_END)
- FIXADDR Area: 固定映射区域,区域的虚拟内存用作特殊的目的,例如 Permanent mapping Area 用于永久(持久)映射使用, 又如 KMAP 区域用于临时映射,满足特定需求,其范围是 [FIXADDR_START, VSYSCALL_ADDR)
- Leacy SYSCALL: 特殊的系统调用区域,已提供给所有进程共同使用,其范围是: [VSYSCALL_ADDR, VSYSCALL_ADDR + PAGE_SIZE)
- Tail Hole: 最后 2M 区域预留作为 Hole.
PTDUMP 数据结构
struct ptdump_range 数据结构描述虚拟内存一段 Region 的范围, start 成员描述 Region 的起始虚拟地址, end 成员描述 Region 的结束虚拟地址. PTDUMP 机制 输出页表内容时将虚拟内存划分成不同的 Region,例如 User Space、Kernel Space 以及 LDT Remap 等,该数据结构就是用来描述这些区域的范围.
struct ptdump_state 数据结构用于记录 PTDUMP 过程中的中间态数据,其 note_page 成员指向具体架构提供的记录页表内容函数,也就是每个架构都需要实现自己的记录页表内容函数,并且在遍历到每一级页表时,如果该页表是最后一级页表或者空页表,那么该函数会被调用,另外该成员的 level 参数指明了页表的级数,level 为 4 的时候表示 PGD Entry,level 为 1 的时候表示 PTE Entry,-1 则表示 Unknow 的状态; effective_prot 成员指向具体架构提供的有效页表过滤函数,同理每个架构按需实现各自的页表过滤函数,并且在遍历每一级页表的时候就会被调用,其主要目的就是过滤一下每一级页表里无效的页表内容; range 成员则用于记录需要 DUMP 的虚拟内存区域.
struct addr_marker 数据结构用于记录虚拟内存一段 Region 的信息,start_address 成员描述该虚拟内存区域的起始地址,name 成员描述该虚拟内存区域的名字,max_lines 成员控制该区域是否跳过显示.
struct pg_state 数据结构是 X86 架构提供了由于记录 PTDUMP 过程中使用的数据,以便记录 X86 架构的页表内容,那么其成员含义如下:
- ptdump: 描述需要 DUMP 虚拟内存一段 Region 的范围.
- level: 描述遍历页表的级数
- current_prot: 描述当前页表的属性值
- effective_prot: 描述页表属性有效值
- prot_levels: 记录每一次遍历页表时所有级页表的属性值
- start_address: 每次遍历虚拟内存区域的起始地址
- lines: 记录 Region Entry 的数量
- to_dmesg: 用于控制页表信息是否往 Demsg 里输出
- check_wx: 用于控制页表遍历时检测可写可执行的页表
- wx_pages: 记录虚拟内存区域里可写可执行的页表数量
- seq: 执行 seq file 文件缓存.
enum address_markers_idx 枚举体由于定义 PTDUMP 机制可以 DUMP 的虚拟内存区域,其配合 struct addr_marker 数据结构一同使用. address_markers[] 数组是 X86 架构定义的虚拟内存区域信息,从定义中可以看到每个区域的名字以及起始虚拟地址,其在定义的时候,所有区域的起始虚拟地址都为 0,可以从其名字知道对应的 address_markers_idx 的含义.
PTDUMP 源码解析
PTDUMP 机制源码一共分作三个模块,首先是位于 “mm/ptdump.c” 文件的函数,其提供了公用的接口实现对页表遍历过程,以及遍历到每一级页表时进行 effective_prot 和 note_page 调用; 接着是各个架构提供的虚拟内存区域划分逻辑,其负责根据上层请求将其转换成不同的遍历页表需求,并提供遍历虚拟内存的范围; 最后一部分是与用户交互的 debugfs 接口,其用来收集用户的需求。
LEVEL1: Debugfs Interface
PTDUMP 机制在内核启动过程中调用 pt_dump_debug_init() 函数注册了 “/sys/kernel/debug/page_tables” 目录,并在该目录下注册了 kernel、current_kernel、current_user 和 efi 四个伪文件.
PTDUMP 机制为 4 个伪文件提供 SHOW 属性,因此四个伪文件只读,那么当读取 kernel 文件时,ptdump_show() 函数会被调用,可以看到其核心调用 ptdump_walk_pgd_level_debugfs() 函数,读取其他文件也类似,只是传递给 ptdump_walk_pgd_level_debugfs 函数的参数不同而已,对于 kernel 其 mm 是 init_mm, efi 其 mm 是 efi_mm, 而 current_kernel 和 current_user 的页表则是 current->mm.
LEVEL2: PTDUMP Request
当内核启动过程是,__initcall() 函数会调用 pt_dump_init() 函数对 PTDUMP 机制进行初始化. 函数只做了一个事,就是为 address_markers[] 数组里的虚拟内存区域设置起始虚拟地址,从这里可以知道 Linux 内核对每个区域起始虚拟地址所使用的宏,例如内核线性映射区域的起始地址就是 PAGE_OFFSET, 再如 VMALLOC 区域的起始虚拟地址为 VMALLOC_START.
ptdump_walk_pgd_level_debugfs 函数是承接 Debugfs 接口文件的请求,然后将其进行转换传递给 PTDUMP 核心层,可以看到函数首先获得 PGD,然后根据参数 user 判断虚拟内存的范围,然后最终调用 ptdump_walk_pgd_level_core() 函数.
ptdump_walk_pgd_level_core 函数是 PTDUMP 机制的核心入口,每个架构都会实现各自的 PTDUMP 入口函数。mm 参数指向某个进程或者内核线程的地址空间,参数 pgd 指向 PGD 页表, checkwx 成员用于控制对可读可执行的检测,dmesg 成员用于控制 DUMP 数据是否向 dmesg 输出. 函数首先在 69-77 行定义了 DUMP 的虚拟地址范围,其定义了 struct ptdump_range 数组,ptdump_ranges[] 数组包含两个核心成员,第一个成员的范围是 [0, PTRS_PER_PGD * PGD_LEVEL_MULT / 2), 这段虚拟内存区域就是用户空间所占用的虚拟内存,[GUARD_HOLE_END_ADDR, ~0UL) 这段虚拟内存区域就是内核空间占用的虚拟内存,那么可以知道默认情况下 PTDUMP 覆盖了用户空间和内核空间。函数接着在 79-89 行定义了 struct pg_stat 数据结构的 st,其用于存储 PTDUMP 过程中的数据,可以看到 81 行定义了 note_page 接口调用函数,以及 82 行 effective_prot 接口调用函数,最后将遍历范围在 83 行存储到 st.ptdump.range 里, 接着再将其余数据也存储到 st 里. 函数最后在 91 行调用 ptdump_walk_pad() 函数进行虚拟内存区域的页表遍历.
LEVEL3 PageWalk NotePage
ptdump_walk_pgd 函数用来对给定的虚拟内存区域进行页表遍历,其从 st 参数中获得 st->range,该数据包含了需要遍历页表的虚拟内存. PTDUMP 机制提供了 ptdump_ops 回调函数合集,可以看到当页表遍历到 PGD、P4D、PUD、PMD、PTE 页表时都会调用到 ptdump_ops 提供的会调函数,以此处理和记录不同级页表的内容.
ptdump_ops 提供的回调函数处理逻辑基本相同,以 ptdump_pmd_entry 为例进行讲解,当 PageWalk 机制遍历到 PMD 页表时,并且 PMD Entry 不为空,那么 ptdump_pmd_entry 函数就会被调用,函数首先调用 READ_ONCE() 函数并发的读取一次 PMD Entry 的值,如果函数在 110 行检查到架构的 st 实现了 effective_prot 接口,那么调用对应的 effective_prot() 函数,并且第二个参数传入 3,接着函数在 112 行调用 pmd_leaf() 函数检测到 PMD 是叶子,那么此时 PMD Entry 为最后一级页表,那么调用 st 提供的 note_page 接口来记录页表内容,结论完毕后将 PageWalk 机制的 action 设置为 ACTION_CONTINUE, 以此继续遍历下一个 PMD Entry.
ptdump_ops 提供了 pte_entry 回调函数 ptdump_hole, 当遍历到某一级页表时,如果页表为空,那么该回调函数会被调用,其通过 depth 参数来区分目前是哪一级页表为空,PGD 时 depth 为 0, PTE 时 depth 为 4. 可以看到当页表为空是,调用 note_page 记录页表内容.
不同架构实现 effective_prot 有所不同,上图是 X86 架构的实现,当 257 行检测到 level 小于等于 0,那么表示页表是 PGD Entry,那么直接将 prot 作为有效的页表; 反之其他级的页表,其会将上一级页表的 effective 和当前 prot 进行与操作,以此确保向上一级页表对齐。最后再将有效的页表内存存储在 st->prot_levels[level] 里.
不同架构实现 note_page 函数有所不同,上图是 X86 架构的实现,其作用是记录页表的内容,看着代码行数很多,其实函数就在做一个事情,将页表内容解析出来,然后存储到指定的缓存里. 首先是 304-305 和 354-355 行用于输出通过虚拟内存 Region 的名字,320-323 行用于输出 Region Entry Range 信息, 325-331 行用于输出区域的大小字符串,332-333 行用于输出 PageTable Attribute 字段字符串以及最后一级页表信息. 这些信息通过 pt_dump_seq_printf 函数控制输出方向,可以输出到 dmesg 也可以输出到 seq_file 缓存.
PTDUMP 机制使用
获得当前进程用户空间页表 C 语言版
PTDUMP 机制虽然通过 Debugfs 提供了 4 个伪文件接口获得不同需求的页表,其中包括 current_user 可以获得当前进程用户空间的页表,如果使用 cat 读取该文件,那么只能获得 cat 这个进程用户空间的页表,那么当需要想获得指定进程的用户空间页表怎么办呢? 本节提供了一种思路是在源码级别去读取该文件,那么程序运行时就是该进程读取了 current_user 文件,这样就可以获得对应用户空间的页表。那么接下来通过一个实践案例介绍如何实现这个思路,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] PTDUMP: DUMP Current Userspace PageTable --->
make
# 源码目录
# Module
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PTDUMP-CURRUSER-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-PTDUMP-CURRUSER-default Source Code on Gitee
实践案例有一个用户空间程序构成,其核心逻辑是分配一块 Buffer,然后从 “current_user” 文件读取页表的内容到 Buffer 里,最后就是进行显示。程序在 22 行打开了 current_user 文件,然后在 28 行分配了 4K 的 Buffer,接着打印了 Buffer 的虚拟地址。实践案例在 37 行调用 read() 函数开始读取当前页表内容到 Buffer,然后在 40 行从 Buffer 里打印页表内容,打印完毕之后就是释放内存和关闭文件. 那么接下来在 BiscuitOS 上进行实践:
当 BiscuitOS 启动之后,直接运行 BiscuitOS-PAGING-PTDUMP-CURRUSER-default,可以看到 BUFFER 的虚拟地址 0x55f3ed5632a0,并且看到用户空间页表被打印出来,然后可以看看 BUFFER 所在的区域 0x000055f3ed563000-0x000055f3ed566000 12K USR RW NX pte, 可以知道 BUFFER 所在区域的页表是 USR(用户空间虚拟内存)、RW(可读可写)、NX(不可执行) 且最后以及页表是 pte,那么该区域映射到 4K 的物理页上。从这个实践案例可以知道开发者可以通过这个思路去获得所需的用户空间页表内容。
获得当前进程内核空间页表 C 语言版
PTDUMP 机制虽然通过 Debugfs 提供了 4 个伪文件接口获得不同需求的页表,其中包括 current_kernel 可以获得当前进程内核空间的页表,如果使用 cat 读取该文件,那么只能获得 cat 这个进程内核空间的页表,那么当需要想获得指定进程的内核空间页表怎么办呢? 本节提供了一种思路是在源码级别去读取该文件,那么程序运行时就是该进程读取了 current_kernel 文件,这样就可以获得对应内核空间的页表。那么接下来通过一个实践案例介绍如何实现这个思路,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] PTDUMP: DUMP Current Kernel Space PageTable --->
make
# 源码目录
# Module
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PTDUMP-CURRKERNEL-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-PTDUMP-CURRKERNEL-default Source Code on Gitee
实践案例有一个用户空间程序构成,其核心逻辑是分配一块 Buffer,然后从 “current_kernel” 文件读取页表的内容到 Buffer 里,最后就是进行显示。程序在 22 行打开了 current_kernel 文件,然后在 28 行分配了 32K 的 Buffer,接着打印了 Buffer 的虚拟地址。实践案例在 38 行调用 read() 函数开始读取当前页表内容到 Buffer,然后在 41 行从 Buffer 里打印页表内容,打印完毕之后就是释放内存和关闭文件. 那么接下来在 BiscuitOS 上进行实践:
当 BiscuitOS 启动之后,直接运行 BiscuitOS-PAGING-PTDUMP-CURRKERNEL-default,可以看到 BUFFER 的虚拟地址 0x55f3ed5632a0,打印的信息有些长,其包括了用户空间虚拟地址页表,内核空间页表位于末尾,可以看到内核不同 Region 的页表信息,对开发者调试分析问题有很大帮助.
DUMP 当前内核空间页表
PTDUMP 机制提供了 Debugfs 提供了 4 个伪文件接口获得不同需求的页表,其中对 kernel 文件的读取可以获得内核空间页表内容,使用方法如下:
cat /sys/kernel/debug/page_tables/kernel
可以看到当执行该命令之后,PTDUMP 机制只会输出内核空间虚拟内存的页表,而不输出用户空间虚拟内存页表. 这些信息对相关问题的排查有一定的作用,因此在需要内核页表信息的时候都可以通过该接口获得.
DUMP 当前进程用户空间页表
PTDUMP 机制提供了 Debugfs 提供了 4 个伪文件接口获得不同需求的页表,其中对 current_user 文件的读取可以获得当前进程用户空间页表内容,值得注意的是这里是当前进程的用户空间,使用方法如下:
cat /sys/kernel/debug/page_tables/current_user
可以看到当执行该命令之后,PTDUMP 机制会输出当前进程的用户空间和内核空间虚拟内存的页表,此时当前进程指的是 cat,因此该文件不适合直接通过 cat 命令读取,更时候在代码里打开该文件,那么当前进程就变成所需的进程了.
DUMP 当前进程内核空间页表
PTDUMP 机制提供了 Debugfs 提供了 4 个伪文件接口获得不同需求的页表,其中对 current_kernel 文件的读取可以获得当前进程内核空间页表内容,值得注意的是这里是当前进程的内核空间,使用方法如下:
cat /sys/kernel/debug/page_tables/current_kernel
可以看到当执行该命令之后,PTDUMP 机制会输出当前进程的用户空间和内核空间虚拟内存的页表,此时当前进程指的是 cat,因此该文件不适合直接通过 cat 命令读取,更时候在代码里打开该文件,那么当前进程就变成所需的进程了.
DUMP 当前 EFI 区域页表
PTDUMP 机制提供了 Debugfs 提供了 4 个伪文件接口获得不同需求的页表,其中对 efi 文件的读取 EFI 映射区页表的内容,使用方法如下:
cat /sys/kernel/debug/page_tables/efi
可以看到当执行该命令之后,PTDUMP 机制会输出 EFI 映射区的页表,也可能什么也不输出,这要看机器是已经在使用 EFI 固件了。
PTDUMP 应用场景
内核映射区 WX 区域检查场景
在内核启动阶段,内核会调用 mark_rodata_ro() 函数将内核镜像的 rodata/data/bss/brk Section 标记为 no-executable, 然后将内核镜像代码段标记为 RO,正如上图,内核不允许出现可写又可执行的区域,于是调用 debug_checkwx() 函数进行检查,该函数要其作用需要打开宏 CONFIG_DEBUG_WX. 当函数检查到内核映射区域存在 WX 的区域,那么就会进行报错. 其核心逻辑是调用 PTDUMP 机制的 ptdump_walk_pgd_level_checkwx 函数,该函数会通过 PTDUMP 机制对所有的内核空间虚拟内存的页表进行检查,以便发现出现可写可执行的区域. 那么接下来通过一个实践案例了解 CHECK NX 的过程,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] PTDUMP: Check NX Region --->
make
# 源码目录
# Module
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PTDUMP-CHECK-NX-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-PTDUMP-CHECK-NX-default Source Code on Gitee
实践案例由两部分组成,其中一部分是一个应用程序,其目的是从 “/dev/BiscuitOS-PTDUMP” 设备节点上,通过 mmap 分配内存,然后使用这部分内存,使用完毕之后在释放.
实践案例的另外一部分是一个内核模块,模块基于 MSIC 驱动构建,并向用户空间提供 “/dev/BiscuitOS-PTDUMP” 节点,节点实现了 mmap 接口,当用户空间调用 mmap 函数就会调用到 BiscuitOS_mmap() 函数,该函数首先分配一个物理页,并获得物理页对应的内核空间虚拟内存。接下来将这段虚拟内存构造成可写可执行的区域,于是在 31-32 行调用 set_memory_rw() 和 set_memory_x() 函数,接着在 34 行调用 debug_checkwx() 函数检查内核空间虚拟内存。最后再次将虚拟内存区域修改为非 WX 的区域,那么再次进行检查,于是在 37 行调用 set_memory_nx() 函数将该区域设置为不可执行的,接着在 39 行调用 debug_checkwx() 函数再次检查。检查完毕之后就是建立页表. 那么接下来在 BiscuitOS 上实践该案例:
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本来运行测试用例,可以看到内核报错,报错的信息还是直接指明了 x86/mm: Found insecure W+X mapping at address 0xffffa0cb83901000, 那么首先确认分配的物理页对应的虚拟内存的起始地址是 0xffffa0cb83901000,于是确认报错的 W+X 地址正好是该地址,那么接下来看一下报错的堆栈信息,可以看到 PTDUMP 机制在遍历内核空间虚拟内存的时候,遍历到 0xffffa0cb83901000 对应的 PTE 页表时,arch/x86/mm/dump_pagetables.c 的 246 行报错,此时查看该源码:
在 note_nx() 函数里是在 note_page 里检查到页表里包含 _PAGE_RW 但不包含 _PAGE_NX,那么 note_nx() 函数就会被调用,用于记录 WX 页表的信息,其通过 246 行的 WARN_ONCE() 函数进行报错,但这里仅仅是报错,没有阻止程序的继续运行,那么可以看到实践案例再将虚拟区域的 NX 标志置位之后,那么再次调用 debug_checkwx() 函数是并没有找到任何的 WX 区域,可以从打印的信息看出,其输出了字符串: x86/mm: Checked W+X mappings: passed, no W+X pages found.. 至此实践案例实践完毕,通过该实践案例可以在内核里使用 debug_checknx() 函数对内核空间虚拟区域进行检查, 已发现不符合要求的映射.
PTI 用户空间 WX 区域检查场景
在 Linux 内核中,pti_finalize 函数用于完成页表隔离(Page Table Isolation,PTI)的初始化和收尾工作。页表隔离是一种安全性措施,旨在减轻 Spectre 漏洞的风险,特别是 Spectre Variant 2. 其在页表隔离完毕之后会调用 debug_checkwx_user() 函数对进程的用户空间和内核空间虚拟内存区域检查,如果发现存在可读写可执行的区域就进行报错,因为这样的区域是非法区域. debug_checkwx_user() 函数的功能要其作用,其首先要打开 CONFIG_DEBUG_WX 宏,然后系统硬件上支持 PTI 机制, 只有满足这两个条件才可能启用用户空间虚拟内存检查 WX 区域的能力.
PageWalk 机制实践
BiscuitOS 目前支持对 PTDUMP 机制的实践,开发者可以参考本节在 BiscuitOS 上实践案例. 在实践之前,开发者需要准备一个 Linux 6.0 X86 架构实践环境,可以参考:
部署完毕之后,针对 PTDUMP 机制 的实践,需要 BiscuitOS 使用 make menuconfig 选择如下配置:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] PTDUMP: DUMP Current Userspace PageTable --->
make
# 源码目录
# Module
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PTDUMP-CURRUSER-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-PTDUMP-CURRUSER-default Source Code on Gitee
通过上面的命令,开发者可以获得指定的源码目录,使用 “make download” 命令可以下载实践用的源码, 然后使用 tree 命令可以看到实践源码 main.c 和编译脚本 Makefile. 接下来在当前目录继续使用 “make build” 进行源码编译、打包并在 BiscuitOS 上实践:
BiscuitOS 运行之后,可以直接运行 RunBiscuitOS.sh 脚本直接运行实践所需的所有步骤,开发者只需在意最后的运行结果,可以提升实践效率。以上便是最简单的实践,具体实践案例存在差异,以实践文档介绍为准.