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)是未使用的地址空间,用于捕获非规范地址访问,防止地址越界。
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。
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 级页表为例):
- 虚拟地址分解:将 48 位虚拟地址拆分为 5 个部分:PML4 索引(9 位)、PDP 索引(9 位)、PD 索引(9 位)、PT 索引(9 位)、页内偏移(12 位)。
- 页表遍历:从
CR3寄存器获取 PML4 页表基址,依次索引 PML4 → PDP → PD → PT 四级页表,每级页表项(PTE)指向下一级页表的物理地址。 - 获取物理页框:最后一级页表项(PTE)包含物理页框号(PFN),与页内偏移拼接得到完整的物理地址。
- TLB 缓存:转换结果会缓存到 TLB(Translation Lookaside Buffer)中,加速后续相同页的访问。
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)指令恢复用户态上下文。