Linux 页表映射

Linux 为每个进程分配独立的虚拟地址空间,进程操作的都是虚拟地址,而非直接访问物理内存。这样做的核心优势:

  • 地址空间隔离:进程间虚拟地址互不干扰,一个进程的错误不会影响其他进程。
  • 内存复用:多个进程可共享同一份物理内存(如共享库)。
  • 内存扩容:通过 swap 分区实现「虚拟内存大于物理内存」。

不管是 x86_64 还是 ARM64 架构,Linux 的虚拟地址空间物理上被严格切分为两部分 ,页表 (pgd) 里同时固化了这两段映射,所有用户态进程的mm_struct->pgd都包含这两部分映射

  • 用户态地址空间:低地址段(x86_64 是 0~0x7FFFFFFFFFFF,占 128T;ARM64 是 0~0x0000FFFF_FFFFFFFF,占 48 位)

    • 这部分映射是进程私有的:每个进程的用户态页表不一样,对应各自的代码段、堆、栈、mmap 映射,这也是进程地址空间隔离的核心。
    • 这部分映射只对「用户态(EL0/Ring3)」可见,内核态无法直接访问(有硬件权限拦截)。
  • 内核态地址空间:高地址段(x86_64 是 0xFFFF800000000000~ 顶;ARM64 是 0xFFFF000000000000~ 顶)

    • 这部分映射是全局完全共享 的:所有进程的页表中,内核地址的映射关系一模一样,内核把物理内存、外设寄存器、内核代码 / 数据段,都映射到这个固定的高地址段。
    • 这部分映射只对「内核态(EL1/Ring0)」可见,用户态无法访问(硬件权限拦截)。
    • 内核地址的映射关系是固化的、全局的,只要页表寄存器里加载的是「任意一个合法的进程页表」,就能正确解析所有内核地址。内核线程只是「沾了用户态进程页表的光」,复用了其中的内核映射部分。

用户态进程的页表 = 私有用户页表 + 共享内核页表,内核页表是「一份映射,全系统复用」。

地址翻译的核心流程

虚拟地址到物理地址的翻译由 CPU MMU(内存管理单元) 硬件完成,内核负责维护页表数据结构。基本流程:

  1. 进程访问虚拟地址(VA);
  2. MMU 从 CPU 页表基址寄存器中读取当前页表的根地址(如 x86_64 的 CR3,ARM64 的 TTBR0_EL1);
  3. MMU 遍历多级页表,将虚拟地址拆解为多个页表索引,最终找到对应的物理页帧号(PFN);
  4. 拼接页内偏移,得到最终物理地址(PA);
  5. 若页表中无对应条目(页缺失),触发缺页异常,内核负责分配物理页并填充页表。

页表的多级架构

32 位系统通常使用 2 级页表 ,而 64 位系统因虚拟地址空间过大,采用 3 级或 4 级页表

4 级页表架构(x86_64 标准)

x86_64 虚拟地址为 64 位,但实际只使用 48 位,拆解为 5 部分:

字段 全局页目录索引(PGD) 上级页目录索引(PUD) 中间页目录索引(PMD) 页表项索引(PTE) 页内偏移
位数 9 位 9 位 9 位 9 位 12 位
作用 定位 PGD 表项 定位 PUD 表项 定位 PMD 表项 定位 PTE 表项 页内字节位置
  • 页大小 :页内偏移为 12 位 → 单页大小为 2^12 = 4KB
  • 各级页表大小 :每个索引 9 位 → 每级页表包含 2^9 = 512 个表项 → 每级页表大小为 512 * 8B = 4KB(表项占 8 字节)。

地址翻译流程

  1. CPU 从 CR3 寄存器读取 PGD 表的物理地址;
  2. 用虚拟地址的 PGD 索引,找到对应的 PUD 表项(存储 PUD 表的物理地址);
  3. PUD 索引找到 PMD 表项;
  4. PMD 索引找到 PTE 表项;
  5. PTE 表项存储目标物理页的帧号,拼接页内偏移得到物理地址。

3 级页表架构(ARM64 简化版)

ARM64 支持灵活配置页表层级,当虚拟地址宽度为 42 位时,可省略 PUD 层级,直接用 PGD → PMD → PTE 三级架构:

  • 虚拟地址拆解:PGD(9) + PMD(9) + PTE(9) + 偏移(12) → 共 39 位(可扩展到 42 位)。
  • 核心优势:减少一次内存访问,提升地址翻译效率。

大页(HugePage)的映射优化

标准 4KB 页在大内存场景下会导致页表膨胀(如 1TB 内存需要 256M 个 PTE 表项)。Linux 支持 大页映射,直接跳过部分页表层级:

  • 2MB 大页 :跳过 PTE 层级,由 PMD 表项直接映射 2MB 物理内存(x86_64 中 PMD 索引对应 2MB 空间)。
  • 1GB 大页 :跳过 PUDPMD 层级,由 PGD 表项直接映射 1GB 物理内存。

大页的优势是减少页表项数量、降低 TLB 缓存失效概率;缺点是内存分配粒度变大,容易产生内存碎片。

页表项的核心标志位

页表项中除了存储物理页帧号,还包含一系列硬件定义的标志位,控制内存访问权限和状态,常见标志:

标志位(x86) 含义 内核宏定义
P(Present) 页表项有效,物理页存在于内存 _PAGE_PRESENT
R/W(Read/Write) 页面是否可写 _PAGE_RW
U/S(User/Supervisor) 用户态是否可访问 _PAGE_USER
A(Accessed) 页面是否被访问过(用于 LRU 回收) _PAGE_ACCESSED
D(Dirty) 页面是否被修改过(脏页标记) _PAGE_DIRTY
  • 标志位由 MMU 硬件检查:若进程违反权限(如写只读页),会触发页错误异常。
  • 内核通过设置标志位实现内存保护(如代码段设为只读)。

进程页表的根节点:mm_struct->pgd

每个进程的 mm_struct 结构体中,pgd 字段指向该进程页表的根节点(PGD 表的虚拟地址);

  • 进程切换时 :内核将 next->mm->pgd 的物理地址加载到 CPU 的页表基址寄存器(如 CR3),完成地址空间切换。
  • 内核页表共享:所有进程的 PGD 表中,内核地址空间对应的表项都指向同一个内核页表,实现内核地址全局共享。

页表共享:写时复制(COW)

进程 fork() 时,内核不会复制父进程的页表,而是共享父进程的页表,并将所有页表项设为只读:

  • 当父 / 子进程尝试写页面时,触发缺页异常;
  • 内核为写操作分配新的物理页,复制数据,并修改子进程的页表项指向新物理页;
  • 实现「按需复制」,减少 fork() 开销。

高端内存映射:kmap()

32 位系统中,内核地址空间(3GB~4GB)有限,无法直接映射所有物理内存。内核通过 kmap() 将高端内存(大于 896MB)临时映射到内核地址空间,访问完成后通过 kunmap() 释放映射。

64 位系统因地址空间充足,无需高端内存机制。

内核页表

Linux 内核页表 是内核地址空间的映射载体,是一套全局共享、固化映射的页表结构,与进程的用户态页表共同组成完整的虚拟地址映射体系。内核页表的核心作用是将内核虚拟地址(如内核代码段、物理内存、外设寄存器)映射到物理地址,且所有进程的页表中,内核页表的映射关系完全一致。

每个用户态进程的页表(由 mm_struct->pgd 指向)是 「用户态页表 + 内核页表」的联合体 :

  • 用户态页表部分:对应低地址段,每个进程私有,实现进程地址空间隔离。
  • 内核页表部分:对应高地址段,所有进程共享,内核通过这部分映射访问物理内存和外设。

进程切换时,CPU 切换的是用户态页表的根地址,但内核页表的映射始终存在于页表中,无需重新加载。

内核页表的初始化

内核页表的初始化是系统启动阶段的核心步骤 ,在 start_kernel() 之前的汇编代码和早期内核初始化函数中完成,以 ARM64 为例,核心流程如下:

阶段 1:汇编级临时页表(head.S

系统上电后,CPU 处于实模式(无虚拟地址),内核首先创建临时内核页表

  1. 映射内核代码段(_text ~ _end)到物理地址(通常是物理地址 == 虚拟地址的恒等映射)。
  2. 映射页表自身的物理内存(确保页表访问的合法性)。
  3. 将临时页表的根地址加载到 TTBR1_EL1(ARM64 内核页表基址寄存器),开启 MMU。

目的:让内核从实模式进入虚拟地址模式,执行后续的 C 语言初始化代码。

阶段 2:C 语言级正式页表(paging_init()

内核进入 start_kernel() 后,调用 paging_init() 函数创建正式内核页表,核心操作:

  1. 销毁临时页表:释放汇编阶段的临时页表内存。
  2. 映射物理内存 :将所有物理内存(memblock 管理的内存区域)映射到内核虚拟地址空间的 线性映射区 (ARM64 为 0xFFFF000000000000 开始,x86_64 为 0xFFFF888000000000 开始);线性映射的特点:虚拟地址 = 偏移量 + 物理地址,计算简单,无页表项开销。
  3. 映射外设寄存器 :将外设的物理地址(如 UART、PCIe 控制器)映射到内核的 IO 映射区,方便内核通过内存读写操作外设。
  4. 映射内核镜像:将内核的代码段、数据段、bss 段等固化到内核地址空间,设置权限(代码段只读,数据段可写)。
  5. 初始化高端内存映射(32 位系统特有):32 位内核地址空间有限,无法直接映射全部物理内存,需为高端内存预留动态映射窗口。

阶段 3:页表的全局共享化

正式内核页表初始化完成后,内核会将其设置为全局模板

  • 后续创建的所有用户态进程的页表,都会继承内核页表部分的映射
  • 进程页表初始化时,内核只需填充用户态页表部分,内核页表部分直接复用全局模板。

内核页表的关键映射区域

内核地址空间划分了多个功能区域,不同区域对应内核页表的不同映射策略,以 ARM64 为例,核心区域如下:

映射区域 虚拟地址范围 映射内容 核心特点
线性映射区 0xFFFF000000000000 ~ 0xFFFF7FFF_FFFFFFFF 系统所有物理内存 恒等映射 / 偏移映射,全局共享,支持直接物理内存访问
vmalloc 区 0xFFFF8000_00000000 ~ 0xFFFFBFFF_FFFFFFFF 动态分配的内核虚拟内存 非连续物理内存映射,通过 vmalloc() 分配
IO 映射区 0xFFFFC000_00000000 ~ 0xFFFFDFFF_FFFFFFFF 外设寄存器、PCIe 设备内存 按需映射,通过 ioremap() 建立映射
内核镜像区 0xFFFFE000_00000000 ~ 顶端 内核代码段、数据段、栈 固化映射,权限严格控制(代码段 RO

1. 线性映射区(核心区域)

线性映射区是内核访问物理内存的主要通道,其映射关系为:虚拟地址=物理地址+线性映射偏移量

  • 内核通过 virt_to_phys()/phys_to_virt() 函数快速完成虚拟地址与物理地址的转换。
  • 线性映射区的页表项权限为内核态可读写、用户态不可访问
  • 64 位系统的线性映射区足够大(如 ARM64 支持最大 128TB 物理内存),无需动态调整;32 位系统则需依赖高端内存机制。

2. vmalloc 区(动态内存区)

vmalloc() 函数用于分配非连续的物理内存,其映射特点:

  • 虚拟地址连续,物理地址不连续。
  • 内核通过创建新的页表项,将分散的物理页映射到连续的虚拟地址。
  • vmalloc() 分配的内存比 kmalloc() 慢(需修改页表),适用于大内存分配场景。

3. IO 映射区(外设访问区)

外设的物理寄存器无法直接访问,内核通过 ioremap() 函数将其映射到 IO 映射区:

  • 映射后,内核可通过指针直接读写外设寄存器(如 *((volatile unsigned int *)io_addr) = val)。
  • 映射完成后需通过 iounmap() 释放,避免页表泄漏。

内核页表的管理操作 API

内核提供了一系列函数管理内核页表,核心操作包括映射建立、映射销毁、地址转换等:

函数 作用 适用场景
paging_init() 初始化内核页表,建立线性映射和 IO 映射 系统启动阶段
ioremap() 将外设物理地址映射到内核虚拟地址 驱动开发中访问外设寄存器
iounmap() 销毁 ioremap() 建立的映射 驱动卸载时释放映射
vmalloc() 分配连续虚拟地址、非连续物理地址的内存 内核大内存分配
vfree() 释放 vmalloc() 分配的内存 释放动态内核虚拟内存
virt_to_phys() 线性映射区虚拟地址转物理地址 内核态物理内存访问
phys_to_virt() 物理地址转线性映射区虚拟地址 物理内存映射到内核虚拟地址
set_pte_at() 直接修改内核页表项 内核页表动态调整(如高端内存)

内核页表的全局共享机制

内核页表的共享是零开销的,其实现原理:

  • 内核页表的页表项(PGD/PUD/PMD/PTE)存储在物理内存中,所有进程的页表都指向这些共享的页表项。
  • 进程切换时,仅需切换用户态页表的根地址,内核页表的映射无需任何修改。
  • 这种机制保证了内核线程可以借用任意用户态进程的页表 访问内核地址空间(即 active_mm 的核心原理)。

TLB 缓存:减少页表访问开销

MMU 每次翻译地址都要访问多次内存(遍历多级页表),开销较大。CPU 内置 TLB(Translation Lookaside Buffer) 缓存最近使用的虚拟地址→物理地址映射:

  • 当访问虚拟地址时,MMU 先查 TLB,命中则直接得到物理地址;
  • 未命中时,再遍历页表,并将结果写入 TLB。
  • 内核通过 flush_tlb_page() 函数刷新指定虚拟地址的 TLB 缓存。

内核态 TLB 与用户态 TLB 分离

ARM64 等架构支持 TLB 分区(ASID 地址空间标识符),将 TLB 分为内核态 TLB 和用户态 TLB:

  • 内核态 TLB 缓存内核地址的映射,进程切换时无需刷新,提升内核代码的执行效率。
  • 用户态 TLB 缓存用户地址的映射,进程切换时只需刷新用户态 TLB 或更新 ASID。

x86_64 虽不支持 TLB 分区,但通过 CR3 寄存器的 PCD/PWT 位优化缓存策略,减少内核页表的 TLB 失效次数。

内核线程的本质

永远运行在「内核态」的无用户空间进程;

内核线程(kthread)有两个永远不变的核心特征:

  1. task_struct->mm = NULL:内核线程没有任何私有用户态地址空间,也不需要用户态页表,因为它永远不会执行用户态代码、访问用户态内存;
  2. 内核线程的 CPU 特权级永久锁定在内核态(ARM64=EL1,x86=Ring0),CPU 的硬件会强制拦截所有「内核线程访问用户态地址」的行为(直接触发 page fault/Oops)。

内核线程的所有工作:执行内核代码、访问内核数据、操作物理内存、读写外设,这些全部落在「内核地址空间」范围内。

内核线程的执行链路:CPU调度内核线程运行持有active_mm访问内核地址硬件查页表访问物理内存;

内核线程运行的是纯内核代码,所有的内存访问都是「内核虚拟地址」(高地址段),比如:

  • 访问内核的全局变量 init_task
  • 调用内核函数 kmalloc(),申请的内存也是内核地址;
  • 读写内核的链表、结构体(比如mm_struct本身)。

这些访问的地址,全部落在「全局共享的内核地址空间」,没有任何用户态地址的访问

mmactive_mm 的本质区别

  • mm所有权属性,代表「这个进程有没有自己的用户态地址空间」;
  • active_mm运行时属性,代表「这个进程在 CPU 上运行时,CPU 的页表寄存器加载的是谁的页表」,是内核内存管理的「运转凭证」。

对内核线程来说:mm=NULL(无所有权)、active_mm≠NULL(有运行凭证)。

内核线程访问内核地址,不需要 active_mm 帮忙做映射,但是必须要有 active_mm,否则内核线程连运行的资格都没有

内核线程复用「用户态进程的合法页表」来解析全局共享的内核地址,active_mm为内核线程提供内存管理的合法上下文,内核地址的映射由硬件 + 共享页表完成,与 active_mm 无直接关联

借用用户态进程的 mm_struct

当 CPU 从「用户态进程」切换到「内核线程」时,内核的context_switch()函数执行核心逻辑:

  1. 内核线程的mm=NULL,内核会把它的active_mm赋值为上一个在该 CPU 运行的用户态进程的 active_mm
  2. 对被借用的mm_struct执行 atomic_inc(&mm->mm_count),增加内核态引用计数,防止这个 mm_struct 在被借用期间被释放;
  3. 调用switch_mm(prev->active_mm, next->active_mm, next)注意:这个函数此时不会修改 CPU 的页表基址寄存器(因为前后两个 active_mm 指向同一个 mm_struct),只是做一些 TLB 缓存的合法性刷新。
相关推荐
UP_Continue2 小时前
Linux--进程状态
linux·运维·服务器
C++ 老炮儿的技术栈2 小时前
KUKA机器人程序抓料
linux·运维·c语言·人工智能·机器人·库卡
紫神2 小时前
不重启节点情况下删除rook-ceph
linux·运维·服务器·rook-ceph
Source.Liu2 小时前
【Ubuntu】文件与目录管理命令
linux·运维·ubuntu
Linux蓝魔3 小时前
外网同步所有ubuntu源到内网使用
linux·数据库·ubuntu
若风的雨3 小时前
HIP 设备管理与初始化
linux
zfxwasaboy3 小时前
DRM KMS 子系统(5)Device/demo
linux·c语言
物理与数学3 小时前
linux内核常用hook机制
linux·linux内核
周公挚友3 小时前
centos 7.9 防火墙
linux·运维·centos