什么是用户态、内核态?如何切换的?
CPU 有不同特权级,操作系统把"能执行特权指令、能访问全部内存"的那部分代码放在内核态(kernel mode), 应用代码跑在用户态(user mode)。两者之间不是进程切换,而是同一条线程在两种执行环境之间穿越, 入口只有三类:系统调用(system call)、中断(interrupt)、异常(exception)。
1. 为什么要分级
如果应用能直接改页表、关中断、写磁盘控制器寄存器,一个野指针就能搞崩整台机器,一个恶意程序就能读别人密码。 分级的本质是把危险操作收归内核,由内核做检查后再代劳。
| 层面 | 用户态 | 内核态 |
|---|---|---|
| x86 特权级 | Ring 3 | Ring 0 |
| 指令 | 普通算术、跳转 | cli/sti、hlt、改 CR3、in/out |
| 内存访问 | 只能访问页表标记为用户页的地址 | 全部物理内存 |
| 栈 | 用户栈 | 内核栈(每线程一份,通常 8~16KB) |
| 崩溃影响 | 当前进程被杀 | 内核 panic,整机挂 |
2. 切换的三条通道
用户态 内核态
│ syscall 指令(rax=号,rdi/rsi/rdx 传参)
├──────────────────────────────────► entry_SYSCALL_64
│ 保存现场 → 切换内核栈
│ 按 sys_call_table 分发
│ sysret(返回值放 rax) 执行 → 恢复现场
◄──────────────────────────────────┤
| 通道 | 触发方式 | 典型场景 | 是否同步 |
|---|---|---|---|
| 系统调用 | 主动执行 syscall / int 0x80 | read、write、futex | 同步,当前线程进入 |
| 中断 | 外部设备或时钟 | 网卡收包、定时器 tick | 异步,随时可能 |
| 异常 | 指令执行失败 | 缺页(page fault)、除零、断点 | 同步 |
系统调用号与参数寄存器是 ABI 约定,x86-64 上前 6 个参数走 rdi, rsi, rdx, r10, r8, r9,
返回值放 rax,负数表示 -errno。
3. 切换的代价
一次系统调用并不免费:要保存通用寄存器、切换栈、做安全缓解(KPTI 下还要换页表基址), 返回前再做一次反向操作,一次大概几百纳秒。真正贵的是上下文切换(context switch): 换页表导致 TLB 失效、缓存变冷,动辄几微秒。
# 数一下程序到底进了多少次内核
strace -c -f ./server
# 看上下文切换与系统调用次数
vmstat 1 # cs 列:每秒上下文切换
pidstat -w -p 1234 1 # cswch/s 自愿切换、nvcswch/s 非自愿
# 单次系统调用的耗时(约 100~300ns,受 mitigations 影响)
perf stat -e cycles,instructions -r 10 ./bench_getpid
// 减少穿越的常见手段:批量、合并、共享内存
struct iovec iov[3];
writev(fd, iov, 3); // 3 次写合并成 1 次系统调用
// 更彻底的方案:vDSO 把只读数据映射进用户态,读时间完全不进内核
clock_gettime(CLOCK_MONOTONIC, &ts); // 通常由 vDSO 直接返回,无 syscall
高并发场景下还有两条路:io_uring 用共享内存环提交/收割请求,一次 io_uring_enter 可提交成百上千个 IO;
DPDK、用户态协议栈则把设备直接交给用户态驱动,绕过内核网络栈。
4. 常见追问
Q:用户态线程能直接读内核地址吗?
不能。页表项有 U/S 位,内核页对用户态不可见;强行访问触发缺页异常,内核判定非法后给进程发 SIGSEGV。
KPTI 之后,进入内核还会切换一套只含内核映射的页表,进一步限制旁路攻击面。
Q:系统调用和函数调用差在哪?
函数调用只是 call 指令改 RIP,仍在同一特权级、同一地址空间;系统调用要跨特权级,切换栈、校验参数指针、
检查权限,返回时还要处理信号。量级上一个几十纳秒,一个几百纳秒。
Q:切换时内核怎么知道参数在哪?
约定寄存器里放着系统调用号和参数,入口处把用户寄存器压到内核栈,按 sys_call_table[号] 调函数。
指针参数必须先 copy_from_user 拷进内核并用 access_ok 校验,防止用户传内核地址骗内核读写。
小结
用户态与内核态是特权级隔离,切换由系统调用、中断、异常三类事件触发,代价是保存现场与缓存/TLB 变冷。
性能优化的一条主线就是"少穿越":批量(writev、io_uring)、免穿越(vDSO、用户态协议栈)、
把多次小操作合成一次大操作。