中断、异常、系统调用都会把 CPU 从用户态送进内核,但触发来源完全不同:一个是外设发来的异步通知,一个是当前指令闯的祸,一个是程序主动请求特权服务。这篇把三者放在同一张地图上对比,再以 read() 为例走完系统调用的完整路径,说清它为什么比普通函数调用贵,以及 vDSO 这类优化省掉了什么。

它解决什么问题

CPU 平时的职责是按程序计数器顺序执行用户指令,但有三类事情必须打断这条指令流:外设有进展要汇报(网卡收到包、磁盘读写完成、定时器到点);当前指令执行不下去(除零、访问未映射的地址、执行非法指令);程序要办自己没权限办的事(读文件、发网络包、创建进程——这些都涉及硬件与全局状态)。

三类事情对应三个内核入口:中断、异常、系统调用。没有这套机制,要么用户程序用不了任何硬件,要么谁都能直接操作硬件,系统谈不上任何隔离与稳定。

核心原理

三者的本质区别在触发来源与同步性:

  • 中断是异步的:来自 CPU 之外,与当前正在执行哪条指令毫无关系。你的 Java 线程跑得好好的,网卡随时可能收到一个包。
  • 异常是同步的:当前指令自己引发的。除零、缺页、非法地址,问题就出在这条指令身上,重跑一遍还会在原地摔倒(除非内核修复了现场)。
  • 系统调用是主动陷入:程序执行 syscall 这类专门指令,故意进内核。它是同步事件里特殊的一类——不是出错,而是请求服务。

支撑这套机制的是 CPU 特权级。用户态不能执行特权指令、不能访问内核地址空间;进入内核态后才可以操作页表、设备和调度器。三个入口本质上都是一次特权级提升,处理完再降回用户态。

深入机制

一次系统调用的完整旅程

以 Linux x86-64 上的 read(fd, buf, count) 为例:

用户态                                   内核态
| glibc: rax=系统调用号 |
| rdi/rsi/rdx = fd/buf/count |
| -- syscall 指令 ---------------------> | 入口汇编: 切内核栈、swapgs、存寄存器
| | 按系统调用号分发到 sys_read
| | 检查 fd、缓冲区地址、权限
| | VFS / Page Cache / socket 缓冲区取数
| | copy_to_user 拷回用户缓冲区
| <----------- 返回 -------------------- | 检查待处理信号、调度标志,恢复现场
| glibc: 负错误码转 -1 + errno |

几个关键细节:

  1. 参数走约定寄存器:系统调用号放 rax,参数依次放 rdi、rsi、rdx、r10、r8、r9。
  2. syscall 指令本身做很少的事:返回地址和标志寄存器塞进 RCX、R11,跳到内核预设入口。切内核栈、保存通用寄存器都是入口汇编补做的。
  3. 数据没准备好时,线程在内核里睡眠;之后磁盘完成或网卡收包触发硬件中断,内核再唤醒它。一次普通 I/O,把系统调用、中断、调度串成了一条链。
  4. 内核访问用户指针也可能缺页。Linux 用 exception table 记录这些”可能 fault 的访问点”,fault 后跳到修复代码转成 -EFAULT 返回,而不是让内核崩掉。

系统调用为什么贵

普通函数调用全程在用户态,参数传递和现场保存由编译器按 ABI 安排。系统调用多出来一串成本:

  • 现场保存与恢复:入口汇编手工保存一组寄存器,返回时再还原;
  • 权限与参数检查:fd 表查找、用户缓冲区合法性校验、权限判定;
  • 缓存与 TLB 扰动:跨特权级、换栈,内核代码和数据污染缓存;启用 KPTI(内核页表隔离)的机器上进出内核还要切页表,TLB 冲刷更明显;
  • 返回前的杂事:检查待处理信号、抢占标志、是否需要调度。

一次 getpid() 的开销在几十到上百纳秒量级,单次看微不足道,但每秒百万次的小读写累积起来就非常可观。工程优化的方向因此很统一:减少进入内核的次数——标准库缓冲 I/O 攒够再 flush,readv/writev 一次带多段,sendfile 避免用户态中转。

vDSO:根本不进内核

有一类调用只读内核维护的公共数据,比如 gettimeofday()、clock_gettime()。Linux 的做法是 vDSO(virtual dynamic shared object):内核把存放时间数据的页面以只读方式映射进每个进程的地址空间,glibc 的包装函数直接在用户态读这块内存,整条路径不执行 syscall。所以用 strace 跟踪时根本看不到它们——不是没调用,是没陷入。

缺页异常:不一定是错误

Page Fault 只表示这次地址翻译过不去,不等于程序出错。合法缺页(懒分配的堆页首次访问、COW 页被写入、页在 Swap 里)由内核修复后重新执行触发异常的那条指令,程序毫无知觉;地址不落在任何合法 VMA 或权限不符,才投递 SIGSEGV。操作系统的按需分配、写时复制,全是靠”故意触发缺页再修复”实现的。

工程实践

  • strace -c -p <pid> 统计系统调用次数与耗时分布,快速定位”调用过于频繁”的热点。
  • 高频小写先加用户态缓冲;时间获取走热路径时确认它命中 vDSO,而不是真的陷入。
  • 阻塞调用被信号打断会返回 EINTR,这不是 bug,要么重试,要么装 handler 时用 SA_RESTART。
  • 别把”进入内核”等同于”线程上下文切换”:getpid 进内核转一圈还是原线程继续跑,只有阻塞或被抢占时才换人。

对比与权衡

维度 硬件中断 异常 系统调用
触发来源 外设、定时器 当前指令 程序主动执行陷入指令
同步性 异步 同步 同步
典型例子 网卡收包、时钟 缺页、除零、非法指令 read、write、mmap
处理者 中断处理程序(要求快进快出) 异常处理程序 系统调用分发例程
处理完回到哪 被打断处继续,或调度别人 修复后重执行该指令;修不了则投递信号 返回陷入指令的下一条

面试视角

中断、异常、系统调用有什么区别?

看触发来源。中断来自外设,是异步的,与当前指令无关;异常由当前指令引发,是同步的;系统调用是程序执行 syscall 主动陷入,属于故意的同步事件。三者都是特权级提升的入口,处理完大多返回原指令流。

系统调用的流程是怎样的?

以 read 为例:glibc 把系统调用号放入 rax、参数放入约定寄存器,执行 syscall;CPU 跳到内核入口,汇编完成切栈与寄存器保存,按号分发到处理函数,检查 fd 与缓冲区,取数并拷回用户态;返回前检查信号与调度标志,出错由 glibc 转成 -1 加 errno。

系统调用为什么比普通函数调用贵?

多了现场的手工保存恢复、fd 与用户指针的权限检查、跨特权级和换栈带来的缓存与 TLB 扰动(KPTI 下更甚),以及返回前的信号与调度检查。优化思路是减少陷入次数:缓冲、批量、sendfile,只读类调用则由 vDSO 直接在用户态完成。

系统调用一定会发生线程上下文切换吗?

不一定。进入内核只意味着特权级切换;getpid 这类调用很快返回,还是原线程继续跑。只有调用阻塞等资源、或被抢占时,调度器才会把 CPU 交给另一个线程。

缺页异常是不是程序出错了?

不一定。合法缺页(懒分配、COW、Swap 换入)由内核修复后重新执行触发指令,属于正常的按需分配机制;只有地址非法或权限不符时才转 SIGSEGV。

小结

  • 中断异步、异常同步、系统调用是主动陷入,三者共用”特权级提升”这条入口路径。
  • 系统调用的成本来自现场保存、参数与权限检查、缓存/TLB 扰动和返回前的信号调度检查。
  • 优化方向是少进内核:缓冲与批量化;vDSO 让 gettimeofday 这类只读调用完全不陷入。
  • 缺页异常是内存按需分配的工具,合法缺页修复后重执行,非法访问才变 SIGSEGV。