用户态和内核态是什么?系统调用是怎么执行的?

进阶高频原理约 7 分钟读完

一句话回答

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) 为例:

  1. 应用调用 glibc 的 read() 封装函数
  2. 封装函数把系统调用号放进 rax,参数依次放进 rdi、rsi、rdx(更多参数用 r10、r8、r9),执行 syscall 指令
  3. CPU 切换到 ring 0,跳到内核预先设置好的入口地址;内核保存用户态寄存器,切换到这个线程的内核栈
  4. 内核按调用号在系统调用表里找到对应的处理函数并执行:检查参数、找到 fd 对应的文件、读数据,必要时让线程睡眠等待 I/O
  5. 结果写进 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):

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 汇总次数和耗时):

Shell
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 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。