跳到正文

用户态与内核态:一次系统调用到底发生了什么 ​

一句话:CPU 用特权级把「能执行特权指令」和「只能执行普通指令」分开,用户程序通过系统调用主动陷入内核;代价是一次模式切换、寄存器保存和内核路径执行,而不是每次都切换地址空间或刷新 TLB。

适用:x86-64 Linux 4.x–6.x(syscall / sysret 路径);AArch64 对应 svc / eret 与 EL0/EL1。具体开销随 CPU、内核版本和缓解措施变化,量级仅供参考。

为什么需要 ​

如果应用能直接改页表、关中断、访问任意物理内存,一个野指针就能写坏别的进程,一个驱动 bug 就能拖垮整机,多用户和容器隔离也无从谈起。

硬件提供特权级(privilege level,x86 的 Ring 0–3,Linux 只用 Ring 0/3;ARM 的 EL0–EL3)和特权指令:内核运行在最高特权级,应用运行在最低特权级。代价是应用想读写文件、发包、创建进程时不能自己做,必须「申请」内核代劳,这个申请入口就是系统调用(system call)。

核心机制 ​

三种陷入方式 ​

  • 系统调用:主动。应用执行 syscall(x86-64)或 svc(ARM)指令。
  • 中断:被动。网卡、磁盘、定时器发出硬件中断。
  • 异常:被动。缺页(page fault)、除零、非法指令、断点。

三者都走 CPU 的陷入机制,但入口、栈和后续处理不同。系统调用有明确的调用号和参数,异常和中断则要先判断原因。

一次 read 的路径 ​

用户态:  read(fd, buf, n)
  libc:  参数搬进寄存器,rax=SYS_read,执行 syscall
  CPU:   切到 Ring 0,从 MSR_LSTAR 取入口,swapgs 并切到内核栈
  内核:  entry_SYSCALL_64 保存寄存器 -> do_syscall_64
         -> __x64_sys_read -> vfs_read -> file->f_op->read
         -> copy_to_user(buf, kernel_buf, n)
  返回:  sysretq 回到用户态,返回值放在 rax

几个关键点:

  • 同进程的系统调用不换地址空间。CR3 不变,所以没有传统意义上的「切页表 + 刷 TLB」。这是最常见的错误说法。
  • KPTI(Meltdown 缓解)会在用户页表和内核页表之间切换 CR3,用 PCID 减少 TLB 损失。这是额外的安全成本,不是系统调用的固有成本,也不能说成「每次系统调用都刷 TLB」。
  • 真正可能昂贵的是内核路径本身:文件系统查找、锁竞争、设备 I/O、copy_to_user 的数据拷贝。小 buffer 高频调用时,切换和路径开销占比高;大 buffer 时,拷贝带宽占比高。
  • vDSO 把 clock_gettime、gettimeofday 这类只读数据映射到用户态,调用它们不陷入内核;glibc 2.25+ 还缓存了 getpid,所以用 getpid 做基准测不到系统调用。

开销的量级 ​

  • 裸 syscall + sysret + 寄存器保存:几十到一百多纳秒(现代 x86,含缓解措施,随 CPU 和内核配置变化)。
  • 一次完整系统调用(含内核路径):通常几百纳秒到几微秒;带 I/O 或大块拷贝时更高。
  • 进程上下文切换(不同进程):微秒级,还要算地址空间切换和缓存污染;线程切换共享地址空间,便宜一些。

所以「系统调用慢」要拆开看:是模式切换、内核路径,还是数据拷贝。三者优化手段完全不同。

为什么不能完全去掉内核 ​

用户态驱动(DPDK)和 io_uring 能把部分路径搬到用户态或批量化,但那是用「预分配 + 轮询 + 受控共享内存」换掉通用性。通用操作系统仍然需要内核做隔离、调度和资源核算;去掉的是每次调用的固定开销,不是内核的职责。

最小上手 ​

c
// syscall_cost.c: gcc -O2 syscall_cost.c -o syscall_cost
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
int main(void) {
    for (int i = 0; i < 1000000; i++)
        syscall(SYS_getppid);      // 绕过 glibc 缓存,确保每次真陷入
    return 0;
}
bash
perf bench syscall basic                    # 内核自带基准,输出 ns/op
perf stat -e cycles,instructions,syscalls:sys_enter_getppid ./syscall_cost
strace -c -f ./syscall_cost                 # 看调用次数和耗时占比
strace -T -e trace=getppid ./syscall_cost 2>&1 | head

期望 strace -c 显示约 1,000,000 次 getppid。如果 syscalls:sys_enter_getppid 这个 tracepoint 不存在,改用 raw_syscalls:sys_enter。perf bench syscall basic 在 Linux 4.x+ 可用,具体输出随版本变化。

再观察上下文切换:

bash
vmstat 1                    # cs 列是每秒上下文切换次数
pidstat -w 1                # 按进程看 voluntary / nonvoluntary 切换
grep ctxt /proc/<pid>/status

注意:strace 通过 ptrace 拦截每一次系统调用,会把开销放大一个数量级以上。它适合看「调了哪些、调了多少次」,不适合测「一次调用多快」。真实延迟用 perf bench 或自己的微基准。

生产动作 ​

  • 先确认是不是系统调用瓶颈:perf stat 看 IPC 和 syscalls:sys_enter_* 计数;strace -c 看调用次数排名;pidstat -w 和 vmstat 1 看上下文切换。没有证据时不要先怀疑系统调用。
  • 减少调用次数:批量读写用 readv/writev;有缓冲的 I/O 不要按字节循环;大文件考虑 mmap;静态文件用 sendfile/splice 走零拷贝;高并发 I/O 可以评估 io_uring(Linux 5.1+),它把提交和完成批量化,但编程模型和运维复杂度也更高。
  • 减少拷贝:零拷贝路径和直接 I/O 各有适用边界,先确认瓶颈在拷贝带宽还是在模式切换,再选方案。
  • 不要把 strace 长期挂在生产进程上:ptrace 会让进程变慢数倍,还可能被安全策略禁止。低开销统计用 eBPF/bpftrace 挂 tracepoint:syscalls:sys_enter_*,或短时用 perf trace。
  • 容器里的 seccomp:过滤会增加每次调用的检查成本,但通常远小于 I/O;被拦截的调用返回 EPERM,应用表现为 operation not permitted,这类问题先查 seccomp 配置,不要先改内核参数。
  • 观测权限:perf_event_paranoid 限制普通用户采样(常见默认 2 或 4,以发行版为准),kptr_restrict 影响内核符号解析;容器里还可能缺少 CAP_PERFMON/CAP_SYS_ADMIN。
  • 虚拟化环境:guest 的系统调用不一定触发 VM exit(取决于 CPU 虚拟化支持),但高密度主机上的绝对开销不能直接搬到物理机做容量规划。

边界 ​

  • 不是所有「陷入」都是系统调用。缺页异常可能比系统调用更频繁,也更贵;优化系统调用前先看缺页和 I/O。
  • 系统调用开销不是常数。同一调用在不同内核版本、CPU、KPTI/retpoline 开关下差异很大,不要拿单个数字做容量规划。
  • strace/ltrace 的耗时被 ptrace 放大,不能用来判断「系统调用慢不慢」。
  • 用户态协议栈和 DPDK 适合特定高吞吐场景,代价是独占核、绕过内核、运维复杂,不是通用替代。
  • KPTI 等缓解有成本,但关闭会引入安全风险;生产环境不应为了跑分关闭。
  • 微基准在共享和虚拟化环境里噪声大,固定 CPU、多次取中位数、报告完整环境,结论才可比较。

讲清楚 ​

用户态和内核态是 CPU 特权级的两档:应用在低特权级,不能碰页表、中断和硬件;内核在高特权级,负责隔离、调度和 I/O。应用读文件时,libc 把系统调用号放进寄存器并执行 syscall,CPU 切到内核栈和 Ring 0,内核保存寄存器、查系统调用表、走 VFS 和设备驱动,再用 sysret 返回。这里最容易讲错的是成本:同进程的系统调用不换地址空间,所以没有「切页表刷 TLB」;只有 KPTI 这类缓解才会额外切 CR3。真正的开销是模式切换、内核路径和 copy_to_user 的数据拷贝,量级从几十纳秒到几微秒。观测时 strace -c 看次数和路径,perf bench syscall 看真实延迟,不要把 ptrace 放大后的数字当结论。

把一门技术讲透到既懂也会用