三、特权级与门,以及 TSS 的“本来用途”
系列说明:这是"x86 的内存管理是怎么一步步演进来的"系列第六篇。整个系列想把三样东西从 CPU 硬件的视角讲透:段寄存器机制、GDT / TSS 这些 CPU 层面的表和寄存器、页表的硬件翻译机制。主线是一条演进史——从 1978 年的 8086,到 80386 的保护模式,再到今天的 x86-64。
六篇的安排是:第一篇 8086 实模式的段寄存器(为什么会有"段"这东西);第二篇 保护模式的段(选择子、GDT、段描述符的位结构);第三篇(本文) 特权级与门,以及 TSS 当年的“本来用途”;第四篇 x86-64 的简化(段基本废弃,FS/GS 为什么留下);第五篇 内核里的 GS / swapgs 与现代 TSS;第六篇 页表的 CPU 机制(CR3、page walk、PTE、KPTI)。
上一篇我们把保护模式的段机制拆到了 64 位描述符那一层:段寄存器里装的不再是基址,而是选择子;CPU 拿选择子去 GDT/LDT 查描述符,描述符里写着 base、limit、type、DPL、P 等字段;加载段寄存器时,CPU 把这些字段缓存进段寄存器的隐藏部分,之后访存直接用缓存。
但上一篇有两个坑故意没填。
第一个坑是 DPL/RPL/CPL 这三个特权级到底怎么一起判定权限。描述符里那个 DPL,不是孤零零地写在那里给人看的;选择子低 2 位那个 RPL,也不是摆设。CPU 每次加载段寄存器、访问数据段、跳到代码段、穿过门,都要把这几个数字拿出来比较。
第二个坑是 S=0 的系统描述符。上一篇主要讲 S=1 的普通代码段/数据段;但 GDT 里还可以放 TSS 描述符、LDT 描述符、调用门、任务门等“系统描述符”。这些东西就是保护模式最初想象里的“高级玩法”:低特权级代码可以通过门受控地进入高特权级;CPU 甚至可以靠 TSS 做硬件任务切换,一次性保存和恢复整套寄存器。
这一篇就把这两块拼上:先讲 CPL/RPL/DPL 怎么判定一次访问;再讲调用门、中断门、陷阱门怎么让控制流跨特权级;最后讲 TSS 当年的本来用途,以及为什么 Linux 几乎不用硬件任务切换,而改用软件上下文切换。
一、保护模式的“保护”,核心是四个特权级
x86 保护模式把代码分成 4 个特权级,编号是 0、1、2、3:
x86 特权级(数字越小,权限越高)
权限最高
Ring 0 操作系统内核
┌────────┐
│ Ring 1 │ 早期设想给驱动/系统服务
│ ┌────┐ │
│ │Ring2│ │ 早期设想给中间层服务
│ │┌──┐│ │
│ ││R3││ │ 用户程序
│ │└──┘│ │
│ └────┘ │
└────────┘
Ring 3
权限最低
数字越小,权限越高。0 是最高特权级,通常就是内核;3 是最低特权级,通常就是用户态程序。Ring 1 和 Ring 2 在 Intel 的原始设计里有位置,但现代主流操作系统基本不用,Linux、Windows、macOS 大体都是 内核用 Ring 0,用户程序用 Ring 3。
这里先别把“特权级”想成抽象的操作系统概念。它在 CPU 里就是几个 2 位字段,硬件真的拿它们做比较:
- 当前正在执行的代码,是什么特权级?
- 你要访问的段,要求什么特权级?
- 你这次请求,是否故意把自己的权限“压低”了?
这三件事分别对应 CPL、DPL、RPL。
二、CPL / DPL / RPL:三个数字,各管一件事
这三个名字最容易混,因为它们都是 0~3 的数字,而且都叫“Privilege Level”。先别急着背规则,先把它们分别对应到一句人话:
CPL:我现在是谁
当前正在执行的代码,处在 Ring 几?
DPL:目标要求谁能碰
这个段、这个门、这个 TSS,最低允许 Ring 几来访问?
RPL:这次请求按谁的身份算
这个选择子自己声明:本次访问最多只能按 Ring 几的权限来算?
换成一句更短的:
CPL = 当前执行者
DPL = 被访问对象的门槛
RPL = 这张“访问票”上写的请求身份
很多人会卡在 RPL,因为 CPL 和 DPL 都很直观:一个是“我是谁”,一个是“目标要求多高权限”。RPL 看起来像多余的。但它的核心价值恰恰是:高权限代码可以拿一张低权限的票去访问对象,从而避免自己替低权限调用者越权。
下面分开讲。
CPL:当前代码的特权级
CPL(Current Privilege Level) 是“当前正在执行的代码”的特权级。CPU 从哪里知道 CPL?答案很直接:看 CS 选择子的低 2 位。
也就是说:
CPL = CS.selector & 0b11
如果当前 CS 是 0x08,低 2 位是 0,CPL=0,说明现在跑在内核态。如果当前 CS 是 0x1B:
0x1B = 0001_1011b
└─────┬─┘
低 2 位 = 3
那 CPL=3,说明现在跑在用户态。
这就是为什么你常见的 32 位 x86 Linux 选择子长这样:
内核代码段 selector = 0x08 CPL=0
内核数据段 selector = 0x10 RPL=0
用户代码段 selector = 0x1B RPL=3
用户数据段 selector = 0x23 RPL=3
0x18 和 0x20 是 GDT 里的用户代码/数据段下标,低 2 位再填上 3,就变成了 0x1B、0x23。CPU 执行在 CS=0x1B 时,CPL 就是 3。
所以 CPL 不是“某个进程属性”,也不是“某个线程结构体字段”。在 CPU 硬件眼里,CPL 就藏在当前 CS 里。只要 CS 换到了 DPL=0 的内核代码段,CPL 就变成 0;只要 CS 回到 DPL=3 的用户代码段,CPL 就变成 3。
DPL:描述符要求的特权级
DPL(Descriptor Privilege Level) 在段描述符或门描述符里。它表示“这个对象要求什么特权级才能访问”。
上一篇讲 Access Byte 时已经见过 DPL:
Access Byte:
7 6 5 4 3 2 1 0
┌───┬───────┬───┬───┬───┬───┬───┐
│ P │ DPL │ S │ E │DC │RW │ A │
└───┴───────┴───┴───┴───┴───┴───┘
▲
└── 这两位就是 DPL
如果一个数据段描述符的 DPL=0,意思是“只有足够高特权级的代码才能把它当数据段访问”。如果一个用户数据段 DPL=3,那用户态代码就可以访问。
注意“数字越小权限越高”这个方向:CPL=0 比 CPL=3 高。CPU 做权限比较时,经常出现 <= 这种看起来反直觉的关系。
DPL 的含义会随“对象类型”略有差别:
- 对数据段来说,DPL 是“访问这个数据段所需的最低权限门槛”。
- 对普通代码段来说,DPL 基本决定“这段代码运行在 Ring 几”。
- 对门描述符来说,DPL 是“谁有资格使用这个门”,而门后面目标代码段的 DPL 又决定进去以后变成 Ring 几。
所以看到 DPL 时,要先问一句:这是数据段的 DPL,代码段的 DPL,还是门的 DPL?字段名一样,但参与的规则不完全一样。本文先把最容易混的“数据段访问”讲透,再讲门。
RPL:这次请求愿意以什么级别发起
RPL(Requested Privilege Level) 是选择子低 2 位。它表示“这次请求的特权级”。
这东西第一次看很奇怪:既然 CPU 已经知道当前 CPL,为什么选择子里还要带一个 RPL?
关键在于:RPL 可以让高特权级代码主动降低一次访问的权限。这主要是给操作系统检查用户指针用的。
想象一个内核系统调用,用户传进来一个“指向缓冲区的指针”。这个指针本质上可能包含一个段选择子和偏移。如果内核直接用 CPL=0 的身份去访问,那它当然什么都能碰,可能不小心替用户读写了内核自己的私有段。RPL 的设计就是让内核可以说:“虽然我现在是 CPL=0,但这次访问代表用户请求,所以按 RPL=3 来判定。”
于是 CPU 判定数据段访问时,不只看 CPL,也看 RPL。它取两者里“权限更低”的那个,也就是数值更大的那个:
有效特权级 EPL = max(CPL, RPL)
然后拿 EPL 去和目标段的 DPL 比较。
注意这里的 max 不是“取更高权限”,而是“取更低权限”。因为 x86 的数字越大权限越低:
max(0, 3) = 3 → 取 Ring 3,权限更低
max(0, 0) = 0 → 取 Ring 0,权限更高
max(3, 0) = 3 → 当前本来就是用户态,不能靠 RPL=0 提权
所以 RPL 有两个非常重要的结论:
- RPL 不能提权:用户态 CPL=3,就算构造一个 RPL=0 的选择子,
max(3, 0)仍然是 3。 - RPL 可以降权:内核态 CPL=0,如果故意使用 RPL=3 的选择子,
max(0, 3)就变成 3,这次访问按用户权限算。
三、访问数据段:max(CPL, RPL) 必须不高于 DPL
先看最常见的场景:加载 DS/ES/FS/GS,或者通过这些段寄存器访问数据段。
对普通数据段,CPU 的核心规则可以简化成:
max(CPL, RPL) <= DPL
数字越小权限越高,所以这句话的意思是:
“当前代码和本次请求里权限更低的那个,也必须有资格访问目标段。”
举几个具体数值就清楚了。
例子一:用户态访问用户数据段,允许
当前代码:CPL = 3
选择子: RPL = 3
目标段: DPL = 3
max(CPL, RPL) = max(3, 3) = 3
3 <= 3 成立
→ 允许访问
这就是最普通的用户态读写自己的数据。
例子二:用户态访问内核数据段,拒绝
当前代码:CPL = 3
选择子: RPL = 3
目标段: DPL = 0
max(CPL, RPL) = 3
3 <= 0 不成立
→ #GP,一般保护错误
用户态不能把内核数据段选择子装进 DS/ES/FS/GS。就算它知道选择子的数值也没用,CPU 会在加载段寄存器那一刻查描述符、比 DPL,然后直接拦下。
例子三:内核访问内核数据段,允许
当前代码:CPL = 0
选择子: RPL = 0
目标段: DPL = 0
max(CPL, RPL) = 0
0 <= 0 成立
→ 允许访问
这是内核自己的正常访问。
例子四:内核用 RPL=3 的选择子访问内核数据段,拒绝
当前代码:CPL = 0
选择子: RPL = 3
目标段: DPL = 0
max(CPL, RPL) = 3
3 <= 0 不成立
→ #GP
这就是 RPL 的意义:哪怕当前代码是内核,只要这次请求的选择子把 RPL 标成 3,CPU 就按用户级别去卡它。RPL 不是提升权限用的,低特权级代码不能靠把 RPL 改成 0 就碰内核段,因为 max(CPL, RPL) 里还有 CPL=3 卡着;它只能用来把权限压低。
用一小段 32 位保护模式汇编,可以把这个例子写得很直观。假设 GDT 里有这么一项:
GDT[2] = 内核数据段
selector = 0x10 ; Index=2, TI=0, RPL=0
DPL = 0
类型 = 可写数据段
现在 CPU 已经在内核代码段里执行,也就是:
CS = 0x08
CPL = 0
如果正常加载内核数据段,没问题:
mov ax, 0x10 ; 0x10 = Index 2, RPL 0
mov ds, ax ; CPL=0, RPL=0, DPL=0 -> max(0,0)=0 <= 0,允许
但如果故意把同一个内核数据段选择子的低 2 位改成 3,就变成了 0x13:
mov ax, 0x13 ; 0x13 = Index 2, RPL 3
; 注意:它指向的仍然是 GDT[2] 这条内核数据段描述符
mov ds, ax ; CPL=0, RPL=3, DPL=0
; max(0,3)=3,3 <= 0 不成立
; CPU 在这里触发 #GP
mov eax, 0x12345678 ; 执行不到这里
关键点在 0x10 和 0x13 的区别:
0x10 = 0001_0000b
Index = 2, RPL = 0
0x13 = 0001_0011b
Index = 2, RPL = 3
它们的 Index 都是 2,所以查到的是同一条 GDT 描述符,也就是同一个 DPL=0 的内核数据段。区别只在低 2 位 RPL。0x13 等于告诉 CPU:“虽然我现在在 Ring 0 执行,但这次访问按 Ring 3 请求来算。”于是硬件用 max(CPL, RPL) 得到 3,再去和目标段 DPL=0 比,最后拒绝。
把这条规则画成一个架构语义上的逻辑流程图,大致是这样:
加载 DS/ES/FS/GS 或访问数据段
当前 CS 目标段选择子
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 取 CS 低2位 │ │ 拆出 RPL │
│ 得到 CPL │ │ 拆出 Index │
└──────┬──────┘ └──────┬──────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ 查 GDT/LDT │
│ │ 取描述符 │
│ └──────┬──────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ 类型检查 │ 必须是可访问的数据段
│ └──────┬──────┘
│ │
└──────────────┬───────────────────┘
▼
┌───────────────────────┐
│ max(CPL, RPL) <= DPL ? │
└──────┬──────────┬─────┘
│是 │否
▼ ▼
加载成功 #GP
缓存描述符
这条线就是上一篇“保护是真的”的权限版。上一篇我们主要看了 Index 越界、类型不对;这里补上特权级比较。
注意图里有两个输入:目标段选择子提供 RPL 和 Index,当前 CS 提供 CPL。CPL 不是从目标段里来的,也不是从 DS/ES/FS/GS 里来的;它永远来自当前正在执行的代码段,也就是当前 CS 的低 2 位。
严格说,这张图描述的是 x86 架构定义的可观察逻辑,不是现代 CPU 微架构内部逐周期执行图。真实 CPU 里可能有段描述符缓存、并行检查、微码路径等实现细节,但它最终必须表现得等价于这条逻辑:CPL 来自当前 CS,RPL 来自目标选择子,DPL 来自目标描述符,然后按 max(CPL, RPL) <= DPL 判定。
这里还有一个容易被忽略的特殊规则:SS(栈段)比 DS/ES/FS/GS 更严格。加载 SS 时,目标段必须是可写数据段,而且:
CPL == RPL == DPL
也就是说,CPL=3 的用户态只能加载 DPL=3、RPL=3 的用户栈段;CPL=0 的内核只能加载 DPL=0、RPL=0 的内核栈段。栈是函数调用、异常返回、特权级切换的根基,CPU 对它不允许“差不多够权限”这种模糊情况。
一个贯穿例子:内核检查用户传进来的段选择子
把 CPL/DPL/RPL 放到一个具体故事里,会更容易分清。
假设系统里有两个数据段:
内核数据段:
描述符在 GDT[2]
描述符 DPL = 0
常用选择子 = 0x10 ; Index=2, RPL=0
用户数据段:
描述符在 GDT[4]
描述符 DPL = 3
常用选择子 = 0x23 ; Index=4, RPL=3
现在用户程序发起系统调用,进入内核。进入以后:
当前 CPU 正在执行内核代码
所以 CPL = 0
用户同时传进来一个选择子,说“请内核帮我读这个段里的数据”。内核要判断这个选择子是不是用户本来就有权访问的。这里分别看三种情况。
情况一:用户传的是正常用户数据段 0x23。
当前执行者:CPL = 0 内核正在跑
请求选择子:RPL = 3 这张票声明按用户身份访问
目标数据段:DPL = 3 用户数据段允许 Ring 3 访问
EPL = max(CPL, RPL)
= max(0, 3)
= 3
检查:EPL <= DPL
3 <= 3 成立
结果:允许
这符合直觉:用户让内核访问用户自己的数据段,可以。
情况二:用户恶意传内核数据段选择子,但把 RPL 保持为 3。
假设它传的是 0x13,也就是“内核数据段下标 0x10 + RPL=3”:
当前执行者:CPL = 0 内核正在跑
请求选择子:RPL = 3 请求身份仍然是用户
目标数据段:DPL = 0 内核数据段只允许 Ring 0
EPL = max(0, 3) = 3
检查:EPL <= DPL
3 <= 0 不成立
结果:#GP,拒绝
这里最关键:虽然真正执行加载/访问的是内核,CPL=0,但 RPL=3 把这次访问压成了用户权限。CPU 不允许“内核代表用户”去碰内核数据段。
情况三:用户试图把 RPL 改成 0,传 0x10。
这时选择子低 2 位是 0,看起来像“Ring 0 请求”。但如果这条访问真的发生在用户态,CPL 还是 3:
当前执行者:CPL = 3 用户代码正在跑
请求选择子:RPL = 0 用户伪造了一张 Ring 0 的票
目标数据段:DPL = 0 内核数据段
EPL = max(3, 0) = 3
检查:EPL <= DPL
3 <= 0 不成立
结果:#GP,拒绝
所以 RPL 不能帮用户提权。CPU 永远会把 CPL 也算进去。
把三者关系压成一张小表:
场景 CPL RPL DPL EPL=max(CPL,RPL) 结果
---------------------------------------------------------------------
用户访问用户数据段 3 3 3 3 允许
用户访问内核数据段 3 3 0 3 拒绝
用户伪造 RPL=0 访问内核 3 0 0 3 拒绝
内核访问内核数据段 0 0 0 0 允许
内核按用户身份访问内核段 0 3 0 3 拒绝
内核按用户身份访问用户段 0 3 3 3 允许
这张表就是 CPL/DPL/RPL 的核心差异:
CPL 防止“当前低权限代码”越权。
DPL 定义“目标对象”的权限门槛。
RPL 防止“高权限代码替低权限请求”越权。
四、代码段的跳转更复杂:普通跳转不能随便降到内核
数据段访问规则相对直。代码段更麻烦,因为它会改变 CS,而 CS 的低 2 位就是 CPL。换句话说,跳到另一个代码段,不只是“换个地方执行”,还可能改变当前 CPU 特权级。
对普通代码段,先抓住两个结论:
- 同级普通跳转可以。 比如 CPL=3 跳到 DPL=3 的用户代码段,没问题。
- 低特权级不能直接跳到高特权级。 用户态 CPL=3 不能直接
jmp或call到 DPL=0 的内核代码段。否则任何用户程序只要知道内核代码段选择子,就能一脚跨进 Ring 0,保护模式就失去意义了。
所以从 Ring 3 到 Ring 0,不能靠普通 jmp/call。必须走一个受 CPU 认可的“门”。
严格说,代码段里还有一种 一致代码段(conforming code segment),它允许低特权级代码跳进去执行某些共享代码,同时 CPL 不变。这个设计很少出现在现代操作系统主路径里,容易把主线搅乱。本文后面说“跨进内核”时,默认讨论的都是普通非一致代码段;用户态要进入 Ring 0,仍然必须走门、中断、sysenter/syscall 这类受控入口。
这就是门描述符出现的原因。
五、门描述符:跨特权级不能翻墙,只能走门
“门”(gate)也是一种描述符,放在 GDT、LDT 或 IDT 里。它不描述一段连续内存,而是描述一个受控入口:
门描述符记录的不是 base/limit,而是:
- 目标代码段 selector
- 目标入口 offset
- 这个门本身的 DPL
- 门的类型
- P 位
直观地说,门像一个安检口。用户态不能直接跳进内核代码段,但可以请求穿过某个 DPL=3 的门;CPU 检查门允许你进,再检查门后面指向的目标代码段确实是合法的高特权级代码段,然后由 CPU 自动完成 CS/EIP、栈等切换。
常见的门有几类:
- 调用门(call gate):给
CALL far用,设计目的是让低特权级代码受控调用高特权级服务。 - 中断门(interrupt gate):放在 IDT 里,中断/异常进入内核时用。进入处理程序时,CPU 会自动清 IF,屏蔽可屏蔽中断。
- 陷阱门(trap gate):也放在 IDT 里,异常/调试场景常用。和中断门很像,但进入处理程序时不清 IF。
- 任务门(task gate):指向一个 TSS 描述符,触发硬件任务切换。这个留到后面讲 TSS。
调用门:用户态受控 call 到内核
调用门在 32 位保护模式下是一个 8 字节系统描述符,大致长这样:
32 位调用门描述符(示意)
63 48 47 40 39 32
┌────────────────┬────────────┬──────────┐
│ Offset 31:16 │ Access │ 参数个数 │
└────────────────┴────────────┴──────────┘
31 16 15 0
┌────────────────┬────────────────────┐
│ Target Selector│ Offset 15:0 │
└────────────────┴────────────────────┘
逻辑含义:
Target Selector = 门后面要进入哪个代码段
Offset = 进入那个代码段的哪条指令
DPL = 谁可以调用这个门
参数个数 = 跨栈时 CPU 从旧栈复制多少个参数到新栈
一次从用户态通过调用门进入内核,大致经过这些硬件检查:
用户态 CALL far 到调用门
当前 CPL=3
选择子 RPL=3
│
▼
┌───────────────────────┐
│ 查 GDT/LDT,取调用门 │
└──────────┬────────────┘
│
▼
┌────────────────────────────┐
│ max(CPL, RPL) <= 门.DPL ? │ 门是否允许这个调用者使用
└──────────┬─────────────────┘
│
├── 否 ──► #GP
│
└── 是
│
▼
┌───────────────────────┐
│ 取门里的目标代码段 │
└──────────┬────────────┘
│
▼
┌────────────────────────────┐
│ 目标代码段 DPL <= CPL ? │ 只能进同级或更高权限代码
└──────────┬─────────────────┘
│
├── 否 ──► #GP
│
└── 是
│
▼
┌───────────────────────┐
│ CPU 切换 CS:EIP │
│ 必要时切换栈 │
└───────────────────────┘
第二个判断容易看反,单独解释一下。
在 x86 里,数字越小,权限越高。所以:
目标代码段 DPL <= 当前 CPL
意思不是“只能去低权限代码”,而是:
目标代码段必须和当前一样高权限,或者比当前更高权限。
CPL=3 -> 目标 DPL=3:同级调用,可以
CPL=3 -> 目标 DPL=0:通过门进入内核,可以
CPL=0 -> 目标 DPL=3:通过调用门去用户态,不是这条路
这正是调用门的用途:普通 jmp/call 不能让 Ring 3 直接跳进 Ring 0;调用门则给 Ring 3 一个受控入口,让 CPU 在检查门权限、检查目标代码段、必要时换栈之后,进入 DPL 更高权限的代码段。
对比一下更清楚:
普通 far call/jmp 到非一致代码段:
目标代码段 DPL 必须等于 CPL
Ring 3 不能直接 call Ring 0
通过调用门 call:
先检查调用者是否有资格使用这个门
再允许进入 DPL <= CPL 的目标代码段
Ring 3 可以通过 DPL=3 的门进入 DPL=0 的内核入口
如果目标代码段 DPL 比当前 CPL 更高权限(比如从 3 进 0),CPU 还要切栈。为什么?因为不能让内核继续用用户栈。
用户栈在用户地址空间,用户程序可以随便改。如果内核一进来还压返回地址、保存寄存器到用户栈,那用户程序就能伪造内核返回现场,后果很严重。所以跨特权级进入内核时,CPU 必须换到一个内核栈。
这个“新栈在哪”就来自 TSS。
通过调用门从 Ring 3 进入 Ring 0 时,CPU 自动做的事(简化)
旧状态:
CPL=3
SS:ESP = 用户栈
CS:EIP = 用户代码
1. 从当前任务的 TSS 里取 SS0:ESP0
2. 切到 Ring 0 内核栈
3. 在新栈上压入旧 SS、旧 ESP、旧 CS、旧 EIP
4. 如果调用门指定参数个数,再从旧栈复制参数到新栈
5. 加载目标 CS:EIP,CPL 变成 0
新状态:
CPL=0
SS:ESP = 内核栈
CS:EIP = 调用门指定的内核入口
这套设计非常完整:用户态只看到一个门,门后面真正的入口地址和目标代码段由内核在 GDT/LDT 里配置;CPU 负责检查权限、换栈、保存返回现场。
把调用门放在系统调用这条线上看,它的位置是这样的:调用门确实能完成“用户态受控进入内核态”这件事,但现代操作系统通常不用它作为系统调用入口。
现代系统调用走的是别的入口机制:
调用门(call gate):
通过 GDT/LDT 里的调用门描述符进入内核
用 far call 触发
保护模式原始设计支持,但现代 OS 很少拿它做系统调用主路径
int 0x80:
通过 IDT[0x80] 里的中断门/陷阱门进入内核
用 int 指令触发
32 位 Linux 传统系统调用入口
sysenter/sysexit:
通过专门的 MSR 指定内核入口
不走调用门,也不按 int n 查 IDT
后来的 32 位快速系统调用路径
syscall/sysret:
通过专门的 MSR 指定内核入口
不走调用门
x86-64 主流系统调用路径
它们的共同点是:都能把控制流从用户态带进内核态。区别在于硬件入口机制不同。调用门是 GDT/LDT 里的门;int 0x80 是 IDT 里的门;sysenter/syscall 是专门为快速系统调用设计的指令和 MSR 路径。
所以这里提 int 0x80、sysenter、syscall 的目的不是说它们也是调用门,而是为了说明:调用门虽然是 Intel 设计的通用跨特权级调用机制,但现代操作系统做系统调用时,通常选择了别的、更直接的入口机制。
中断门和陷阱门:IDT 里的受控入口
中断门、陷阱门主要放在 IDT(Interrupt Descriptor Table,中断描述符表) 里。IDT 和 GDT 类似,也由一个专门寄存器 IDTR 指着:
IDTR
┌────────────┬────────────┐
│ IDT Base │ IDT Limit │
└────────────┴────────────┘
│
▼
┌──────────────┐
│ IDT[0] #DE │ 除零异常
├──────────────┤
│ IDT[13] #GP │ 一般保护错误
├──────────────┤
│ IDT[14] #PF │ 缺页异常
├──────────────┤
│ IDT[0x80] │ Linux 传统系统调用入口
└──────────────┘
IDT 里的每一项也是门。异常、中断、int n 指令都会通过 IDT 找入口。中断门和陷阱门的主要区别是:
- 中断门:CPU 进入处理程序时会清 EFLAGS.IF,暂时关掉可屏蔽中断。
- 陷阱门:CPU 不清 IF,保留中断使能状态。
权限上,IDT 门也有 DPL。这个 DPL 对硬件中断/异常和软件 int n 的意义不同:
- 外部硬件中断、CPU 自己产生的异常,不受门 DPL 限制。该进就进。
- 软件执行
int n时,CPU 会检查CPL <= 门.DPL。所以用户态能不能主动执行int 0x80,取决于 IDT[0x80] 的门 DPL 是否设成 3。
这里要把名字说准:传统 Linux int 0x80 不是走调用门(call gate),而是走 IDT[0x80] 里的中断门或陷阱门。它能从用户态进内核,是因为内核把 IDT[0x80] 这个门的 DPL 设成 3,允许 CPL=3 的用户代码执行 int 0x80。而大多数异常/中断入口的门 DPL=0,用户态不能随便 int 13 去伪造一个 #GP。
所以两条路要分开:
调用门(call gate):
放在 GDT/LDT 里
用 far call 触发
是保护模式原始设计里的受控跨级调用机制
int 0x80:
查 IDT[0x80]
走中断门或陷阱门
是 32 位 Linux 传统系统调用入口
通过中断门/陷阱门从 Ring 3 进入 Ring 0 时,CPU 同样会切到内核栈;这个栈的位置,还是来自 TSS 里的 SS0:ESP0。
用户态 int 0x80 / 异常 / 中断进入内核(32 位保护模式,简化)
用户态:
CPL=3
SS:ESP = 用户栈
CS:EIP = 用户代码
│
▼
┌───────────────────────┐
│ 用向量号查 IDT │
│ 取中断门/陷阱门 │
└──────────┬────────────┘
│
▼
┌───────────────────────┐
│ 检查 P、类型、DPL │ 软件 int 要看 DPL
└──────────┬────────────┘
│
▼
┌───────────────────────┐
│ 从 TSS 取 SS0:ESP0 │ 发生 CPL 3→0 时换栈
└──────────┬────────────┘
│
▼
┌───────────────────────┐
│ 新栈压入返回现场 │ SS/ESP/EFLAGS/CS/EIP
└──────────┬────────────┘
│
▼
加载门里的 CS:EIP
开始执行内核入口
到这里,TSS 已经出现了两次:调用门跨级要用它找内核栈,中断门跨级也要用它找内核栈。但 TSS 最初被设计出来,并不只是为了存一个内核栈指针。它的野心比这大得多。
六、TSS:Intel 原本想让 CPU 硬件切任务
TSS(Task State Segment,任务状态段) 是保护模式里一种系统段。它的描述符放在 GDT 里,S=0,描述符里的 Type 字段标明“这是一个 TSS”。CPU 里还有一个专门的 TR(Task Register,任务寄存器),用来指向当前任务的 TSS。
这里说的 Type 字段,就是上一篇 Access Byte 里低 4 位那块;当 S=1 时,它表示代码段/数据段的 E/DC/RW/A,当 S=0 时,它改为表示系统描述符类型:
系统段/门描述符的 Access Byte(S=0 时)
7 6 5 4 3 0
┌───┬───────┬───┬─────────┐
│ P │ DPL │ S │ Type │
└───┴───────┴───┴─────────┘
│ │
│ └── Type=1001b:可用 32 位 TSS
│ Type=1011b:忙 32 位 TSS
│ Type=0010b:LDT
│ Type=1100b:32 位调用门
│ Type=0101b:任务门
│
└── S=0 表示系统描述符
所以“这是一个 TSS”不是靠名字判断的,而是 CPU 查到 GDT 里的这条系统描述符后,看到 S=0 且 Type 是 TSS 对应编码,才把它当 TSS 描述符处理。
先把关系画出来:
CPU
┌──────────────────────┐
│ TR = TSS selector │
│ 隐藏部分缓存 TSS base │
└──────────┬───────────┘
│ selector
▼
GDT
┌──────────────────────┐
│ ... │
├──────────────────────┤
│ TSS 描述符 │───► 内存里的 TSS
├──────────────────────┤
│ ... │
└──────────────────────┘
TR 和 CS/DS/SS 这些段寄存器很像:可见部分是一个选择子,隐藏部分缓存了 TSS 描述符里的 base、limit、属性。加载 TR 用 ltr 指令。ltr 是特权指令,只有内核能执行。
32 位 TSS 里到底存了什么
32 位保护模式的 TSS 不是一个小结构。它几乎想把“一个任务的 CPU 状态”全装进去:
32 位 TSS 主要字段(示意,不按完整偏移展开)
┌──────────────────────────────┐
│ Previous Task Link │
├──────────────────────────────┤
│ ESP0, SS0 │ 进入 Ring 0 时用的栈
│ ESP1, SS1 │ 进入 Ring 1 时用的栈
│ ESP2, SS2 │ 进入 Ring 2 时用的栈
├──────────────────────────────┤
│ CR3 │ 任务自己的页表根
├──────────────────────────────┤
│ EIP, EFLAGS │
├──────────────────────────────┤
│ EAX, ECX, EDX, EBX │
│ ESP, EBP, ESI, EDI │
├──────────────────────────────┤
│ ES, CS, SS, DS, FS, GS │
├──────────────────────────────┤
│ LDT Selector │ 任务自己的 LDT
├──────────────────────────────┤
│ I/O Map Base │ I/O 权限位图偏移
└──────────────────────────────┘
这个结构一看就知道 Intel 当年的意图:每个任务一个 TSS,CPU 切任务时自动把旧任务寄存器存进旧 TSS,再从新 TSS 里恢复新任务寄存器。连 CR3 都在里面,说明它连页表切换也想一并包了。
这就是“硬件任务切换”。
七、硬件任务切换:CPU 自动保存旧任务,加载新任务
先把“任务门”这个名字说清楚。
任务门(task gate) 也是一种系统门描述符,但它和调用门/中断门很不一样:它里面不放目标入口 CS:EIP,而是放一个 TSS 选择子。CPU 通过任务门找到目标 TSS 描述符,再触发一次硬件任务切换。
任务门描述符(逻辑含义)
┌──────────────────────────────┐
│ P / DPL / Type=任务门 │
├──────────────────────────────┤
│ TSS Selector │───► GDT 里的某个 TSS 描述符
└──────────────────────────────┘
任务门可以放在 GDT/LDT 里,也可以放在 IDT 里:
- 放在 GDT/LDT 里时,可以用
jmp或call到这个任务门,触发到目标 TSS 的任务切换。 - 放在 IDT 里时,某个中断/异常向量可以对应一个任务门。中断/异常发生后,CPU 不是进入某个处理函数,而是切换到这个任务门指向的 TSS。
所以你问“IDT 里面有任务门吗?”答案是:架构上允许有。IDT 项不只能是中断门/陷阱门,也可以是任务门。只是现代 Linux 基本不用这种方式处理中断/异常;它通常用中断门/陷阱门进入处理程序,而不是让 CPU 为一个中断切到另一个硬件任务。
保护模式提供几种触发硬件任务切换的方式:
- far
jmp或 farcall的 selector 直接指向一个 TSS 描述符。 - far
jmp或 farcall的 selector 指向一个任务门(task gate),任务门再指向 TSS 描述符。 - 中断/异常通过 IDT 里的任务门进入某个 TSS。
iret返回到前一个任务。
这里的 jmp/call 一定要理解成 远跳转/远调用(far control transfer),不是普通的近跳转。普通 jmp label 只改 EIP;far jmp/call 的操作数里有一个 selector,CPU 会拿这个 selector 去 GDT/LDT 查描述符。如果查到的是代码段,就按代码段跳转;如果查到的是 TSS 描述符或任务门,就触发硬件任务切换。
示意一下:
; 假设 0x28 是 GDT 里某个可用 32 位 TSS 描述符的 selector
jmp 0x28:0 ; far jump:selector 指向 TSS,offset 被忽略,触发任务切换
; 假设 0x30 是 GDT/LDT 里某个任务门的 selector
call 0x30:0 ; far call:selector 指向任务门,任务门再指向 TSS
所以更准确的说法不是“跳到 TSS 的某个地址执行”,而是:far jmp/call 的 selector 选中了 TSS 描述符,CPU 于是把这次控制转移解释成任务切换。
一次硬件任务切换,CPU 大致做这些事:
硬件任务切换:Task A -> Task B(简化)
当前任务 A:
TR 指向 TSS_A
CPU 寄存器里是 A 的现场
│ call/jmp 任务门或 TSS 描述符
▼
┌──────────────────────────────┐
│ 1. 检查目标 TSS 描述符 │
│ P 位、类型、DPL、limit │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 2. 把当前寄存器保存到 TSS_A │
│ EIP/EFLAGS/GPR/段寄存器等 │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 3. 把目标 TSS 标记为 busy │
│ 更新 TR 指向 TSS_B │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 4. 从 TSS_B 恢复寄存器 │
│ CR3/EIP/EFLAGS/GPR/段寄存器│
└──────────────┬───────────────┘
│
▼
开始执行 Task B
这套机制甚至支持“嵌套任务”。这里正好回答一个关键问题:切到 Task B 之后,怎么回到 Task A?Task A 的信息存在哪?
分两种情况。
第一种是 jmp 触发的任务切换。jmp 是“直接切过去”,不建立返回关系。CPU 会把 Task A 的寄存器现场保存到 TSS_A,再从 TSS_B 恢复现场,但不会在 TSS_B 里记录“我是从 A 来的”。以后要不要切回 A,得靠操作系统再次显式切换。
第二种是 call 或中断/异常任务门触发的任务切换。这种切换是“嵌套”的,CPU 会建立一条返回链:
Task A --call/中断任务门--> Task B
CPU 自动做两件额外的事:
1. 把旧任务 A 的 TSS selector 写进 TSS_B.Previous Task Link
2. 把新任务 B 的 EFLAGS.NT 位置 1
Previous Task Link 就是 32 位 TSS 里的第一个字段,也叫 backlink。它保存的不是一整份 Task A 状态,而是 Task A 的 TSS 选择子。Task A 的完整寄存器现场已经在切换时保存进 TSS_A 了。
所以返回链长这样:
当前正在跑 Task B
TR ──► TSS_B
┌──────────────────────────────┐
│ Previous Task Link = TSS_A sel│───► GDT 里的 TSS_A 描述符
├──────────────────────────────┤
│ B 的 EIP/EFLAGS/寄存器... │
└──────────────────────────────┘
TSS_A 里保存着 Task A 被切走时的寄存器现场
当 Task B 执行 iret,并且当前 EFLAGS.NT=1 时,CPU 不把它当成普通中断返回,而是当成“从嵌套任务返回”:
Task B 执行 iret
│
▼
CPU 发现 EFLAGS.NT=1
│
▼
读取 TSS_B.Previous Task Link
│
▼
找到 TSS_A
│
▼
把 Task B 当前现场保存进 TSS_B
│
▼
从 TSS_A 恢复寄存器、CR3、EIP、EFLAGS...
│
▼
TR 重新指向 TSS_A,Task A 继续执行
所以答案是:上一个任务的完整现场存在它自己的 TSS 里;“上一个任务是谁”这个链接存在当前 TSS 的 Previous Task Link 字段里。 但这只适用于 call 或中断/异常任务门造成的嵌套任务切换;jmp 任务切换不提供这种自动返回链。
从设计上看,它非常诱人:操作系统好像只要给每个进程准备一个 TSS,调度时 jmp 一下,CPU 就替你保存/恢复寄存器、切 CR3、切 LDT、处理 busy 位,任务切换就完成了。
但现实里,Linux 几乎不用它。
八、为什么 Linux 不用硬件任务切换
Linux 选择的是软件上下文切换:内核自己写汇编/代码,明确保存需要保存的寄存器,明确切换内核栈、地址空间和调度状态。TSS 还在,但不再拿来存“每个任务的一整套寄存器现场”。
原因主要有几类。
1. 太重:CPU 保存恢复的东西过多
硬件任务切换按 TSS 格式保存/恢复一整套状态:通用寄存器、段寄存器、EIP、EFLAGS、CR3、LDT 等。可操作系统并不总是需要切这么多东西。
线程切换时,如果两个线程在同一个进程里,共享同一套页表,就不该无脑切 CR3。内核还可以根据调用约定只保存必要寄存器,浮点/SIMD 状态甚至可以延迟处理。硬件任务切换的粒度太粗,CPU 规定了你必须怎么切,操作系统反而不自由。
2. 不灵活:调度器想维护的不只是 CPU 寄存器
一次现代进程/线程切换,不只是寄存器现场。内核还要处理调度队列、运行时间统计、抢占状态、内核栈、TLS、地址空间引用计数、内核锁、跟踪点、性能计数、安全检查等一堆软件状态。
这些东西硬件 TSS 完全不知道。既然大部分工作仍然要内核自己做,那把最核心的切换动作交给一个黑盒式硬件流程,反而让系统更难控制。
3. 性能不可控:硬件微码路径不一定快
硬件任务切换不是一条普通的“寄存器搬运”指令,它涉及描述符检查、TSS 读写、busy 位、CR3、段寄存器重载等复杂流程。对现代 CPU 来说,这种复杂、很少用的路径很难像常规指令序列那样被优化。
软件上下文切换虽然看起来“手工”,但路径短、可控、可按架构演进调整。操作系统知道哪些状态真的需要保存,CPU 也更容易把常见指令序列跑快。
4. 可移植性:内核不想把调度模型绑死在 x86 TSS 上
Linux 是跨架构内核。ARM、RISC-V、PowerPC 都没有 x86 这种 TSS 硬件任务切换。内核调度器如果围着 x86 TSS 设计,跨架构会很难看。
所以 Linux 的主线做法是:调度器和线程模型保持软件抽象;每个架构只提供一小段 switch_to 之类的底层切换代码。x86 的 TSS 只保留那些硬件仍然强制需要的字段。
总结成一句话:
Intel 原本想:任务切换 = CPU 读写 TSS 自动完成
Linux 实际做:任务切换 = 内核软件自己切
TSS 只留下“进内核要用哪个栈”等硬件必须字段
第五篇讲 x86-64 现代 TSS 时,这个结论会再回来:长模式下 TSS 不再保存通用寄存器现场,主要剩 RSP0/RSP1/RSP2、IST 和 I/O 位图。
九、TSS 虽然不切任务了,但还负责跨级换栈
别误会:Linux 不用硬件任务切换,不代表 TSS 没用了。至少在 32 位保护模式下,TSS 里的 SS0:ESP0 仍然非常关键。
当 CPU 从 Ring 3 通过中断门、陷阱门、调用门进入 Ring 0 时,硬件必须换到内核栈。这个内核栈指针就来自当前 TSS:
32 位保护模式:Ring 3 -> Ring 0 自动换栈
当前用户态:
CPL = 3
SS:ESP = 用户栈
发生中断/异常/int 0x80
│
▼
CPU 查 IDT 门,发现目标代码段 DPL=0
│
▼
发生 CPL 特权级变化:3 -> 0
│
▼
CPU 从当前 TSS 读取:
SS0
ESP0
│
▼
切到内核栈 SS0:ESP0
│
▼
在新栈上压入旧用户态现场:
old SS
old ESP
EFLAGS
old CS
old EIP
error code(某些异常才有)
│
▼
加载内核 CS:EIP,开始执行处理程序
所以即便 Linux 不用 TSS 做“任务切换”,它仍然要给每个 CPU 准备一个 TSS,并在合适的时候更新 TSS.ESP0,让“下一次从用户态进内核”能落到当前线程的内核栈上。
这里的“每个 CPU 一个 TSS”,说的是现代 Linux 这类系统的实际用法:TSS 不再是每个线程一份、用来保存整套硬件任务现场;而是每个 CPU 有一个当前加载到 TR 里的 TSS,供这个 CPU 发生特权级切换时查栈指针。
为什么是每 CPU?因为“从用户态进内核时 CPU 要从哪里取内核栈”这件事,是每个 CPU 独立发生的。CPU0 进内核要读 CPU0 当前 TR 指向的 TSS;CPU1 进内核要读 CPU1 自己的 TSS。不同 CPU 同时跑不同线程,不能共用同一个“当前内核栈指针”。
那什么时候更新 TSS.ESP0?典型时机是调度器在某个 CPU 上切换当前线程时。
假设 CPU0 原来跑线程 A,现在调度器决定切到线程 B:
CPU0 当前 TSS:
ESP0 = A 的内核栈顶
调度切换 A -> B
│
▼
内核把 CPU0 当前 TSS.ESP0 改成:
ESP0 = B 的内核栈顶
这里先只借用一个必要背景:每个线程都有自己的内核栈。线程在用户态运行时,用的是用户栈;一旦通过中断、异常、系统调用进入内核,CPU 不能继续用用户栈,而要切到这个线程对应的内核栈。TSS.ESP0 存的就是“当前 CPU 上正在运行的这个线程,下次从用户态进内核时应该切到的内核栈顶”。
至于一个 Linux 线程/进程的完整内存布局,比如用户栈、内核栈、task_struct、thread_info、页表、vm_area_struct 之间怎么组织,这里先不展开。那已经不是 x86 段/TSS 机制本身的问题,更适合放到后面的 Linux 内存管理系列里系统讲。
之后 CPU0 从内核返回到 B 的用户态。再后来,B 在用户态运行时发生中断、异常或系统调用:
B 正在用户态运行
│
▼
发生 CPL 3 -> 0
│
▼
CPU0 从自己的 TSS 读取 ESP0
│
▼
得到 B 的内核栈顶
│
▼
硬件切到 B 的内核栈,并压入用户态返回现场
所以更新的不是“整个 TSS 里的寄存器现场”,而主要是 这个 CPU 当前 TSS 里的 Ring 0 栈指针:32 位下是 SS0:ESP0(通常 SS0 长期不变,变的是 ESP0),64 位长模式下对应的是 RSP0。它的目的很明确:保证下一次当前线程从用户态陷入内核时,CPU 自动切到这个线程自己的内核栈。
这也是 TSS 在现代系统里最重要的残余用途之一。等到第五篇讲长模式,你会看到名字变了:32 位是 SS0:ESP0,64 位是 RSP0;另外还有 IST(Interrupt Stack Table)给 NMI、double fault 等特殊入口准备独立栈。
十、把门、TSS、特权级串成一次完整的跨级进入
现在把这一篇的几个部件拼成一条完整链路。以 32 位 Linux 传统 int 0x80 为例,用户态进内核大致是这样:
用户程序
CPL=3,CS=用户代码段,SS:ESP=用户栈
int 0x80
│
▼
┌──────────────────────────────┐
│ 1. CPU 用 0x80 查 IDT │
│ 找到 IDT[0x80] 中断/陷阱门 │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 2. 检查门描述符 │
│ P=1,类型正确 │
│ 软件 int 要求 CPL <= 门DPL │
│ 不满足 -> #GP │
│ P=0 -> #NP │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 3. 取门里的目标 CS:EIP │
│ 目标代码段 DPL=0 │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 4. 检查目标代码段 │
│ P=1,类型是代码段 │
│ DPL <= CPL,否则 #GP │
│ P=0 -> #NP │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 5. 发现 CPL 要从 3 变成 0 │
│ 必须换栈 │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 6. 从 TR 指向的当前 TSS │
│ 读取 SS0:ESP0 │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 7. 切到内核栈,压入返回现场 │
│ old SS/ESP/EFLAGS/CS/EIP │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ 8. 加载内核 CS:EIP │
│ CPL 变成 0,进入内核入口 │
└──────────────────────────────┘
这张图里最重要的两个特权级检查是:
1. 软件 int 能不能使用这个 IDT 门:
CPL <= 门.DPL
不成立:#GP
2. 这个门指向的目标代码段能不能进入:
目标代码段 DPL <= 当前 CPL
不成立:#GP
另外还有一些不是“特权级大小比较”,但同样会在这条链上触发异常的检查:
IDT 门 P=0:
#NP
目标代码段 P=0:
#NP
门类型不对、目标描述符类型不对、选择子越界等:
通常是 #GP
这条链路里,每个硬件结构都有位置:
- IDTR / IDT:告诉 CPU 中断向量对应哪个门。
- 门描述符:规定这个入口谁能用、入口地址在哪。
- GDT 里的代码段描述符:规定目标代码段的 DPL 和属性。
- TR / TSS:给 CPU 提供跨级后的内核栈。
- CS 选择子低 2 位:决定进入后 CPL 变成几。
这就是保护模式“受控跨级”的完整样子。它不是一条简单跳转,而是一套由描述符、门、TSS、特权级比较共同组成的硬件流程。
十一、收尾:保护模式原本是一套完整的“分段操作系统模型”
到这里,保护模式的段机制已经比上一篇完整得多了。
上一篇我们讲的是普通段:选择子、GDT、段描述符、base/limit、隐藏缓存。那一层解决的是“一个段在哪里、多大、能不能访问”。
这一篇补上的是系统层:
- CPL / RPL / DPL:CPU 用这三个 2 位数字做权限判定。数据段访问时看
max(CPL, RPL) <= DPL;代码跳转和门调用还有更细的规则。低特权级不能靠伪造选择子直接进入高特权级。 - 门描述符:调用门、中断门、陷阱门提供受控入口。跨特权级时,CPU 不只是改 CS:EIP,还会自动切栈、保存返回现场。
- TSS / TR:TSS 最初设计成“一个任务的硬件现场”,里面有 CR3、EIP、EFLAGS、通用寄存器、段寄存器、LDT、I/O 位图等;TR 指向当前 TSS。
- 硬件任务切换:Intel 原本允许 CPU 通过 TSS/任务门自动保存旧任务、加载新任务。但 Linux 几乎不用这套机制,因为它重、不灵活、和现代调度器的软件状态配合不好。
- TSS 的残余核心用途:即使不用硬件任务切换,TSS 仍然给跨特权级进入内核提供栈指针。32 位下是 SS0:ESP0,64 位下会变成 RSP0 和 IST。
如果站在 80386 时代看,Intel 设计的是一套很完整的“分段操作系统模型”:每个任务可以有自己的 LDT、自己的 TSS,段描述符负责隔离,门负责跨级调用,CPU 负责硬件任务切换。
但操作系统后来走向了另一条路:内存隔离主要交给分页,任务切换主要交给软件,分段被压成平坦模型,只留下特权级入口和少数系统字段还在发挥作用。
这就自然引出下一篇:到了 x86-64,段机制被进一步简化。CS/DS/SS/ES 的 base/limit 基本被硬件忽略,平坦模型几乎写进了架构;但 FS/GS 的 base 却被特意留下,用来做 TLS、per-CPU 数据等现代系统里非常关键的事情。段机制作为“内存划分”的主角退场了,但它的几个影子还留在 CPU 最热的路径上。