2. 虚拟地址空间布局:用户空间与内核空间划分、x86_64与ARM64布局对比、地址转换原理

2.1 虚拟地址空间的基本划分

在 Linux 内核中,虚拟地址空间是进程看到的连续线性地址范围。为了隔离操作系统内核与用户进程,虚拟地址空间被划分为两个主要区域:用户空间和内核空间。这种划分是现代操作系统实现保护模式的基础。

划分的核心原则:

  • 用户空间:运行用户态应用程序,权限受限(Ring 3),无法直接访问硬件或内核数据结构。
  • 内核空间:运行操作系统内核代码、驱动和核心服务,拥有最高权限(Ring 0),可以执行特权指令。
  • 隔离保护:用户进程无法直接访问内核空间地址,任何尝试都会触发段错误(Segmentation Fault)或页面错误(Page Fault)。
关键概念: 虚拟地址空间的划分比例由 CONFIG_PAGE_OFFSET 和体系结构决定。在 64 位系统中,通常采用 1:1 或 3:1 的划分策略,但实际可用地址范围受硬件支持限制。

2.2 x86_64 架构的虚拟地址空间布局

x86_64 架构使用 48 位虚拟地址(高 16 位必须为符号扩展),总虚拟地址空间为 256 TB。Linux 内核采用经典的 3:1 划分(用户空间 128 TB,内核空间 128 TB),但实际布局更为精细。

地址范围 大小 区域名称 用途
0x0000000000000000 - 0x00007fffffffffff 128 TB 用户空间 用户进程代码、堆、栈、共享库、mmap 区域
0xffff800000000000 - 0xffff87ffffffffff 8 TB 内核直接映射区 物理内存直接映射(线性映射)
0xffff880000000000 - 0xffffc7ffffffffff 64 TB vmalloc 区域 非连续内存分配、模块加载
0xffffc80000000000 - 0xffffe8ffffffffff 32 TB 固定映射区 固定虚拟地址映射(如 ACPI、IO 空间)
0xffffea0000000000 - 0xffffefffffffffff 6 TB 模块映射区 内核模块加载
0xfffff80000000000 - 0xffffffffffffffff 2 TB 内核栈、陷阱、CPU 私有数据 每个 CPU 的 per-CPU 区域、中断栈
注意: x86_64 的“空洞”区域(0x0000800000000000 - 0xffff7fffffffffff)是未使用的地址空间,用于捕获非规范地址访问,防止地址越界。
x86_64 虚拟地址空间布局(48位) 用户空间 (128 TB) 0x0000000000000000 - 0x00007fffffffffff 代码段 | 数据段 | 堆 | 栈 | 共享库 | mmap 非规范地址空洞(未使用) 内核空间 (128 TB) 直接映射区 (8 TB) — 物理内存线性映射 vmalloc 区域 (64 TB) — 非连续内存分配 固定映射区 (32 TB) — ACPI / IO 空间 模块映射区 (6 TB) — 内核模块加载 per-CPU 区域 / 内核栈 / 陷阱 (2 TB) 低地址 高地址

2.3 ARM64 架构的虚拟地址空间布局

ARM64 架构支持 48 位或 52 位虚拟地址(取决于页表等级配置)。Linux 内核在 ARM64 上通常使用 48 位地址,并采用 1:1 划分(用户空间 256 TB,内核空间 256 TB),但内核空间内部布局与 x86_64 有显著差异。

地址范围 大小 区域名称 用途
0x0000000000000000 - 0x0000ffffffffffff 256 TB 用户空间 用户进程代码、堆、栈、共享库
0xffff000000000000 - 0xffff7fffffffffff 128 TB 内核直接映射区 物理内存线性映射(PAGE_OFFSET)
0xffff800000000000 - 0xffffbfffffffffff 64 TB vmalloc 区域 非连续内存分配
0xffffc00000000000 - 0xffffdfffffffffff 32 TB 固定映射区 / 模块区 固定映射、内核模块
0xffffe00000000000 - 0xffffffffffffffff 32 TB 内核栈、陷阱、CPU 私有数据 per-CPU 区域、中断向量
架构差异提示: ARM64 没有 x86_64 的“非规范地址空洞”,因为 ARM64 的地址空间是连续的。此外,ARM64 的内核直接映射区起始地址 PAGE_OFFSET 通常为 0xffff000000000000,而 x86_64 为 0xffff800000000000。
ARM64 虚拟地址空间布局(48位) 用户空间 (256 TB) 0x0000000000000000 - 0x0000ffffffffffff 代码段 | 数据段 | 堆 | 栈 | 共享库 内核空间 (256 TB) 直接映射区 (128 TB) — PAGE_OFFSET 线性映射 vmalloc 区域 (64 TB) — 非连续内存分配 固定映射区 / 模块区 (32 TB) 内核栈 / per-CPU / 陷阱 (32 TB) 低地址 高地址

2.4 x86_64 与 ARM64 布局对比

对比维度 x86_64 ARM64
虚拟地址位数 48 位(高 16 位符号扩展) 48 位(可选 52 位)
用户空间大小 128 TB 256 TB
内核空间大小 128 TB 256 TB
划分比例 3:1(用户:内核) 1:1(用户:内核)
地址空洞 存在非规范地址空洞 无空洞,地址空间连续
直接映射起始地址 0xffff800000000000 0xffff000000000000
模块映射区 独立区域(6 TB) 与固定映射区共享(32 TB)
页表等级 4 级(PML4 → PDP → PD → PT) 4 级(PGD → PUD → PMD → PTE)
实战要点: 在编写可移植内核模块时,不能硬编码直接映射区的起始地址。应使用 PAGE_OFFSET 宏(x86_64 为 0xffff800000000000,ARM64 为 0xffff000000000000)来获取当前架构的线性映射基址。

2.5 地址转换原理:从虚拟地址到物理地址

虚拟地址不能直接用于访问物理内存,必须通过 MMU(内存管理单元) 进行地址转换。转换过程基于 页表(Page Table),将虚拟页号(VPN)映射到物理页框号(PFN)。

转换的核心步骤(以 x86_64 4 级页表为例):

  1. 虚拟地址分解:将 48 位虚拟地址拆分为 5 个部分:PML4 索引(9 位)、PDP 索引(9 位)、PD 索引(9 位)、PT 索引(9 位)、页内偏移(12 位)。
  2. 页表遍历:从 CR3 寄存器获取 PML4 页表基址,依次索引 PML4 → PDP → PD → PT 四级页表,每级页表项(PTE)指向下一级页表的物理地址。
  3. 获取物理页框:最后一级页表项(PTE)包含物理页框号(PFN),与页内偏移拼接得到完整的物理地址。
  4. TLB 缓存:转换结果会缓存到 TLB(Translation Lookaside Buffer)中,加速后续相同页的访问。
x86_64 4级页表地址转换流程 虚拟地址 (48位) PML4 (9位) PDP (9位) PD (9位) PT (9位) 偏移 (12位) 页表遍历(MMU 硬件执行) PML4 表 PDP 表 PD 表 PT 表 物理地址 = PFN (40位) + 偏移 (12位)

2.6 地址转换的硬件加速:TLB 与页表缓存

每次内存访问都遍历 4 级页表会带来巨大的性能开销。现代 CPU 通过以下机制加速地址转换:

  • TLB(Translation Lookaside Buffer):CPU 内部的硬件缓存,存储最近使用的虚拟页到物理页的映射。TLB 命中时,无需遍历页表。
  • 多级 TLB:L1 TLB(指令/数据分离,容量小但极快)、L2 TLB(统一,容量更大)。
  • 页表缓存(Page Walk Cache):缓存中间级页表(PML4、PDP、PD)的条目,减少页表遍历的访存次数。
  • 大页(Huge Pages):使用 2 MB 或 1 GB 的大页,减少页表层级和 TLB 压力。
性能优化建议: 对于数据库、HPC 等内存密集型应用,启用透明大页(Transparent Huge Pages, THP)可以显著降低 TLB 未命中率,提升性能。但实时系统可能需要关闭 THP 以避免内存分配延迟抖动。

2.7 地址转换的代码视角:内核中的页表操作

Linux 内核提供了一套体系结构无关的页表操作 API,用于管理虚拟地址到物理地址的映射。以下是一个简单的示例,展示如何在内核模块中查找虚拟地址对应的物理地址:

#include <linux/mm.h>
#include <linux/highmem.h>
#include <asm/pgtable.h>

unsigned long virt_to_phys_addr(unsigned long vaddr)
{
    pgd_t *pgd;
    p4d_t *p4d;
    pud_t *pud;
    pmd_t *pmd;
    pte_t *pte;
    unsigned long phys_addr = 0;

    // 获取进程的页全局目录
    pgd = pgd_offset(current->mm, vaddr);
    if (pgd_none(*pgd) || pgd_bad(*pgd))
        return 0;

    // 遍历 p4d(5级页表时使用,4级页表直接返回 pgd)
    p4d = p4d_offset(pgd, vaddr);
    if (p4d_none(*p4d) || p4d_bad(*p4d))
        return 0;

    // 遍历 pud
    pud = pud_offset(p4d, vaddr);
    if (pud_none(*pud) || pud_bad(*pud))
        return 0;

    // 遍历 pmd
    pmd = pmd_offset(pud, vaddr);
    if (pmd_none(*pmd) || pmd_bad(*pmd))
        return 0;

    // 遍历 pte
    pte = pte_offset_kernel(pmd, vaddr);
    if (pte_none(*pte))
        return 0;

    // 提取物理页框号并加上页内偏移
    phys_addr = (pte_pfn(*pte) << PAGE_SHIFT) | (vaddr & ~PAGE_MASK);
    return phys_addr;
}
EXPORT_SYMBOL(virt_to_phys_addr);
注意: 上述代码仅用于演示页表遍历逻辑。在实际内核开发中,应优先使用 virt_to_phys()(用于直接映射区)或 follow_pte() 等封装函数,避免直接操作页表带来的竞态和可移植性问题。

2.8 用户空间与内核空间的切换:系统调用与上下文

当用户进程需要执行特权操作(如读写文件、分配内存)时,必须通过 系统调用 切换到内核空间。切换过程涉及:

  • 指令切换:执行 syscall(x86_64)或 SVC(ARM64)指令,CPU 自动将特权级提升至 Ring 0/EL1。
  • 栈切换:从用户栈切换到内核栈(每个进程拥有独立的内核栈)。
  • 地址空间切换:虽然页表不变(用户空间和内核空间共享同一套页表),但访问权限发生变化——内核代码可以访问整个地址空间,用户代码只能访问用户空间。
  • 返回用户空间:系统调用完成后,通过 sysret(x86_64)或 ERET(ARM64)指令恢复用户态上下文。
用户空间与内核空间切换流程 用户空间 Ring 3 / EL0 应用程序代码 内核空间 Ring 0 / EL1 系统调用处理函数 syscall / SVC sysret / ERET 切换关键操作 1. 保存用户态寄存器(通用寄存器、栈指针、返回地址) 2. 切换到内核栈 | 3. 执行系统调用服务例程 | 4. 恢复用户态上下文 ← 上一章 📖 返回目录 下一章 →