五、内核里的 GS / swapgs,与现代 TSS

7 阅读20分钟

五、内核里的 GS / swapgs,与现代 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)


上一篇讲到 x86-64 长模式做了一次大幅简化:普通的 CS/DS/ES/SS 不再用 base/limit 做地址划分,分段基本退到后台;但 FS/GS 的 base 被单独保留下来。用户态常用 FS base 指向 TLS,内核则常用 GS base 指向当前 CPU 的 per-CPU 数据区。

这一篇接着讲两个问题:

   1. 用户态进内核后,GS 怎么从"用户 GS"变成"内核 GS"2. TSS 既然不再做硬件任务切换,为什么 x86-64 Linux 还离不开它?

答案分别是:

   swapgs:交换 IA32_GS_BASE 和 IA32_KERNEL_GS_BASE
   现代 TSS:主要留下 RSP0、IST 和 I/O bitmap

先说明本文实验环境。我的主机是 macOS ARM64,不能直接跑 x86-64 Linux 内核路径;下面涉及用户态可验证行为的结果,是在 Docker 的 linux/amd64 Ubuntu 容器里实际跑出来的:

$ uname -a
Linux cfb835b407ab 6.12.65-linuxkit #1 SMP Thu Jan 15 14:58:53 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux

内核态的 TSS、MSR、入口汇编不能从普通用户态直接读写;那部分不会伪造"实验输出",而是按 CPU 架构规则和 Linux 入口代码来讲。

一、内核为什么需要 GS:快速找到当前 CPU 的数据区

用户态有一个很高频的问题:怎么快速找到"当前线程"的 TLS。第四篇已经看到,x86-64 Linux 通常用 FS base 解决:

   用户态:
     FS.base -> 当前线程 TLS / TCB

内核也有一个类似但不完全一样的问题:怎么快速找到"当前 CPU"的私有数据。

这类数据叫 per-CPU data。它不是每个线程一份,而是每个 CPU 一份:

   CPU0:
     当前正在运行的 task 指针
     调度器运行队列
     中断/抢占计数
     每 CPU 统计数据

   CPU1:
     当前正在运行的 task 指针
     调度器运行队列
     中断/抢占计数
     每 CPU 统计数据

内核访问这些数据非常频繁。如果每次都先读 CPU 编号,再去全局数组里算偏移,成本太高,也不够顺手。更自然的方式是:让每个 CPU 都有一个自己的基址,内核代码用固定偏移访问。

GS 正好适合做这件事:

mov    rax, qword ptr gs:offset

同一条指令,在 CPU0 上用 CPU0 的 GS.base,在 CPU1 上用 CPU1 的 GS.base。指令不用知道自己跑在哪个 CPU 上;当前 CPU 由 GS.base 这个 CPU 状态决定。

所以 x86-64 Linux 的常见分工可以压成一句话:

   用户态 FS:当前线程指针
   内核态 GS:当前 CPU 指针

这和 ARM64 Linux 形成了一个很有意思的对照。ARM64 没有 x86 这种 FS/GS 段基址机制,Linux/arm64 在内核态常用 sp_el0 保存 current 指针。也就是说:

   x86-64 Linux:
     用 GS.base 找 per-CPU,再从 per-CPU 找 current

   ARM64 Linux:
     内核态直接把 current 放在 sp_el0 这种系统寄存器里

两边不是指令集形式相同,而是目标相同:给内核一条很便宜的路径,拿到当前执行上下文。

二、问题来了:用户态也有 GS,内核也想用 GS

FS/GS 在长模式下是特殊的,但它们仍然是 CPU 状态。用户态可以有自己的 GS base,内核也想把 GS base 用作 per-CPU 基址。

那从用户态进入内核时,CPU 该怎么办?

一种粗暴办法是:内核入口每次都用 wrmsr 把 IA32_GS_BASE 改成内核 per-CPU base,返回用户态前再改回去。但 wrmsr 是很重的特权指令,不适合放在每次系统调用、中断、异常入口上。

所以 x86-64 提供了一个专门指令:swapgs

它只做一件事:

   交换:
     IA32_GS_BASE
     IA32_KERNEL_GS_BASE

可以把这两个 MSR 想成两个槽:

   IA32_GS_BASE          当前真正用于 gs:offset 的 GS base
   IA32_KERNEL_GS_BASE   备用槽,专门供 swapgs 对调

在用户态执行时,Linux 通常让它们长这样:

   IA32_GS_BASE          = 用户 GS base
   IA32_KERNEL_GS_BASE   = 内核 per-CPU base

进入内核后,入口代码执行一次:

swapgs

硬件对调两个 MSR:

   IA32_GS_BASE          = 内核 per-CPU base
   IA32_KERNEL_GS_BASE   = 用户 GS base

于是内核里的 gs:offset 就能访问 per-CPU 数据。返回用户态前再执行一次 swapgs,两个值换回来。

image.png

这就是 swapgs 的本质:它不是"进入内核"指令,也不是"切换任务"指令;它只是一个很窄的 GS base 对调指令。真正的入口控制流来自 syscall、中断门、异常门等机制。

三、用户态实际执行 swapgs 会怎样

swapgs 是特权指令,不能在 Ring 3 用户态随便执行。这里用一个小程序实测。

程序做三件事:

  1. arch_prctl(ARCH_GET_FS/ARCH_GET_GS) 读用户态可读的 FS/GS base。
  2. 在用户态执行 swapgs
  3. 在用户态执行 rdmsrIA32_GS_BASE

完整代码如下:

// gs_probe.c
#define _GNU_SOURCE

#include <asm/prctl.h>
#include <errno.h>
#include <signal.h>
#include <setjmp.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <sys/utsname.h>
#include <unistd.h>

static sigjmp_buf env;
static volatile sig_atomic_t last_signal;

static void handler(int sig, siginfo_t *info, void *ctx)
{
    (void)info;
    (void)ctx;
    last_signal = sig;
    siglongjmp(env, 1);
}

static void install_handlers(void)
{
    struct sigaction sa;

    memset(&sa, 0, sizeof(sa));
    sa.sa_sigaction = handler;
    sa.sa_flags = SA_SIGINFO;

    sigaction(SIGILL, &sa, NULL);
    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGBUS, &sa, NULL);
}

static void try_instruction(const char *name, void (*fn)(void))
{
    last_signal = 0;

    if (sigsetjmp(env, 1) == 0) {
        fn();
        printf("%-10s -> executed\n", name);
    } else {
        printf("%-10s -> signal %d (%s)\n",
               name, last_signal, strsignal(last_signal));
    }
}

static void do_swapgs(void)
{
    asm volatile(".byte 0x0f, 0x01, 0xf8" ::: "memory");
}

static void do_rdmsr_gs_base(void)
{
    unsigned int lo, hi;
    asm volatile("rdmsr" : "=a"(lo), "=d"(hi) : "c"(0xC0000101));
}

int main(void)
{
    struct utsname u;
    unsigned long fs = 0;
    unsigned long gs = 0;
    long ret_fs;
    long ret_gs;

    uname(&u);
    printf("uname      -> %s %s %s\n", u.sysname, u.release, u.machine);

    ret_fs = syscall(SYS_arch_prctl, ARCH_GET_FS, &fs);
    printf("ARCH_GET_FS -> ret=%ld errno=%d fs=0x%lx\n",
           ret_fs, ret_fs ? errno : 0, fs);

    ret_gs = syscall(SYS_arch_prctl, ARCH_GET_GS, &gs);
    printf("ARCH_GET_GS -> ret=%ld errno=%d gs=0x%lx\n",
           ret_gs, ret_gs ? errno : 0, gs);

    install_handlers();
    try_instruction("swapgs", do_swapgs);
    try_instruction("rdmsr", do_rdmsr_gs_base);

    return 0;
}

编译运行:

gcc -O2 -Wall gs_probe.c -o gs_probe
./gs_probe

反汇编确认生成的就是 swapgsrdmsr

$ objdump -dr -Mintel /tmp/gs_probe | grep -A8 "<main>"
0000000000001040 <main>:
    1040:	f3 0f 1e fa          	endbr64
    1044:	0f 01 f8             	swapgs
    1047:	b9 01 01 00 c0       	mov    ecx,0xc0000101
    104c:	0f 32                	rdmsr
    104e:	31 c0                	xor    eax,eax
    1050:	c3                   	ret

完整探针实际输出如下:

$ /tmp/gs_probe
uname      -> Linux 6.12.65-linuxkit x86_64
ARCH_GET_FS -> ret=0 errno=0 fs=0x7fffff5b0180
ARCH_GET_GS -> ret=0 errno=0 gs=0x0
swapgs     -> signal 11 (Segmentation fault)
rdmsr      -> signal 11 (Segmentation fault)

这几行说明两件事。

第一,用户态可以通过 Linux 提供的系统调用接口读 FS/GS base。这个容器里,当前线程 FS base 是一个非零地址,GS base 是 0。GS base 为 0 很常见,因为普通 x86-64 Linux 用户程序通常不用 GS。

第二,用户态直接执行 swapgsrdmsr 都不行。CPU 在 CPL=3 执行这些特权指令时会抛异常,Linux 把异常递送成用户态信号;这次实测收到的是 SIGSEGV

所以用户态不能直接窥探或切换内核 GS。真正的 swapgs 只能在内核入口/出口这类 Ring 0 路径上执行。

四、一次 syscall 入口里,swapgs 放在哪里

在 64 位 Linux 上,普通系统调用入口来自 syscall 指令。syscall 本身做的事比很多人想象得少:

   保存用户 RIP 到 RCX
   保存用户 RFLAGS 到 R11
   从 MSR 载入内核 CS/SS/RIP
   按 MSR 掩码清理一部分 RFLAGS

   但它不自动保存通用寄存器
   也不自动切换 RSP

注意最后一句:syscall 不自动切换栈。

这和中断门/异常门从用户态进内核不一样。中断/异常发生 CPL 3 -> 0 转换时,CPU 会从 TSS.RSP0 取内核栈;但 syscall 这条快速入口不走这套自动 TSS 换栈流程。

Linux 的 x86-64 syscall 入口大致是下面这样。这里先只盯住 swapgs 和换栈;如果想看从 svc / syscall 指令到内核汇编、pt_regs、再到 C 函数的完整入口链路,可以对照 syscall 系列第三篇《内核入口 el0_svc / entry_SYSCALL_64 的汇编做了什么——从异常向量到 C 函数》。

entry_SYSCALL_64:
    swapgs
    movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    SWITCH_TO_KERNEL_CR3
    movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp
    ...

流程含义是:

   1. syscall 让 CPU 跳到内核入口,但 RSP 仍是用户栈指针
   2. swapgs,把 GS 切成内核 per-CPU base
   3. 先把用户 RSP 暂存起来
   4. 软件加载当前任务的内核栈顶到 RSP
   5. 在内核栈上构造 pt_regs,进入 C 层系统调用分发

为什么 swapgs 必须很早?因为后面马上要用 PER_CPU_VAR(...) 访问 per-CPU 数据。PER_CPU_VAR 在 x86-64 上本质就是依赖 GS base 的固定偏移访问。GS 没换成内核 GS,内核就找不到自己的 per-CPU 区。

这里还有一个细节:上面的 TSS_sp2 并不是硬件任务切换的意思。Linux 在 64 位 syscall 入口里把 TSS 的 sp2 当成一个方便的 per-CPU scratch slot,用来临时保存用户 RSP。长模式硬件不会在 syscall 入口自动使用 RSP2

把 syscall 入口画成步骤就是:

   用户态
     │
     │ syscall
     ▼
   CPU 从 MSR 装载内核入口 RIP
     │        (不换 RSP)
     ▼
   entry_SYSCALL_64
     │
     ├─ swapgs
     │     GS.base = 内核 per-CPU base
     │
     ├─ 保存用户 RSP
     │
     ├─ 软件切到内核栈
     │
     └─ 调 do_syscall_64

这也是第五篇和第六篇的连接点:如果开启 KPTI,入口路径还会切 CR3,把用户页表切到内核页表。swapgs 解决的是"GS base 指向哪块 per-CPU 数据";CR3 解决的是"当前虚拟地址空间用哪套页表"。两者是两套机制。

五、中断/异常从用户态进内核:TSS.RSP0 登场

现在看另一条入口:中断或异常。

如果中断/异常发生在用户态,并且目标门进入 Ring 0,CPU 会发现这次控制流要从 CPL=3 进入 CPL=0。这个时候,硬件会自动换栈。

换到哪里?看当前 CPU 的 TSS。

长模式 TSS 里有三个特权级栈指针:

   RSP0
   RSP1
   RSP2

现代 Linux 基本只用 Ring 0 和 Ring 3,所以关键是 RSP0。当 CPU 从用户态进入内核态时:

RSP = TSS.RSP0

然后 CPU 在这个新栈上压入返回现场,典型内容包括:

   旧 SS
   旧 RSP
   RFLAGS
   旧 CS
   旧 RIP
   可能还有 error code

这一步是硬件自动完成的。最简单的理解是:

   TSS.RSP0 里放着一个内核栈顶地址。
   用户态发生中断/异常后,CPU 第一脚就落到这个栈上。

但 Linux 的具体实现可以更绕一点。TSS.RSP0 不一定永远直接指向"当前任务最终要使用的那条内核栈";在一些配置和入口路径上,它可以先指向一段 入口栈,也就是每 CPU 准备的一小段中转栈。CPU 先按硬件规则切到这段入口栈,入口汇编站稳以后,再由软件切到当前任务自己的内核栈。

可以画成两层:

   CPU 硬件只知道这一层:

     用户栈
       │  中断/异常,CPL 3 -> 0
       ▼
     TSS.RSP0 指向的栈


   Linux 入口代码可能再做一层:

     TSS.RSP0 指向的入口栈
       │  入口汇编检查状态、切 GS/CR3、准备 pt_regs
       ▼
     当前任务的内核栈

所以这句话不是说 TSS 有了新机制,也不是说 CPU 会自动认识"trampoline stack"。CPU 只认识 TSS.RSP0。入口栈 / trampoline stack 是 Linux 把 RSP0 指向哪里、以及入口汇编接下来怎么搬家的实现选择。

也就是说,中断/异常入口和 syscall 入口的关键区别是:

   中断/异常门:
     CPL 3 -> 0 时,CPU 自动从 TSS.RSP0 换栈

   syscall:
     CPU 不自动换 RSP,Linux 入口代码自己切到内核栈

这就是为什么 TSS 在 x86-64 里还活着。它不再保存"一个任务的整套寄存器",但 CPU 仍然要从它那里拿到一些入口栈信息。

六、现代 TSS 长什么样

第三篇讲过,80386 时代的 TSS 很大,里面可以放 EIP、EFLAGS、CR3、通用寄存器、段寄存器等一整套任务状态。CPU 可以通过任务门或 TSS 描述符做硬件任务切换。

x86-64 长模式把这条路基本废掉了。现代 TSS 的核心字段变成:

   RSP0
   RSP1
   RSP2
   IST1 ... IST7
   I/O permission bitmap offset

image.png

逐个看。

RSP0/RSP1/RSP2 是特权级栈指针。现代系统几乎只关心 RSP0

   从 Ring 3 进入 Ring 0 的中断/异常:
     CPU 用 TSS.RSP0 作为新内核栈

ISTInterrupt Stack Table,一共有 7 项。第三篇《特权级与门,以及 TSS 的本来用途》在"中断门和陷阱门:IDT 里的受控入口"这一节里已经拆过 IDT 门描述符的静态结构:长模式 IDT gate 里有一个 IST 字段,可以指定一个 IST 编号。

如果某个异常门指定了 IST,CPU 进入这个异常时会直接切到对应的 IST 栈,而不是普通的 RSP0 路径。

这主要给特别危险的异常用。例如:

   NMI:不可屏蔽中断,可能打断几乎任何位置
   #DF:双重故障,普通栈可能已经坏了
   #MC:机器检查,状态可能很脆弱

这些路径需要一块更独立的栈,避免"异常发生时当前栈已经不可用"导致处理器继续崩。

I/O permission bitmap 用来控制低特权级代码能不能执行端口 I/O 指令。这里要先解释一下 x86 的 端口 I/O

x86 除了普通内存地址空间,还有一套独立的 I/O port 地址空间。CPU 访问内存用 mov/load/store 这类指令;访问 I/O 端口用专门的 in/out 指令:

in   al, dx      ; 从 dx 指定的 I/O 端口读 1 字节到 al
out  dx, al      ; 把 al 写到 dx 指定的 I/O 端口

这里的 dx 不是内存地址,而是一个端口号。早期 PC 上,很多硬件控制寄存器就是通过端口 I/O 暴露出来的。比如键盘控制器、串口、PIC、PIT、部分 legacy 设备,都可能有自己的端口号。也就是说,out dx, al 不是在写某个普通变量,而可能是在直接改硬件设备状态。

所以 CPU 必须限制低特权级代码随便执行这些指令。否则用户程序如果能任意 out,就可以直接操作硬件,绕过内核驱动和权限模型。

x86 对端口 I/O 的权限判断分两层。

第一层是 IOPL(I/O Privilege Level)。它在 RFLAGS/EFLAGS 里,是一个 0 到 3 的特权级数字;如果你对 RFLAGS/EFLAGS 不熟,可以先看文末附录。

   如果 CPL <= IOPL:
     当前代码可以执行端口 I/O

   如果 CPL > IOPL:
     当前代码权限不够,继续查 TSS 里的 I/O permission bitmap

注意方向还是 x86 那套规则:数字越小权限越高。内核 CPL=0,通常天然满足 CPL <= IOPL;用户态 CPL=3,一般不满足,因为现代内核通常不会让普通进程拥有高 I/O 权限。

第二层才是 TSS 里的 I/O permission bitmap。TSS 里不是直接塞整张位图,而是放一个 offset:

   TSS
   ┌──────────────────────────┐
   │ RSP0 / RSP1 / RSP2       │
   │ IST1 ... IST7            │
   │ ...                      │
   │ I/O bitmap base offset   │──┐
   └──────────────────────────┘  │
                                 ▼
                         I/O permission bitmap

这张 bitmap 的粒度是 一个端口一位

   bit = 0:允许访问这个 I/O port
   bit = 1:禁止访问这个 I/O port

端口号怎么对应到位?可以粗略理解成:

   port N 对应 bitmap 里的第 N 位

例如:

   端口 0x3f8  ->  bitmap 第 0x3f8 位
   端口 0x60   ->  bitmap 第 0x60

这里的"一个端口一位"要和操作数宽度配起来看;更准确地说,I/O permission bitmap 的每一 bit 对应一个 I/O port byte address。若当前权限条件需要查 I/O bitmap,则 in al, dx / out dx, al 是 8 位 I/O,只检查 port Nin ax, dx / out dx, ax 是 16 位 I/O,会检查 port Nport N+1in eax, dx / out dx, eax 是 32 位 I/O,会检查 port Nport N+3。这里没有漏掉 64 位寄存器:IN/OUT 指令没有 in rax, dxout dx, rax 这种 64 位端口 I/O 形式,long mode 下标量端口 I/O 也只有 8/16/32 位宽度。

所以一次用户态端口 I/O 的硬件判定可以画成这样:

   用户态执行 in/out
          │
          ▼
   当前 CPL <= IOPL ?
          │
     ┌────┴────┐
     │是       │否
     ▼         ▼
   允许     查当前 TSS 的 I/O bitmap
               │
               ▼
        目标端口范围对应 bit 是否全为 0?
               │
          ┌────┴────┐
          │是       │否
          ▼         ▼
        允许       #GP

这解释了为什么 I/O bitmap 还放在 TSS 里:它是 CPU 做端口 I/O 权限检查时要读的架构状态。虽然现代 TSS 不再保存整套任务寄存器,不再做硬件任务切换,但它仍然保存了 CPU 入口和权限检查需要的少数字段。

在 Linux 上,普通用户程序默认不能直接执行端口 I/O。少数程序如果确实需要直接访问某些 legacy 端口,通常要通过内核接口申请权限,例如 ioperm() 给一段端口范围开权限,或者更粗的 iopl() 调整 I/O 特权级;这些操作需要足够的权限,常见要求是 CAP_SYS_RAWIO。内核最终要么调整任务的 I/O bitmap,要么调整相关 I/O 权限状态。普通应用不该绕过驱动直接碰硬件端口。

反过来,长模式 TSS 不再承担这些职责:

   不保存当前任务的 RIP
   不保存当前任务的 CR3
   不保存通用寄存器
   不靠硬件任务门切换 Linux 进程

Linux 做上下文切换时,仍然是软件保存/恢复寄存器、切内核栈、必要时切 CR3。第三篇里那个"硬件任务切换"的宏大设计,在现代 64 位 Linux 里不是主线。

七、TR 和每 CPU TSS:CPU 怎么找到这张表

TSS 仍然是一个由 CPU 认识的系统结构,所以它仍然要通过 GDT 描述符登记。

第二篇讲过普通代码段/数据段描述符是 8 字节;在 x86-64 里,TSS 描述符是系统描述符,而且是 16 字节,因为它要保存 64 位 TSS base。

静态结构可以画成这样:

   x86-64 TSS 描述符,16 字节

   高 8 字节:

    127                            96 95                         64
   ┌────────────────────────────────┬─────────────────────────────┐
   │ Reserved                       │ Base 63:32                 │
   └────────────────────────────────┴─────────────────────────────┘

   低 8 字节:

    63             56 55 52 51 48 47  46 45  44 43      40 39             32
   ┌────────────────┬─────┬─────┬────┬────┬────┬─────────┬────────────────┐
   │ Base 31:24     │Flags│Limit│ P  │ DPL│ S=0│ Type    │ Base 23:16     │
   └────────────────┴─────┴─────┴────┴────┴────┴─────────┴────────────────┘
    31             16 15                                                   0
   ┌────────────────┬──────────────────────────────────────────────────────┐
   │ Base 15:0      │ Limit 15:0                                           │
   └────────────────┴──────────────────────────────────────────────────────┘

字段含义是:

   Base:
     TSS 结构体在内存里的线性地址。
     由 Base[15:0]、Base[23:16]、Base[31:24]、Base[63:32] 拼成 64 位。

   Limit:
     TSS 的大小边界。CPU 用它确认访问 TSS 字段时没有越过描述符声明的范围。

   Type:
     0x9 = available 64-bit TSS
     0xB = busy 64-bit TSS

   S:
     系统描述符固定为 0。TSS 描述符不是普通代码段/数据段。

   DPL:
     这个 TSS 描述符可被哪些特权级的软件路径访问。
     普通内核用 ltr 加载自己的 TSS,通常是 Ring 0 路径。

   P:
     Present。P=0 时,这个 TSS 描述符不可用。

   Flags:
     对 TSS 描述符来说大多不像普通段描述符那样有实际意义。

为什么 TSS 描述符要扩成 16 字节?关键就是 Base[63:32]。普通 8 字节段描述符只能容纳 32 位 base;但长模式下 TSS 结构体可能位于 64 位线性地址空间,所以系统描述符要多占一个 8 字节槽,把高 32 位 base 放进去。

Type 里的 available / busy 也和第三篇讲的硬件任务切换历史有关。CPU 执行 ltr 加载 TSS 后,会把对应 TSS 描述符标成 busy。现代 Linux 不靠硬件任务切换来回切 TSS,但这个架构状态仍然存在。

启动 CPU 时,内核会准备每个 CPU 自己的 TSS,然后执行 ltr 加载任务寄存器 TR:

   TR.selector -> GDT 里的 TSS 描述符
                    │
                    ├── TSS base
                    └── TSS limit / type / P

和段寄存器类似,TR 也有可见的 selector 和 CPU 内部缓存的描述符信息。ltr 之后,CPU 就知道"当前 CPU 的 TSS 在哪里"。

为什么是每 CPU 一份 TSS,而不是每进程一份?

因为现代 TSS 的用途已经变了。它主要服务于 CPU 入口路径:

   当前 CPU 的 RSP0
   当前 CPU 的 IST 栈
   当前 CPU 的 I/O bitmap 状态

这些天然是 per-CPU 的。进程切换时,Linux 不会让 CPU 硬件切到另一个 TSS;它只会更新当前 CPU 的 TSS.RSP0,让下一次从用户态发生中断/异常时,硬件能落到正确的内核栈。

可以这么理解:

   80386 设想:
     一个任务一个 TSS,任务切换时 CPU 切 TSS

   现代 x86-64 Linux:
     一个 CPU 一个主要 TSS,任务切换时软件更新其中少数字段

八、把 interrupt 和 syscall 放到一张对照表里

现在把两条入口路径并排看:

   用户态 -> 内核态:中断/异常门

     1. CPU 查 IDT 门描述符
     2. 发现目标 CPL=0,当前 CPL=3
     3. CPU 从当前 TSS.RSP0 取新 RSP
     4. CPU 在新内核栈上压旧 SS/RSP/RFLAGS/CS/RIP
     5. 进入内核异常/中断入口
     6. 内核入口根据路径处理 GS、CR3、pt_regs 等状态
   用户态 -> 内核态:syscall

     1. CPU 从 MSR 取内核 CS/SS/RIP
     2. CPU 保存用户 RIP 到 RCX,保存 RFLAGS 到 R11
     3. CPU 不自动换 RSP
     4. Linux 入口执行 swapgs
     5. Linux 软件保存用户 RSP,切到内核栈
     6. 进入 do_syscall_64

它们都从 Ring 3 进入 Ring 0,但硬件自动做的事情不一样。

这也是很多 x86-64 入口代码看起来复杂的原因:不是所有"进内核"都走同一条硬件路径。中断门、异常门、syscallsysenter、虚拟化、KPTI、IST、NMI,都有各自的入口约束。swapgs 和 TSS 是其中两块关键拼图。

九、swapgs 进出必须配对,但不是所有内核入口都能盲目 swapgs

swapgs 的危险在于它没有"当前状态检查"。它只是交换两个 MSR。

如果当前已经是内核 GS,再执行一次 swapgs,就会把用户 GS 换回来。此时内核再执行 gs:offset,就可能拿用户 GS base 当 per-CPU base,用错地址。

所以入口代码必须知道自己当前面对的是哪种情况:

   从用户态进入:
     需要 swapgs,切到内核 GS

   从内核态发生异常/中断:
     不能盲目 swapgs,因为本来就在内核 GS

   返回用户态:
     需要 swapgs,恢复用户 GS

   返回内核态:
     不需要恢复用户 GS

这就是为什么 Linux 入口汇编里会有很多判断:看中断栈上保存的 CS 低两位,判断来源是不是用户态;NMI、machine check、double fault 这类路径还要更谨慎,因为它们可能打断内核执行,也可能打断用户执行。

swapgs 很小,但放错位置就是入口级 bug。现代 CPU 还出现过和 swapgs 推测执行相关的安全问题,内核入口周围因此有额外的屏障和缓解逻辑。这里先不展开;主线只需要记住:

   swapgs 是一个无条件对调指令。
   正确性来自内核入口代码对"当前来自用户态还是内核态"的判断。

十、收束:段机制留下的两个影子

到这里,段机制的现代形态已经很清楚了。

第一篇里,段寄存器是 8086 为了够到 1MB 地址空间做的补丁:

   物理地址 = 段 << 4 + 偏移

第二、三篇里,保护模式把段扩展成选择子、GDT/LDT、描述符、权限、门、TSS、硬件任务切换。

第四篇里,x86-64 长模式基本废掉普通分段,只留下 FS/GS base 作为特殊基址。

本文看到,到了现代 Linux 内核,段机制留下两个很实用的影子:

   GS base:
     不再拿来分割地址空间,而是作为当前 CPU 的快速基址。

   TSS:
     不再拿来硬件任务切换,而是给 CPU 入口路径提供 RSP0、IST、I/O bitmap。

所以第五篇的结论可以写得很短:

   x86-64 没有把旧机制完全删掉。
   它把旧机制压缩成少数入口路径需要的 CPU 状态。

   GS 负责快速定位 per-CPU。
   swapgs 负责用户 GS / 内核 GS 对调。
   TSS 负责特权级入口换栈和特殊异常栈。

下一篇就进入第三条主线:分页。长模式下普通段基本透明以后,真正决定虚拟地址怎么变成物理地址的,是 CR3、四级页表、PTE、TLB,以及 KPTI 这类现代内核隔离设计。

附录:RFLAGS 是什么

RFLAGS 是 x86-64 里的 标志寄存器。它不像 RAX/RBX 那样主要拿来放普通整数,而是保存一组 CPU 状态位和控制位。

先看名字关系:

   FLAGS   :16 位时代的标志寄存器
   EFLAGS  :32 位扩展后的标志寄存器
   RFLAGS  :x86-64 长模式下的 64 位标志寄存器

它们不是三套完全不同的东西,而是一套寄存器随架构位宽扩展出来的名字。本文讨论的是 x86-64,所以后面直接说 RFLAGS。它是 64 位寄存器,但低 32 位基本继承 EFLAGS,高 32 位目前大多是保留位。

这类标志大致分三类:

   状态标志:
     算术/逻辑指令执行后留下的结果状态,比如是否为 0、是否进位、是否溢出。

   控制标志:
     控制 CPU 某些行为,比如方向标志 DF、中断允许标志 IF。

   系统标志:
     和特权级、虚拟化、调试、I/O 权限相关,比如 IOPL、NT、RF、VM。

直接把 64 位 RFLAGS 画出来:

   RFLAGS,64 bit

    63                                                    32 31        22
   ┌────────────────────────────────────────────────────────┬────────────┐
   │ reserved                                               │ reserved   │
   └────────────────────────────────────────────────────────┴────────────┘

    21 20 19 18 17 16 15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
   ┌──┬──┬──┬──┬──┬──┬──┬─────┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
   │ID│VIP│VIF│AC│VM│RF│NT│IOPL │OF│DF│IF│TF│SF│ZF│  │AF│  │PF│  │CF│
   └──┴──┴──┴──┴──┴──┴──┴─────┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

   bit 1 固定为 1;图里留空的 bit 3515 是保留位。

几个和本文关系近的字段:

   IF,bit 9:
     Interrupt Flag。控制可屏蔽外部中断是否允许递送。
     中断门和陷阱门的区别之一,就是中断门进入处理程序时会清 IF,
     陷阱门不会清 IF。

   IOPL,bit 12-13:
     I/O Privilege Level。端口 I/O 的粗粒度权限门槛。
     执行 in/out 这类 I/O 指令时,CPU 会拿 CPL 和 IOPL 比较。

   DF,bit 10:
     Direction Flag。影响字符串指令处理方向,比如 movs/stos 是地址递增还是递减。

   TF,bit 8:
     Trap Flag。单步调试用,置位后每执行一条指令可触发调试异常。

   ZF/SF/CF/OF/PF/AF:
     算术/逻辑结果标志。比如 cmp/test 之后,条件跳转 jz/jnz/jc/jo 等会看这些位。

本文前面说 syscall 会把用户 RFLAGS 保存到 R11,意思就是:CPU 进入内核时不能把用户态这些状态位丢掉。等 sysret 返回用户态时,还要根据约定恢复必要的标志状态。

IOPL 放在 RFLAGS/EFLAGS 里也正是这个原因:它是当前执行上下文的一部分,CPU 执行 in/out 时会直接读取它。若当前特权级已经满足:

   CPL <= IOPL

端口 I/O 直接允许;否则才继续查 TSS 里的 I/O permission bitmap,做更细的端口级判断。


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

下一篇:页表的 CPU 机制——CR3、page walk、PTE、TLB 与 KPTI