三、特权级与门,以及 TSS 的“本来用途”

13 阅读40分钟

三、特权级与门,以及 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

0x180x20 是 GDT 里的用户代码/数据段下标,低 2 位再填上 3,就变成了 0x1B0x23。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
   ┌───┬───────┬───┬───┬───┬───┬───┐
   │ PDPLSEDCRWA │
   └───┴───────┴───┴───┴───┴───┴───┘
          ▲
          └── 这两位就是 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 ; 执行不到这里

关键点在 0x100x13 的区别:

   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 特权级。

对普通代码段,先抓住两个结论:

  1. 同级普通跳转可以。 比如 CPL=3 跳到 DPL=3 的用户代码段,没问题。
  2. 低特权级不能直接跳到高特权级。 用户态 CPL=3 不能直接 jmpcall 到 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 0x80sysentersyscall 的目的不是说它们也是调用门,而是为了说明:调用门虽然是 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=0Type 是 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 里时,可以用 jmpcall 到这个任务门,触发到目标 TSS 的任务切换。
  • 放在 IDT 里时,某个中断/异常向量可以对应一个任务门。中断/异常发生后,CPU 不是进入某个处理函数,而是切换到这个任务门指向的 TSS。

所以你问“IDT 里面有任务门吗?”答案是:架构上允许有。IDT 项不只能是中断门/陷阱门,也可以是任务门。只是现代 Linux 基本不用这种方式处理中断/异常;它通常用中断门/陷阱门进入处理程序,而不是让 CPU 为一个中断切到另一个硬件任务。

保护模式提供几种触发硬件任务切换的方式:

  • far jmp 或 far call 的 selector 直接指向一个 TSS 描述符。
  • far jmp 或 far call 的 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(简化)

   当前任务 ATR 指向 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_structthread_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 最热的路径上。


上一篇:保护模式的段——选择子、GDT 与 64 位段描述符

下一篇:x86-64 的简化——段机制基本退场,FS 和 GS 为什么留下