程序里一行 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 举例,从用户态那一行开始:
- 用户程序把系统调用号放进
rax(write是 1),参数放进rdi、rsi、rdx、r10、r8、r9。 - 执行
syscall指令。CPU 干了三件事:把当前的 RFLAGS 存进r11、把返回地址(下一条指令的位置)存进rcx;从IA32_LSTAR这个 MSR 里取出内核入口地址跳过去;把栈从用户栈切到内核栈。 - 入口
entry_SYSCALL_64处,先swapgs把 GS 基址换成内核的,再把用户寄存器一股脑保存在内核栈上。 - 拿
rax里的号去查一张表sys_call_table,跳进对应的内核函数(比如__x64_sys_write)。 - 内核函数干活,写文件、发网络包。
- 返回时把用户寄存器恢复回去,执行
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;只有它阻塞了,才会引出后者。