GDT、段权限检查与特权级切换 —— CPL/RPL/DPL 三方博弈 + syscall 免检跳转 + 三层保护
这是 segment-management(内核段管理总纲)的拆分篇之一,由原「GDT 与段权限检查」「syscall/sysret 的特权级切换与三层保护」两篇合并而成。讲完整的两条主线:① 段权限体系怎么工作——GDT 是配置中心、8 字节段描述符逐位含义、权限检查公式
max(CPL, RPL) <= DPL、CPL/RPL/DPL 三角关系;② 系统调用凭什么能合法跳特权级——syscall/sysret靠 MSR_STAR 免检加载 CS/SS,以及用户态为什么永远碰不到内核内存(段 DPL + 页表 U/S + KPTI 三层保护)。相关:段选择子结构见演进篇,观测调试见segment-management-observation,分页寻址见addressing。
一、GDT(全局描述符表):段管理的"配置中心"
GDT 是 CPU 级别的全局数据结构,存储所有任务共享的段描述符。每个 CPU 核心有自己的 GDTR 寄存器指向它。
GDTR 寄存器
┌──────────────────────────────────────┐
│ Base (64 bits) │ Limit (16) │
│ 指向 GDT 的线性地址 │ GDT 字节数-1 │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ GDT[0] NULL 描述符(必须为空) │ 8 bytes
│ GDT[1] 内核代码段 (32位兼容) │ 8 bytes
│ GDT[2] 内核代码段 (64位) DPL=0 │ 8 bytes ← CS=0x10 内核态代码
│ GDT[3] 内核数据段 (64位) DPL=0 │ 8 bytes ← DS/SS=0x18 内核态数据/栈
│ GDT[4] 用户代码段 (32位兼容) │ 8 bytes
│ GDT[5] 用户数据段 (64位) DPL=3 │ 8 bytes ← DS=0x2B 用户态数据
│ GDT[6] 用户代码段 (64位) DPL=3 │ 8 bytes ← CS=0x33 用户态代码
│ GDT[7] (保留) │ 8 bytes
│ GDT[8] TSS(低64位) │ 8 bytes
│ GDT[9] TSS(高64位) │ 8 bytes
│ ... per-CPU 的 TLS 描述符等 │
└──────────────────────────────────────┘1.1 段描述符的 8 字节格式(深度拆解)
63 55 47 39 31 23 15 7 0
├────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ Base │ Flags │ Limit │ Access │ Base │ Base[23:0] │ Limit │
│ 31:24 │ 4bit │ 19:16 │ Byte │ 23:16 │ │ 15:0 │
├────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
逐字节拆解:
字节 0-1: Limit[15:0] — 段界限低 16 位(长模式忽略)
字节 2-3: Base[15:0] — 段基址低 16 位(长模式忽略)
字节 4-5: Base[23:16] — 段基址中 8 位
字节 5: Access Byte — **最关键的权限字节**(见下)
字节 6: Flags[3:0] + Limit[19:16] — 标志 + 界限高 4 位
字节 7: Base[31:24] — 段基址高 8 位1.2 Access Byte —— 段权限的核心
Access Byte (字节 5) 的逐位含义:
Bit 7 6 5 4 3 2 1 0
├────┼────┼────┼────┼────┼────┼────┼────┤
│ P │ DPL│ S │ Type │ A │
└────┴────┴────┴────┴────┴────┴────┴────┘
P (Present): 1 = 段在内存中,0 = 不在(触发 #NP 异常)
DPL (Descriptor Privilege Level): 00=ring0, 01=ring1, 10=ring2, 11=ring3
S (System): 1 = 代码/数据段,0 = 系统段(TSS/LDT/调用门等)
Type[3:0]: S=1 时定义段的读写执行属性
A (Accessed): CPU 自动设置,表示该段被访问过Type 字段的详细定义(S=1,即代码/数据段):
Type 的 bit 3 区分代码段还是数据段:
数据段 (Type bit 3 = 0):
bit 2 E (Expand-down) 0=向上扩展, 1=向下扩展(栈)
bit 1 W (Writable) 0=只读, 1=可写
bit 0 A (Accessed)
0010 = 可读写数据段 (Linux 内核/用户数据段)
0110 = 向下扩展可读写数据段(栈段)
代码段 (Type bit 3 = 1):
bit 2 C (Conforming) 0=非一致代码段, 1=一致代码段
bit 1 R (Readable) 0=只执行, 1=可读可执行
bit 0 A (Accessed)
1010 = 可执行可读代码段 (Linux 内核/用户代码段)关键观察:内核代码段和用户代码段的描述符几乎一模一样——相同的基址(0)、相同的 Type(可执行可读),唯一的区别就是 DPL。这就是段权限隔离的核心:DPL 不同,能加载这些段的代码也不同。
二、段权限检查:CPU 怎么阻止用户态加载内核段
2.1 加载段寄存器时的权限检查
每当代码执行 mov ds, ax 或 pop ds 等指令加载段寄存器时,CPU 硬件自动执行以下检查:
if (目标段选择子指向一个数据段或非一致代码段) {
// 必须同时满足两个条件:
// 1. CPL <= DPL(当前特权级不低于描述符要求的特权级)
// 2. RPL <= DPL(请求的特权级不低于描述符要求的特权级)
if (max(CPL, RPL) > DPL) {
触发 #GP(0) 异常 // 权限不足!
}
}具体例子:
用户态尝试加载内核数据段:
mov ax, 0x18 ; 内核数据段选择子, RPL=0
mov ds, ax ; 触发检查: max(CPL=3, RPL=0) = 3 > DPL=0 → #GP!
内核态可以加载任何段:
mov ax, 0x2B ; 用户数据段选择子, RPL=3
mov ds, ax ; 触发检查: max(CPL=0, RPL=3) = 3 ≤ DPL=3 → OK
; 内核可以访问用户数据,但通常不会这样做这就是段保护的核心答案:用户态
CS=0x33(CPL=3)尝试加载内核数据段DS=0x18(DPL=0)时,max(3, 0) = 3 > 0→ 触发#GP(0)异常,内核将其转为 SIGSEGV。段级保护拦住的是"加载内核段选择子"这个动作本身(页表 U/S 和 KPTI 另两层见 §四)。
2.2 代码段切换的特殊规则
代码段的加载不是通过 mov cs, ax(这在长模式下被禁止),而是通过以下方式:
| 切换方式 | CPL 变化 | 规则 |
|---|---|---|
jmp far / call far 到非一致代码段 | CPL 不变 | 要求 CPL == DPL,且 RPL <= DPL |
call far 到一致代码段 | CPL 不变 | 要求 CPL >= DPL(允许低特权级调用高特权级代码) |
syscall 指令 | CPL 变为 0 | 硬件自动加载 MSR_STAR 中预设的内核 CS |
sysret 指令 | CPL 变为 3 | 硬件自动加载 MSR_STAR 中预设的用户 CS |
| 中断/异常门 | CPL 变为目标 DPL | 通过 IDT 中的门描述符 |
Linux 不使用
call far调用门做系统调用,而是用syscall/sysret快速路径。一致代码段(Conforming Code)在 Linux 中也不使用。
2.3 CPL、RPL、DPL 三者的关系
┌─────────────────────────────────────────────────┐
│ CPL (Current Privilege Level) │
│ 当前正在执行的代码的特权级 │
│ 存储在 CS 寄存器的低 2 位 (CS.RPL) │
│ ring 0 = 最高权限, ring 3 = 最低权限 │
├─────────────────────────────────────────────────┤
│ RPL (Requested Privilege Level) │
│ 段选择子中的请求特权级 │
│ 表示"我想以什么身份访问这个段" │
│ 可以被软件设置(但 max(CPL,RPL) 决定实际权限) │
├─────────────────────────────────────────────────┤
│ DPL (Descriptor Privilege Level) │
│ 目标段描述符中的特权级 │
│ 表示"访问这个段需要至少什么权限" │
│ 由操作系统在 GDT 中设置,用户态无法修改 │
└─────────────────────────────────────────────────┘
权限检查公式(访问数据段或非一致代码段):
max(CPL, RPL) <= DPL → 通过
max(CPL, RPL) > DPL → #GP 异常RPL 存在的意义:防止低特权级代码"借用"高特权级的选择子。例如内核代码(ring 0)想代表用户态(ring 3)访问一个段,可以用 ARPL 指令把选择子 RPL 设为 3,以内核身份检查用户权限——max(CPL=0, RPL=3) = 3 > DPL=0 → #GP,防止了"借权"。
三、syscall/sysret:免检的特权级切换
syscall 指令之所以能"合法地"切换特权级,是因为它不走正常的段加载权限检查路径。CPU 硬件直接根据 MSR_STAR 中的预设值写入 CS 和 SS,不检查 CPL vs DPL。
; syscall 指令的段切换硬件操作
CS.Selector := MSR_STAR[47:32] ; = 0x10 (内核代码段选择子)
CS.Type := 11 (Execute/Read)
CS.DPL := 0 ; ← CPL 变成 ring 0
SS.Selector := MSR_STAR[47:32] + 8 ; = 0x18 (内核数据段选择子)
SS.DPL := 0
; 注意:DS/ES 没有被 syscall 切换!内核入口代码必须手动做这件事MSR_STAR 的设置(开机时执行一次):
// MSR_STAR 布局:
// [63:48] = 用户态 sysret 时用的 CS/SS 基值 (__USER_CS = 0x30)
// [47:32] = 内核态 syscall 时用的 CS/SS 基值 (__KERNEL_CS = 0x10)
// syscall 时: CS = 0x10, SS = 0x18 ; sysret 时: CS = 0x33, SS = 0x2B
wrmsrl(MSR_STAR, ((u64)__USER_CS << 48) | ((u64)__KERNEL_CS << 32));关键点:
syscall是硬件特权指令,只有 ring 0 才能写 MSR_STAR——用户态改不了。这个寄存器一写好,syscall指令的段切换就是"出厂预设",不需要(也不会)做max(CPL,RPL) <= DPL检查,因为 CPU 已经知道目标段是它自己预设的内核段。这就是"合法跳转"的本质。
3.1 内核入口手动完成剩余切换
; entry_SYSCALL_64 的关键代码(简化)
SYSCALL_ENTRY:
swapgs ; GS 从用户 TLS → 内核 per-CPU
movq %rsp, PER_CPU_VAR(rsp_scratch) ; 暂存用户 RSP
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp ; RSP → 内核栈
movl $__KERNEL_DS, %eax ; __KERNEL_DS = 0x18
movl %eax, %ds ; ★ 手动加载内核数据段到 DS/ES
movl %eax, %es
... 保存寄存器现场、切内核 CR3(KPTI)...
call *sys_call_table(, %rax, 8)为什么 DS/ES 必须手动切换? syscall 硬件只切 CS 和 SS,不切 DS/ES。如果内核代码继续用用户态的 DS(DPL=3),当它访问内核数据时会触发页表 U/S 位检查失败(内核页 U/S=0,用户态段 CPL=3 无法访问)。所以必须手动加载内核 DS/ES。返回路径 sysret 反向操作:手动恢复用户 DS/ES、切回用户 CR3、swapgs 回用户 GS,然后 sysretq 硬件切回 CS=0x33/SS=0x2B。
四、用户态为什么碰不到内核内存:三层保护
| 保护层 | 机制 | 保护范围 | 可绕过吗 |
|---|---|---|---|
| 段 DPL | 加载段寄存器时检查 CPL vs DPL | 阻止加载内核段选择子 | Meltdown 推测执行可绕过 |
| 页表 U/S 位 | 每次内存访问时检查 CPL vs PTE.U/S | 阻止访问内核物理页 | Meltdown 推测执行可绕过 |
| KPTI 页表隔离 | 用户态页表中根本没有内核映射 | 物理上阻断所有内核地址 | 不可绕过 |

三层的关系:段 DPL 拦"动作"(加载段选择子),页表 U/S 拦"访问"(读内核物理页),KPTI 拦"看见"(用户态页表里压根没有内核映射,推测执行都拿不到)。KPTI 的代价是每次 syscall/中断进出都要切 CR3(用 PCID 优化),详见 tlb。页表结构与 page walk 细节见addressing。
五、Linux 内核对 GDT 的运行时管理
每个 CPU 核心有自己的一份 GDT(虽然内容基本相同):GDTR 寄存器是 per-CPU 的,TSS 是 per-CPU 的必须放在各自 GDT 中,TLS 描述符可能每个线程不同。
// arch/x86/include/asm/desc.h
struct gdt_page {
struct desc_struct gdt[GDT_ENTRIES]; // 默认 32 个条目
} __aligned(PAGE_SIZE);
DECLARE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page);初始化时机:start_kernel() → setup_arch() → init_per_cpu_var(gdt_page) 初始化模板 → load_per_cpu_segments() 加载到当前 CPU;secondary CPU 启动走 cpu_init() → load_gdt() → load_segments()。
动态修改:GDT 平时基本静态,唯一动态修改是 TLS 描述符——arch_prctl(ARCH_SET_FS, base_addr) 设置 FS 基址;wrfsbase/wrgsbase 指令(需 CR4.FSGSBASE=1)可直接设 FS/GS 基址寄存器,绕过 GDT,是更高效的 TLS 访问方式。
五、两个实战追问(加深理解)
5.1 "mov ds, 0x18" 在用户态为什么必炸
; 用户态 CPL=3
mov ax, 0x18 ; 内核数据段选择子(DPL=0, RPL=0)
mov ds, ax ; 检查 max(CPL=3, RPL=0) = 3 > DPL=0 → #GP(0)
; → 内核把 #GP 转成 SIGSEGV → 进程崩溃这正是 segment-management-observation 篇里"写内联汇编验证段权限"实验的原理——gdb 停住后 info registers cs ds 看到的 CS=0x33 就是 CPL=3 的现场证据。
5.2 内核态为什么能"合法"访问用户内存
内核 CPL=0,加载用户数据段 DS=0x2B 时检查 max(CPL=0, RPL=3) = 3 ≤ DPL=3 → 通过。这就是 copy_from_user/copy_to_user 能读写用户缓冲区的段级基础——但真正让内核敢碰用户地址的是页表 U/S 位(内核页 U/S=0 只给 CPL=0 访问)。两个机制配合:段检查管"段选择子合法性",页表检查管"具体页的访问权限"。
一句话总结
段权限体系 = GDT(配置中心)+ 检查公式 + 免检快车道:GDT 每 CPU 一份,8 字节描述符的 Access Byte 里 DPL 决定"访问这段需要什么权限",检查公式
max(CPL, RPL) <= DPL拦住越权(用户态 CPL=3 加载 DPL=0 的内核段 → #GP → SIGSEGV),RPL 防"借权";而syscall/sysret靠 ring 0 才能写的 MSR_STAR 免检加载预设 CS/SS 实现合法跳特权级(硬件只切 CS/SS,内核入口还要手动切 DS/ES/GS/RSP/CR3);用户态碰不到内核内存靠三层保护层层递进——段 DPL 拦动作、页表 U/S 拦访问(主防线)、KPTI 拦看见(Meltdown 免疫)。