用户态与内核态到底怎么切换?从系统调用到上下文切换

程序里一行 write(fd, buf, n),看着和普通函数调用没什么两样,但这一行执行下去,CPU 的特权级换了,栈也换了,还跳进了一段用户程序看不见的代码。这篇文章就把"用户态进内核态"这一下到底发生了什么讲清楚,再说明它和"上下文切换"根本不是一回事------这两个词经常被混着用。


特权级

CPU 不是只有"能跑代码"这一种状态。x86 定义了 4 个特权级 ring0ring3,Linux 只用了两头:ring0 给内核,ring3 给用户程序。ARMv8 上对应的是 EL0EL3,用户代码跑在 EL0,内核跑在 EL1。

那 CPU 怎么知道自己现在处于哪个级?x86 上靠 CPL (Current Privilege Level),就是段寄存器 CS 的低两位。执行一条特权指令、或者访问一段内存的时候,硬件会拿 CPL 去比对,不允许就是直接 #GP(通用保护异常)。

区分两种态的,其实就两条规则:

用户态能做的 内核态能做的
执行特权指令 不行 行,比如 cli/sti 开关中断、读写控制寄存器、hlt
访问内存 只能碰页表里标了 User 的页 全都能碰

内存那条靠页表项里的 U/S 位(User/Supervisor)。用户态访问一个 U=0 的页,MMU 直接报异常。所以用户程序就算拿到了一个内核地址,也读不到东西------它连那段地址对应的物理页都进不去。


怎么进内核

用户态想进内核,只有三条路,都是硬件帮忙切过去的:

途径 触发方式 例子
系统调用 主动,程序自己发起 read、write、fork
中断 被动,外部设备异步触发 网卡收到包、定时器到点
异常 被动,指令执行出错 缺页、除零、非法指令

系统调用是程序"求"内核办事,另外两个是内核"夺"回控制权------不管是哪种,最后 CPU 都会跳到内核预先登记好的入口(系统调用走一个专门的 MSR,中断/异常走 IDT),特权级从 ring3 翻到 ring0。


一次系统调用发生了什么

我们拿 x86-64 上执行 write 举例,从用户态那一行开始:

  1. 用户程序把系统调用号放进 rax(write 是 1),参数放进 rdi、rsi、rdx、r10、r8、r9。
  2. 执行 syscall 指令。CPU 干了三件事:把当前的 RFLAGS 存进 r11、把返回地址(下一条指令的位置)存进 rcx;从 IA32_LSTAR 这个 MSR 里取出内核入口地址跳过去;把栈从用户栈切到内核栈。
  3. 入口 entry_SYSCALL_64 处,先 swapgs 把 GS 基址换成内核的,再把用户寄存器一股脑保存在内核栈上。
  4. 拿 rax 里的号去查一张表 sys_call_table,跳进对应的内核函数(比如 __x64_sys_write)。
  5. 内核函数干活,写文件、发网络包。
  6. 返回时把用户寄存器恢复回去,执行 sysretq:切回用户栈、恢复 RFLAGS、特权级降回 ring3,跳回 rcx 存的返回地址。

整件事对用户程序是透明的,它只看到 write 返回了一个值。这套流程在CS162 Note2 的 Dual Mode Operation里也讲过一次,那里是从"OS 怎么保证自己不被打穿"的角度讲的,这里是从"一次调用经过了哪些寄存器"的角度。

这解释了为什么内核栈通常只有 8KB 或 16KB,还特别强调"内核里不能递归太深、不要在栈上放大数组"------每个线程除了用户栈,还有一块很小的内核栈,进内核时用的就是它。


切换的代价

一次 mode switch(特权级切换)的开销,落在几件事上:

  • 保存和恢复用户的寄存器、栈指针
  • 换栈(用户栈 ↔ 内核栈)
  • 回用户态前的一堆检查

这些还算小的。真正把开销抬上去的是 KPTI (Kernel Page Table Isolation)。2018 年的 Meltdown 漏洞让人发现:只要内核的页表还映射在用户进程的页表里,用户程序就能靠 CPU 的乱序执行偷读到内核内存。修法是用户态运行时,页表里干脆不映射内核地址,内核映射只在大内核页表里存在。代价是每次进出内核都得换一次页表基址(CR3),TLB 跟着失效一部分。于是一次系统调用的成本被抬高了一截,某些密集做系统调用的程序会明显变慢。


mode switch 不是 context switch

这才是标题后半段要说的。这两个词长得像,但说的完全是两件事:

mode switch context switch
换的是什么 CPU 特权级(用户态 ↔ 内核态) 当前在跑的线程/进程
谁换的 硬件 + 内核入口代码 内核调度器
换完还在同一个线程吗 还在 换成了另一个
发生在哪 用户态和内核态的边界 内核态内部

关键区别:mode switch 不换线程,context switch 才换。 而且它俩可以单独发生。

我们看一种情况。如果这个调用很快返回,比如 getpid(),整个过程就是:用户态 →(mode switch) 内核态,查一下 PID,→(mode switch) 用户态。从头到尾是同一个线程,没发生 context switch。

如果这个调用会阻塞,比如 read() 等磁盘,那故事变长:

text 复制代码
用户态:read(fd, ...)
  → mode switch 进内核
  内核发现数据还没到,把当前线程挂起
  → context switch 切到线程 B          ← 这里换了线程
  线程 B 跑了一阵,磁盘好了
  → context switch 切回线程 A
  内核把数据拷贝到用户缓冲区
  → mode switch 回用户态

可以看到:一次阻塞的系统调用,前面一次 mode switch,中间可能夹着两次 context switch(切走、切回),最后再一次 mode switch。 mode switch 是"进门",context switch 是"换人干活"。

反过来也成立:context switch 完全可以不涉及 mode switch。内核里两个线程互相让路、或者一轮调度,切换发生在内核态内部,从头到尾没回过用户态。所以**"系统调用慢是因为上下文切换"这句话是错的**------普通系统调用只有 mode switch,没有 context switch;只有它阻塞了,才会引出后者。

相关推荐
imDwAaY1 小时前
进程、线程、协程有什么区别?从资源分配到调度,一篇理清
后端·操作系统
威哥爱编程1 小时前
【AI全栈12-09】Spring Boot 用流式输出做打字机对话:点下去不再干等
java·spring boot·后端
威哥爱编程1 小时前
【AI全栈12-08】Spring Boot 用 MCP 统一接入内部系统:让 AI 接一次,全公司复用
java·spring boot·后端
deviant-ART1 小时前
Java 并发编程:深入理解 synchronized 的使用与原理
java·开发语言·后端
tachibana21 小时前
Go语言里slice是怎么扩容的?
后端·golang·go·切片
西瓜很甜哟2 小时前
golang实现大文件分片上传
后端·golang·html
sp42a2 小时前
简单的 Spring 文件上传与下载组件
java·后端·spring
步行cgn2 小时前
为什么 Spring 建议使用构造器注入?
java·后端·spring
小蒜学长2 小时前
基于Vue.js + Spring Boot的酒店客房预订系统设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·酒店客房预订系统