用户态和内核态是什么?系统调用是怎么执行的?
一句话回答
CPU 有特权级:内核运行在最高特权级(x86 的 ring 0),可以执行所有指令、访问所有内存;应用运行在低特权级(ring 3),不能直接操作硬件和内核内存。应用要读文件、发网络包,只能通过系统调用请求内核代劳:把调用号和参数放进寄存器,执行 syscall 指令陷入内核,内核执行完再返回用户态。除了系统调用,异常(缺页、除零)和硬件中断也会进入内核。系统调用要保存和恢复上下文,还会影响缓存和 TLB,所以要减少调用次数:用缓冲、批量接口、io_uring、零拷贝。
详细解析
为什么要分两种状态
如果应用能直接操作磁盘、网卡和别的进程的内存,一个有 bug 或恶意的程序就能破坏整个系统。分出特权级后:
- 特权指令(修改页表、关中断、访问 I/O 端口)只能在内核态执行,用户态执行会触发异常
- 内核的内存在页表中标记为只有内核态能访问
- 应用想用硬件资源,必须走内核提供的接口,内核负责检查权限、做资源隔离
用户态和内核态说的是 CPU 当前的运行模式,同一个线程会在两种状态之间来回切换,不是两个不同的线程。
进入内核的三种方式
| 方式 | 触发者 | 例子 |
|---|---|---|
| 系统调用 | 程序主动发起 | read、write、open、epoll_wait |
| 异常 | 执行当前指令时出错,同步发生 | 缺页、除零、非法指令 |
| 硬件中断 | 外部设备,异步发生 | 网卡收到数据、时钟中断、磁盘读完 |
时钟中断是调度的基础:即使线程在死循环,内核也能定期拿回控制权,决定要不要切换到其他线程,见 上下文切换。
一次系统调用的过程
以 x86-64 Linux 上调用 read(fd, buf, n) 为例:
- 应用调用 glibc 的
read()封装函数 - 封装函数把系统调用号放进
rax,参数依次放进rdi、rsi、rdx(更多参数用r10、r8、r9),执行syscall指令 - CPU 切换到 ring 0,跳到内核预先设置好的入口地址;内核保存用户态寄存器,切换到这个线程的内核栈
- 内核按调用号在系统调用表里找到对应的处理函数并执行:检查参数、找到 fd 对应的文件、读数据,必要时让线程睡眠等待 I/O
- 结果写进
rax(出错时是负的错误码,glibc 把它转成 -1 并设置errno),恢复用户态寄存器,执行返回指令回到 ring 3
开销在哪里
- 直接开销:特权级切换、保存和恢复寄存器、参数检查、在内核和用户空间之间拷贝数据
- 间接开销:内核代码和数据挤占 CPU 缓存、TLB,回到用户态后应用的缓存命中率下降
- 安全补丁:为了缓解 Meltdown,内核引入了页表隔离(KPTI),进出内核时要切换页表,CPU 不支持 PCID 时还要刷 TLB,开销更明显;不受 Meltdown 影响的 CPU 默认不启用 KPTI
一次系统调用本身并不算很慢,问题出在高频的小调用上,比如每写一个字节就调用一次 write。有些高频调用通过 vDSO 在用户态完成,比如 clock_gettime,内核把时间数据映射到进程地址空间,读时不用陷入内核。
减少系统调用
- 缓冲:先写进用户态的缓冲区,攒够一批再一次
write,比如 Go 的bufio、C 的 stdio - 批量接口:
writev/readv一次读写多个缓冲区,sendmmsg/recvmmsg一次收发多个 UDP 包,epoll 一次返回多个就绪事件 - io_uring:Linux 5.1 引入。应用和内核共享提交队列和完成队列,把一批请求放进提交队列后一次通知内核,开启内核轮询模式(SQPOLL)时由内核线程轮询提交队列,提交时通常不用系统调用(轮询线程空闲休眠后要用
io_uring_enter唤醒一次);它同时是真正的异步 I/O,对普通文件也有效 - 零拷贝:用
sendfile、splice让数据不经过用户空间,见 零拷贝
代码示例
无缓冲和有缓冲写文件的对比(Go):
package main
import (
"bufio"
"os"
)
func main() {
f, _ := os.Create("/tmp/raw.txt")
defer f.Close()
for i := 0; i < 10000; i++ {
f.WriteString("x\n") // ❌ 每次都是一个 write 系统调用
}
g, _ := os.Create("/tmp/buf.txt")
defer g.Close()
w := bufio.NewWriter(g) // 默认 4096 字节的缓冲区
for i := 0; i < 10000; i++ {
w.WriteString("x\n") // ✅ 写进缓冲区,满了才调用 write
}
w.Flush() // 别忘了把剩余数据写出去
}
用 strace 统计系统调用(-f 跟踪子线程,-c 汇总次数和耗时):
go build -o demo . && strace -f -c -e trace=write ./demo
# 只看某个运行中的进程:-T 显示每次调用的耗时,-tt 显示时间戳
strace -f -tt -T -e trace=read,write,openat -p 1234
能看到第一个循环产生了一万次 write,第二个只有几次。strace 会显著拖慢被跟踪的进程,线上要谨慎使用。
面试官可能追问
系统调用和普通函数调用有什么区别?
普通函数调用在同一个特权级、同一个栈上跳转,只是压栈和跳转。系统调用要切换特权级和栈,经过固定的入口进入内核,内核还要校验所有参数,因为用户传来的指针和长度都不可信。所以系统调用不能随意内联或优化,次数多了开销明显。
什么是中断上下文?和进程上下文有什么区别?
系统调用发生在进程上下文里:内核代表某个线程在执行,可以睡眠等待(比如等磁盘)。硬件中断处理程序运行在中断上下文里,不属于任何线程,不能睡眠,要尽快结束。Linux 把中断处理分成上半部(快速确认、取数据)和下半部(软中断、工作队列,做耗时的处理),网络收包的大部分协议处理就在软中断里完成,top 里的 si 就是软中断占用的 CPU。
怎么判断一个进程系统调用太多?
top 里 sy(内核态 CPU)很高,就要怀疑系统调用或内核处理太多。用 strace -c -p <pid> 短时间采样,看哪个调用次数最多、耗时最长;也可以用 perf top 看内核里的热点函数。常见原因是小块读写、频繁的 gettimeofday(未走 vDSO 的环境)、大量的 futex(锁竞争)。
易错点
- "切换到内核态"不是切换到另一个线程,还是同一个线程,只是换了特权级和栈
- 系统调用和上下文切换是两回事:系统调用不一定发生线程切换,只有线程在内核里阻塞或被抢占时才会切到别的线程
- 缓冲写入后程序崩溃或忘记
Flush,缓冲区里的数据就丢了;需要持久化保证时还要fsync
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。