系统调用深入细节 —— 同一线程从用户态到内核态的完整过程
前一篇 syscall 讲了系统调用的基本过程、开销来源和优化思路。本篇深入更底层的问题:同一个线程从用户态进入内核态,到底发生了什么?内核怎么管理代码段和数据段的权限?段寄存器在这过程中怎么变化?特权级切换的硬件细节是什么? 这是理解"为什么用户态不能访问内核内存""为什么 syscall 之后栈会变"的关键。
本文涉及大量 x86-64 硬件寄存器(CR3/MSR/段寄存器等),不熟悉可先看 x86-64-registers。
零、核心问题:同一个线程,为什么进入内核后"变了一个人"?
一句话定位:同一个线程、同一个
task_struct,但执行环境全换了——栈、段、页表、特权级。这就是"模式切换"的本质:不是换人,是同一个人的"身份"变了。
一、段寄存器的管理:用户态和内核态怎么隔离
1.1 为什么需要段
在 x86-64 长模式下,分段机制被大幅简化,但段寄存器仍然存在且至关重要——它们决定当前代码运行在哪个特权级(ring)。
x86-64 中四个关键段寄存器:
| 寄存器 | 全称 | 作用 | 用户态值 | 内核态值 |
|---|---|---|---|---|
| CS | Code Segment | 代码段选择子,决定当前特权级(CPL) | 0x33(ring 3) | 0x10(ring 0) |
| SS | Stack Segment | 栈段选择子 | 0x2B(ring 3) | 0x18(ring 0) |
| DS | Data Segment | 数据段选择子 | 0x2B(ring 3) | 0x18(ring 0) |
| ES | Extra Segment | 附加数据段 | 同 DS | 同 DS |
注意:在 x86-64 长模式下,DS/ES/SS 的基址和界限被硬件忽略(强制为 0 和 4GB 以上),但段选择子中的 RPL(Requested Privilege Level)字段仍然有效,用于权限检查。
1.2 段选择子的结构
每个段寄存器实际存的是一个 16 位的段选择子:
CS = 0x33 = 0b 0000 0000 0011 0011
│ ││ ││
│ ││ └── RPL = 11 (ring 3)
│ │└── TI = 0 (GDT)
│ └── Index = 6 (GDT[6])
└── 高 13 位 = 索引
CS = 0x10 = 0b 0000 0000 0001 0000
│ ││ ││
│ ││ └── RPL = 00 (ring 0)
│ │└── TI = 0 (GDT)
│ └── Index = 2 (GDT[2])关键:选择子的低 2 位(RPL)和 CS 寄存器中的 CPL 共同决定当前特权级。
1.3 GDT(全局描述符表)中的段描述符
CPU 根据段选择子的 Index 字段去 GDT 中查找对应的段描述符(8 字节):
GDT 典型布局(Linux x86-64):
索引 偏移 内容 用途
─────────────────────────────────────────────────
0 0x00 NULL 描述符 必须为空
1 0x08 内核代码段(32位) 兼容模式用
2 0x10 内核代码段(64位) DPL=0 syscall 入口
3 0x18 内核数据段(64位) DPL=0 内核栈/数据
4 0x20 用户代码段(32位) 兼容模式用
5 0x28 用户数据段(64位) DPL=3
6 0x30 用户代码段(64位) DPL=3 用户态代码
7 0x38 (保留)
8 0x40 TSS(Task State Segment) 存储内核栈指针等段描述符的格式(8 字节):
63 55 47 39 31 23 15 7 0
├────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ Base │ Flags │ Limit │ Access │ Base │ Base[23:0] │ Limit │
│ 31:24 │ │ 19:16 │ Byte │ 23:16 │ │ 15:0 │
├────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
Access Byte 的关键位:
Bit 7 P (Present) = 1(段在内存中)
Bit 6-5 DPL (Descriptor Privilege Level) = 00(ring0) 或 11(ring3)
Bit 4 S (System) = 1(代码/数据段) 0(系统段如 TSS)
Bit 3-0 Type = 1010(代码段-可执行可读) 或 0010(数据段-可读写)DPL 是关键的隔离机制:CPU 在加载段寄存器时会检查 max(CPL, RPL) <= DPL。用户态 CPL=3,只能加载 DPL=3 的段描述符——这保证了用户态永远无法加载内核的段(DPL=0)。
1.4 syscall 指令如何切换段
syscall 指令执行时,CPU 硬件自动完成段切换:
; syscall 指令的硬件动作(简化)
; 1. 保存返回信息
RCX := RIP ; 保存用户态返回地址
R11 := RFLAGS ; 保存用户态标志寄存器
; 2. 加载内核入口地址
RIP := MSR_LSTAR ; 跳到内核入口点(开机时设定)
; 3. 切换代码段为内核段(ring 0)
CS := MSR_STAR[47:32] & ~3 ; CS.Selector = 内核代码段索引
; CS.Base = 0(长模式忽略)
; CS.Limit = 0(长模式忽略)
; CS.DPL = 0 → CPL = 0
; 4. 切换栈段
SS := (MSR_STAR[47:32] + 8) | 3 ; SS.Selector = 内核数据段索引
; RPL = 0
; 5. 清除中断标志(可选)
RFLAGS := RFLAGS & ~MSR_FMASK注意:syscall 指令只切换 CS 和 SS,不切换 DS/ES。内核入口代码需要手动加载内核数据段到 DS/ES:
; Linux entry_SYSCALL_64 的简化代码
swapgs ; 切换到内核的 GS 基址(访问 per-CPU 数据)
mov rsp, PER_CPU_VAR(cpu_current_top_of_stack) ; 切换到内核栈
pushq $__USER_DS ; 准备用户 SS
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp0) ; 保存用户 RSP
...
mov ax, $__KERNEL_DS ; 加载内核数据段
mov ds, ax
mov es, ax1.5 用户态能"看到"内核内存吗
不能,有两个层面的保护:
层面一:段级别保护
- 用户态 CS 的 DPL=3,无法加载 DPL=0 的段。
- 即使尝试
mov ds, 0x18(内核数据段),CPU 会检查max(CPL=3, RPL=0) > DPL=0,抛出#GP异常。
层面二:页表级别保护(更关键的隔离)
- 内核页表条目通常设置 U/S 位 = 0(Supervisor-only)。
- 用户态 CPL=3 访问 U/S=0 的页面 → 页错误(page fault)。
- 这就是为什么即使用户态知道内核地址,也无法读写。
KPTI 的强化:
- Meltdown 漏洞后,KPTI 让用户态和内核态使用两套不同的页表。
- 用户态页表中完全没有内核地址的映射(除了极少量的 trampoline 代码)。
- 即使 CPU 推测执行,也读不到内核数据——页表里根本没有。
二、同一线程从用户态到内核态的完整过程
2.1 完整的步骤拆解

2.2 栈的变化:同一个线程的两套栈
同一个 task_struct
├── thread_struct.sp0 → 内核栈顶(TSS 中也存了一份)
├── thread_struct.sp → 内核栈当前指针(上下文切换时用)
└── 用户栈 → 用户态 RSP 指向的位置
系统调用前后栈的变化:
时间点 RSP 指向 栈内容 CS
─────────────────────────────────────────────────────────────────
syscall 前 用户栈顶 main() 的栈帧 0x33
handler() 的栈帧
read() 的栈帧
syscall 执行中 用户栈顶(短暂) 同上 0x10
(硬件切了CS但还没切RSP)
entry_SYSCALL_64 内核栈顶 pt_regs(用户寄存器) 0x10
↓ sys_read() 的栈帧
vfs_read() 的栈帧
...
sysret 后 用户栈顶 read() 返回后 0x33
继续使用用户栈每个线程的内核栈在哪?
- 内核栈通常在
task_struct所在页面的末尾(通过THREAD_SIZE宏)。 - x86-64 默认
THREAD_SIZE = 16KB(两个页),thread_info结构也在同一块内存的起始处。 - 可以通过
current宏(读取 per-CPU 的GS段)快速找到当前 task 的内核栈。
// Linux 内核中的简化示意
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE / sizeof(long)];
};
// 内核栈从 stack 数组的高地址向低地址增长2.3 页表的切换:为什么有时要换 CR3
PCID(Process Context ID)优化:
- 给不同页表打上标签(PCID)。
- 切 CR3 时如果不换 PCID,可以不刷 TLB(TLB 条目带有 PCID 标签)。
- 用户态和内核态各用一个 PCID → 切换时不刷 TLB → 显著降低 KPTI 的开销。
三、特权级切换的硬件细节
3.1 MSR 寄存器:syscall 的配置中心
| MSR 寄存器 | 作用 | 内容 |
|---|---|---|
MSR_LSTAR (0xC0000082) | syscall 入口地址 | entry_SYSCALL_64 的虚拟地址 |
MSR_STAR (0xC0000081) | 段选择子配置 | [63:48]=用户 CS/SS, [47:32]=内核 CS/SS |
MSR_FMASK (0xC0000084) | RFLAGS 清除掩码 | 进入内核时自动清除的标志位(如 IF=中断使能) |
MSR_CSTAR (0xC0000083) | 兼容模式入口 | 32 位程序用 syscall 时的入口 |
MSR_SFMASK (0xC0000085) | 兼容模式 RFLAGS 掩码 | 32 位兼容模式的标志清除 |
开机时 Linux 设置这些 MSR:
// arch/x86/kernel/cpu/common.c 中的简化逻辑
void syscall_init(void) {
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
wrmsrl(MSR_STAR, (unsigned long)((__USER_CS << 48) | (__KERNEL_CS << 32)));
wrmsrl(MSR_FMASK, X86_EFLAGS_TF | X86_EFLAGS_DF | X86_EFLAGS_IF | ...);
}3.2 syscall/sysret 的精确硬件序列
syscall 指令的硬件微操作(不可中断的原子序列):
1. RCX := RIP ; 保存用户态返回地址
2. R11 := RFLAGS ; 保存标志寄存器
3. RIP := LSTAR ; 跳转到内核入口
4. CS.Selector := STAR[47:32] ; 内核代码段
CS.Base := 0
CS.Limit := 0xFFFFFFFF
CS.Type := 11 (Execute/Read)
CS.DPL := 0
CS.P := 1
CS.L := 1 (64-bit)
CS.D := 0
→ CPL 变为 0
5. SS.Selector := STAR[47:32] + 8 ; 内核数据段
SS.Base := 0
SS.Limit := 0xFFFFFFFF
SS.Type := 3 (Read/Write)
SS.DPL := 0
SS.P := 1
6. RFLAGS := RFLAGS & ~FMASK ; 清除指定标志位
sysretq 指令的硬件微操作:
1. CS.Selector := STAR[63:48] ; 用户代码段
CS.DPL := 3
→ CPL 变为 3
2. SS.Selector := STAR[63:48] + 8 ; 用户数据段
3. RIP := RCX ; 恢复用户返回地址
4. RFLAGS := R11 ; 恢复标志寄存器
5. RFLAGS.IF := 1 ; 重新开启中断3.3 为什么 rcx 和 r11 被征用
普通 x86-64 函数调用约定(System V ABI):
| 参数位置 | 寄存器 |
|---|---|
| 第 1 参数 | rdi |
| 第 2 参数 | rsi |
| 第 3 参数 | rdx |
| 第 4 参数 | rcx |
| 第 5 参数 | r8 |
| 第 6 参数 | r9 |
系统调用约定:
| 参数位置 | 寄存器 |
|---|---|
| 第 1 参数 | rdi |
| 第 2 参数 | rsi |
| 第 3 参数 | rdx |
| 第 4 参数 | r10(不是 rcx!) |
| 第 5 参数 | r8 |
| 第 6 参数 | r9 |
因为 syscall 指令硬件自动写 RCX(保存 RIP)和 R11(保存 RFLAGS),用户态在这两个寄存器里放参数会被覆盖。所以系统调用约定把第 4 参数挪到了 r10,r11 则完全不用于传参。
四、内核数据段与用户数据段的本质区别
虽然在 x86-64 长模式下,DS/ES/SS 的基址和界限被硬件忽略(都等于 0 和最大值),但它们的权限属性仍然有效:
用户数据段 (0x2B):
DPL = 3 → 用户态可以加载
Type = 2 (Read/Write)
内核数据段 (0x18):
DPL = 0 → 只有 ring 0 可以加载
Type = 2 (Read/Write)实际保护主要靠页表 U/S 位,但段权限提供额外的纵深防御:
- 用户态代码(CS.DPL=3)试图加载内核数据段(DPL=0)→
#GP(0)异常。 - 即使有某种方法绕过了页表保护,段级别仍然阻止访问。
五、观测手段
5.1 查看 MSR 寄存器
# 需要 root 权限
sudo rdmsr 0xC0000082 # MSR_LSTAR,syscall 入口地址
sudo rdmsr 0xC0000081 # MSR_STAR,段选择子配置
sudo rdmsr 0xC0000084 # MSR_FMASK,标志清除掩码5.2 查看 GDT
# 通过 debugfs 查看
sudo cat /sys/kernel/debug/x86/gdt | head -205.3 确认 KPTI 是否开启
dmesg | grep -i "page.*table.*isolation\|PTI\|KPTI"
# 或
cat /proc/cpuinfo | grep -o 'pcid' | head -1 # PCID 支持5.4 用 gdb 观察段寄存器
(gdb) info registers cs ds es ss
cs 0x33 51
ds 0x0 0
es 0x0 0
ss 0x2b 435.5 测量 syscall 开销
# 使用 lmbench 的 lat_syscall
lat_syscall null # 空系统调用的延迟
lat_syscall read # read 的延迟
lat_syscall write # write 的延迟
# 对比 KPTI 开启和关闭的差异
# 启动参数添加 pti=off 关闭 KPTI六、一句话总结
同一个线程从用户态进入内核态,thread ID 不变,task_struct 不变,但执行环境彻底换了:CS 从 0x33(ring 3)变为 0x10(ring 0)、SS/DS/ES 从用户数据段切为内核数据段、RSP 从用户栈切到内核栈(task_struct 所在页的末尾)、GS 从 TLS 切到 per-CPU 区域、若开 KPTI 还要切 CR3 换页表。整个过程由
syscall指令硬件触发,CPU 原子完成 CS/SS 切换和 RIP/RFLAGS 保存,然后跳转到 MSR_LSTAR 指向的 entry_SYSCALL_64;内核入口代码完成栈切换、寄存器保存(pt_regs)、DS/ES 切换、可能的页表切换。返回时sysret做反向操作。段选择子中的 DPL 和页表中的 U/S 位构成双重保护,确保用户态无法访问内核内存。rcx和r11被 syscall 硬件征用,所以系统调用约定的第 4 参数必须用r10而非rcx。
七、与仓库其他文档的关系
- 系统调用基础:syscall — 系统调用是什么、开销来源、优化方法、vDSO
- 段管理深入:segment-management — 本文 §一 的独立深化版,完整拆解 GDT 描述符、CPL/RPL/DPL 权限检查、段保护与页表保护的双层机制
- read+write 完整过程:read-write-process — 以 read/write 为例的完整链路,含模式切换/栈变化/特权级硬件细节
- 上下文切换:context-switch — 模式切换 vs 上下文切换的区别(本篇关注前者)
- 中断处理:interrupts — 另一种进入内核态的方式(被动),与 syscall(主动)对比
- 内存布局:memory-layout — 用户态和内核态的地址空间划分
- KPTI/熔断:branch-prediction — Meltdown 漏洞与 KPTI 页表隔离的来龙去脉