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(内存管理单元) 硬件完成,内核负责维护页表数据结构。基本流程:
- 进程访问虚拟地址(VA);
- MMU 从 CPU 页表基址寄存器中读取当前页表的根地址(如 x86_64 的
CR3,ARM64 的TTBR0_EL1); - MMU 遍历多级页表,将虚拟地址拆解为多个页表索引,最终找到对应的物理页帧号(PFN);
- 拼接页内偏移,得到最终物理地址(PA);
- 若页表中无对应条目(页缺失),触发缺页异常,内核负责分配物理页并填充页表。
页表的多级架构
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 字节)。
地址翻译流程:
- CPU 从
CR3寄存器读取PGD表的物理地址; - 用虚拟地址的
PGD索引,找到对应的PUD表项(存储PUD表的物理地址); - 用
PUD索引找到PMD表项; - 用
PMD索引找到PTE表项; 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 大页 :跳过
PUD和PMD层级,由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 处于实模式(无虚拟地址),内核首先创建临时内核页表:
- 映射内核代码段(
_text~_end)到物理地址(通常是物理地址 == 虚拟地址的恒等映射)。 - 映射页表自身的物理内存(确保页表访问的合法性)。
- 将临时页表的根地址加载到
TTBR1_EL1(ARM64 内核页表基址寄存器),开启 MMU。
目的:让内核从实模式进入虚拟地址模式,执行后续的 C 语言初始化代码。
阶段 2:C 语言级正式页表(paging_init())
内核进入 start_kernel() 后,调用 paging_init() 函数创建正式内核页表,核心操作:
- 销毁临时页表:释放汇编阶段的临时页表内存。
- 映射物理内存 :将所有物理内存(
memblock管理的内存区域)映射到内核虚拟地址空间的 线性映射区 (ARM64 为0xFFFF000000000000开始,x86_64 为0xFFFF888000000000开始);线性映射的特点:虚拟地址 = 偏移量 + 物理地址,计算简单,无页表项开销。 - 映射外设寄存器 :将外设的物理地址(如 UART、PCIe 控制器)映射到内核的 IO 映射区,方便内核通过内存读写操作外设。
- 映射内核镜像:将内核的代码段、数据段、bss 段等固化到内核地址空间,设置权限(代码段只读,数据段可写)。
- 初始化高端内存映射(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)有两个永远不变的核心特征:
task_struct->mm = NULL:内核线程没有任何私有用户态地址空间,也不需要用户态页表,因为它永远不会执行用户态代码、访问用户态内存;- 内核线程的 CPU 特权级永久锁定在内核态(ARM64=EL1,x86=Ring0),CPU 的硬件会强制拦截所有「内核线程访问用户态地址」的行为(直接触发 page fault/Oops)。
内核线程的所有工作:执行内核代码、访问内核数据、操作物理内存、读写外设,这些全部落在「内核地址空间」范围内。
内核线程的执行链路:CPU调度内核线程运行 → 持有active_mm → 访问内核地址 → 硬件查页表 → 访问物理内存;
内核线程运行的是纯内核代码,所有的内存访问都是「内核虚拟地址」(高地址段),比如:
- 访问内核的全局变量
init_task; - 调用内核函数
kmalloc(),申请的内存也是内核地址; - 读写内核的链表、结构体(比如
mm_struct本身)。
这些访问的地址,全部落在「全局共享的内核地址空间」,没有任何用户态地址的访问。
mm 和 active_mm 的本质区别
mm:所有权属性,代表「这个进程有没有自己的用户态地址空间」;active_mm:运行时属性,代表「这个进程在 CPU 上运行时,CPU 的页表寄存器加载的是谁的页表」,是内核内存管理的「运转凭证」。
对内核线程来说:mm=NULL(无所有权)、active_mm≠NULL(有运行凭证)。
内核线程访问内核地址,不需要 active_mm 帮忙做映射,但是必须要有 active_mm,否则内核线程连运行的资格都没有。
内核线程复用「用户态进程的合法页表」来解析全局共享的内核地址,active_mm为内核线程提供内存管理的合法上下文,内核地址的映射由硬件 + 共享页表完成,与 active_mm 无直接关联。
借用用户态进程的 mm_struct
当 CPU 从「用户态进程」切换到「内核线程」时,内核的context_switch()函数执行核心逻辑:
- 内核线程的
mm=NULL,内核会把它的active_mm赋值为上一个在该 CPU 运行的用户态进程的 active_mm; - 对被借用的
mm_struct执行atomic_inc(&mm->mm_count),增加内核态引用计数,防止这个 mm_struct 在被借用期间被释放; - 调用
switch_mm(prev->active_mm, next->active_mm, next),注意:这个函数此时不会修改 CPU 的页表基址寄存器(因为前后两个 active_mm 指向同一个 mm_struct),只是做一些 TLB 缓存的合法性刷新。