中断、异常与系统调用详解:从内核入口到缺页异常
中断、异常、系统调用都会把 CPU 从用户态送进内核,但触发来源完全不同:一个是外设发来的异步通知,一个是当前指令闯的祸,一个是程序主动请求特权服务。这篇把三者放在同一张地图上对比,再以 read() 为例走完系统调用的完整路径,说清它为什么比普通函数调用贵,以及 vDSO 这类优化省掉了什么。
它解决什么问题
CPU 平时的职责是按程序计数器顺序执行用户指令,但有三类事情必须打断这条指令流:外设有进展要汇报(网卡收到包、磁盘读写完成、定时器到点);当前指令执行不下去(除零、访问未映射的地址、执行非法指令);程序要办自己没权限办的事(读文件、发网络包、创建进程——这些都涉及硬件与全局状态)。
三类事情对应三个内核入口:中断、异常、系统调用。没有这套机制,要么用户程序用不了任何硬件,要么谁都能直接操作硬件,系统谈不上任何隔离与稳定。
核心原理
三者的本质区别在触发来源与同步性:
- 中断是异步的:来自 CPU 之外,与当前正在执行哪条指令毫无关系。你的 Java 线程跑得好好的,网卡随时可能收到一个包。
- 异常是同步的:当前指令自己引发的。除零、缺页、非法地址,问题就出在这条指令身上,重跑一遍还会在原地摔倒(除非内核修复了现场)。
- 系统调用是主动陷入:程序执行
syscall这类专门指令,故意进内核。它是同步事件里特殊的一类——不是出错,而是请求服务。
支撑这套机制的是 CPU 特权级。用户态不能执行特权指令、不能访问内核地址空间;进入内核态后才可以操作页表、设备和调度器。三个入口本质上都是一次特权级提升,处理完再降回用户态。
深入机制
一次系统调用的完整旅程
以 Linux x86-64 上的 read(fd, buf, count) 为例:
用户态 内核态 |
几个关键细节:
- 参数走约定寄存器:系统调用号放
rax,参数依次放rdi、rsi、rdx、r10、r8、r9。 syscall指令本身做很少的事:返回地址和标志寄存器塞进RCX、R11,跳到内核预设入口。切内核栈、保存通用寄存器都是入口汇编补做的。- 数据没准备好时,线程在内核里睡眠;之后磁盘完成或网卡收包触发硬件中断,内核再唤醒它。一次普通 I/O,把系统调用、中断、调度串成了一条链。
- 内核访问用户指针也可能缺页。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。

