特权级与系统寄存器:CPU 只对汇编说实话 (Privilege Levels & System Registers)


章节概述

CPU 内部有一组”C 语言看不见”的寄存器——控制寄存器(CR0~CR4)、模型特定寄存器(MSR)、描述符表寄存器(GDTR/IDTR)——它们决定了 CPU 的工作模式、内存保护策略和性能特性。访问这些寄存器的指令(mov cr0, raxrdmsrlgdtcpuid)在 C 语言中没有语法对应——只有汇编(或编译器 intrinsic 包装的汇编)能触碰它们。本章带你从 Ring 3 的”用户视角”切换到 Ring 0 的”系统视角”。

核心理念:C 编译器只为你翻译”算法”——a + b 变成 add*p = v 变成 mov [addr], v。但 CPU 的”配置开关”——分页是否启用、保护模式是否生效、APIC 基址在哪——这些不需要翻译,它们是 CPU 的原始操作,只能由汇编指令直接控制。


第一节:特权级——CPU 的”门禁系统”

1.1 四个 Ring,两个在用

x86 定义了 4 个特权级别(Ring 0 ~ Ring 3),数字越小权限越高:

graph TD
    subgraph "Ring 3 — 用户态"
        R3_L["· 不能执行 in/out(取决于 IOPL)<br/>· 不能修改 CR0~CR4<br/>· 不能执行 rdmsr/wrmsr<br/>· 不能执行 lgdt/lidt<br/>· 不能执行 hlt/cli/sti"]
    end
    subgraph "Ring 0 — 内核态"
        R0_L["· 可以使用所有指令<br/>· 可以访问所有系统寄存器<br/>· 可以映射/取消映射内存"]
    end
    R3_L -->|"权限提升"| R0_L

Ring 1 和 Ring 2 是 x86 设计的历史遗留——最初设想给设备驱动使用。实践中 Linux、Windows 和 macOS 都跳过了它们,直接使用 Ring 0(内核)和 Ring 3(用户)的两层模型。

1.2 CPL、DPL、RPL 三权分立

CPU 在每次内存访问和控制转移时都执行权限检查,三个字段配合工作:

; CPL 在 CS 寄存器中 —— 读取当前特权级
mov ax, cs
and ax, 11b ; 低 2 位 = CPL
; CPL=0 → Ring 0, CPL=3 → Ring 3
 
; 在裸机/内核中,你的代码通常 CPL=0
; 在 Linux 用户态,你的代码始终 CPL=3

权限检查规则(简化版):

检查场景规则违反后果
访问代码段(跳转)CPL ≤ DPL(目标代码),且 RPL ≤ DPL#GP 异常
访问数据段CPL ≤ DPL(数据段),且 RPL ≤ DPL#GP 异常
执行 I/O 指令CPL ≤ IOPL(EFLAGS bit 12-13)#GP 异常
执行特权指令CPL = 0(必须 Ring 0)#GP 异常
访问 MSRCPL = 0#GP 异常

#GP(General Protection Fault,中断号 13)→ Linux 中的”SIGSEGV 段错误”就是 #GP#PF(Page Fault)在操作系统层面的体现。

1.3 如何从 Ring 3 进入 Ring 0

普通程序不能”自己提升权限”——否则安全模型就崩溃了。CPU 提供了严格的入口:

方式指令使用场景
系统调用syscall / sysenter / int 0x80用户程序请求内核服务
中断外部硬件中断(键盘、时钟、网卡)硬件事件强制切换到内核
异常除零错误、页错误、保护错误错误处理跳入内核
任务切换call 门 / jmp极少使用(历史遗留)

系统调用的完整机制见 中断与系统调用 章节——那里会详解 syscall 指令如何在一条指令内完成:CPL 切换 + 跳转到内核入口 + 保存返回地址。


第二节:控制寄存器 CR0~CR4 —— CPU 的模式开关

2.1 控制寄存器总览

寄存器名称核心作用
CR0Control Register 0保护模式、分页、FPU 控制、缓存控制
CR1保留(不可用)
CR2Page Fault Linear Address保存最后一次 pf 异常的线性地址
CR3Page Directory Base Register页表物理基址(根页表地址)
CR4Control Register 4架构扩展开关(PAE、OSXSAVE、SMEP、SMAP)

CR1 在设计时被保留但从未被使用。x86-64 体系还定义了 CR8(Task Priority Register,用于 APIC 优先级),但 CR5/CR6/CR7 暂未定义。

2.2 CR0 —— 开关最多的寄存器

; 读取 CR0(汇编专有——没有对应的 C 语法)
mov rax, cr0 ; Intel 语法: mov 通用寄存器, 控制寄存器
; AT&T: mov %cr0, %rax

CR0 的关键位:

名称含义
bit 0 (PE)Protection Enable1 = 启用保护模式。0 = 实模式。启动时必须设置的第一个关键位
bit 1 (MP)Monitor Coprocessor与 TS 配合控制 x87 FPU 操作
bit 2 (EM)Emulation1 = 无 FPU(软件仿真),0 = 使用硬件 FPU
bit 3 (TS)Task Switched每次任务切换时自动置 1,用于惰性 FPU 上下文保存
bit 16 (WP)Write Protect1 = 内核不能写入只读用户页(阻止内核级利用)
bit 29 (NW)Not Write-through缓存写策略(与 CD 位配合)
bit 30 (CD)Cache Disable1 = 全局禁用缓存
bit 31 (PG)Paging1 = 启用分页。保护模式必须先于分页启用

实操:检查是否已启用保护模式和分页(QEMU 裸机)

; check_cr0.asm — 读取 CR0 并检查 PE 和 PG 位
[BITS 32] ; 假设已在保护模式下
 
start:
 mov eax, cr0 ; 读 CR0 到 EAX
 
 ; 检查 PE (bit 0)
 test eax, 1
 jz .no_pe
 ; PE = 1: 保护模式已启用
 
 ; 检查 PG (bit 31)
 test eax, 0x80000000
 jz .no_pg
 ; PG = 1: 分页已启用
 
.no_pe:
.no_pg:
 ; ... 在 VGA 上显示状态 ...
 
 hlt

写入 CR0 需要 Ring 0 权限。用户态程序执行 mov cr0, rax#GP(0) → SIGSEGV。这就是为什么 C 语言不能”启用分页”——不是不能表达,而是 CPU 物理上阻止了它。

2.3 CR2:页错误的”罪证”

当 CPU 触发 #PF(Page Fault,中断号 14)时,CR2 自动记录导致错误的线性地址

; page_fault_handler — #PF 异常处理(简化)
page_fault_handler:
 mov rax, cr2 ; 读取触发页错误的地址
 ; CR2 = 那个"空指针解引用"或"写入只读页"的地址
 ; 在内核中,你会检查这个地址是否合法……
 
 ; 将错误地址打印到 VGA/串口
 ; 然后决定:是杀掉进程,还是换入被 swap 出去的页面
 iretq

这个寄存器是调试段错误(SIGSEGV)的”黄金线索”——GDB 在段错误时显示的 fault addr 就是从 CR2 读取的。

2.4 CR3:页表的根

CR3 保存了顶级页表的物理地址(4KB 对齐):

; 读取当前页表基址
mov rax, cr3 ; RAX = 页表根物理地址
 
; 切换页表(上下文切换时发生)
mov rax, new_page_table_phys_addr
mov cr3, rax ; 设置新的页表根 → TLB 自动刷新(除 Global Page 外)

每次 Linux 做进程切换(switch_mm)时,都会加载目标进程的 pgd(Page Global Directory)到 CR3。这是汇编级别的关键操作——C 语言无法表达,内核中用内联汇编实现。

2.5 CR4:架构扩展的总开关

mov rax, cr4 ; 读取 CR4
名称含义
bit 5 (PAE)Physical Address Extension1 = 启用 PAE 分页(36 位物理地址)。64 位模式下强制启用
bit 7 (PGE)Page Global Enable1 = 允许全局页(不因写 CR3 而刷新 TLB)
bit 9 (OSFXSR)OS FXSave/FXRSTOR1 = OS 支持 FXSAVE/FXRSTOR(SSE 所需)
bit 10 (OSXMMEXCPT)OS XMM Exception1 = OS 支持 xf(SIMD 浮点异常)
bit 18 (OSXSAVE)OS XSAVE Support1 = OS 支持 XSAVE/XRSTOR(AVX 所需)

这些位的组合决定了 CPU 的”功能开关”——开启 AVX 需要 OSXSAVE(bit 18) + XCR0 中的 SSE/AVX 状态位。C 语言的 #include <immintrin.h> 能用 AVX 的前提是操作系统的启动代码已设置正确的 CR4.OSXSAVE——这些步骤隐藏在 start_kernel() 中,由汇编完成。


第三节:CPUID —— CPU 的”自述文件”

3.1 CPUID 的工作原理

cpuid 指令根据 EAX(和 ECX)输入值,返回 CPU 的各类信息到 EAX/EBX/ECX/EDX:

; 查询 CPU 厂商字符串
mov eax, 0 ; Leaf 0: 最大支持 leaf 号 + 厂商 ID
cpuid ; 结果: EAX = 最大 leaf 号
 ; EBX:EDX:ECX = 12 字节 ASCII 厂商字符串
 
; 打印厂商字符串
; EBX = "Genu" (GenuineIntel)
; EDX = "ineI"
; ECX = "ntel"

3.2 完整示例:检查 AVX 支持

; check_avx.asm — 用 CPUID 查询 CPU 是否支持 AVX
; 编译: nasm -f elf64 check_avx.asm && ld check_avx.o -o check_avx
 
section .data
 msg_avx db 'AVX supported!', 0x0a
 msg_avx_len equ $ - msg_avx
 msg_noavx db 'AVX NOT supported.', 0x0a
 msg_noavx_len equ $ - msg_noavx
 
section .text
 global _start
 
_start:
 ; 第一步: 检查 leaf 1 的 ECX bit 28 (AVX)
 mov eax, 1
 cpuid
 test ecx, (1 << 28) ; 测试 AVX 标志位
 jnz .avx_supported
 
 mov rax, 1
 mov rdi, 1
 mov rsi, msg_noavx
 mov rdx, msg_noavx_len
 syscall
 jmp .exit
 
.avx_supported:
 ; 第二步: 确认 OS 已启用 AVX (XCR0 检查)
 ; (在 Linux 用户态可省略,内核已设置)
 mov rax, 1
 mov rdi, 1
 mov rsi, msg_avx
 mov rdx, msg_avx_len
 syscall
 
.exit:
 mov rax, 60
 xor rdi, rdi
 syscall

这个程序可以在 Linux 用户态运行(cpuid 不是特权指令)。但查询到的 AVX 标志位只是 CPU 支持 AVX —— 实际能否使用还需操作系统在 CR4.OSXSAVE 和 XCR0 中启用。编译器 intrinsic __builtin_cpu_supports("avx") 本质就是封装了这个 cpuid 查询。

3.3 CPUID Leaf 快速参考

Leaf (EAX)ECX (sub-leaf)返回信息
0最大 leaf 号 + 厂商字符串 “GenuineIntel” / “AuthenticAMD”
1CPU 型号、家族、步进 + 特性标志 (SSE/AVX/… )
0x80000001扩展特性标志 (64-bit, NX, SYSCALL)
2缓存和 TLB 描述符
40, 1, 2…确定性缓存参数(每核缓存拓扑)
70扩展特性 (AVX2, AVX-512, SMEP, SMAP)
0xD0, 1XSAVE 区域大小(AVX 上下文保存需要)
; 查询是否支持 64 位长模式(Extended Feature Leaf)
mov eax, 0x80000001
cpuid
test edx, (1 << 29) ; EDX bit 29 = LM (Long Mode)
jnz .supports_64bit
; 不支持 64 位长模式 — 无法运行 x86-64 代码

第四节:MSR — 用 rdmsr/wrmsr 读写”隐藏开关”

4.1 MSR 是什么

MSR(Model-Specific Register)是 CPU 内部的一组特殊寄存器,通过索引号访问,而非地址。访问需要两条指令:

; 读 MSR
mov ecx, msr_index ; 索引号放入 ECX
rdmsr ; 结果: EDX(高32位) : EAX(低32位)
; 组合 64 位值: shl rdx, 32 | rax
 
; 写 MSR
mov ecx, msr_index ; 索引号放入 ECX
mov eax, value_low32
mov edx, value_high32
wrmsr ; 写入 MSR[ECX] ← EDX:EAX

rdmsrwrmsr特权指令——仅在 Ring 0 可用。在 Linux 用户态执行直接触发 SIGSEGV。这也是为什么必须用 QEMU 裸机或编写内核模块来学习它们。

4.2 关键 MSR 列表

MSR 地址名称作用
0x1BIA32_APIC_BASEAPIC 基址(物理地址)+ APIC 全局启用位
0xC0000080IA32_EFER扩展功能启用:SCE (syscall 启用)、LME (长模式)、LMA (活跃)、NXE (不可执行页)
0xC0000081STAR系统调用目标地址和段选择子(64-bit SYSCALL/SYSRET)
0xC0000082LSTAR64-bit SYSCALL 目标 RIP(代码入口地址)
0xC0000083CSTAR兼容模式 SYSCALL 目标 RIP(32 位入口)
0xC0000084IA32_FMASKSYSCALL 时自动清除 EFLAGS 的掩码
0x277IA32_PAT页属性表(Page Attribute Table,控制内存类型)
0x2FFIA32_MTRR_DEF_TYPEMTRR 默认内存类型和启用位
0x10IA32_TIME_STAMP_COUNTER时间戳计数器(可通过 rdmsr 或 rdtsc 读取)

4.3 实操:启用长模式(进入 64 位模式的关键步骤)

; 切换到长模式(64 位)的核心步骤(在裸机启动代码中使用)
; 假设已设置好页表(含 PML4、PDPT、PD、PT),CR3 已指向 PML4
 
; Step 1: 启用 PAE(CR4 的 bit 5)
mov eax, cr4
or eax, (1 << 5) ; 设置 PAE 位
mov cr4, eax
 
; Step 2: 设置 CR3 为 PML4 物理地址
mov eax, pml4_phys_addr
mov cr3, eax
 
; Step 3: 启用长模式(EFER MSR 的 bit 8 = LME)
mov ecx, 0xC0000080 ; IA32_EFER
rdmsr
or eax, (1 << 8) ; 设置 LME (Long Mode Enable)
wrmsr
 
; Step 4: 启用分页(CR0 的 bit 31)
mov eax, cr0
or eax, 0x80000000 ; 设置 PG 位
mov cr0, eax
 
; 现在 CPU 进入兼容模式(32位代码但页表是64位结构)
; 通过一个远跳转 (far jump) 加载 64 位代码段描述符完成最终切换

这段代码在 C 语言中完全无法表达——mov cr0, eaxrdmsrwrmsr 没有 C 语言对应语法。唯一的”变通”是编译器 intrinsic(__write_cr0()__readmsr() 等),但它们是内置函数,直接映射到这些汇编指令。


第五节:GDTR / LDTR / IDTR —— 描述符表寄存器

5.1 SGDT:偷窥 GDT 的存储地址

GDT(Global Descriptor Table)定义了段的基址、限长和特权级。其位置保存在 GDTR 寄存器中:

; 读取 GDTR — 获取 GDT 的内存地址和大小
; 结构: [2字节 Limit] [8字节 Base Address](64位模式下)
sub rsp, 10 ; 分配 10 字节空间
sgdt [rsp] ; SGDT 不覆盖任何通用寄存器,直接写内存
; 现在 [rsp+0] = Limit (16 bits)
; [rsp+2] = Base (64 bits in long mode)
; 比较: SLDT 读取 LDTR (Local Descriptor Table Register)
sldt ax ; SLDT 将 LDTR 的段选择子写入通用寄存器
; 设置 GDT(LGDT)——与 SGDT 对称
lgdt [gdt_descriptor] ; gdt_descriptor: dw limit, dq base
; LGDT 后通常紧跟着一个 far jump 来重载 CS 选择子

5.2 SIDT / LIDT:中断描述符表

IDT(Interrupt Descriptor Table)定义了中断/异常的入口地址:

; 读取 IDTR
sub rsp, 10
sidt [rsp] ; 获取 IDT 的位置和大小
 
; 设置 IDT
lidt [idt_descriptor] ; 告诉 CPU 新的中断处理表在哪里

lgdtlidt 是最典型的”C 不能做的事”——这两条指令只能由汇编执行,没有 intrinsic 替代(因为修改 GDT/IDT 本身太危险,编译器有意不提供 intrinsic)。


小节练习


章节测试

一、判断题

判断题 1

EFER MSR(0xC0000080)中的 LME(Long Mode Enable)是进入 64 位长模式的必要条件。 ( )

  • 正确

  • 错误

判断题 2

cpuid 指令本身是特权指令,只能在 Ring 0 下执行。 ( )

  • 正确

  • 错误

判断题 3

CR3 寄存器保存的是虚拟地址,指向当前进程的页表。 ( )

  • 正确

  • 错误

判断题 4

sgdtsidt 指令可以将 GDT/IDT 描述符存储到内存中,在用户态可以执行。 ( )

  • 正确

  • 错误

判断题 5

x86-64 架构中,CR4.PAE 位在 64 位模式下可手动设为 0。 ( )

  • 正确

  • 错误


动手练习题

练习题 1:CPUID 信息搜集器

难度: 简单

编写一个 Linux 用户态 NASM 程序,用 cpuid 指令查询以下信息并以可读格式打印(通过 sys_write 系统调用):

  • 最大 CPUID leaf 号(leaf 0)
  • 厂商字符串(leaf 0 的 EBX:EDX:ECX,12 字节 ASCII)
  • 处理器品牌字符串(leaf 0x80000002 ~ 0x80000004,48 字节 ASCII)
  • 是否支持 SSE、SSE2、AVX、AVX2

提示:品牌字符串需要分 3 次 cpuid 调用(leaf 0x80000002/3/4),每次返回 16 字节。

练习题 2:亲手启用分页

难度: 简单

编写 QEMU 裸机程序,完成以下序列——这是自制内核的”Hello World”:

  1. 从实模式切换到保护模式(设置 GDT + CR0.PE=1)
  2. 在 32 位代码中设置 2 级页表(Page Directory + Page Table)
  3. 映射第一个 4MB 物理内存到两个虚拟地址(1:1 映射 + 高地址映射)
  4. 启用分页(CR0.PG=1)并验证数据在不同虚拟地址下的访问一致性

参考:需要 mov cr0, eaxmov cr3, eax(没有 C 语言语法可替代)——这就是”为什么只有汇编能直接操作硬件”的最佳例证。

练习题 3:MSR 探险者

难度: 简单

在 QEMU 裸机中编写程序,读取以下 MSR 并在 VGA 屏幕上显示:

  • IA32_APIC_BASE (0x1B) — 查看 APIC 基址和启用状态
  • IA32_EFER (0xC0000080) — 解析 LME、LMA、SCE、NXE 位
  • IA32_PAT (0x277) — 查看页属性表(每种内存类型的编码)

每个 MSR 的 64 位值用十六进制显示(如 APIC_BASE: 0xFEE00000)。用 rdmsr — write 是危险的(除非你知道在做什么),读取是安全的。

注意:需要 QEMU 裸机(QEMU 的用户态不支持 rdmsr)。QEMU Monitor 中执行 info registers 不显示 MSR —— 因为这些是”隐藏”寄存器,只有 rdmsr 指令能窥见。

练习

以下题目从汇编/底层视角训练相关能力:

题号题目链接涉及知识点
136只出现一次的数字https://leetcode.cn/problems/single-number/位运算、标志位理解
191位1的个数https://leetcode.cn/problems/number-of-1-bits/位操作指令
371两整数之和https://leetcode.cn/problems/sum-of-two-integers/加减法硬件实现理解