中断、异常与系统调用详解:从内核入口到缺页异常
系统调用从用户态进入内核态,只是理解这条路径的起点。沿着一次 read() 往下看,还会遇到几个紧密相关的问题:
read()是怎么进入内核的?- 时钟中断为什么能让正在运行的线程停下来?
- Page Fault 为什么有时是正常行为,有时又会变成
SIGSEGV? - 系统调用进了内核,是否一定会发生线程上下文切换?
这些问题可借助一条 read(fd, buf, count) 调用串起来看。用户程序执行 read(),glibc 把系统调用号和参数放到约定寄存器里,CPU 执行 syscall 进内核。内核检查 fd、缓冲区地址、文件状态,再决定从 Page Cache、文件系统、socket 缓冲区或设备驱动里取数据。
如果数据已经准备好,内核把数据复制回用户缓冲区,read() 很快返回。数据没准备好时,当前线程可能睡眠;磁盘 I/O 完成或网卡收到数据后,硬件中断进入内核,内核再唤醒等待队列里的线程。线程再次被调度到 CPU 上,read() 才继续返回。
一次普通 I/O 跑起来后,系统调用、中断、异常、调度常连在一起。
进入内核的几类事件
CPU 正常执行用户程序时,下一条指令由程序计数器和跳转逻辑决定。外设事件、当前指令出错、用户程序主动请求内核服务,都会让控制流转到内核。CSAPP 把这类跳出正常指令流的情况称为异常控制流(Exceptional Control Flow),并把 interrupt、trap、fault、abort 作为不同类型。
常见入口可按来源分:
- 中断(Interrupt):来自外部硬件,和当前指令没有直接关系。网卡收包、磁盘 I/O 完成、定时器到点,都属于这一类。
- 陷入(Trap):程序主动执行特殊指令进入内核。系统调用就是最常见的 trap。
- 故障(Fault):当前指令执行时遇到问题,但内核可能进行修复。典型例子是缺页异常,修好后会重新执行触发 fault 的那条指令。
- 终止(Abort):处理器发现难以恢复的严重错误,通常不再回到原来的指令流。

trap 这个词在不同资料里的用法不完全一样。CSAPP 语境下,它通常指程序主动触发的同步异常,比如系统调用;RISC-V 则把 trap 定义为异常或中断引起的控制转移。本文提到“trap / 系统调用”时使用前一种狭义含义,涉及 RISC-V 时会单独说明。
中断、异常、系统调用和信号的关系
这几个词容易混,因为它们不在同一层。
硬件中断、同步异常和系统调用描述 CPU 为什么进入内核;信号则是内核向进程或线程交付的软件通知。信号可能由硬件异常转化而来,也可能由其他进程、终端或定时器产生。
先把这几个概念放到一张表里:
| 概念 | 触发来源 | 同步/异步 | 谁处理 | 常见结果 |
|---|---|---|---|---|
| 硬件中断 | 外设或定时器 | 异步 | 内核中断处理程序 | 处理设备事件、唤醒等待任务、触发调度 |
| 同步异常 | 当前指令执行过程 | 同步 | 内核异常处理程序 | 修复后重试、转成信号、终止进程 |
| 系统调用 | 用户程序执行 syscall/ecall 等指令主动触发的 trap | 同步 | 内核系统调用入口 | 返回结果、返回错误码、阻塞等待资源 |
| 信号 | 内核或进程发出的通知 | 通常异步,也可能由异常引发 | 目标进程的默认动作或用户态 signal handler | 忽略、终止、暂停、继续、执行 handler |
这张表里的同步/异步,看的是事件是不是由当前指令引出来。除零、非法指令、Page Fault、系统调用都和当前正在执行的指令有关,所以是同步事件。硬件中断来自外设或定时器,CPU 正在跑 Java 线程时,网卡也可能刚好收到包,这件事和当前那条用户代码没有直接关系,所以是异步事件。
阻塞和非阻塞是另一个维度。read() 进入内核这一步是同步的,但进入内核后,如果资源还没有准备好,阻塞 fd 会让线程睡眠;设置了 O_NONBLOCK 的 fd 可能直接返回 EAGAIN。
硬件中断像是外设敲了一下 CPU。比如 CPU 正在跑你的 Java 线程,时钟中断来了,CPU 跑完当前指令后会进入内核的中断入口。内核更新时钟、统计运行时间,必要时让调度器把 CPU 交给另一个线程。你的代码里没有写过让出 CPU,但它还是可能被抢占。
异常出在当前指令身上。除以 0、执行非法指令、访问没有权限的地址,都是这条指令触发的问题。缺页异常也算这一类:进程访问某个虚拟地址,页表里暂时没有有效映射,CPU 只能把现场交给内核。同步异常不代表一定能修好,内核修不了时,还是会投递信号或终止进程。
系统调用就是用户程序主动找内核帮忙。用户态程序不能直接读磁盘、改页表、操作网卡,所以 glibc 的 read()、write()、fork()、mmap() 最后都要走到内核提供的系统调用接口。
信号不属于 CPU 入口机制。它是内核给进程或线程发的通知。非法内存访问可能先触发 Page Fault,内核发现无法修复,再给进程投递 SIGSEGV。用户按下 Ctrl+C,终端驱动会让内核给前台进程组发 SIGINT。另一个进程也可调用 kill() 发信号。
几个容易混的维度可以拆开看:
| 维度 | 关注的问题 | 例子 |
|---|---|---|
| 同步/异步事件 | 事件是否由当前指令直接触发 | Page Fault 是同步异常;网卡中断是异步中断 |
| 阻塞/非阻塞 I/O | 资源未就绪时线程是否等待 | 阻塞 read() 会睡眠;非阻塞 read() 可返回 EAGAIN |
| 用户态/内核态切换 | 是否进入内核执行特权代码 | syscall、Page Fault、硬件中断都会进入内核 |
| 线程上下文切换 | CPU 是否从一个线程切到另一个线程 | 阻塞、抢占、调度时可能发生 |
信号 handler 也不是内核函数。内核通常在从内核态返回用户态前检查待处理信号;如果要执行 handler,就准备用户栈、寄存器和 trampoline,再让线程回到用户态执行 handler。因此,信号 handler 通常不是在任意机器指令之间立刻插入执行,而是等线程从内核态返回用户态,或从可中断等待中被唤醒后,再按内核安排进入 handler。多线程程序还要多留意一步:发给进程的信号,不一定由预想中的那个线程处理,内核会选择一个没有屏蔽该信号的线程。
事件处理完后,回到哪里也不一样:
| 类型 | 处理后通常回到哪里 |
|---|---|
| 中断 | 回到被打断的位置继续执行,或者调度到别的线程 |
| Trap / 系统调用 | 通常回到陷入指令之后继续执行;系统调用重启等情况除外 |
| Fault / 缺页 | 修复后重新执行触发 fault 的那条指令 |
| Abort | 通常不返回原程序 |
这张表只描述最常见路径。真实系统里,内核还可能投递信号、重启系统调用、切换到别的线程,或者直接终止进程。
用户态/内核态切换与上下文切换
用户态和内核态的差别在 CPU 特权级。用户态不能执行特权指令,不能随便访问内核地址空间;内核态可以管理页表、设备、中断控制器和调度器。
从用户态进入内核态,不是普通函数调用。CPU 和内核必须留下足够的现场信息,否则后面不知道该回到用户程序哪条指令。
x86-64 上,64 位系统调用通常走 syscall 指令;异常和外部中断更多走 IDT 中配置好的入口。有些异常会压入错误码,有些不会;NMI、Double Fault 这类特殊入口还可能使用 IST 栈。
本文用 Linux x86-64 举例,所以主要写 syscall。其他架构或旧 ABI 可能使用 int 0x80、sysenter、ecall、svc 等入口指令,寄存器约定也不同。
syscall 指令本身做的事有限。它会把返回地址和标志寄存器放到 RCX、R11,但不会像普通函数调用那样保存完整寄存器现场,也不会自动切到内核栈。Linux 入口汇编还要继续完成切栈、swapgs、保存寄存器等工作。
还要区分用户态/内核态切换和线程上下文切换:
- 用户态/内核态切换:CPU 从低特权级进入高特权级,执行内核代码,再返回用户态。
- 上下文切换:调度器把 CPU 从一个线程或进程切给另一个执行实体。
系统调用一定会进入内核,但不一定切换到另一个线程。getpid() 这类调用通常很快返回,还是当前线程继续运行。read() 如果要等待数据,内核可能挂起当前线程,先调度别的线程。另外,一些时间相关接口可借助 vDSO 在用户态完成,比如 clock_gettime()、gettimeofday() 在某些架构和配置下可以读取内核映射给用户态的数据页,不一定每次都真正进入内核。

read() 的系统调用路径
以 Linux x86-64 上的 read(fd, buf, count) 为例,业务代码一般调用的是 glibc 包装函数,不会自己写汇编。

glibc 会把系统调用号放进 rax,把参数放进约定寄存器。x86-64 的系统调用参数依次放在 rdi、rsi、rdx、r10、r8、r9。
CPU 执行 syscall 后,会按架构约定跳到内核配置好的入口。Linux 入口代码保存后续要用到的寄存器状态,再根据系统调用号分发到 read 对应的处理函数。内核会检查 fd、访问权限和其他参数;真正向用户缓冲区复制数据时,地址问题仍可能导致 EFAULT。
目标是普通文件时,路径会走 VFS 和具体文件系统,优先从 Page Cache 拿数据。目标是 socket 时,内核会检查接收缓冲区有没有数据。数据准备好后,内核把数据复制到用户传入的 buf。
内核访问用户缓冲区时,也可能触发 Page Fault。Linux 会把这类可能 fault 的用户内存访问点记录在 exception table 里;如果 fault 发生在可修复位置,内核会跳到对应 fixup 代码,把结果转换成 -EFAULT 这类错误返回,而不是直接让内核崩溃。
例如,把 read() 的 buf 传成明显不可写的地址,系统调用通常不会把内核一起拖死,而是返回 -1,并把 errno 设为 EFAULT。
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
int fd = open("/dev/zero", O_RDONLY);
char *p = (char *)1;
ssize_t n = read(fd, p, 1); // n == -1, errno == EFAULT
printf("n=%zd errno=%d\n", n, errno);
}read(2) 对 EFAULT 的描述就是用户缓冲区不在可访问地址空间里。对应到内核路径,问题出在内核把数据拷回用户缓冲区这一步。x86 的 exception table 文档里也用 get_user() 做例子:可能 fault 的用户内存访问指令会和一段 fixup 代码配对;Page Fault 发生后,内核能查到这对地址,就把返回值改成 -EFAULT,再跳到 fixup 路径继续收尾。
系统调用返回时,成功的 read() 返回实际读到的字节数,这个值可以小于 count,不算错误。失败时内核返回负错误码,glibc 包装函数通常把它转成 -1 并设置 errno。用户缓冲区不可访问时可能得到 EFAULT;阻塞等待期间被信号打断时,可能得到 EINTR。
写生产代码时,不要默认一次 read() 要么读满,要么失败。阻塞系统调用等待期间,如果线程收到信号并执行了 handler,系统调用可能返回 EINTR;如果信号到达前已经读到部分数据,read() 也可能直接返回已经读到的字节数,而不是失败。如果安装 handler 时使用了 SA_RESTART,部分阻塞系统调用会在 handler 返回后自动重启。是否重启,取决于接口类型和信号处理设置。
时钟中断与抢占
抢占式操作系统不能指望每个程序主动让出 CPU。教材通常把这条路径简化为内核配置定时器、硬件周期性产生中断。现代 Linux 支持 tickless,实际机器不一定始终按固定频率产生调度 tick,但定时器中断仍是理解抢占的基础模型。
假设线程 A 正在用户态运行。定时器到点后,CPU 进入内核的时钟中断处理程序。内核更新当前线程的运行时间,检查是否要进行调度。如果不用调度,处理结束后返回 A,A 继续执行;如果要调度,内核保存 A 的执行现场,选出线程 B,切到 B 的内核栈和寄存器上下文,最后从内核返回到 B 的用户态位置。
OSTEP 讲 Limited Direct Execution 时,也是按这条链路展开的:定时器中断先让硬件和内核保存当前进程的用户寄存器,内核再调用切换例程保存旧进程上下文、恢复新进程上下文,最后通过 return-from-trap 回到新进程。
这条路径一定发生了中断,是否发生上下文切换则取决于调度器是否选中了另一个线程。
硬件中断处理程序一般要快进快出,不能像普通进程上下文那样随便阻塞等待。较重的工作会被延后到软中断、工作队列或内核线程里处理。
网卡收包就是一个常见例子。Linux NAPI 的基本路径是:设备先用硬件中断通知主机,驱动在中断处理里调度 NAPI,后面的包处理通常在 softirq 上下文里运行。驱动调度 NAPI 后通常会保持 IRQ masked,直到 NAPI polling 结束,因为这段时间继续收硬中断没有必要。处理量过大或 softirq 被推迟时,也可能由 ksoftirqd 这类内核线程继续处理。线上看到 ksoftirqd 或 %si 长时间偏高时,要联想到网络包处理、软中断压力和中断亲和性。硬中断只负责挂起后续工作,批量处理 packet 时已经切到了 softirq 或内核线程上下文。
缺页异常的正常路径和错误路径
Page Fault 这个名字容易让人以为程序已经出错。实际上,它只表示 CPU 做地址翻译或权限检查时,当前页表项没法直接完成这次访问。
常见情况有几类:
- 页还没分配物理内存,例如懒分配的堆页第一次被访问;
- 页在文件或 Swap 里,当前还没驻留到内存;
- COW 页被写入,页表暂时标成只读,要内核复制一份;
- 访问权限不对,比如用户态访问内核页、写只读页、执行不可执行页;
- 地址根本不属于进程合法的虚拟地址区域。

内核处理 Page Fault 时,先看地址是否落在进程合法的 VMA 中,再看访问类型和权限是否契合。
合法缺页可以修复。内核分配物理页、从文件读页、从 Swap 换入,或者处理 COW,更新页表后返回。CPU 会重新执行触发异常的那条指令。这类缺页可能是 minor fault,也可能是 major fault,差别在于是否要实际 I/O。
地址不属于任何合法 VMA,或者访问方式违反页级权限时,内核通常会向当前线程投递 SIGSEGV。访问 NULL 附近、写只读映射都属于这类情况。C/C++ 里的越界访问则不保证触发 Page Fault:如果目标地址仍在已映射且权限允许的页面内,程序可能只是破坏了相邻数据。只有越界地址落到未映射区域或违反页权限时,硬件才会通过 Page Fault 把问题交给内核。
xv6 的 COW fork 和 lazy allocation 很适合帮助理解这一点。父子进程先共享只读页,谁写谁触发页故障,内核复制页面后让写入继续;进程扩大地址空间时,内核可以先只记录范围,等第一次访问再分配物理页。两个场景都借助 Page Fault 把工作延后。
userfaultfd(2) 可以作为一个高级例子:用户态注册某段内存后,missing、minor 或 write-protect 这类 page fault 可以变成 fd 上的事件;触发 fault 的线程先阻塞,另一个用户态线程补页、继续或解除写保护后再让它继续。这类机制常用于虚拟机迁移、懒加载和脏页跟踪,但普通后端业务很少直接用。
系统调用的成本
系统调用比普通函数调用重。普通函数调用仍在用户态,按照 ABI 传递参数、保存必要现场并完成跳转和返回;系统调用还要切到内核态,经过入口代码保存现场、执行权限检查,并可能访问页表、文件对象、设备驱动或等待队列。从内核返回用户态前,内核还可能检查待处理信号、抢占和调度标志等状态。
但系统调用的成本不能一概而论。getpid() 这类调用主要花在进入和退出内核;read() 碰到磁盘 I/O 时,主要成本在等待设备和数据复制。基于 futex 实现的锁在无竞争时通常只执行用户态原子操作,不调用 futex(2);发生竞争、需要线程睡眠或唤醒时,才通过 futex(2) 进入内核。
工程上不要为了少一次系统调用牺牲正确性。更常见的优化是批量化和减少无意义等待:缓冲 I/O、一次读写更多数据、I/O 多路复用、sendfile()、mmap()、io_uring,分别在不同场景里减少模式切换、复制或等待成本。
面试回答要点
回答“中断、异常、系统调用是什么关系”,可以按入口来源说:
CPU 正常按指令流执行。外设事件、当前指令错误、用户程序主动请求内核服务,都会让控制流进入内核。硬件中断来自外部设备,是异步的;同步异常由当前指令触发;系统调用则是程序通过
syscall、ecall等指令主动触发的 trap,也属于同步事件。内核处理完以后,可能返回原程序继续执行,也可能调度别的线程,或者向进程投递信号。
回答“系统调用流程”,可以抓 read():
glibc 包装函数把系统调用号和参数放到约定寄存器里,执行
syscall。CPU 先按架构约定进入内核入口,Linux 入口代码再保存后续需要的寄存器状态,并根据系统调用号分发到对应处理函数,检查参数和权限,执行 VFS、网络、内存管理等逻辑。返回时把结果放回寄存器;出错时通常由 glibc 转成-1和errno。如果调用要等待 I/O,线程会阻塞,后续设备中断再唤醒它。
回答“缺页异常和非法访问的区别”,抓住内核分流:
Page Fault 只是 CPU 发现这次地址翻译或权限检查过不去。内核会判断地址和权限是否合法。合法缺页可以修复,比如分配匿名页、从文件或 Swap 调页、处理 COW,然后重新执行触发异常的指令;非法访问无法修复,通常投递
SIGSEGV,进程默认终止。
用户态/内核态切换和线程上下文切换不是一回事。一次真正的系统调用一定进入内核,但只有调度器选择了另一个执行实体时,CPU 才会切换线程,例如当前线程阻塞、主动让出 CPU 或被抢占。
