Linux内核分析之基础知识-00

1.1 寄存器与数据类型

x86_64 架构在传统 x86 的基础上将通用寄存器从 32 位扩展到 64 位,并新增了八个通用寄存器(R8–R15)。本节系统性地介绍 Linux 7.0 内核在 x86_64 模式下所使用的各类寄存器,包括通用寄存器、指令指针、标志寄存器、段寄存器、控制寄存器、模型专用寄存器(MSR)、调试寄存器以及 SIMD 扩展寄存器,最后给出内核中常用的数据类型定义。


1.1.1 通用寄存器(General-Purpose Registers, GPRs)

寄存器层级命名

x86_64 的每个通用寄存器都可以按不同位宽寻址。以 RAX 为例,其层级关系如下:

┌─────────────────────────────────────────────────────────┐
│                    RAX (64 位)                          │
├────────────────────────┬────────────────────────────────┤
│        高 32 位        │           EAX (32 位)           │
├───────────┬────────────┼───────────┬────────────────────┤
│  高 16 位 │    AX (16 位)          │                    │
│           ├──────────┬─────────────┤                    │
│           │ AH (8位) │ AL (8位)    │                    │
└───────────┴──────────┴─────────────┴────────────────────┘

下表列出了全部 16 个通用寄存器及其不同位宽的名称:

64 位 32 位 16 位 高 8 位 低 8 位 传统用途
RAX EAX AX AH AL 累加器 / 系统调用号 / 返回值
RBX EBX BX BH BL 基址寄存器(被调用者保存)
RCX ECX CX CH CL 计数器(移位、循环、第 4 参数)
RDX EDX DX DH DL 数据 / I/O / 第 3 参数 / 返回值高 64 位
RSI ESI SI — SIL 源变址 / 第 2 参数
RDI EDI DI — DIL 目的变址 / 第 1 参数
RBP EBP BP — BPL 栈帧指针(被调用者保存)
RSP ESP SP — SPL 栈指针
R8 R8D R8W — R8B 第 5 参数
R9 R9D R9W — R9B 第 6 参数
R10 R10D R10W — R10B SYSCALL 指令保存 RFLAGS
R11 R11D R11W — R11B SYSCALL 指令保存 RIP
R12 R12D R12W — R12B 被调用者保存
R13 R13D R13W — R13B 被调用者保存
R14 R14D R14W — R14B 被调用者保存
R15 R15D R15W — R15B 被调用者保存

注意:对 32 位寄存器(如 EAX)写入时,高 32 位自动清零;而对 16 位或 8 位寄存器写入时,高位保持不变。这是 x86_64 的一条重要规则,内核代码中经常利用此特性进行零扩展。

System V AMD64 ABI 函数调用约定

Linux 内核遵循 System V AMD64 ABI 调用约定(内核自身代码也是如此),其核心规则如下:

参数传递(按顺序使用):

参数位置 整数/指针类型 浮点类型
第 1 个 RDI XMM0
第 2 个 RSI XMM1
第 3 个 RDX XMM2
第 4 个 RCX XMM3
第 5 个 R8 XMM4
第 6 个 R9 XMM5
第 7 个起 栈 XMM6–XMM7 起入栈

返回值:整数类型通过 RAX 返回,128 位整数通过 RDX:RAX 返回。浮点值通过 XMM0(或 XMM1:XMM0)返回。

寄存器分类:

  • 调用者保存(Caller-saved):RAX, RCX, RDX, RSI, RDI, R8–R11。被调函数可以自由修改这些寄存器,调用者在函数调用前需自行保存。
  • 被调用者保存(Callee-saved):RBX, RBP, R12–R15。被调函数若使用这些寄存器,必须在返回前恢复其原始值。
  • 栈指针:RSP 始终指向栈顶,必须 16 字节对齐。

系统调用中的特殊用途

在 SYSCALL / SYSRET 指令中,部分寄存器承担固定角色:

寄存器 系统调用中的用途
RAX 系统调用号(输入)/ 返回值(输出)
RDI 第 1 参数(arg0)
RSI 第 2 参数(arg1)
RDX 第 3 参数(arg2)
R10 第 4 参数(arg3)——注意不是 RCX
R8 第 5 参数(arg4)
R9 第 6 参数(arg5)
RCX 被 SYSCALL 用来保存返回地址 RIP
R11 被 SYSCALL 用来保存 RFLAGS

关键细节:SYSCALL 指令不使用 RCX 传第 4 参数,而是使用 R10,因为 RCX 被硬件自动用于保存返回地址。内核入口代码 entry_SYSCALL_64 会将 R10 复制到 RCX,以匹配 C 函数的调用约定。


1.1.2 指令指针寄存器(RIP)

RIP 是 64 位指令指针寄存器,指向下一条将要执行的指令地址。

┌──────────────────────────────────────────────────────────┐
│                    RIP (64 位)                            │
│              下一条指令的虚拟地址                           │
└──────────────────────────────────────────────────────────┘

RIP 的关键特性:

  • 不可直接读写:x86 没有提供 MOV RIP, ... 之类的指令。RIP 只能通过控制流指令间接修改:

  • JMP / JE / JNE 等:条件与无条件跳转

  • CALL / RET:函数调用与返回(同时操作栈)
  • SYSCALL / SYSRET:系统调用进入与返回
  • INT n / IRET:中断与中断返回
  • LOOP:循环指令

  • 在 64 位模式下的寻址:RIP-relative 寻址是 64 位模式的默认方式,广泛用于位置无关代码(PIC)。内核中通过 RIP 相对寻址访问全局变量,如:

asm movq %gs:current_task(%rip), %rax

  • 中断/异常保存:当异常或中断发生时,CPU 自动将当前 RIP 压入内核栈(对于异常还会压入错误码)。pt_regs 结构体中保存了这些值。

1.1.3 标志寄存器(RFLAGS)

RFLAGS 是 64 位标志寄存器,其中低 32 位称为 EFLAGS。在 64 位模式下,高 32 位保留未使用。

┌───┬───────────────────────────┬──────────────────────────────────────────┐
│   │       高 32 位(保留)     │              EFLAGS (32 位)              │
└───┴───────────────────────────┴──────────────────────────────────────────┘

EFLAGS 各标志位分布(位号从右到左):

 31                                                        0
 ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
 │0│0│0│0│0│0│0│0│0│0│I│V│V│A│V│R│0│N│IOPL│O│D│I│T│S│Z│0│A│0│P│1│C│
 │ │ │ │ │ │ │ │ │ │ │D│M│I│C│M│F│ │T│    │F│F│F│F│F│F│ │F│ │F│ │F│
 └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
 位:31                                                 0

内核中重要的标志位:

标志 位号 名称 内核中的用途
CF 0 进位标志 无符号溢出检测;cmpxchg 指令使用
PF 2 奇偶标志 低 8 位中 1 的个数的奇偶性
AF 4 辅助进位 BCD 运算辅助
ZF 6 零标志 比较结果为零;条件跳转 JE/JNE
SF 7 符号标志 结果为负;JS/JNS
TF 8 陷阱标志 单步调试(ptrace 机制)
IF 9 中断使能 关键标志:CLI 禁用中断、STI 启用中断
DF 10 方向标志 字符串操作方向:CLD(递增)/ STD(递减)
OF 11 溢出标志 有符号溢出检测
IOPL 12-13 I/O 特权级 I/O 操作的最低特权级要求(Ring 0 为 0)
NT 14 嵌套任务 任务嵌套标志
RF 16 恢复标志 禁止调试异常在断点处重复触发
VM 17 虚拟 8086 64 位模式下忽略
AC 18 对齐检查 启用非对齐内存访问检测(与 SMAP 配合)
ID 21 CPUID 可用 可通过翻转此位判断是否支持 CPUID

IF 标志与中断控制是内核中最关键的操作之一。Linux 内核使用 CLI/STI 指令控制中断:

  • 进入中断处理程序后,CPU 自动清除 IF(关中断)
  • spin_lock_irqsave() 在获取自旋锁的同时保存 RFLAGS 并关闭中断
  • spin_unlock_irqrestore() 恢复 RFLAGS,从而恢复中断状态

1.1.4 段寄存器

x86_64 有 6 个 16 位段寄存器:CS, DS, ES, FS, GS, SS。

┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│  CS  │ │  DS  │ │  ES  │ │  FS  │ │  GS  │ │  SS  │
│ 16位 │ │ 16位 │ │ 16位 │ │ 16位 │ │ 16位 │ │ 16位 │
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘

64 位模式下的段寄存器行为

在 64 位(长模式)下,分段机制被大幅简化:

段寄存器 64 位模式下的行为
CS 仍然有效,定义当前代码段特权级(CPL),基址固定为 0
DS 基址强制为 0,被忽略
ES 基址强制为 0,被忽略
SS 基址强制为 0,被忽略
FS 有效:基址通过 MSR 设置,用于用户态 TLS
GS 有效:基址通过 MSR 设置,用于内核态 per-CPU 数据

CS 段选择子

Linux 内核定义了两个关键代码段选择子:

#define __KERNEL_CS   0x10    // 内核代码段:GDT 第 2 项,DPL=0
#define __USER_CS     0x33    // 用户代码段:GDT 第 6 项,DPL=3

CS 寄存器的低 2 位(CPL)反映当前特权级:内核态为 0,用户态为 3。

FS/GS 与 per-CPU 数据

FS 和 GS 是 64 位模式下最重要的段寄存器,它们各自有一个隐藏的 64 位基址寄存器(不暴露在段描述符中),通过 MSR 读写:

MSR 用途
MSR_FS_BASE (0xC0000100) FS 段基址(用户态 TLS)
MSR_GS_BASE (0xC0000101) GS 段基址(当前 GS,内核态指向 per-CPU 数据)
MSR_KERNEL_GS_BASE (0xC0000102) 备用 GS 基址(用户态 GS)

SWAPGS 指令是内核进入/退出系统调用的关键操作:

用户态 → 内核态(entry_SYSCALL_64):
  SWAPGS                    ; 交换 MSR_GS_BASE 与 MSR_KERNEL_GS_BASE
                             ; 此时 GS 基址指向当前 CPU 的 per-CPU 数据区

内核态 → 用户态(返回前):
  SWAPGS                    ; 再次交换,恢复用户态 GS 基址

内核通过 GS 段寄存器实现 per-CPU 变量的高效访问。例如,current_task 是一个 per-CPU 变量,内核通过 %gs:current_task 快速获取当前进程的 task_struct 指针,无需查表。


1.1.5 控制寄存器(Control Registers)

控制寄存器用于管理 CPU 的全局运行状态,包括分页、缓存、保护模式等。

CR0 — 系统控制寄存器

┌─────┬─────┬────┬─────┬───┬───┬─────┬─────┬──────────────┬─────┬───────┬─────┐
│ PG  │ CD  │ NW │ AM  │   │ WP│     │ NE  │              │ ET  │  TS   │  PE │
│ 31  │ 30  │ 29 │ 18  │   │ 16│     │  5  │              │  4  │  3    │  0  │
└─────┴─────┴────┴─────┴───┴───┴─────┴─────┴──────────────┴─────┴───────┴─────┘
位 名称 内核用途
0 PE(Protection Enable) 保护模式使能,开机即设为 1
3 TS(Task Switched) 任务切换延时保存 FPU 状态
5 NE(Numeric Error) 内部 x87 错误报告
16 WP(Write Protect) 关键:Ring 0 写保护只读页,内核 copy_from_user 依赖此位
29 NW(Not Write-through) 写穿透控制
30 CD(Cache Disable) 禁用 CPU 缓存
31 PG(Paging) 关键:分页使能,内核启动时设置

CR2 — 页故障线性地址

当发生页故障(Page Fault, 异常 #PF)时,CPU 自动将触发故障的线性地址写入 CR2。内核页故障处理程序 do_page_fault() 读取 CR2 以获取故障地址:

// arch/x86/mm/fault.c
static noinline void __do_page_fault(struct pt_regs *regs, unsigned long hw_error_code,
                                      unsigned long address)  // address 即从 CR2 读取

注意:在读取 CR2 之前,如果该地址位于用户空间,内核需要先检查是否可能来自对 user_mode(regs) 的判断,以区分内核态缺页和用户态缺页。

CR3 — 页目录基址

CR3 保存顶层页表的物理地址(即 PML4 表的基地址)。

┌──────────────────────────────┬──────────────────────────────┐
│     PML4 物理基址(高位)     │   低位(标志,如 PCID 等)    │
│          [51:12]             │          [11:0]              │
└──────────────────────────────┴──────────────────────────────┘
  • 低 12 位可用于 PCID(Process-Context Identifier,需 CR4.PCIDE=1),用于 TLB 刷新优化。
  • 每次进程切换时,内核通过写入 CR3 切换地址空间。write_cr3() 是一个关键操作。
  • 内核使用 __flush_tlb_all() 等函数管理 TLB 刷新。

CR4 — 扩展控制寄存器

位 名称 内核用途
5 PAE 物理地址扩展(64 位模式必须为 1)
7 PGE 页全局使能,全局页不被刷新(PGE 标志)
9 OSFXSR 操作系统支持 FXSAVE/FXRSTOR
10 OSXMMEXCPT 操作系统支持 SIMD 浮点异常
17 PCIDE PCID 使能(TLB 刷新优化)
20 SMEP Supervisor Mode Execution Prevention:内核态禁止执行用户态代码
21 SMAP Supervisor Mode Access Prevention:内核态禁止访问用户态数据(需 STAC/CLAC 控制)
57 LA57 5 级分页使能(Linux 7.0 支持 57 位虚拟地址)

CR8 — 任务优先级寄存器(TPR)

CR8 映射到 Local APIC 的 Task Priority Register(TPR),用于控制中断优先级阈值。内核很少直接操作 CR8,而是通过 APIC 的 MMIO 寄存器操作。


1.1.6 模型专用寄存器(MSR, Model-Specific Registers)

MSR 通过 RDMSR/WRMSR 指令访问(需 ECX 指定 MSR 编号,结果在 EDX:EAX 中)。内核封装了 rdmsrl() 和 wrmsrl() 辅助函数。

系统调用相关 MSR

MSR 地址 用途
MSR_STAR 0xC0000081 bits [47:32] = SYSCALL 目标 CS,bits [63:48] = SYSRET 目标 CS
MSR_LSTAR 0xC0000082 SYSCALL 入口点:entry_SYSCALL_64
MSR_CSTAR 0xC0000083 32 位兼容模式 SYSCALL 入口点
MSR_SYSCALL_MASK 0xC0000084 SYSCALL 执行时自动清除的 RFLAGS 位

MSR_SYSCALL_MASK 的典型值为 X86_EFLAGS_TF | X86_EFLAGS_DF | X86_EFLAGS_IF | X86_EFLAGS_IOPL,即进入内核时自动关闭中断、清除方向标志和陷阱标志。

扩展功能使能寄存器

MSR 地址 关键位
MSR_EFER 0xC0000080 SCE(位 0):SYSCALL/SYSRET 使能
LME(位 8):长模式使能
LMA(位 10):长模式激活(只读)
NXE(位 11):No-Execute 使能

内核启动时在 startup_64 中设置 EFER.LME 和 EFER.NXE,之后启用分页(CR0.PG=1)时,CPU 自动将 EFER.LMA 置 1。

GS/FS 基址 MSR

已在 1.1.4 节中介绍。SWAPGS 指令本质上就是交换 MSR_GS_BASE 和 MSR_KERNEL_GS_BASE 的值。


1.1.7 调试寄存器(Debug Registers)

x86_64 提供 8 个调试寄存器(DR0–DR7),其中 DR4 和 DR5 在 CR4.DE=1 时保留。

寄存器 用途
DR0–DR3 4 个断点线性地址(每个 64 位)
DR6 调试状态寄存器:记录触发调试异常的原因
DR7 调试控制寄存器:设置断点条件(读/写/执行、数据长度、使能)

内核中的 ptrace 子系统利用调试寄存器实现硬件断点:

// arch/x86/kernel/hw_breakpoint.c
int arch_install_hw_breakpoint(struct perf_event *bp);

DR7 的控制位布局:

┌─────────┬───────────┬──────────┬───────────┬──────────────┐
│  全局控制 │  DR3 控制  │  DR2 控制 │  DR1 控制  │   DR0 控制   │
│  [31:16] │  [31:30]  │ [23:22]  │ [15:14]   │   [7:6]      │
└─────────┴───────────┴──────────┴───────────┴──────────────┘
每个断点 2 位:00=执行, 01=写, 10=I/O(不常用), 11=读/写
每个断点 2 位长度:00=1字节, 01=2字节, 10=8字节, 11=4字节

1.1.8 SIMD 扩展寄存器

x86_64 支持多级 SIMD(Single Instruction Multiple Data)扩展寄存器:

┌──────────────────────────────────────────────────────────────┐
│                    ZMM (512 位, AVX-512)                      │
├──────────────────────┬───────────────────────────────────────┤
│    YMM 高 128 位     │          XMM (128 位, SSE)             │
│   (AVX 扩展)         │                                       │
└──────────────────────┴───────────────────────────────────────┘
寄存器组 位宽 数量 指令集
XMM0–XMM15 128 位 16 SSE/SSE2/SSE3/SSSE3/SSE4.x
YMM0–YMM15 256 位 16 AVX/AVX2
ZMM0–ZMM31 512 位 32 AVX-512

在 AVX-512 启用后,XMM 和 YMM 寄存器是 ZMM 寄存器的低 128 位和低 256 位别名。

XSAVE 区域与上下文切换

这些扩展寄存器状态通过 XSAVE / XRSTOR 指令进行批量保存和恢复。内核为每个任务维护一个 XSAVE 区域:

// 进程的 FPU 状态
struct fpu {
    unsigned int initialized;
    union {
        struct fpstate          *fpstate;
        struct fpstate __user   *fpstate_ptr;
    };
    ...
};

XSAVE 区域的布局由 CPU 攓取的 XSAVE header 决定,可能包含以下组件:

组件 位号 内容
x87 FPU 0 传统 FPU 状态
SSE 1 XMM0–XMM15
AVX 2 YMM 高 128 位
OPMASK 5 AVX-512 掩码寄存器 k0–k7
ZMM_Hi256 6 AVX-512 ZMM 高 256 位
Hi16_ZMM 7 AVX-512 ZMM16–ZMM31

内核在上下文切换时使用 switch_fpu_finish() / switch_fpu_prepare() 管理这些状态,并采用 lazy FPU restore(延迟恢复)策略:仅在实际使用 FPU/SIMD 指令时才触发 #NM 异常来恢复状态。


1.1.9 内核数据类型

基本大小与命名

大小(位) Intel 术语 C 类型(内核) 无符号 有符号
8 Byte char u8 s8
16 Word short u16 s16
32 DWord int u32 s32
64 QWord long long u64 s64

在 x86_64 上,unsigned long 为 64 位,内核大量使用 unsigned long 表示虚拟地址和物理地址。

地址类型

内核对不同类型的地址有专门的类型标注:

类型 定义 用途
unsigned long 纯整数 虚拟地址、物理地址(转换为整数时)
phys_addr_t u64 物理地址
void __iomem * 指针标注 I/O 映射内存地址(sparse 检查)
void __user * 指针标注 用户空间地址(sparse 检查)
void * 普通指针 内核空间虚拟地址

__user 和 __iomem 是通过 __attribute__((noderef, address_space(...))) 实现的稀疏(sparse)静态检查标注,在编译为普通内核时无实际效果,但通过 sparse 工具可以检测内核空间与用户空间指针的混用错误。

页表相关类型

typedef unsigned long   pteval_t;    // 页表项值
typedef unsigned long   pmdval_t;
typedef unsigned long   pudval_t;
typedef unsigned long   p4dval_t;
typedef unsigned long   pgdval_t;

在 x86_64 上这些类型均为 64 位无符号整数,与寄存器位宽完全匹配。



小结

x86_64 架构为 Linux 内核提供了丰富的寄存器资源。通用寄存器从 8 个扩展到 16 个,配合 System V AMD64 ABI 的调用约定,使函数参数传递更加高效。段寄存器中的 FS/GS 通过 MSR 和 SWAPGS 指令实现了高效的 per-CPU 数据和 TLS 支持。控制寄存器和 MSR 为内核提供了对分页、缓存、安全特性(SMEP/SMAP)的精确控制。SIMD 扩展寄存器通过 XSAVE 机制管理,内核在上下文切换时对这些状态进行保存与恢复。理解这些寄存器的用途和操作方式,是深入阅读 Linux 内核源码的基础。

1.2 CPU 运行模式:实模式、保护模式与长模式

x86_64 架构的处理器在演进过程中积累了三种主要的运行模式:实模式(Real Mode)、保护模式(Protected Mode)和长模式(Long Mode)。这三种模式分别对应 16 位、32 位和 64 位的计算能力,反映了从 Intel 8086 到现代 64 位处理器的历史发展脉络。Linux 内核在启动过程中会依次经历这三种模式,最终进入 64 位长模式运行。理解这些模式的工作机制,是深入分析 x86_64 架构下 Linux 内核启动流程和内存管理的基础。

1.2.1 实模式(Real Mode)

实模式是 x86 处理器在上电复位(power-on reset)后进入的默认运行模式。这一模式的存在是为了与最早的 Intel 8086 处理器保持向后兼容。

地址空间与寻址方式

实模式采用 20 位地址总线,因此可寻址的物理内存空间为 1MB(2^20 字节),地址范围为 0x00000 至 0xFFFFF。然而,实模式下的寄存器仅为 16 位宽,单个寄存器无法直接表示 20 位地址。为了解决这一问题,x86 采用了"段地址:偏移量"(segment:offset)的寻址方案。最终线性地址的计算公式为:

线性地址 = 段寄存器值 × 16 + 偏移量

例如,段地址 0x1000 加上偏移量 0x0020,对应的物理地址为 0x10020。这种机制使得 16 位寄存器的组合能够覆盖 20 位地址空间。不过,通过巧妙的段地址与偏移量组合(如 0xFFFF:0xFFFF),最大可寻址到 0x10FFEF,超出 1MB 边界约 64KB,这就是著名的"A20 地址线"问题。

基本特征

实模式几乎没有现代处理器所具备的保护机制:没有内存保护、没有特权级划分、没有分页(paging)支持。所有代码都以最高权限运行,任何程序都可以直接读写任意内存地址和硬件端口。中断处理通过位于物理地址 0 处的中断向量表(Interrupt Vector Table,IVT)实现,IVT 包含 256 个中断向量,每个向量占用 4 字节(2 字节段地址 + 2 字节偏移量),总计占用 1KB 空间。

实模式使用 16 位寄存器(AX、BX、CX、DX 等)和 16 位指令集,功能非常有限。

Linux 中的实模式代码

在 Linux 内核中,实模式启动代码位于 arch/x86/boot/ 目录下。传统的 BIOS 启动方式会将引导扇区加载到内存地址 0x7C00 处执行,随后进入实模式下的内核设置流程。这些代码负责进行基本的硬件探测、收集 BIOS 提供的系统信息、设置最基本的运行环境,然后尽快切换到保护模式。Linux 内核的设计哲学是:在实模式中停留的时间越短越好。实模式 1MB 的地址空间限制对于现代操作系统来说是不可接受的,因此内核会在完成必要的初始化后立即退出实模式。

1.2.2 保护模式(Protected Mode)

保护模式是 Intel 从 80286 开始引入的运行模式,在 80386 中得到进一步完善,提供了 32 位寻址能力和一系列硬件保护机制。

进入保护模式

从实模式切换到保护模式的关键步骤是设置控制寄存器 CR0 中的 PE(Protection Enable)位:

CR0.PE = 1    ; 设置 PE 位,启用保护模式

但在设置此位之前,软件必须先建立好全局描述符表(GDT)并使用 LGDT 指令加载其基地址和界限,否则处理器在切换模式后将无法正确执行后续指令。

32 位寄存器与寻址

进入保护模式后,寄存器从 16 位扩展为 32 位(EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP 等),指令指针也变为 32 位的 EIP。32 位寻址理论上提供了 4GB 的平坦地址空间(flat address space),但在使用分段机制时,实际可访问的地址范围由段描述符中的基地址(base)和界限(limit)决定。

分段机制与描述符表

保护模式下的分段机制通过全局描述符表(Global Descriptor Table,GDT)和局部描述符表(Local Descriptor Table,LDT)实现。每个段描述符占 8 字节,包含以下关键信息:

  • 基地址(Base):32 位,指定段在线性地址空间中的起始地址
  • 界限(Limit):20 位,指定段的大小(以字节或 4KB 为粒度)
  • 特权级(DPL):2 位,定义段的特权级别(0-3)
  • 类型(Type):定义段是代码段、数据段、TSS 还是门描述符
  • 存在位(P):标识段是否在物理内存中

Linux 内核在保护模式下使用平坦内存模型(flat model),即代码段和数据段的基地址都设为 0,界限都设为 4GB-1,从而让分段机制对软件透明,转而依靠分页机制来实现内存保护。

特权级机制

保护模式引入了 4 个特权级别(Ring 0 到 Ring 3),Ring 0 为最高特权级,Ring 3 为最低特权级。这一机制被称为"保护环"(protection ring)。然而,Linux 内核只使用了 Ring 0(内核态)和 Ring 3(用户态)两个级别,Ring 1 和 Ring 2 并未使用。这种简化设计也影响了其他操作系统(如 Windows),使得"内核态/用户态"的二分模型成为主流。

分页机制

在保护模式下,分页是可选功能。启用分页需要:

  1. 设置 CR0.PG = 1,启用分页
  2. CR3 寄存器指向页目录(Page Directory)的物理地址

启用分页后,32 位线性地址被分为三部分:页目录索引(10 位)、页表索引(10 位)和页内偏移(12 位),形成经典的两级页表结构,每个页的大小为 4KB。在启用了物理地址扩展(PAE,Physical Address Extension)的情况下,还可支持 36 位物理地址,最大寻址 64GB 物理内存。

中断描述符表

保护模式使用中断描述符表(Interrupt Descriptor Table,IDT)替代实模式的 IVT。IDT 通过 LIDT 指令加载,其中每个描述符称为"门描述符"(gate descriptor),包括中断门(Interrupt Gate)、陷阱门(Trap Gate)和任务门(Task Gate)。门描述符包含目标代码段选择子、段内偏移量、特权级等信息,处理器在响应中断时会自动进行权限检查和栈切换。

任务状态段

任务状态段(Task State Segment,TSS)是 x86 架构中用于硬件级任务切换的机制。TSS 中保存了任务的所有寄存器状态,处理器可通过硬件自动切换 TSS 来实现任务切换。然而,Linux 内核并未使用硬件任务切换,而是采用软件方式管理任务上下文,TSS 在 Linux 中的主要用途仅限于为内核态栈提供入口点(特别是处理权限级别变化时的栈切换)。

Linux 中的保护模式代码

在 Linux 启动过程中,保护模式是一个过渡阶段。相关的关键代码位于 arch/x86/boot/compressed/head_64.S 中的 startup_32 函数。这段代码在保护模式下设置好初步的页表(identity mapping 和内核映射),为进入 64 位长模式做准备。Linux 并不在保护模式下进行实质性的内核初始化工作,而是尽快过渡到长模式。

1.2.3 长模式(Long Mode)

长模式是 AMD 在 AMD64(即 x86-64)架构中引入、随后被 Intel 在 EM64T/Intel 64 中采用的 64 位运行模式。这是现代 64 位 Linux 内核的最终运行模式。

启用长模式的步骤

从保护模式进入长模式需要严格按照以下顺序执行:

  1. CR0.PE = 1:确认已在保护模式
  2. CR4.PAE = 1:启用物理地址扩展(PAE),这是长模式的前提条件
  3. 设置页表:建立四级页表结构,并将 CR3 指向 PML4(Page Map Level 4)表的基地址
  4. EFER.LME = 1:在扩展功能使能寄存器(EFER,通过 MSR 0xC0000080 访问)中设置 LME(Long Mode Enable)位
  5. CR0.PG = 1:启用分页——此步骤将触发从保护模式到长模式的实际切换

这一过程中,必须在启用分页之前先设置好页表,否则处理器会触发异常。值得注意的是,EFER.LME 的设置必须在 CR0.PG 置位之前完成,否则会触发 #GP(General Protection)异常。

64 位寄存器与扩展寄存器

长模式下,所有通用寄存器扩展为 64 位宽度:RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP,指令指针扩展为 64 位的 RIP。此外,还新增了 8 个通用寄存器 R8 至 R15,使通用寄存器总数达到 16 个。这些额外的寄存器显著减少了函数调用中寄存器保存/恢复的开销,也有助于编译器进行更高效的优化。

平坦内存模型与段寄存器

长模式在设计上强制采用平坦内存模型。CS、DS、ES、SS 段寄存器的基地址在硬件层面被固定视为 0,界限被忽略,这意味着传统分段机制在 64 位模式下实际上已被废弃。FS 和 GS 是仅有的两个保留可自定义基地址的段寄存器,其基地址通过 MSR(Model-Specific Register)进行设置:

  • IA32_FS_BASE(MSR 0xC0000100):设置 FS 段基地址
  • IA32_GS_BASE(MSR 0xC0000101):设置 GS 段基地址
  • IA32_KERNEL_GS_BASE(MSR 0xC0000102):用于 SWAPGS 指令交换 GS 基地址

Linux 内核利用 GS 段寄存器来指向当前处理器的 per-CPU 数据区,FS 段寄存器则用于线程局部存储(Thread Local Storage,TLS)。

虚拟地址空间布局

长模式支持 48 位虚拟地址空间,理论上可寻址 2^48 = 256 TB 的虚拟内存。然而,x86-64 架构要求虚拟地址必须满足"规范形式"(canonical form),即第 47 位至第 63 位必须全部相同(符号扩展)。这导致虚拟地址空间被分为两个不连续的区域:

  • 用户空间(低半部分):0x0000000000000000 至 0x00007FFFFFFFFFFF,大小为 128 TB
  • 内核空间(高半部分):0xFFFF800000000000 至 0xFFFFFFFFFFFFFFFF,大小为 128 TB

中间的"非规范地址区域"(0x0000800000000000 至 0xFFFF7FFFFFFFFFFF)不可使用,访问该区域会触发 #GP 异常。Linux 内核将内核空间与用户空间以约 2:1 或 1:1 的比例划分(具体取决于 PAGE_OFFSET 的配置),在默认配置下,用户空间占低地址 128 TB,内核空间占高地址 128 TB。

物理地址空间

长模式理论上支持 52 位物理地址,可寻址 4 PB(Peta Byte)的物理内存。但实际支持的物理地址位数取决于处理器的具体实现,通常为 46 位或 48 位,可通过 CPUID 指令查询。

RIP 相对寻址

长模式引入了 RIP 相对寻址(RIP-relative addressing)机制,允许指令通过相对于当前 RIP 的偏移量来引用数据。这一特性使得位置无关代码(Position-Independent Code,PIC)的生成更加高效,是内核地址空间布局随机化(KASLR)等技术的基础。

快速系统调用指令

长模式引入了 SYSCALL 和 SYSRET 指令,取代了传统的 INT 0x80 / IRET 中断方式来进行系统调用。SYSCALL/SYSRET 避免了中断处理流程中的大量开销(如中断向量查找、权限检查等),直接通过 MSR 中预设的入口地址进行切换,速度显著提升。相关 MSR 包括:

  • IA32_STAR(MSR 0xC0000081):存放 SYSCALL 目标代码段选择子和基地址
  • IA32_LSTAR(MSR 0xC0000082):存放 SYSCALL 的 64 位目标 RIP
  • IA32_CSTAR(MSR 0xC0000083):存放兼容模式下的 SYSCALL 目标 RIP
  • IA32_FMASK(MSR 0xC0000084):存放 SYSCALL 执行时需要清除的 RFLAGS 位

NX 位与页面保护

长模式的页表项中新增了 NX(No-Execute)位,也称为 XD(Execute Disable)位。该位允许操作系统将特定内存页面标记为不可执行,从而实现数据执行保护(Data Execution Prevention,DEP/W^X),有效防御缓冲区溢出等代码注入攻击。Linux 内核通过这个硬件特性实现了严格的内存权限控制。

四级页表结构

长模式默认使用四级页表结构进行虚拟地址到物理地址的翻译:

  1. PML4(Page Map Level 4):由虚拟地址的第 39-47 位索引,CR3 指向 PML4 表
  2. PDPTR(Page Directory Pointer Table)/ PDPT:由第 30-39 位索引
  3. PD(Page Directory):由第 21-30 位索引
  4. PT(Page Table):由第 12-20 位索引
  5. 页内偏移:由第 0-11 位表示

每级页表包含 512 个 8 字节的表项,每个标准页大小为 4KB。此外还支持 2MB 大页(huge page,在 PD 层直接映射)和 1GB 大页(在 PDPTR 层直接映射)。

五级页表(可选)

从较新的处理器开始,x86-64 架构支持可选的五级页表结构,通过设置 CR4.LA57 位启用。五级页表在 PML4 之上新增 PML5(Page Map Level 5)层级,将虚拟地址位数从 48 位扩展到 57 位,虚拟地址空间从 256 TB 扩展到 128 PB。Linux 内核从 4.14 版本开始支持五级页表,通过配置选项 CONFIG_X86_5LEVEL 进行选择。启用五级页表后,虚拟地址空间布局变为:

  • 用户空间:0x0000000000000000 至 0x00FFFFFFFFFFFFFF(低 56 位地址空间)
  • 内核空间:0xFF00000000000000 至 0xFFFFFFFFFFFFFFFF

1.2.4 Linux 启动中的模式转换

Linux 内核在启动过程中依次经历实模式、保护模式,最终进入长模式。整体模式转换流程如下:

实模式  ──(CR0.PE=1)──>  保护模式  ──(EFER.LME=1, CR0.PG=1)──>  长模式

具体来说:

  1. 实模式 → 保护模式:这一转换发生在 arch/x86/boot/pmjump.S 中。实模式下的启动代码(arch/x86/boot/ 目录下的各个源文件)完成硬件检测和信息收集后,设置好 GDT、IDT 等数据结构,然后设置 CR0.PE 位并执行远跳转指令,正式进入保护模式。

  2. 保护模式 → 长模式:这一转换发生在 arch/x86/boot/compressed/head_64.S 的 startup_32 函数中。在保护模式下,代码首先启用 PAE(CR4.PAE=1),设置好初步的四级页表(包括身份映射和内核映射),然后通过 wrmsr 指令设置 EFER.LME=1,最后通过设置 CR0.PG=1 启用分页,触发到长模式的切换。随后执行一条远跳转指令清除流水线中的旧指令,跳转到 64 位代码入口 startup_64。

这种逐步升级的设计确保了内核在每种模式下只停留最短的时间,只在必要的最低限度环境中完成准备工作,然后立即进入更高能力的模式。

1.2.5 兼容子模式(Compatibility Mode)

长模式还包含一个重要的子模式——兼容模式(Compatibility Mode),它允许 64 位操作系统无缝运行 32 位应用程序。

工作原理

在兼容模式下,处理器保持 64 位长模式的内存管理和分段机制(包括 64 位页表和 64 位 IDT),但使用 32 位的寄存器和 32 位寻址方式执行指令。这意味着 32 位应用程序看到的仍然是传统的 4GB 地址空间,但其虚拟地址到物理地址的翻译完全由 64 位页表完成,操作系统也以 64 位模式处理系统调用和中断。

模式判别

处理器通过 CS 段寄存器中的描述符属性来区分当前是完整 64 位模式还是兼容模式:

  • 完整 64 位模式:CS.L = 1,CS.D = 0。此时使用 64 位寄存器和 64 位寻址,默认操作数大小为 32 位(需通过 REX 前缀扩展为 64 位)。
  • 兼容模式:CS.L = 0,CS.D = 1。此时使用 32 位寄存器和 32 位寻址,行为类似于保护模式。

Linux 中的兼容模式支持

Linux 内核通过 arch/x86/entry/entry_64_compat.S 中的入口代码来处理 32 位系统调用。当 32 位应用程序执行 INT 0x80 或 SYSCALL(兼容模式版本)时,处理器通过兼容模式入口进入内核,内核在处理完毕后通过 SYSRET 或 IRET 返回到 32 位用户态。这一机制是 Linux 能在 64 位系统上运行 32 位 ELF 程序(即 ia32 兼容层)的硬件基础。相关代码涉及 arch/x86/ia32/ 目录下的多个源文件,包括系统调用表映射和信号处理等。

兼容模式的存在使得 x86-64 架构在提供 64 位计算能力的同时,保持了与大量 32 位软件的二进制兼容性,这是该架构能够迅速取代传统 x86 的关键因素之一。

1.3 分段与分页机制

x86_64 架构的内存管理经历了从分段(Segmentation)到分页(Paging)的漫长演进。在 64 位长模式(Long Mode)下,分段机制已基本废弃,分页成为虚拟内存管理的核心手段。本章将深入剖析这两套机制在 Linux 7.0 内核中的实际运用。


1.3.1 分段机制:从主角到配角

历史背景

8086 处理器仅有 16 位寄存器,直接寻址空间限制在 64KB。为了将寻址范围扩展到 1MB(20 位地址),Intel 引入了分段机制:段寄存器提供 16 位段基址,左移 4 位后与 16 位偏移量相加,得到 20 位线性地址。这是分段机制的原始动机——用 16 位寄存器访问 20 位地址空间。

进入 32 位保护模式后,段寄存器中存放的不再是段基址本身,而是段选择子(Segment Selector),用于索引全局描述符表(GDT)中的段描述符。每个描述符包含 32 位基地址(Base)、20 位段限长(Limit)以及特权级(DPL)等访问控制信息。CPU 在每次内存访问时都会进行段限长检查和特权级验证,分段成为内存保护的第一道防线。

64 位长模式下的分段

然而在 64 位长模式下,这一曾经举足轻重的机制被大幅削弱。CS、DS、ES、SS 四个段寄存器的基地址被硬件强制设为 0,段限长检查被忽略——分段机制实际上被"绕过"了。线性地址直接等于偏移量。这一设计的目的是简化地址翻译流程,与 RISC 架构的设计哲学靠拢。

唯一的例外是 FS 和 GS 段寄存器。这两个寄存器在 64 位模式下依然保留了可配置的 64 位基地址,通过模型特定寄存器(MSR)读写:

  • IA32_FS_BASE(MSR 0xC0000100):FS 段基址
  • IA32_GS_BASE(MSR 0xC0000101):GS 段基址
  • IA32_KERNEL_GS_BASE(MSR 0xC0000102):用于 SWAPGS 交换的影子 GS 基址

Linux 内核对 FS/GS 的使用

Linux 内核巧妙地利用了 FS/GS 这一特殊机制:

内核态 GS 基址指向当前 CPU 的 per-CPU 数据结构(struct pcpu_hot),其中存储了当前任务指针、栈指针、中断栈等频繁访问的热数据。用户态 GS 基址则指向线程局部存储(TLS)区域,供 glibc 等用户态库使用。

SWAPGS 指令是这一机制的关键:它原子性地交换 MSR_GS_BASE 和 MR_KERNEL_GS_BASE 的值。内核的进入与退出流程如下:

用户态 → 内核态(syscall / 中断):
    SWAPGS                    // 用户 GS 基址保存到 MSR_KERNEL_GS_BASE,
                              // 内核 per-CPU 基址加载到 MSR_GS_BASE

内核态 → 用户态(sysret / iret):
    SWAPGS                    // 内核 GS 基址保存回 MSR_KERNEL_GS_BASE,
                              // 用户 GS 基址恢复到 MSR_GS_BASE

通过这一对原子交换,内核无需额外的内存访问即可完成 per-CPU 数据区域的切换,极大地提升了系统调用和中断处理的效率。

GDT 结构

尽管分段机制已大幅弱化,x86_64 仍要求 GDT 存在。Linux 为每个 CPU 维护独立的 GDT,存放在 per-CPU 区域中。典型的 GDT 布局如下:

条目 名称 用途
0 NULL 空描述符(必须)
1 __KERNEL_CS 64 位内核代码段,DPL=0
2 __KERNEL_DS 64 位内核数据段,DPL=0
3 __USER32_CS 32 位兼容模式用户代码段,DPL=3
4 __USER_DS 用户数据段(32/64 位共用),DPL=3
5 — 未使用 / 系统保留
6 __USER_CS 64 位用户代码段,DPL=3

在 64 位模式下,这些代码段/数据段描述符的基地址和限长字段实际上不起作用,CPU 仅使用其中的代码段属性(如默认操作数大小、L 位指示 64 位模式)和特权级信息。每个 CPU 拥有独立 GDT 的设计,使得内核可以为不同 CPU 设置不同的 FS/GS 基址,实现 per-CPU 数据的隔离。


1.3.2 分页机制——内存管理的核心

分段在 64 位时代已经名存实亡,分页则是现代操作系统内存管理的绝对核心。分页机制将线性地址空间划分为固定大小的页(Page),通过多级页表将虚拟页映射到物理页帧。

四级页表(4-Level Paging)

x86_64 的默认分页模式是四级页表,由 CR3 寄存器出发,依次经过四级页表完成地址翻译:

CR3 ──→ PML4(Page Map Level 4)
            │
            ▼
        PDPTR(Page Directory Pointer Table)
            │
            ▼
         PD(Page Directory)
            │
            ▼
         PT(Page Table)
            │
            ▼
       物理页面(Physical Page, 4KB)

每一级页表包含 512 个条目(Entry),每个条目占 8 字节,因此一个完整的页表恰好占用一个 4KB 页面。

48 位规范虚拟地址(Canonical Address)的位域分解如下:

 63        48 47   39 38   30 29   21 20   12 11        0
┌───────────┬───────┬───────┬───────┬───────┬───────────┐
│ 符号扩展  │ PML4  │ PDPTR │  PD   │  PT   │ 页内偏移  │
│ (16 bit)  │ index │ index │ index │ index │ (12 bit)  │
└───────────┴───────┴───────┴───────┴───────┴───────────┘
  • [63:48]:符号扩展位。若第 47 位为 0,则 [63:48] 必须全为 0(用户空间);若第 47 位为 1,则 [63:48] 必须全为 1(内核空间)。不满足此规范的地址被称为"非规范地址",访问将触发 #GP 异常。
  • [47:39]:PML4 表索引(9 位,0-511)
  • [38:30]:PDPTR 表索引(9 位)
  • [29:21]:PD 表索引(9 位)
  • [20:12]:PT 表索引(9 位)
  • [11:0]:页内偏移(12 位,0-4095)

地址翻译的计算公式为:每级取出索引 i,从对应页表基地址读取第 i 个 8 字节条目,将其中的物理页号(位 [51:12])作为下一级页表的物理基地址,重复直至最后一级得到最终物理地址,再拼接页内偏移。

采用 4KB 小页时,四级页表支持的最大虚拟地址空间为:

512 × 512 × 512 × 512 × 4KB = 512^4 × 4KB = 256TB

即 128TB 用户空间 + 128TB 内核空间。

大页(Huge Pages)

四级页表的四级遍历虽然灵活,但对于大内存工作负载而言,页表遍历的开销和 TLB 压力都不容忽视。x86_64 支持两种大页模式:

2MB 大页:当 PD 条目中的 PS(Page Size)位为 1 时,该条目不再指向下一级页表,而是直接映射一个 2MB 的物理连续区域。此时虚拟地址的 [20:0] 共 21 位作为页内偏移:

 63    48 47  39 38  30 29  21 20                 0
┌───────┬─────┬─────┬─────┬───────────────────────┐
│符号扩展│PML4 │PDPTR│ PD  │     2MB 页内偏移       │
│       │     │     │     │      (21 bit)          │
└───────┴─────┴─────┴─────┴───────────────────────┘

1GB 大页:当 PDPTR 条目中的 PS 位为 1 时,直接映射 1GB 物理连续区域。虚拟地址 [29:0] 共 30 位作为页内偏移:

 63    48 47  39 38  30 29                         0
┌───────┬─────┬─────┬─────────────────────────────┐
│符号扩展│PML4 │PDPTR│        1GB 页内偏移          │
│       │     │     │         (30 bit)             │
└───────┴─────┴─────┴─────────────────────────────┘

Linux 内核广泛使用大页:内核正文映射(kernel text)通常使用 2MB 大页以减少 TLB 失效,vmalloc 区域和大型内存分配也倾向于使用大页来降低页表开销。用户态程序可通过 hugetlbfs 或 mmap(MAP_HUGETLB) 接口请求大页。

五级页表(5-Level Paging)

随着内存容量的持续增长(单台服务器配备数 TB 内存已不罕见),四级页表提供的 256TB 虚拟空间在某些场景下已显不足。Intel 引入了五级页表,通过在 PML4 之上新增一级 PML5(Page Map Level 5),将虚拟地址宽度扩展至 57 位。

CR3 ──→ PML5(Page Map Level 5)
            │
            ▼
         PML4
            │
            ▼
        PDPTR
            │
            ▼
          PD
            │
            ▼
          PT
            │
            ▼
       物理页面(4KB)

57 位虚拟地址的位域分解:

 63   57 56   48 47  39 38  30 29  21 20  12 11       0
┌──────┬──────┬─────┬─────┬─────┬─────┬─────┬────────┐
│符号  │ PML5 │PML4 │PDPTR│ PD  │ PT  │     │页内    │
│扩展  │index │index│index│index│index│     │偏移    │
└──────┴──────┴─────┴─────┴─────┴─────┴─────┴────────┘
  • PML5 索引占据位 [56:48](9 位)
  • 虚拟空间扩展至 128PB(Petabytes)

五级页表由 CR4.LA57 位启用。Linux 7.0 通过编译选项 CONFIG_X86_5LEVEL=y 支持五级页表,并在启动阶段根据硬件能力动态决定是否启用。相关初始化代码位于 arch/x86/boot/compressed/head_64.S(早期引导阶段的页表建立)和 arch/x86/kernel/head64.c(内核启动后的页表切换)中。内核通过 pgtable_l5_enabled 变量在运行时标记当前是否启用了五级页表,确保代码能同时兼容四级和五级模式。


1.3.3 页表条目(PTE)格式

每一级页表的条目都是 64 位宽,其位域定义如下:

 63  58 57  52 51      12 11  9 8  7  6  5  4  3  2  1  0
┌─────┬─────┬───────────┬────┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ 可用│ 可用│ 物理页号  │可用│G │PS│D │A │PCD│PWT│U/S│R/W│P │
│(OS) │(OS) │ (40 bit)  │(OS)│  │  │  │  │   │   │   │   │  │
└─────┴─────┴───────────┴────┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

各标志位的含义:

位 名称 含义
0 P(Present) 页是否存在。P=0 时访问触发 #PF,此时其余位可由 OS 自由使用(如存放 swap 位置信息)。
1 R/W(Read/Write) R/W=0 为只读页,R/W=1 为可读写页。与 U/S 位配合实现写保护。
2 U/S(User/Supervisor) U/S=0 为特权页(Ring 0-2),U/S=1 为用户页(Ring 3 可访问)。
3 PWT(Page Write Through) 控制该页的缓存写策略:直写(Write-Through)或回写(Write-Back)。
4 PCD(Page Cache Disable) PCD=1 时禁用该页的缓存。用于映射 I/O 设备寄存器等需要强一致性的区域。
5 A(Accessed) CPU 在首次读取该页时自动置位。内核可利用此位实现近似 LRU 页面替换算法。
6 D(Dirty) CPU 在首次写入该页时自动置位。内核据此判断页面是否需要写回磁盘。
7 PS/PAT 在非末级页表中为 PS(Page Size)位,PS=1 表示大页映射;在末级页表中为 PAT 位,用于选择页属性表中的内存类型。
8 G(Global) G=1 标记该页为全局页,CR3 切换时不刷新对应的 TLB 条目(需 CR4.PGE=1)。内核映射通常设置此位。
9-11 Available 供操作系统自由使用。Linux 用这些位存储 _PAGE_SOFT_DIRTY、_PAGE_FILE 等软件标志。
12-51 Physical Address 40 位物理页帧号(Physical Page Number),可寻址 2^52 字节 = 4PB 物理内存。
52-58 Available 供 OS 使用的扩展可用位。
63 NX(No-Execute) NX=1 时禁止从该页取指令执行。这是数据执行防护(DEP/W^X)的核心硬件支撑。

其中 P、R/W、U/S、NX 四个标志位构成了页级内存保护的基础:内核通过组合这些位实现只读数据页、不可执行代码、用户不可见的内核页等安全隔离。


1.3.4 CR3 寄存器

CR3 是页表体系的入口寄存器,存放顶层页表(PML4 或 PML5)的物理地址:

 63                52 51        12 11  10  9  8  7  6  5  4  3  2  1  0
┌───────────────────┬─────────────┬──────────────────────┬──┬──┬──┬──┐
│    保留 / MBZ     │  物理页号   │      保留            │PCD│PWT│  PCID  │
│                   │ (40 bit)    │                      │   │   │(0-11) │
└───────────────────┴─────────────┴──────────────────────┴──┴──┴──┴──┘
  • 位 [51:12]:顶层页表的物理页帧号。由于页表本身必须页对齐,低 12 位不需要存储。
  • 位 [11:0]:PCID(Process-Context Identifier),当 CR4.PCIDE=1 时有效。12 位 PCID 可区分 4096 个不同进程的 TLB 条目,避免每次进程切换时刷新全部 TLB。
  • 位 [4]:PCD,位 [3]**:PWT,控制顶层页表本身的缓存属性。

Linux 对 PCID 的使用:Linux 启动时检测 CPU 是否支持 PCID(通过 cpuid 指令),若支持则在 CR4 中启用 PCIDE 位。此后每个进程的 mm_struct 中保存一个 PCID 值,进程切换时将 PCID 写入 CR3 的低 12 位。这样即使不同进程切换,TLB 中属于前一进程的条目也不会被立即丢弃——它们被标记了不同的 PCID,CPU 只会匹配当前 PCID 的条目。这一优化显著减少了 TLB 抖动(Thrashing)带来的性能损失。


1.3.5 TLB(Translation Lookaside Buffer)

四级甚至五级页表的遍历需要 4-5 次内存访问,如果每次地址翻译都走完整路径,性能将不可接受。TLB 是 CPU 内部缓存最近使用的页表翻译结果的硬件结构,将虚拟页号到物理页号的映射缓存起来,使得绝大多数地址翻译可以在单个时钟周期内完成。

TLB 的一致性维护是操作系统与硬件协同的关键:

操作 效果
INVLPG addr 使指定地址对应的单个 TLB 条目失效
CR3 重写(mov cr3) 刷新所有非全局(G=0)TLB 条目
全局页(CR4.PGE=1 且 G=1) CR3 切换时不刷新,内核映射受益于此
PCID 标记 TLB 条目带有 PCID 标签,仅当 PCID 匹配时才命中

Linux 内核在修改页表(如缺页处理分配新页、mprotect 修改权限)后,必须主动调用 flush_tlb_page()、flush_tlb_range() 等函数使对应的 TLB 条目失效,确保后续访问使用更新后的页表。在 SMP 系统中,还需要通过 IPI(处理器间中断)通知其他 CPU 刷新其 TLB 中的过期条目,这是 flush_tlb_others() 的职责。


1.3.6 Linux 内核虚拟内存布局(x86_64)

在四级页表模式下,Linux 7.0 的 x86_64 内核虚拟地址空间布局如下。所有内核地址的高 16 位全为 1(符号扩展),与用户空间的高 16 位全为 0 形成鲜明分界:

虚拟地址范围                                                    用途
─────────────────────────────────────────────────────────────────────────
0x0000000000000000 - 0x00007FFFFFFFFFFF (128TB)                用户空间
─────────────────────────────────────────────────────────────────────────
0xFFFF800000000000 - 0xFFFF87FFFFFFFFFF (128TB)                直接映射(物理内存)
0xFFFF888000000000 - 0xFFFF8FFFFFFFFFFF (128TB)                直接映射(物理内存,主区域)
0xFFFF900000000000 - 0xFFFF97FFFFFFFFFF (128TB)                vmalloc / ioremap
0xFFFF980000000000 - 0xFFFF9FFFFFFFFFFF (128TB)                vmalloc(备用区域)
0xFFFFA000000000000 - 0xFFFFEAFFFFFFFFFF (~40TB)               vmemmap(struct page 数组)
0xFFFFEB0000000000 - 0xFFFFFE7FFFFFFFFF                        未使用
0xFFFFFE8000000000 - 0xFFFFFEFFFFFFFFFF                        CPU entry area(入口区域)
0xFFFFFF0000000000 - 0xFFFFFFFF7FFFFFFF                        %esp fixup 栈
0xFFFFFFFF80000000 - 0xFFFFFFFFAFFFFFFF (512MB)                内核正文(__START_KERNEL_map)
0xFFFFFFFFB0000000 - 0xFFFFFFFFBFFFFFFF (256MB)                内核数据
0xFFFFFFFFC0000000 - 0xFFFFFFFFFFFFFFFF (1GB)                  Fixmap / early ioremap
─────────────────────────────────────────────────────────────────────────

各区域的详细说明:

用户空间(0x0000000000000000 - 0x00007FFFFFFFFFFF):低 128TB 完全归用户进程使用,包括代码段、数据段、堆、共享库映射区和栈。每个进程拥有独立的用户空间映射。

直接映射区域(0xFFFF888000000000 起):这是内核访问物理内存的窗口。物理页帧号 pfn 对应的虚拟地址为 0xFFFF888000000000 + pfn * PAGE_SIZE。内核通过 __va() 宏将物理地址转换为该区域内的虚拟地址,通过 __pa() 宏执行反向转换。这个区域使得内核能够以简单的线性偏移访问任意物理内存页。

vmalloc 区域(0xFFFF900000000000 起):用于非连续物理页面的虚拟连续映射。vmalloc() 在此区域分配虚拟连续但物理不连续的内存,适用于大块内存的动态分配。ioremap() 也在此区域将设备 MMIO 空间映射到内核虚拟地址。

vmemmap 区域(0xFFFFA00000000000 起):每个物理页帧对应一个 struct page 结构体,所有这些结构体在此区域连续排列。物理页帧 pfn 对应的 struct page 地址为 vmemmap + pfn。这一设计使得从物理地址到 struct page 的转换只需一次加法。

CPU entry area(0xFFFFFE80000000000 起):每个 CPU 的入口区域包含异常入口栈、SYSCALL 入口栈和 trampoline 页。这些区域在 Meltdown 漏洞(KPTI)缓解措施中扮演重要角色。

内核正文(0xFFFFFFFF80000000 起):即 __START_KERNEL_map 区域,存放内核的代码段。此区域通过独立的页表映射,不参与直接映射区域的线性偏移关系。内核编译时的虚拟起始地址 _text 即位于此处。此区域通常使用 2MB 大页映射以优化 TLB 利用率。

Fixmap(0xFFFFFFFFC0000000 起):固定映射区域,用于早期启动阶段(直接映射区域尚未建立时)的永久或临时映射。early_ioremap() 在此区域提供启动阶段的 I/O 映射能力。


小结

分段与分页是 x86 架构内存管理的两大支柱。在 64 位时代,分段已退居幕后,仅 FS/GS 基址管理这一残存功能仍在内核中发挥关键作用。分页则成为虚拟内存管理的核心机制:四级(或五级)页表实现了灵活高效的虚拟-物理地址翻译,PTE 中的标志位提供了细粒度的访问控制,TLB 保证了翻译性能,而 CR3 与 PCID 的配合优化了进程切换的开销。理解这些硬件机制,是深入理解 Linux 内核内存管理子系统——从伙伴分配器到缺页处理,从 slab 分配器到 KSM——的必要基础。

1.4 中断与异常

中断和异常是 CPU 与操作系统协作的核心机制。它们使处理器能够响应外部硬件事件、处理指令执行中的错误,并实现系统调用等关键功能。在 x86_64 架构下,Linux 7.0 内核对中断和异常的管理涉及硬件电路、CPU 微架构、入口代码、C 处理函数等多个层面。本节将逐一剖析这些内容。

1.4.1 中断与异常的基本概念

x86_64 架构将"中断"(interrupt)和"异常"(exception)统称为"中断事件",但二者在来源和语义上有本质区别。

中断是异步事件,由外部硬件设备在任意时刻发出。典型来源包括键盘输入、磁盘 I/O 完成通知、定时器 tick 等。中断的发生与 CPU 当前正在执行的指令没有因果关系。x86_64 将中断分为两类:

  • 可屏蔽中断(Maskable Interrupt):通过 CPU 的 INTR 引脚传递。软件可以通过 CLI 指令将 EFLAGS.IF 清零来禁止响应可屏蔽中断,通过 STI 指令恢复。这意味着操作系统可以在执行临界区代码时暂时屏蔽中断,保证原子性。
  • 不可屏蔽中断(Non-Maskable Interrupt, NMI):通过 CPU 的 NMI 引脚传递,无法通过软件手段屏蔽。NMI 通常用于报告严重的硬件故障,如内存校验错误、总线错误等。

异常是同步事件,由 CPU 在执行指令的过程中自行产生。异常的发生总是可以追溯到一条具体的指令。根据恢复可能性和返回地址的不同,异常被分为三类:

类型 英文名 特征 返回地址 典型示例
故障 Fault 可恢复 指向引发异常的指令 #PF(缺页异常)、#GP(一般保护异常)
陷阱 Trap 有意为之 指向下一条指令 #BP(断点 INT3)、#OF(溢出)
终止 Abort 不可恢复 不可预测 #DF(双重故障)、#MC(机器检查)

故障(Fault)是最常见的异常类型。以缺页异常为例,当 CPU 访问一个尚未建立页表映射的虚拟地址时,会触发 #PF,此时保存的 RIP 指向那条引发异常的指令。操作系统在页故障处理程序中完成映射后,通过 IRET 指令返回,CPU 会重新执行那条指令,此时映射已就绪,指令得以正常完成。

陷阱(Trap)的典型用例是断点指令 INT3(操作码 0xCC)。调试器在代码中插入 INT3 以暂停程序执行,保存的 RIP 指向 INT3 之后的那条指令,因此恢复执行时不会再次触发断点。

终止(Abort)表示发生了严重的不可恢复错误。双重故障(#DF)意味着在处理一个异常的过程中又发生了另一个异常,这通常表明内核栈或 IDT 表已被破坏,内核只能触发 panic。

1.4.2 中断描述符表(IDT)

x86_64 架构通过中断描述符表(Interrupt Descriptor Table, IDT)将中断向量号映射到对应的处理程序。IDT 最多包含 256 个表项(向量 0-255),每个表项是一个门描述符(Gate Descriptor),在 64 位模式下占用 16 字节。

IDT 的基地址和界限存储在 IDTR(Interrupt Descriptor Table Register)系统寄存器中。IDTR 包含两个字段:16 位的 limit(表大小减一)和 64 位的 base(IDT 的线性地址)。操作系统通过 LIDT 指令加载 IDTR,通过 SIDT 指令读取 IDTR 的内容。

向量号分配如下表所示:

向量号范围 用途 说明
0-19 CPU 固定异常 #DE(0), #DB(1), #NMI(2), #BP(3), #OF(4), #BR(5), #UD(6), #NM(7), #DF(8), #TS(10), #NP(11), #SS(12), #GP(13), #PF(14), #MF(16), #AC(17), #MC(18), #XM(19) 等
20-31 Intel 保留 未分配,由架构保留
32-255 用户自定义 外部硬件中断(IRQ 从 32 开始以避免与 CPU 异常冲突)

Linux 在初始化阶段通过 idt_setup_early_handler() 和 idt_setup_from_tables() 等函数逐步构建完整的 IDT。

1.4.3 门描述符类型

x86_64 的 IDT 表项支持以下几种门描述符类型:

中断门(Interrupt Gate, type=14):当 CPU 通过中断门进入处理程序时,会自动清除 EFLAGS.IF,即禁止后续的可屏蔽中断。这保证了中断处理程序在入口处不会被打断。Linux 中的大多数硬件中断处理程序使用中断门。

陷阱门(Trap Gate, type=15):CPU 进入处理程序时不会修改 IF 标志,即中断保持原来的开关状态。Linux 的异常处理程序(如 #PF、#GP)通常使用陷阱门,因为异常处理过程中可能需要响应中断。

任务门(Task Gate, type=12):用于硬件任务切换。Linux 不使用硬件任务切换机制,因此任务门在 Linux 中没有实际用途,仅在理论层面存在。

64 位模式下的门描述符包含以下关键字段:

  • Offset(偏移量):分为两部分存储,共 64 位,组成处理程序的线性地址
  • Segment Selector(段选择子):处理程序所在代码段的段选择子,Linux 中为内核代码段 __KERNEL_CS
  • IST(Interrupt Stack Table):3 位字段,值为 0 表示不使用 IST,值为 1-7 表示使用对应的 IST 栈
  • DPL(Descriptor Privilege Level):2 位字段,限制哪些特权级可以通过软件中断(INT n)触发此门

1.4.4 硬件中断交付机制

8259A PIC(传统可编程中断控制器)

在早期的 PC 架构中,两片 8259A PIC 级联提供 15 条 IRQ 线。主片处理 IRQ 0-7,从片处理 IRQ 8-15,从片级联到主片的 IRQ 2 上。8259A 的向量基址在初始化时设定:主片从向量 32 开始(IRQ 0 → 向量 32),从片从向量 40 开始(IRQ 8 → 向量 40)。8259A 存在严重的限制:仅支持单 CPU、IRQ 数量有限、优先级固定、不支持中断共享(SMP 场景下)。现代 x86_64 系统中 8259A 仅在极早期启动阶段使用,随后即切换到 APIC。

I/O APIC

I/O APIC 是现代系统的标准中断控制器,提供 24 个或更多的 IRQ 输入引脚。与 8259A 相比,I/O APIC 的优势包括:支持多 CPU 中断路由(可以将中断定向到特定 CPU)、支持消息信号中断(MSI)、每个引脚可以独立配置触发模式(边沿/电平)和极性。I/O APIC 通过中断重定向表(Redirection Table)将每个 IRQ 映射到目标 CPU 和向量号。

Local APIC

每个 CPU 核心都有一个本地 APIC(Local APIC,简称 LAPIC),负责接收来自 I/O APIC 的中断消息、处理本地中断(如定时器、温度传感器、性能计数器),以及发送和接收处理器间中断(IPI, Inter-Processor Interrupt)。Linux 利用 IPI 实现 TLB 刷新(smp_call_function)、停机控制(REBOOT IPI)、调度器负载均衡等操作。

中断重映射(VT-d)

在虚拟化场景中,Intel VT-d 技术(Intel Virtualization Technology for Directed I/O)提供中断重映射功能。它允许 Hypervisor 将设备发出的中断重定向到正确的虚拟机,同时防止恶意设备发起中断攻击。中断重映射通过 IOMMU 中的中断重映射表实现,每个中断请求都经过翻译和权限检查。

IRQ 到向量的映射

在 Linux 7.0 中,IRQ 到向量的映射策略如下:

  • 传统 ISA 设备:IRQ 0 映射到向量 32,IRQ 1 映射到向量 33,依此类推,直到 IRQ 15 映射到向量 47。这种固定映射仅在早期启动或特定遗留设备上使用。
  • I/O APIC 设备:Linux 为每个 CPU 分配独立的向量空间。向量分配在 arch/x86/kernel/apic/vector.c 中管理,采用 per-CPU 位图分配策略。可用向量范围从 FIRST_EXTERNAL_VECTOR(0x20,即 32)到 FIRST_SYSTEM_VECTOR(约 0xEF),系统向量从顶部向下分配,设备向量从底部向上分配。

1.4.5 Linux 中断入口路径

当中断或异常发生时,CPU 硬件自动完成以下操作:

  1. 根据向量号索引 IDT 表,找到对应的门描述符
  2. 检查特权级变化(如果从用户态进入内核态,即 CPL 从 3 变为 0)
  3. 如果特权级发生变化,CPU 切换到内核栈(从 TSS 中获取 RSP0),并将用户态的 SS 和 RSP 压入新栈
  4. 将 RFLAGS、CS、RIP 压栈;如果是异常且有错误码,将错误码压栈
  5. 如果是中断门,清除 IF 标志
  6. 从门描述符中加载新的 CS 和 RIP,跳转到处理程序

Linux 的中断入口代码位于 arch/x86/entry/ 目录下。以 64 位模式为例,入口流程如下:

硬件中断 → IDT 表项 → entry_64.S 中的汇编入口
    → PUSH_AND_CLEAR_REGS(保存所有通用寄存器)
    → 如果从用户态进入:切换到内核栈(SWITCH_TO_KERNEL_CR3)
    → 调用 C 语言处理函数(如 do_page_fault、do_general_protection)
    → 中断返回路径(interrupt_return)
    → POP_REGS + IRET 恢复现场

PUSH_AND_CLEAR_REGS 宏将所有 15 个通用寄存器(R15 到 RDI)压入栈中,并将它们清零以防止内核信息泄露。随后通过 SWITCH_TO_KERNEL_CR3 宏完成页表切换(KPTI,内核页表隔离),防止 Meltdown 类侧信道攻击。

异常的入口代码稍有不同。每个异常有独立的入口桩(stub),部分异常需要压入伪造的错误码(CPU 不自动生成错误码的异常需要在栈上压入一个 -1 作为占位符,以保持栈帧格式统一)。入口代码最终调用 idtentry 宏定义的处理函数。

1.4.6 Linux 使用的关键 x86_64 异常

下表列出了 Linux 7.0 内核实际使用的 CPU 异常及其用途:

向量 助记符 名称 错误码 Linux 中的用途
0 #DE 除法错误 无 整数除零或除法溢出,向进程发送 SIGFPE
1 #DB 调试异常 无 硬件断点、观察点,用于 kgdb、kprobes、perf
2 #NMI 不可屏蔽中断 无 硬件故障报告、性能分析(perf NMI)、看门狗
3 #BP 断点 无 INT3 指令触发,用于 kgdb 断点、kprobes
4 #OF 溢出 无 INTO 指令且 OF=1 时触发,实际很少使用
5 #BR BOUND 范围越界 无 BOUND 指令越界,主要用于 32 位兼容模式
6 #UD 无效操作码 无 执行未定义指令,用于 CPU 特性模拟(如缺省的 SIMD 指令通过模拟执行)
7 #NM 设备不可用 无 历史上用于 FPU 懒惰上下文切换(CR0.TS 标志),现代内核中已不常用
8 #DF 双重故障 有(始终为 0) 灾难性错误,内核触发 panic
10 #TS 无效 TSS 有 任务状态段无效,通常表示内核数据结构损坏
11 #NP 段不存在 有 段描述符的 Present 位为 0,Linux 中极少触发
12 #SS 栈段故障 有 栈段相关错误,如 SS 选择子无效
13 #GP 一般保护 有 段级保护违规或非法内存访问,向进程发送 SIGSEGV
14 #PF 缺页异常 有 按需调页、写时复制(COW)、权限违例的核心机制
16 #MF x87 FPU 错误 无 x87 浮点运算异常
17 #AC 对齐检查 有 启用 CONFIG_ALIGNMENT_TRAP 后检测非对齐访问(CPL=3 时)
18 #MC 机器检查 无 硬件错误检测,由 MCE(Machine Check Exception)子系统处理
19 #XM SIMD 浮点异常 无 SSE/AVX 浮点运算异常
20 #VE 虚拟化异常 无 Intel TDX(Trust Domain Extensions)中使用
21 #CP 控制保护异常 有 Intel CET(Control-flow Enforcement Technology)影子栈违例

缺页异常(#PF)详解

缺页异常是 Linux 内存管理子系统最核心的异常。当 CPU 访问的虚拟地址没有对应的页表映射或违反了页表权限时,触发 #PF。CPU 硬件提供以下关键信息:

  • CR2 寄存器:保存引发缺页的线性地址(即程序试图访问的虚拟地址)
  • 错误码:一个 5 位(或更宽)的字段,编码了缺页的具体原因:
位 名称 含义
0 P(Present) 0 = 页不存在,1 = 页存在但权限不足
1 W(Write) 0 = 读访问,1 = 写访问
2 U(User) 0 = 内核态访问,1 = 用户态访问
3 RSVD(Reserved) 1 = 保留了保留位(通常表示页表损坏)
4 I(Instruction) 1 = 取指时触发(执行权限违例)

Linux 的 do_page_fault() 函数接收这些信息后,区分以下情况:内核态缺页(可能是由 fixup 表修复的异常访问,也可能来自 copy_from_user 等与用户空间交互的函数)、用户态缺页(按需调页、COW、栈扩展或真正的段违例)。最终处理结果要么成功建立映射并返回,要么向用户进程发送 SIGSEGV 或 SIGBUS 信号。

1.4.7 IST(中断栈表)

在 x86_64 的 TSS(Task State Segment)中,除了传统的 RSP0-RSP2 三个栈指针外,还有 7 个 IST(Interrupt Stack Table)指针(IST1-IST7)。IST 机制允许特定的中断或异常在独立的、已知安全的栈上执行,而不使用当前栈。

IST 的使用场景是那些可能在栈已经损坏或栈状态不可信时触发的异常:

IST 编号 用途 对应异常 说明
IST 0(IST1) 双重故障 #DF 双重故障意味着当前栈可能已损坏,必须切换到已知安全栈
IST 1(IST2) NMI #NMI NMI 可以在任何时刻中断内核,包括内核栈切换过程中,必须使用独立栈
IST 2(IST3) 机器检查 #MC 硬件错误可能导致栈不可信
IST 3(IST4) 调试异常 #DB 早期启动阶段的调试需要独立栈,防止递归栈使用
IST 4(IST5) #VC(SEV-ES) #VC AMD SEV-ES 中的 #VC 异常需要独立的栈来处理加密状态下的退出

IST 栈在系统启动时通过 cpu_init() 函数分配和初始化,每个 IST 栈通常为 4KB(一页)或 8KB。IST 的选择由 IDT 表项中的 IST 字段决定,CPU 硬件在中断入口时自动从 TSS 中加载对应的 RSP。

需要注意的是,IST 栈空间有限,且不能嵌套使用(同一个 IST 不能同时被两层使用),因此 IST 处理程序必须尽可能精简,快速处理并切换回普通栈。

1.4.8 软件中断与系统调用

软件中断是指通过指令主动触发的中断。在 x86_64 架构中,Linux 使用以下几种机制:

INT 0x80(传统系统调用)

在 32 位 Linux 中,用户程序通过 INT 0x80 指令触发系统调用。系统调用号通过 EAX 寄存器传递,参数通过 EBX、ECX、EDX、ESI、EDI、EBP 寄存器传递。该机制在 64 位系统中仍然可用(通过兼容模式),但性能较差,因为 INT 指令需要完整的中断入口和出口开销。

SYSCALL/SYSRET(64 位快速系统调用)

SYSCALL/SYSRET 是 64 位 Linux 的主要系统调用机制,由 AMD 首先引入,Intel 也予以支持。与 INT 0x80 相比,SYSCALL 的优势在于:

  • 极低的入口开销:SYSCALL 不经过 IDT 查找,而是直接从 MSR(Model Specific Register)中加载目标 CS、RIP 和 RFLAGS 掩码
  • 专用寄存器:IA32_LSTAR MSR 存放系统调用入口地址(Linux 中为 entry_SYSCALL_64),IA32_STAR MSR 存放 CS/SS 选择子
  • 参数传递:系统调用号通过 RAX 传递,参数通过 RDI、RSI、RDX、R10、R8、R9 传递(遵循 System V AMD64 ABI,注意第 4 个参数使用 R10 而非 RCX,因为 RCX 被 SYSCALL 指令用于保存返回地址)

Linux 7.0 的 entry_SYSCALL_64 入口代码执行以下操作:将 RFLAGS 保存到 R11、将返回地址保存到 RCX、切换到内核栈、保存用户态寄存器、调用对应的系统调用处理函数、通过 SYSRET 返回用户态。

SYSENTER/SYSEXIT(32 位快速系统调用)

SYSENTER/SYSEXIT 是 Intel 引入的 32 位快速系统调用机制,使用 IA32_SYSENTER_CS、IA32_SYSENTER_EIP、IA32_SYSENTER_ESP 三个 MSR。Linux 在 32 位兼容模式下仍支持此机制,入口为 entry_SYSENTER_compat。

三种系统调用机制的对比如下:

特性 INT 0x80 SYSENTER/SYSEXIT SYSCALL/SYSRET
架构 所有 x86 Intel 32/64 位 AMD64 / Intel 64
入口方式 IDT 查找 MSR 直接跳转 MSR 直接跳转
栈切换 硬件自动 硬件自动 软件手动
寄存器保存 硬件保存部分 硬件保存部分 软件保存全部
性能 最慢 较快 最快
Linux 入口 entry_INT80_compat entry_SYSENTER_compat entry_SYSCALL_64

在现代 Linux 7.0 内核中,64 位原生程序统一使用 SYSCALL/SYSRET,32 位兼容程序使用 SYSENTER/SYSEXIT 或 INT 0x80(后者仅作为兼容后备)。

小结

中断和异常是操作系统与硬件交互的基础设施。中断是异步的外部事件,异常是同步的指令执行结果。x86_64 通过 IDT 将中断向量号映射到处理程序,通过 IDTR 寄存器定位 IDT。现代系统使用 APIC 架构替代传统 8259A PIC,支持多核中断路由和 MSI。Linux 的中断入口代码在汇编层面完成寄存器保存、栈切换和页表切换,然后调用 C 语言处理函数。IST 机制为关键异常提供独立栈以保证可靠性。在系统调用方面,SYSCALL/SYSRET 是 64 位系统的首选机制,提供最低的切换开销。

1.5 特权级与保护机制

x86_64 架构提供了完善的多级特权保护体系,Linux 内核利用这一硬件基础构建了内核态与用户态之间严格的隔离屏障。本节将深入剖析保护环(Protection Rings)、特权检查规则、用户态与内核态之间的切换机制、页表级保护、I/O 保护,以及 Linux 在 x86_64 上采用的各类安全特性。

1.5.1 保护环模型

x86_64 架构定义了四个特权级别(Protection Ring),从 Ring 0 到 Ring 3,权限依次递减:

  • Ring 0(最高特权级):内核模式。运行在此级别的代码拥有对全部硬件资源的访问权限,包括直接操作 I/O 端口、修改控制寄存器(CR0~CR4)、访问所有内存区域等。Linux 内核代码运行在 Ring 0。
  • Ring 1 和 Ring 2:中间特权级。Linux 并未使用这两个级别,所有非内核代码均运行在 Ring 3。某些虚拟机监视器(Hypervisor)会利用 Ring 1 来实现客户操作系统的隔离,但 Linux 内核自身不涉及。
  • Ring 3(最低特权级):用户模式。运行在此级别的代码受到严格限制:无法直接访问 I/O 端口、无法执行特权指令、只能访问被标记为用户可访问的内存页面。所有用户空间应用程序均运行在 Ring 3。

在 CPU 硬件层面,特权信息通过以下三个关键字段来编码:

  • CPL(Current Privilege Level,当前特权级):存储在 CS(代码段)寄存器的第 0 位和第 1 位。CPL 反映了当前正在执行的代码的特权级别。当 CPU 运行内核代码时 CPL=0,运行用户态代码时 CPL=3。
  • RPL(Requestor Privilege Level,请求者特权级):存储在段选择子(Segment Selector)的第 0 位和第 1 位。RPL 用于在段选择子被加载到段寄存器时标识请求者的特权级别,起到额外的权限约束作用。
  • DPL(Descriptor Privilege Level,描述符特权级):存储在段描述符或门描述符中。DPL 定义了访问该段或通过该门所需的最低特权级别。

1.5.2 特权检查规则

CPU 在执行各类操作时,会依据 CPL、RPL 和 DPL 执行严格的权限检查。不同的操作类型对应不同的检查规则:

数据段访问:当试图通过段选择子访问数据段时,CPU 要求 max(CPL, RPL) <= DPL。也就是说,CPL 和 RPL 中较低的特权级别(数值较大者)必须不超过目标段的 DPL。这一机制确保低特权级代码无法直接访问高特权级的数据段。

代码段执行:当通过远跳转或远调用进入新的代码段时,通常要求 CPL == DPL,即当前特权级必须与目标代码段的特权级相等。x86 架构还定义了一类"一致代码段"(Conforming Segment),允许低特权级代码调用高特权级的一致代码,但特权级不会发生切换(CPL 保持不变)。Linux 内核不使用一致代码段。

栈段检查:栈段(SS)的 DPL 必须始终等于 CPL。每次特权级切换时,CPU 会自动加载与新 CPL 匹配的 SS 段选择子,确保栈段的特权级始终与当前执行级别一致。

1.5.3 用户态与内核态的切换

用户态与内核态之间的切换是 Linux 系统中最关键的操作之一。x86_64 提供了多种机制实现这一过程。

用户态进入内核态

SYSCALL 指令:这是 64 位 Linux 系统调用使用的快速入口机制。SYSCALL 指令利用 MSR(Model Specific Register)中预设的入口地址直接跳转到内核空间。与传统的中断机制不同,SYSCALL 不会自动切换栈——内核代码需要在入口处手动完成栈切换。具体而言,SYSCALL 从 IA32_STAR MSR(地址 0xC0000081)读取目标 CS 和 SS 值,从 IA32_LSTAR MSR(地址 0xC0000082)读取入口 RIP,将返回地址保存到 RCX,将原始 RFLAGS 保存到 R11,然后跳转至内核入口点执行。Linux 7.0 中,SYSCALL 入口点位于 entry_SYSCALL_64,该入口会通过 swapgs 指令切换 GS 基址,并从 per-CPU 数据结构中获取内核栈指针。

INT n 指令(如 INT 0x80):这是 32 位时代遗留的系统调用接口。通过中断门进入内核时,CPU 会自动根据 TSS(Task State Segment)中的信息完成栈切换,将用户态的 SS、RSP、RFLAGS、CS 和 RIP 压入内核栈。这一机制虽然安全但开销较大,现代 Linux 仅保留兼容性支持。

CPU 异常:当发生缺页异常(Page Fault)、通用保护错误(General Protection Fault)等异常时,CPU 同样会自动切换栈(通过 TSS)并跳转到对应的中断描述符表(IDT)项所指向的处理程序。异常入口代码将完整的异常帧(包括错误码)保存在内核栈上,然后调用相应的处理函数。

内核态返回用户态

SYSRET 指令:与 SYSCALL 配对的快速返回指令。SYSRET 从 RCX 恢复 RIP,从 R11 恢复 RFLAGS,并恢复用户态的 CS 和 SS,将 CPL 切换回 Ring 3。

IRET 指令:中断返回指令,用于从通过 INT 指令或异常进入的内核代码返回。IRET 从栈上弹出 RIP、CS、RFLAGS、RSP 和 SS(64 位模式下为 IRETQ),完整恢复进入内核前的用户态上下文。

栈切换机制

当 CPU 从 Ring 3 进入 Ring 0 时(通过中断或异常),硬件会自动从当前 CPU 的 TSS 中读取 Ring 0 的 SS 和 RSP 值,并将用户态的 SS:RSP 压入新的内核栈顶。对于 SYSCALL,由于不自动切换栈,内核入口代码需要手动从 per-CPU 区域读取内核栈地址。每个 CPU 核心都有自己的 TSS 结构,其中存储了 Ring 0~Ring 2 的栈指针,在 cpu_init() 函数中完成初始化。

SWAPGS 指令

SWAPGS 是 x86_64 引入的特权指令,它交换 IA32_GS_BASE MSR 和 IA32_KERNEL_GS_BASE MSR 的值。Linux 利用这一机制管理 per-CPU 数据:在用户态时,GS 基址指向用户空间的 TLS(Thread Local Storage)区域;进入内核态后,通过 SWAPGS 将 GS 基址切换到内核的 per-CPU 数据区域,从而内核可以通过 __per_cpu_offset 快速访问当前 CPU 的私有数据。退出内核态时再次执行 SWAPGS 恢复用户态 GS 基址。

1.5.4 页表级保护机制

除了段级保护外,x86_64 通过页表项中的多个标志位提供了更细粒度的内存保护:

U/S 位(User/Supervisor):页表项的第 2 位。当 U/S=0 时,该页面为超级用户页,仅 Ring 0 可以访问;当 U/S=1 时,该页面为用户页,Ring 0 和 Ring 3 均可访问。内核线性地址空间的所有页面的 U/S 位均被设为 0,确保用户态代码无法直接读取或修改内核内存。

R/W 位(Read/Write):页表项的第 1 位。当 R/W=0 时,页面只读;当 R/W=1 时,页面可读写。结合 CR0.WP(Write Protect)位,内核可以实现写保护——当 WP=1 时,即使在 Ring 0,对只读页面的写入也会触发缺页异常。Linux 在设置了 CR0.WP 后,利用这一机制捕获内核代码对只读页面的非法修改。

NX 位(No-Execute):页表项的第 63 位(在支持该特性的 CPU 上)。当 NX=1 时,该页面不允许执行代码。Linux 利用 NX 位实现数据段不可执行策略,防止栈溢出攻击(如将恶意代码注入栈区并执行)。

SMEP(Supervisor Mode Execution Prevention)

SMEP 通过 CR4 寄存器的第 20 位启用。当 CR4.SMEP=1 时,CPU 在 Ring 0 执行代码时若试图执行 U/S=1 的用户页面,将触发通用保护错误(#GP)。这一机制从根本上阻止了 ret2user 攻击——攻击者即使在内核中获得了代码执行能力,也无法跳转到用户空间预先布置的恶意代码执行。SMEP 由 Linux 在启动阶段通过 cpu_init() 和 CR4 写入启用。

SMAP(Supervisor Mode Access Prevention)

SMAP 通过 CR4 寄存器的第 21 位启用。当 CR4.SMAP=1 时,CPU 在 Ring 0 执行代码时若试图读写 U/S=1 的用户页面,将触发缺页异常。这一机制进一步强化了内核与用户空间的隔离,防止内核意外引用用户空间指针(例如通过精心构造的指针进行的攻击)。

内核需要在系统调用中合法地读写用户空间内存(如 copy_to_user 和 copy_from_user),为此 x86_64 提供了 STAC(Set AC flag)和 CLAC(Clear AC flag)指令。AC 标志(EFLAGS 的第 18 位)是 SMAP 的软件覆写开关:当 AC=1 时,SMAP 检查被暂时禁止。Linux 在 copy_to_user、copy_from_user、get_user、put_user 等函数中使用 STAC 临时关闭 SMAP,完成数据拷贝后立即通过 CLAC 恢复 SMAP 保护。

1.5.5 I/O 保护

x86_64 通过 IOPL(I/O Privilege Level)机制控制对 I/O 端口的访问权限:

  • IOPL 存储在 RFLAGS 寄存器的第 12~13 位。
  • IN、OUT、INS、OUTS 等 I/O 指令只有在 CPL <= IOPL 时才被允许执行。
  • Linux 为所有用户进程设置 IOPL=0,由于用户进程的 CPL=3(3 > 0),因此普通用户态代码无法直接执行 I/O 指令,任何尝试都会触发通用保护错误。

对于需要直接操作硬件的特权驱动程序(如 X Server),Linux 提供了两个系统调用:

  • iopl():将调用进程的 IOPL 提升至指定级别(通常为 3),使其获得完全的 I/O 端口访问权限。此操作需要 CAP_SYS_RAWIO 能力。
  • ioperm():以位图方式精细控制特定 I/O 端口范围的访问权限。内核为每个进程维护一个 I/O 位图(I/O Bitmap),该位图存储在进程的 io_bitmap 字段中,在任务切换时被加载到 TSS 的相应区域。

1.5.6 Linux 能力模型

Linux 采用了简洁的二级特权模型:内核运行在 Ring 0,用户空间运行在 Ring 3。系统调用是两级之间唯一的通道。内核在系统调用边界上对所有来自用户空间的输入进行严格验证,包括指针合法性、缓冲区大小、文件描述符有效性等。

传统的 Unix 模型将权限简单划分为 root(UID 0)和普通用户,但 Linux 引入了细粒度的能力(Capability)模型。能力机制将传统 root 权限分解为数十种具体的能力,例如:

  • CAP_SYS_ADMIN:最广泛的能力,涵盖挂载文件系统、设置主机名等大量管理操作。
  • CAP_NET_ADMIN:网络管理能力,允许修改路由表、网络接口配置等。
  • CAP_SYS_RAWIO:直接 I/O 端口访问能力。
  • CAP_SYS_PTRACE:进程跟踪能力。
  • CAP_DAC_OVERRIDE:绕过文件权限检查的能力。

每个进程拥有一个能力集合,内核在执行特权操作前检查调用进程是否拥有相应的能力,而非简单地检查 UID 是否为 0。这一模型大大降低了过度授权的风险——进程只需获得完成其功能所需的最小能力集。

1.5.7 Linux 在 x86_64 上的安全特性

除了硬件提供的保护机制外,Linux 还利用多种硬件和软件技术增强内核安全性:

KASLR(Kernel Address Space Layout Randomization):内核地址空间布局随机化。在每次启动时,内核代码段、模块区域、页表等关键数据结构的虚拟地址会被随机偏移,使得攻击者难以预测内核中特定函数或数据的地址。KASLR 的熵值取决于可用的物理内存大小,通常可达 20 位以上。

KPTI(Kernel Page Table Isolation):内核页表隔离,针对 Meltdown 漏洞的缓解措施。Meltdown 攻击利用了乱序执行(Out-of-Order Execution)的侧信道效应,允许用户态程序读取内核线性地址空间的内容。KPTI 的核心思想是为用户态和内核态维护两套独立的页表:用户态运行时仅映射极少量的内核入口/出口代码(trampoline),内核态运行时才映射完整的内核地址空间。页表切换在用户态和内核态的入口/出口路径中完成,虽然引入了 TLB 刷新开销,但有效阻止了 Meltdown 类攻击。

SMEP/SMAP:前述的硬件强制内核/用户空间隔离机制,共同构成了防御 ret2user 和内核引用用户空间攻击的硬件屏障。

W^X(Write XOR Execute)策略:内核页面要么可写要么可执行,不可同时具备两种属性。通过页表项的 R/W 和 NX 位联合实现,阻止攻击者在内核中注入并执行任意代码。Linux 内核模块在加载时,其代码段被设为只读+可执行,数据段设为可读写+不可执行,严格遵循 W^X 原则。

Stack Protector(栈保护器):在每个函数的栈帧中插入一个随机值(canary,金丝雀),函数返回前检查该值是否被修改。若 canary 发生变化,说明发生了栈缓冲区溢出,内核立即触发 panic。Canary 值在每个任务创建时随机生成,存储在任务结构体的 stack_canary 字段中,由 GCC 的 -fstack-protector-strong 选项自动插入检测代码。

Shadow Stack(影子栈,CET):Intel Control-flow Enforcement Technology(CET)提供的硬件级返回地址保护。CPU 维护一个独立的影子栈,每次 CALL 指令执行时将返回地址同时写入普通栈和影子栈,RET 指令执行时比较两者是否一致。若检测到不匹配,说明返回地址被篡改,触发控制保护异常(#CP)。Linux 7.0 对影子栈提供了初步支持,在进程创建时通过 map_shadow_stack 系统调用分配影子栈页面。

IBRS/STIBP(Spectre 缓解):针对 Spectre 变体 2(分支目标注入)的缓解措施。IBRS(Indirect Branch Restricted Speculation)通过 MSR 限制间接分支预测,防止攻击者训练分支预测器来泄露敏感数据。STIBP(Single Thread Indirect Branch Predictors)防止单个物理核心上的超线程共享分支预测资源。Linux 通过 spectre_v2 启动参数控制这些缓解措施的启用策略。

L1TF/Foreshadow 缓解:L1 Terminal Fault 是另一种利用 L1 数据缓存的侧信道攻击。Linux 通过在缺页处理中清除 L1 缓存、禁用超线程等手段进行缓解。对于虚拟化场景,内核确保客户机的页表项中不会出现宿主机敏感数据的物理地址。

这些安全特性层层叠加,共同构成了 Linux 内核在 x86_64 平台上的纵深防御体系。从硬件强制的特权级隔离到软件实现的各类缓解措施,每一层都增加了攻击者的利用成本,有效地保护了系统的安全性和完整性。

1.6 Linux 内核安全加固机制全景

Linux 内核面对的现实威胁环境极其复杂:本地提权、容器逃逸、内核信息泄露、控制流劫持、堆喷射攻击……单一的防御手段远远不够。Linux 7.0 在 x86_64 上构建了一套纵深的加固体系,按照防护目标可以分为四大类:地址空间保护、控制流完整性、数据完整性和信息泄露防护。本节将对这些机制逐一进行深入剖析。

相关源码索引:推荐的加固基线配置见 kernel/configs/hardening.config。


1.6.1 地址空间保护

地址空间保护的目标是让攻击者无法预测或利用内核的虚拟地址布局。

KASLR —— 内核地址空间布局随机化

配置项:CONFIG_RANDOMIZE_BASE、CONFIG_RANDOMIZE_MEMORY

源码位置:arch/x86/Kconfig(第 2094-2127 行)、arch/x86/boot/compressed/kaslr.c

防护目标:阻止攻击者利用硬编码的内核虚拟地址进行 ROP/JOP 攻击。

工作原理:在每次启动时,内核解压器将内核映像加载到一个随机物理地址(对齐到 CONFIG_PHYSICAL_ALIGN,最小 2MB)。虚拟地址随之偏移,使得攻击者无法提前知道特定函数或数据结构的地址。CONFIG_RANDOMIZE_MEMORY 进一步随机化内核直接映射区(physmap)、vmalloc 区、vmemmap 区的基地址,在 x86_64 上每个区域平均可获得约 30,000 种可能的虚拟地址。

KASLR 的熵受限于物理地址位数和对齐约束,通常约 20+ 位熵。引导参数 nokaslr 可禁用此特性(仅用于调试)。

// arch/x86/boot/compressed/kaslr.c
// KASLR 在解压阶段选择随机物理地址
static unsigned long find_random_phys_addr(unsigned long minimum,
                                            unsigned long image_size)

KPTI —— 内核页表隔离

配置项:CONFIG_MITIGATION_PAGE_TABLE_ISOLATION

源码位置:arch/x86/mm/pti.c、arch/x86/entry/entry_64.S

防护目标:防御 Meltdown 类侧信道攻击(利用乱序执行读取内核地址空间)。

工作原理:维护两套页表——

页表 内核态使用 用户态使用
内核 PGD 完整内核映射 ✗
用户 PGD 仅入口/出口跳板代码 ✗

每次系统调用入口/出口时切换 CR3 寄存器。用户态运行时,页表中几乎不存在内核映射,Meltdown 攻击读到的只是跳板代码。代价是每次系统调用需要两次 TLB 刷新,性能影响约 1-5%(可通过 nopti 参数禁用)。

// arch/x86/mm/pti.c — 初始化 KPTI 页表
void __init pti_init(void)
{
    // 为用户 PGD 创建最小化的内核映射(仅入口跳板 + vsyscall)
}

MMAP_MIN_ADDR —— 低地址映射防护

配置项:CONFIG_DEFAULT_MMAP_MIN_ADDR(DAC 层,默认 4096)、CONFIG_LSM_MMAP_MIN_ADDR(LSM 层,x86 默认 65536)

源码位置:security/min_addr.c、mm/mmap.c

防护目标:阻止 NULL 指针解引用攻击。如果攻击者能 mmap(0, ...) 在地址 0 映射一个可写页面,则内核中的 NULL 指针解引用 bug 就变成了一条可利用的任意写原语。

工作原理:取 DAC 值和 LSM 值的较大者作为最终的 mmap_min_addr。非特权进程的 mmap() 调用如果请求的地址低于此阈值,将被直接拒绝。该值可通过 /proc/sys/vm/mmap_min_addr 在运行时调整(需 CAP_SYS_RAWIO)。

// security/min_addr.c
unsigned long mmap_min_addr = CONFIG_DEFAULT_MMAP_MIN_ADDR;
unsigned long dac_mmap_min_addr = CONFIG_DEFAULT_MMAP_MIN_ADDR;

内核栈偏移随机化

配置项:CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT

源码位置:arch/x86/entry/entry_64.S、include/linux/randomize_kstack.h

防护目标:增加内核栈地址的不可预测性,阻止基于栈布局的攻击。

工作原理:每次系统调用入口时,内核使用随机值对栈指针进行额外对齐偏移(最多 255 字节的随机偏移量)。这破坏了系统调用之间栈帧的空间连续性,使得攻击者难以通过一个漏洞推断出其他系统调用的栈布局。


1.6.2 控制流完整性

控制流完整性(CFI)的目标是确保程序的执行流只能沿着合法的路径前进,攻击者无法通过覆写函数指针或返回地址来劫持控制流。

CFI —— 编译器级控制流完整性

配置项:CONFIG_CFI(原 CONFIG_CFI_CLANG,Linux 7.0 统一更名为 CONFIG_CFI)

源码位置:arch/Kconfig(第 905-985 行)

防护目标:阻止间接函数调用被重定向到任意内核地址(ROP/JOP 攻击)。

工作原理:使用 Clang 编译器的 -fsanitize=kcfi 选项,编译器在编译期为每个函数的类型签名生成一个唯一标记(tag),嵌入到函数入口之前的 .kcfi_traps 节中。每个间接调用点在调用前插入运行时类型检查代码,验证目标地址处的标记是否与调用点期望的类型匹配。如果不匹配,内核触发 panic(在非宽容模式下)。

间接调用前:
    mov  -4(%rax), %ecx      ; 读取目标函数前的类型标记
    cmp  $EXPECTED_TAG, %ecx ; 与期望类型比较
    jne  .cfi_failure         ; 不匹配则 panic
    call *%rax                ; 匹配则正常调用

CONFIG_CFI_ICALL_NORMALIZE_INTEGRES 可归一化整数类型标记,用于 Rust 互操作。生产环境应确保 CONFIG_CFI_PERMISSIVE 未设置(严格模式)。

Stack Protector —— 栈金丝雀

配置项:CONFIG_STACKPROTECTOR(基础模式)、CONFIG_STACKPROTECTOR_STRONG(增强模式,默认启用)

源码位置:arch/Kconfig(第 697-733 行)

防护目标:检测栈缓冲区溢出对返回地址的覆写。

工作原理:编译器通过 -fstack-protector-strong 选项在含局部数组、取地址操作或包含数组的结构体的函数中插入金丝雀值检查。金丝雀在每个任务创建时随机生成,存储在 task_struct->stack_canary 中。函数入口将金丝雀压入栈帧,返回前校验——若被覆写,调用 __stack_chk_fail() 触发 panic。

模式 覆盖函数比例 代码增量
STACKPROTECTOR ~3% ~0.3%
STACKPROTECTOR_STRONG ~20% ~2.5%

Shadow Stack —— 影子栈(Intel CET)

配置项:CONFIG_X86_USER_SHADOW_STACK

源码位置:arch/x86/Kconfig(第 1888-1904 行)

防护目标:硬件级检测返回地址篡改,防御 ROP 攻击。

工作原理:在支持 CET 的 Intel CPU 上,CPU 硬件维护一个独立的影子栈。每次 CALL 指令将返回地址同时写入普通栈和影子栈,RET 指令比较两者——不匹配则触发 #CP(Control Protection)异常。内核通过 map_shadow_stack 系统调用为用户进程分配影子栈页面,通过 WRUSS 指令管理影子栈令牌。应用程序需显式选择启用。

SMEP / SMAP —— 内核/用户空间执行与访问隔离

配置项:硬件特性,由 CR4.SMEP(第 20 位)和 CR4.SMAP(第 21 位)控制

源码位置:arch/x86/kernel/cpu/common.c(CR4 配置)

防护目标:

  • SMEP:阻止 ret2user 攻击——内核态无法执行用户空间(U/S=1)页面中的代码。
  • SMAP:阻止内核意外访问用户空间内存——内核态无法读写用户空间页面(copy_to_user/copy_from_user 除外,通过 STAC/CLAC 指令临时禁用)。

IBRS / STIBP —— Spectre 变体 2 缓解

配置项:CONFIG_MITIGATION_SPECTRE_V2

源码位置:arch/x86/kernel/cpu/bugs.c

防护目标:防御分支目标注入(Branch Target Injection)侧信道攻击。

工作原理:

  • IBRS(Indirect Branch Restricted Speculation):通过 MSR 限制间接分支预测器在特权级切换时的行为。
  • STIBP(Single Thread Indirect Branch Predictors):防止单个物理核心上的超线程共享分支预测资源。
  • eIBRS(Enhanced IBRS):新型 CPU 硬件内置支持,只需写入一次 MSR 即可持久生效。
  • RETPOLINE:软件层面的替代方案,通过返回 trampoline 隔离间接分支预测。

引导参数 spectre_v2= 控制启用策略。


1.6.3 数据完整性

数据完整性机制确保内核数据结构不被非法篡改,堆分配器的元数据不被破坏。

Slab Freelist Hardening —— SLAB 空闲链表加固

配置项:CONFIG_SLAB_FREELIST_HARDENED

源码位置:mm/slub.c(第 495-523 行)

防护目标:阻止堆溢出攻击通过覆写 slab 空闲链表指针(next-pointer poisoning)获得任意写原语。

工作原理:每个 kmem_cache 在创建时获得一个随机密钥 s->random。空闲链表指针使用以下公式编码:

encoded_ptr = actual_ptr XOR s->random XOR swab(ptr_storage_addr)

其中 swab() 是字节序交换操作。解码时执行相同的 XOR 运算还原。攻击者如果不了解 per-cache 的随机密钥和指针存储地址,就无法构造有效的编码指针——覆写空闲链表指针只会导致解码出无效地址,触发 kernel oops 而非任意写入。

// mm/slub.c — freelist 指针编码/解码
static inline void *freelist_ptr(const struct kmem_cache *s, void *ptr,
                                  unsigned long *addr)
{
    unsigned long mask = s->random ^ (unsigned long)addr;
    return (void *)((unsigned long)ptr ^ mask ^ swab((unsigned long)addr));
}

Slab Freelist Random —— 空闲链表顺序随机化

配置项:CONFIG_SLAB_FREELIST_RANDOM

源码位置:mm/slab_common.c(第 1038-1075 行)

防护目标:打破 slab 分配的确定性顺序,阻止堆喷射攻击预测对象相邻关系。

工作原理:每个 slab 页创建时,空闲对象的顺序通过 Fisher-Yates 洗牌算法随机化,随机序列存储在 cachep->random_seq 中。攻击者无法预测下一次分配返回哪个索引的对象,从而无法精确控制堆布局。

Hardened Usercopy —— 加固的用户空间拷贝

配置项:CONFIG_HARDENED_USERCOPY

源码位置:mm/usercopy.c(完整文件,288 行)

防护目标:阻止通过 copy_to_user()/copy_from_user() 越界读写堆对象,以及阻止将内核代码段地址泄露到用户空间。

工作原理:__check_object_size() 在每次 copy_to_user/copy_from_user 路径中被调用(通过内联的 check_object_size()),执行四层检查:

  1. 地址合法性检查:拒绝 NULL 指针和回绕地址。
  2. 栈对象检查:通过 arch_within_stack_frames() 验证缓冲区完全位于有效栈帧内。
  3. 堆对象检查:验证拷贝范围不超出 slab 对象边界。对于使用 kmem_cache_create_usercopy() 创建的缓存,拷贝必须在声明的 [useroffset, useroffset + usersize] 白名单范围内。
  4. 内核代码段检查:拒绝任何与 _stext~_etext 重叠的拷贝操作,防止内核代码泄露到用户空间。
// mm/usercopy.c — 核心检查函数
void __check_object_size(const void *ptr, unsigned long n, bool to_user)
{
    if (check_bogus_address(ptr, n))  return;
    if (check_stack_object(ptr, n))   return;
    if (check_heap_object(ptr, n, to_user)) return;
    check_kernel_text_object(ptr, n);
}

用户拷贝白名单机制:SLAB 缓存通过 kmem_cache_create_usercopy() 声明哪些字节范围可以安全地与用户空间交换:

// 例如:msg_msg 缓存允许用户空间访问消息头之后的部分
msg_cache = kmem_cache_create_usercopy("msg_msg", sizeof(struct msg_msg) + MAX_MSG_SIZE,
                                        sizeof(struct msg_msg), 0,
                                        0, MAX_MSG_SIZE, NULL);

RANDSTRUCT —— 结构体布局随机化

配置项:CONFIG_RANDSTRUCT_FULL(完全随机化)或 CONFIG_RANDSTRUCT_PERFORMANCE(缓存行内随机化)

源码位置:scripts/gcc-plugins/randomize_layout_plugin.c(GCC)、scripts/Makefile.randstruct(构建集成)

防护目标:阻止攻击者利用已知的内核结构体字段偏移进行攻击(如篡改 file_operations 函数指针表)。

工作原理:在编译时,所有标记了 __randomize_layout 的结构体(以及仅包含函数指针的结构体)的字段顺序会被随机打乱。随机种子存储在 scripts/basic/randomize.seed 中,编译模块时需要使用相同种子以保证 ABI 兼容。

  • RANDSTRUCT_FULL:所有成员位置完全随机化。
  • RANDSTRUCT_PERFORMANCE:仅在缓存行(64 字节)边界内随机化,减少性能影响。

make mrproper 会删除种子文件,重新编译将产生完全不同的结构体布局。

LIST_HARDENED —— 链表完整性检查

配置项:CONFIG_LIST_HARDENED

源码位置:include/linux/list.h

防护目标:检测内核链表操作中的内存损坏。

工作原理:在 list_del()、list_add() 等操作中插入额外的完整性检查,验证 prev->next == entry 和 next->prev == entry 等不变量。如果检测到损坏,触发 WARN 并阻止操作继续。

KFENCE —— 内核电子围栏

配置项:CONFIG_KFENCE

源码位置:mm/kfence/core.c、mm/kfence/report.c

防护目标:在生产环境中以极低性能开销检测堆越界访问和释放后使用(Use-After-Free)。

工作原理:KFENCE 采用采样策略。默认每 100ms(CONFIG_KFENCE_SAMPLE_INTERVAL),从常规 SLAB 分配器中抽取一次分配,将其重定向到 KFENCE 专用池。每个 KFENCE 对象独占一个页面,两侧各有保护页:

[保护页(不可访问)] [对象页(实际对象)] [保护页(不可访问)]
  • 越界访问:触及保护页 → 触发页面错误 → KFENCE 捕获并报告。
  • 释放后使用:对象释放后其页面被取消映射 → 访问触发页面错误 → 报告 UAF。
  • 无效释放:检测 double-free 和错误指针释放。

池大小由 CONFIG_KFENCE_NUM_OBJECTS(默认 255)控制。KFENCE 不替代 KASAN,而是作为 KASAN 的生产环境补充——KASAN 开销大适合测试,KFENCE 开销极小适合部署。

FORTIFY_SOURCE —— 编译期缓冲区溢出检测

配置项:CONFIG_FORTIFY_SOURCE

源码位置:include/linux/fortify-string.h

防护目标:在编译期和运行时检测 memcpy()、strcpy() 等函数中的缓冲区溢出。

工作原理:编译器通过 __builtin_object_size() 在编译时推断目标缓冲区大小。如果拷贝长度超出目标缓冲区,编译期能直接报错;运行时则调用 fortify_panic() 触发 oops。


1.6.4 信息泄露防护

信息泄露防护的目标是确保攻击者无法从内核获取有助于构建利用的信息。

Dmesg 限制

配置项:CONFIG_SECURITY_DMESG_RESTRICT

源码位置:kernel/printk/printk.c(第 603 行)

防护目标:阻止非特权进程通过内核环形缓冲区获取内核地址、模块加载地址等敏感信息。

工作原理:设置 dmesg_restrict sysctl 的初始值。当 dmesg_restrict=1 时,只有拥有 CAP_SYSLOG 能力的进程才能通过 dmesg(8) 或 /proc/kmsg 读取内核日志。内核日志中可能包含函数地址、内存映射信息等对攻击者有价值的数据。

// kernel/printk/printk.c
static int dmesg_restrict = IS_ENABLED(CONFIG_SECURITY_DMESG_RESTRICT);

KSTACK_ERASE —— 内核栈擦除

配置项:CONFIG_KSTACK_ERASE(原 CONFIG_STACKLEAK,Linux 7.0 更名)

源码位置:include/linux/kstack_erase.h、kernel/kstack_erase.c、arch/x86/entry/calling.h(第 370-393 行)

防护目标:阻止通过未初始化的栈变量泄露敏感信息,阻止攻击者读取先前系统调用残留的栈数据。

工作原理:在每个系统调用返回前,将使用过的内核栈区域覆写为毒化值 0xFFFFBEEF。擦除范围由 __sanitizer_cov_stack_depth() 插桩跟踪的最低栈深度决定。性能影响约 1%。

// arch/x86/entry/calling.h
.macro STACKLEAK_ERASE
    call stackleak_erase    // 擦除使用过的栈区域
.endm

INIT_ON_ALLOC / INIT_ON_FREE —— 内存初始化

配置项:CONFIG_INIT_ON_ALLOC_DEFAULT_ON、CONFIG_INIT_ON_FREE_DEFAULT_ON

源码位置:mm/mm_init.c(第 2502-2523 行)、mm/slab.h(第 675-695 行)

防护目标:

  • INIT_ON_ALLOC:阻止未初始化堆内存导致的信息泄露和不可预测行为。
  • INIT_ON_FREE:缩短敏感数据在内存中的生命周期,防御冷启动攻击。

工作原理:

  • INIT_ON_ALLOC:所有页面和 slab 对象在分配时被清零。通过静态密钥 init_on_alloc 控制,可由 init_on_alloc=0 引导参数覆盖。性能影响 <1%。
  • INIT_ON_FREE:所有页面和 slab 对象在释放时被清零。比 alloc 初始化更强——释放后的内容立即被抹除。性能影响 3-5%(需要触碰冷内存)。可由 init_on_free=0 覆盖。

对于有自定义构造函数的 slab 缓存或使用 SLAB_TYPESAFE_BY_RCU 的缓存,自动跳过初始化。

STRICT_KERNEL_RWX / STRICT_MODULE_RWX —— 内核代码只读

配置项:CONFIG_STRICT_KERNEL_RWX、CONFIG_STRICT_MODULE_RWX

源码位置:arch/x86/mm/init_64.c(mark_rodata_ro())

防护目标:阻止运行时修改内核代码和只读数据(代码注入、ROP 写入)。

工作原理:内核初始化完成后,mark_rodata_ro() 将内核代码段的页表权限设为只读+可执行(R+X),只读数据段设为只读+不可执行(R),非代码内存设为不可执行(NX)。模块加载时同样遵循 W^X 原则。这确保了整个内核地址空间严格遵循"可写与可执行互斥"原则。

// arch/x86/mm/init_64.c
void mark_rodata_ro(void)
{
    // 将内核代码段设为只读+可执行
    // 将 rodata 设为只读
    // 将非代码区域设为 NX
}

STATIC_USERMODEHELPER —— 静态用户态助手

配置项:CONFIG_STATIC_USERMODEHELPER、CONFIG_STATIC_USERMODEHELPER_PATH

源码位置:kernel/umh.c(第 368-423 行)

防护目标:阻止攻击者通过篡改用户态助手路径(如 /proc/sys/kernel/modprobe、core_pattern)来以 root 权限执行任意用户态程序。

工作原理:启用后,所有 call_usermodehelper() 调用都被重定向到单一静态路径(默认 /sbin/usermode-helper):

// kernel/umh.c
if (static_usermodehelper_path[0]) {
    sub_info->path = CONFIG_STATIC_USERMODEHELPER_PATH;
    // 原始路径作为第一个参数传递给静态助手
}

静态助手接收原始请求的二进制路径作为参数,自行决定是否执行。如果将路径设为空字符串,则完全禁用所有用户态助手调用。


1.6.5 侧信道与 speculative 执行防护

Spectre 变体 1(Bounds Check Bypass)

缓解方式:array_index_mask_nospec() —— 在索引使用前通过推测安全的掩码操作确保索引不越界。

Spectre 变体 2(Branch Target Injection)

缓解方式:见上文 IBRS/STIBP/RETPOLINE 描述。

Meltdown(Rogue Data Cache Load)

缓解方式:见上文 KPTI 描述。

L1TF(L1 Terminal Fault / Foreshadow)

配置项:CONFIG_MITIGATION_L1TF

源码位置:arch/x86/kernel/cpu/bugs.c

防护目标:阻止利用 L1 数据缓存读取宿主机或跨虚拟机数据。

缓解方式:在缺页处理中清除 L1 缓存、对 VMM 确保客户机页表项不含宿主机敏感物理地址、必要时禁用超线程。

MDS(Microarchitectural Data Sampling)

缓解方式:在用户态切换时刷新 CPU 内部缓冲区(通过 VERW 指令或缓冲区清除)。


1.6.6 加固特性速查表

下表汇总了 Linux 7.0 x86_64 上所有主要加固特性,按类别组织:

地址空间保护

特性 配置项 防护对象 性能影响
KASLR CONFIG_RANDOMIZE_BASE 内核地址预测 无
内存区域随机化 CONFIG_RANDOMIZE_MEMORY 内核内存区域布局 无
KPTI CONFIG_MITIGATION_PAGE_TABLE_ISOLATION Meltdown 1-5%
MMAP_MIN_ADDR CONFIG_DEFAULT_MMAP_MIN_ADDR NULL 指针解引用 无
栈偏移随机化 CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT 内核栈地址预测 极小

控制流完整性

特性 配置项 防护对象 性能影响
KCFI CONFIG_CFI 间接调用劫持 ~1%
栈金丝雀 CONFIG_STACKPROTECTOR_STRONG 栈缓冲区溢出 ~2.5% 代码增量
影子栈 (CET) CONFIG_X86_USER_SHADOW_STACK ROP 攻击 需硬件支持
SMEP CR4.SMEP ret2user 无
SMAP CR4.SMAP 内核访问用户内存 无

数据完整性

特性 配置项 防护对象 性能影响
SLAB 空闲链表加固 CONFIG_SLAB_FREELIST_HARDENED 堆元数据破坏 极小
SLAB 顺序随机化 CONFIG_SLAB_FREELIST_RANDOM 堆喷射 极小
Hardened Usercopy CONFIG_HARDENED_USERCOPY copy_*_user 越界 极小
结构体随机化 CONFIG_RANDSTRUCT_FULL 结构体字段偏移利用 可变
链表完整性 CONFIG_LIST_HARDENED 链表损坏 极小
KFENCE CONFIG_KFENCE 堆越界/UAF 检测 极小
FORTIFY_SOURCE CONFIG_FORTIFY_SOURCE 缓冲区溢出 极小

信息泄露防护

特性 配置项 防护对象 性能影响
Dmesg 限制 CONFIG_SECURITY_DMESG_RESTRICT 内核日志泄露 无
栈擦除 CONFIG_KSTACK_ERASE 残留栈数据泄露 ~1%
分配清零 CONFIG_INIT_ON_ALLOC_DEFAULT_ON 未初始化堆泄露 <1%
释放清零 CONFIG_INIT_ON_FREE_DEFAULT_ON 敏感数据生命周期 3-5%
内核代码只读 CONFIG_STRICT_KERNEL_RWX 代码注入 无
模块代码只读 CONFIG_STRICT_MODULE_RWX 模块代码注入 无
静态用户态助手 CONFIG_STATIC_USERMODEHELPER 助手路径篡改 无

1.6.7 纵深防御架构图

                         攻击者
                           │
                  ┌────────┴────────┐
                  │  信息收集阶段    │  ← KASLR, Dmesg限制, 栈擦除, 释放清零
                  └────────┬────────┘
                           │
                  ┌────────┴────────┐
                  │  漏洞触发阶段    │  ← KFENCE, FORTIFY_SOURCE, Hardened Usercopy
                  └────────┬────────┘
                           │
                  ┌────────┴────────┐
                  │  控制流劫持阶段  │  ← CFI, Stack Protector, Shadow Stack, SMEP
                  └────────┬────────┘
                           │
                  ┌────────┴────────┐
                  │  数据篡改阶段    │  ← SLAB Freelist Hardening, RANDSTRUCT, W^X
                  └────────┬────────┘
                           │
                  ┌────────┴────────┐
                  │  提权/逃逸阶段   │  ← SMAP, KPTI, MMAP_MIN_ADDR, STATIC_UMH
                  └────────┬────────┘
                           │
                         失败

这套纵深防御体系的设计原则是:任何单一防御手段的失效都不会导致整体安全性的崩溃。攻击者需要同时突破多层防御才能成功利用内核漏洞,而每一层都显著增加了攻击的复杂度和成本。

推荐实践:生产环境内核应启用 kernel/configs/hardening.config 中的全部选项,该文件由内核社区维护,提供了当前最佳实践的加固基线。