特权级与系统寄存器:CPU 只对汇编说实话 (Privilege Levels & System Registers)
章节概述
CPU 内部有一组”C 语言看不见”的寄存器——控制寄存器(CR0~CR4)、模型特定寄存器(MSR)、描述符表寄存器(GDTR/IDTR)——它们决定了 CPU 的工作模式、内存保护策略和性能特性。访问这些寄存器的指令(mov cr0, rax、rdmsr、lgdt、cpuid)在 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 异常 |
| 访问 MSR | CPL = 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 控制寄存器总览
| 寄存器 | 名称 | 核心作用 |
|---|---|---|
| CR0 | Control Register 0 | 保护模式、分页、FPU 控制、缓存控制 |
| CR1 | — | 保留(不可用) |
| CR2 | Page Fault Linear Address | 保存最后一次 pf 异常的线性地址 |
| CR3 | Page Directory Base Register | 页表物理基址(根页表地址) |
| CR4 | Control 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, %raxCR0 的关键位:
| 位 | 名称 | 含义 |
|---|---|---|
| bit 0 (PE) | Protection Enable | 1 = 启用保护模式。0 = 实模式。启动时必须设置的第一个关键位 |
| bit 1 (MP) | Monitor Coprocessor | 与 TS 配合控制 x87 FPU 操作 |
| bit 2 (EM) | Emulation | 1 = 无 FPU(软件仿真),0 = 使用硬件 FPU |
| bit 3 (TS) | Task Switched | 每次任务切换时自动置 1,用于惰性 FPU 上下文保存 |
| bit 16 (WP) | Write Protect | 1 = 内核不能写入只读用户页(阻止内核级利用) |
| bit 29 (NW) | Not Write-through | 缓存写策略(与 CD 位配合) |
| bit 30 (CD) | Cache Disable | 1 = 全局禁用缓存 |
| bit 31 (PG) | Paging | 1 = 启用分页。保护模式必须先于分页启用 |
实操:检查是否已启用保护模式和分页(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 Extension | 1 = 启用 PAE 分页(36 位物理地址)。64 位模式下强制启用 |
| bit 7 (PGE) | Page Global Enable | 1 = 允许全局页(不因写 CR3 而刷新 TLB) |
| bit 9 (OSFXSR) | OS FXSave/FXRSTOR | 1 = OS 支持 FXSAVE/FXRSTOR(SSE 所需) |
| bit 10 (OSXMMEXCPT) | OS XMM Exception | 1 = OS 支持 xf(SIMD 浮点异常) |
| bit 18 (OSXSAVE) | OS XSAVE Support | 1 = 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” |
| 1 | — | CPU 型号、家族、步进 + 特性标志 (SSE/AVX/… ) |
| 0x80000001 | — | 扩展特性标志 (64-bit, NX, SYSCALL) |
| 2 | — | 缓存和 TLB 描述符 |
| 4 | 0, 1, 2… | 确定性缓存参数(每核缓存拓扑) |
| 7 | 0 | 扩展特性 (AVX2, AVX-512, SMEP, SMAP) |
| 0xD | 0, 1 | XSAVE 区域大小(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
rdmsr和wrmsr是特权指令——仅在 Ring 0 可用。在 Linux 用户态执行直接触发 SIGSEGV。这也是为什么必须用 QEMU 裸机或编写内核模块来学习它们。
4.2 关键 MSR 列表
| MSR 地址 | 名称 | 作用 |
|---|---|---|
0x1B | IA32_APIC_BASE | APIC 基址(物理地址)+ APIC 全局启用位 |
0xC0000080 | IA32_EFER | 扩展功能启用:SCE (syscall 启用)、LME (长模式)、LMA (活跃)、NXE (不可执行页) |
0xC0000081 | STAR | 系统调用目标地址和段选择子(64-bit SYSCALL/SYSRET) |
0xC0000082 | LSTAR | 64-bit SYSCALL 目标 RIP(代码入口地址) |
0xC0000083 | CSTAR | 兼容模式 SYSCALL 目标 RIP(32 位入口) |
0xC0000084 | IA32_FMASK | SYSCALL 时自动清除 EFLAGS 的掩码 |
0x277 | IA32_PAT | 页属性表(Page Attribute Table,控制内存类型) |
0x2FF | IA32_MTRR_DEF_TYPE | MTRR 默认内存类型和启用位 |
0x10 | IA32_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, eax、rdmsr、wrmsr没有 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 新的中断处理表在哪里
lgdt和lidt是最典型的”C 不能做的事”——这两条指令只能由汇编执行,没有 intrinsic 替代(因为修改 GDT/IDT 本身太危险,编译器有意不提供 intrinsic)。
小节练习
章节测试
一、判断题
判断题 1
EFER MSR(0xC0000080)中的 LME(Long Mode Enable)是进入 64 位长模式的必要条件。 ( )
正确
错误
点击查看答案 解析: LME (bit 8) 是进入长模式的开关之一。完整的 64 位切换需要:CR4.PAE=1 + CR3 指向有效 PML4 + EFER.LME=1 + CR0.PG=1。之后通过远跳转完成切换。
答案: 正确
判断题 2
cpuid指令本身是特权指令,只能在 Ring 0 下执行。 ( )
正确
错误
点击查看答案 解析:
cpuid可以在所有特权级别执行——用户态程序可以查询 CPU 厂商、特性标志等。这也是为什么你可以在普通 C 程序和 NASM Linux 用户态程序中使用它。答案: 错误
判断题 3
CR3 寄存器保存的是虚拟地址,指向当前进程的页表。 ( )
正确
错误
点击查看答案 解析: CR3 保存的是物理地址——顶级页表(PML4 或 PAE PDPT 或 Page Directory)的物理基址。CPU 通过物理地址直接遍历页表,不经过虚拟地址转换。
答案: 错误
判断题 4
sgdt和sidt指令可以将 GDT/IDT 描述符存储到内存中,在用户态可以执行。 ( )
正确
错误
点击查看答案 解析:
sgdt和sidt是"存储"(store)指令——只读不写,可以在用户态执行。lgdt和lidt是"加载"(load)指令——修改 GDT/IDT,需要 Ring 0 权限。答案: 正确
判断题 5
x86-64 架构中,CR4.PAE 位在 64 位模式下可手动设为 0。 ( )
正确
错误
点击查看答案 解析: 在 IA-32e 模式(64 位模式)下,PAE 被强制启用(CR4.PAE 始终为 1)。这就是为什么长模式必须配合 PAE 分页——64 位地址空间需要 4 级页表结构。
答案: 错误
动手练习题
练习题 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”:
- 从实模式切换到保护模式(设置 GDT + CR0.PE=1)
- 在 32 位代码中设置 2 级页表(Page Directory + Page Table)
- 映射第一个 4MB 物理内存到两个虚拟地址(1:1 映射 + 高地址映射)
- 启用分页(CR0.PG=1)并验证数据在不同虚拟地址下的访问一致性
参考:需要
mov cr0, eax、mov 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/ | 加减法硬件实现理解 |