系统调用与中断 入门
3 个机制把控制权移过用户 / 内核边界:syscall(你代码主动求)、interrupt(硬件要求注意)、exception(CPU 在你代码上出错)。 socket 的每次 read、每次按键、每次磁盘 I/O、每次缺页都骑这 3 个之一。 5 个 section:3 扇门对比;IDT 怎么把中断连到 handler;Linux 的top-half / softirq 分裂 + 可交互的 packet 到达走读;signal —— 回到用户代码的异步通知;最后是速查表。
跨越特权边界的 3 扇门
硬件正好暴露 3 个机制把控制权移到内核态。懂每个事件用哪个, 告诉你谁发起的、保存了什么状态、代价是什么。
3 扇门存在,每个有不同的发起者和不同风味的代价:
- Syscall —— 用户代码主动向内核求东西。 指令(x86-64 上
syscall、ARM64 上svc #0)由用户执行; 参数在公认的寄存器里(assembly-isa primer 讲调用约定)。 代价:包括 KPTI 开销 ~150 ns。每次read、write、open、fork都用。 - Interrupt —— 硬件要求注意。NIC 收到 packet、定时器响了、 磁盘完成 I/O。来自 CPU 指令流之外;能抢占任何用户(或内核)代码。 代价随 handler 变,但进出跟 syscall 类似(~150 ns)。
- Exception —— CPU 在用户自己的指令上 fault。除 0、 解引用未映射地址、执行未定义 opcode、撞上 debug 断点。 CPU 状态跟中断入口类似,但原因是代码本身而不是外部事件。
3 个共通的
3 个过渡都原子地:(1)抬高特权环从 3 到 0;(2)切到每 CPU 内核栈; (3)保存用户指令指针、栈指针、标志好让内核能回去; (4)跳到地址在 boot 时配置好的内核 handler。 硬件保证用户代码不能拦截这个 —— 进 ring 0 的唯一路径是经内核初始化时写好的受控入口。
微妙的差别
同步 vs 异步。Syscall 和 exception 是跟用户代码同步的 —— 它们发生是因为当前指令流做了什么。Interrupt 是异步的;能在任何指令边界触发, 包括另一个内核 handler 期间(有嵌套规则)。
可恢复性。Syscall 总返回 syscall 之后那条指令。 Interrupt 返回 IRQ 触发时所在位置,像啥都没发生(内核中间可能干了不少)。 Exception 返回 faulting 指令(重试,在内核修好原因之后 —— 比如缺页), 或者修不了就杀掉进程。
Vector 分发。3 个都用 CPU 一张叫 Interrupt Descriptor Table(IDT,下一节)的表。 每个 vector(条目索引 0-255)映射到一个固定的内核函数。 Syscall 用单独的快路径机制(x86-64 上 SYSCALL/SYSRET、ARM64 上 SVC), 为了速度绕过 IDT,但概念上仍是一次 vector 跳转。
为啥生产里这有意义
- 中断风暴。行为异常的 NIC 或者触发中断洪流的软件 bug 能饿死用户代码。 修法是修源、用 IRQ affinity 隔离风暴、或者中断合并(NIC 固件抬 IRQ 前批处理)。
- 减少 syscall。最常见的高吞吐服务器技术。每次 syscall 是 ~150 ns 纯开销; 用 io_uring 或 epoll 批处理把数量砍数量级。
- 缺页是 exception,不只是信号。handler 决定是分配一页(好)、 swap 一进(慢但对)、还是送 SIGSEGV(你代码错了)。
要点。「3 扇通向内核态的门:syscall(你求)、interrupt(硬件要求)、 exception(你代码 fault)。3 个都原子地抬特权、切栈、跳到固定 handler。 同步 vs 异步、可恢复 vs 重试-或-死,是实际差别。 高吞吐服务器执着于减少 syscall 数;中断风暴是真实失败模式。」
IDT —— 中断到 handler 的接线
Interrupt Descriptor Table 是 256 项函数指针数组。 中断或 exception 触发时,CPU 用 vector 号索引这张表、跳到 handler。
x86-64 上,IDT 住在内存里,CPU 的 IDTR 寄存器指向它。 256 项里每个都是 16 字节的gate descriptor, 装着 handler 地址、特权环过渡规则、栈切换提示。 前 32 项保留给 CPU 定义的 exception;剩下 224 项软件可分配给硬件中断。
Vector 分配
- 0-31:CPU exception。除 0(0)、debug 断点(1)、缺页(14)、 general protection(13)、无效 opcode(6),还有一小撮。架构上固定;内核在 boot 填。
- 32-127:外部硬件中断(legacy)。IRQ 控制器(APIC)把基于线的中断 路由到这些 vector。现代系统用 Message-Signalled Interrupt(MSI / MSI-X), 设备直接把 vector 写到内存。
- 128-255:软件中断和现代 MSI。Linux 用 vector 128 给 legacy
int $0x80syscall 机制(已被 SYSCALL 取代), 并动态把更高号分给 MSI 驱动的设备。
中断实际怎么触发
走一下 NIC 中断:
- NIC 写一个 Message-Signalled Interrupt:在 magic 地址写 magic 值, CPU 看成「vector N 上来了中断」。Vector 是内核在 PCIe enumeration 时分给这 NIC 的。
- CPU 完成当前指令(中断推迟到指令边界以保语义清晰)、查 IDT 第 N 项。
- 第 N 项告诉 CPU:handler 地址 H、目标特权环 0、从每 CPU TSS 装内核栈。 CPU 把用户 RIP / RSP / RFLAGS 压到内核栈、跳到 H。
- H 是内核的通用 IRQ 入口点。按 vector 分发到注册的驱动 IRQ handler (这例子里是 NIC 的)。
为啥 syscall 绕过 IDT
早期 x86 syscall 用 int $0x80 —— 跟任何别的中断完全一样过 IDT。 慢:descriptor 加载 + 特权检查 ~200 cycle。AMD64 加了 SYSCALL指令,用 MSR(LSTAR 装 handler 地址、STAR 装段选择子)直接跳、不查 IDT。 ~75 cycle。Linux 在早期 2.6 内核默认换成 SYSCALL;int $0x80还为 legacy 代码工作。
每 CPU 的 IDT 和 APIC
SMP 系统每颗 CPU 有自己的 IDT(逻辑上;大多数内核通过把所有 CPU 的 IDTR 设成同一张表来共享)。 哪颗 CPU 处理哪个 IRQ 的路由,是 IO-APIC(基于线)或 LAPIC + MSI 机制(现代)的工作。 IRQ affinity(可通过 /proc/irq/N/smp_affinity 设)把特定 IRQ 钉到特定 CPU —— 对性能很重要,尤其是高速率 NIC,把中断在 CPU 间弹会杀 cache 局部性。
要点。「IDT 是 256 项的 handler 地址表。Vector 0-31 = CPU exception、 32+ = 硬件中断(今天 MSI 路由)。CPU 硬件每次中断都索引它找 handler。 Syscall 通过专用的 SYSCALL 指令绕过它(比 legacy int $0x80 快得多)。 IRQ affinity 决定哪颗 CPU 处理哪个设备 —— 高速率硬件上真实的性能旋钮。」
Top-half / softirq —— Linux 的延迟工作模型
NIC 一秒能抬几百万次中断。中断 handler 必须保持很小,否则饿死别的一切。 Linux 通过把工作分两半解决。
硬件中断本身(IRQ handler,有时叫 top half)在当前 IRQ 线禁掉的状态下跑 —— 在它返回之前同类型新中断到不了。如果它花 10 μs 处理 packet, 别的 packet 在那 10 μs 里都不能触发它的中断,把吞吐限到每 IRQ 线 100K packet/sec。 现代 100 Gb/s NIC 能送数百万 packet/sec。
修法:把 top half 保持很小。就 ack 设备、把 packet 指针存到内核能找到的地方、 排个 softirq 做真实工作、返回。Softirq 跑时中断开着, 所以下一次中断不再阻塞。不需要锁住中断线的真实工作,在这个低优先级上下文里发生。
NAPI —— 轮询把戏
在非常高的 packet 速率下,即便每 packet 一次中断也太贵。NAPI(New API,~2002)让 NIC 驱动在收到爆发的第一次中断后, 禁掉设备中断,改成 poll RX ring 找更多 packet。 ring 空一段时间后,重新开中断。这把一次中断摊到可能几千 packet 上, 让 10/40/100 Gb/s NIC 可行。
Tasklet、workqueue、threaded IRQ
3 个其他延迟工作机制跟 softirq 并存:
- Tasklet —— softirq 之上的老 API,新代码里弃用了。 同样思路,簿记略不同。
- Workqueue —— 能睡的工作(比如要分配内存或等锁) 通过 workqueue 在内核线程上下文里跑。比 softirq 延迟更高, 但能做 softirq 做不了的事。
- Threaded IRQ —— 新机制,IRQ handler 本身就是个内核线程。 Linux RT preempt 补丁用它让 IRQ 处理可调度 (这样高优先级实时工作能抢慢的 IRQ handler)。
为啥这对用户代码重要
你不写 softirq handler —— 它们是内核的事。但 3 个运营推论影响用户代码:
ksoftirqdCPU%。如果 top 里看到ksoftirqd/N烧 CPU,你内核在做太多 softirq 工作 —— 通常来自网络或磁盘洪水。用perf top看 NIC 中断率。- IRQ affinity 重要。把 NIC IRQ 散到 CPU 上 (通过
set_irq_affinity.sh或 RPS / RFS)让 softirq 在多核上跑。 没它,一颗核处理所有 packet 成瓶颈。 - 用户代码里的延迟。长跑的 softirq 能把用户线程调度延迟毫秒级。 实时配置启用 PREEMPT_RT 和 threaded IRQ 把这个限死。
要点。「Linux 把中断处理分裂:top-half(IRQ handler)很小 —— ack 设备、排延迟工作 —— softirq 做真实处理,中断重新开。 NAPI 通过驱动侧轮询扩展,在高速率下摊销开销。 用户代码以 ksoftirqd CPU 时间、以及为跨核扩展需要的 IRQ-affinity / RPS 调参 感受到这件事。」
Signal —— 回到用户代码的异步通知
内核需要一种方式告诉进程「刚有件事发生在你身上」。Signal 是 POSIX 的答案。 强大、quirks 满满,比任何别的机制都更是「Ctrl-C 神秘 crash」bug 的源头。
Signal 是一个小整数(1-31 标准、32-64 实时)送给进程表示事件。 SIGTERM 意思是「请干净地退出」;SIGKILL 意思是「内核现在就结束你,你抵抗不了」; SIGSEGV 意思是「你访问了不该访问的内存」;SIGPIPE 意思是「你写到了对端已关的 socket」。 大约 30 个标准 signal 存在;大多数对应用代码从不重要。
Signal 怎么送达
内核决定送 signal 时(比如别的进程的 kill(pid, 15)、或者 fault 的 SIGSEGV、 或者子退出时的 SIGCHLD),它在目标进程的 pending-signal mask 上设一位。 目标进程下次从内核态返回用户态时,内核检查 mask、挑当前没被屏蔽的最高优先级 pending signal、 安排 signal handler 跑而不是下一条用户指令。handler 返回后, 控制权恢复到 signal 中断的地方。
默认动作
每个 signal 有默认处置:term(杀进程)、core(杀 + dump core)、ignore(啥也不做)、stop、continue。 进程可以通过 signal(POSIX)或 sigaction(POSIX 带更多旋钮)装 handler。两个 signal 不能被 catch 或忽略: SIGKILL 和 SIGSTOP。其他都你来 override。
Async-signal-safety —— 坑
Signal handler 能在任何指令边界触发,包括进程在 malloc、printf、或别的库调用中间。如果 handler 然后调一个函数, 那函数要的锁已经被被中断的代码持着,进程死锁。 POSIX 标准列出 ~30 个 async-signal-safe 的函数 —— 保证可重入、不取锁。其他从 signal handler 调都不安全。
signal-handler 安全子集包括 write(syscall,不是 stdio)、_exit、sigaction、信号 mask 操纵、简单整数赋值。不安全:malloc、printf、任何锁、glibc 大部分。 生产代码的标准习语是 handler 里啥都不做,除了设一个主循环 poll 的 sig_atomic_t flag。
实时 signal
标准 signal(1-31)是合并的 —— 你在进程处理第一个 SIGUSR1 之前送两个, 只送达一次。实时 signal(SIGRTMIN..SIGRTMAX、32-64)是排队的 —— 每次送独立送达,你能通过 sigqueue 附一小份 payload。 高速率进程间通知场景用,合并会丢信息。
signalfd —— 让 signal 能 epoll
Signal 处理跟任何 epoll 驱动的服务器主事件循环冲突。signalfd 创建一个文件描述符,signal pending 时变可读; 你能像任何别的 fd 那样 epoll 它,把 signal 当普通 readiness 事件处理。 现代服务器(nginx、envoy 等)用它完全绕开 signal-handler。
要点。「Signal 是从内核到进程的异步通知。 标准集(~30 个)覆盖终止、fault、子状态、IO 事件。 Handler 在任何指令边界触发,所以里面只有 async-signal-safe 函数合法 —— 设个 flag、主循环处理。实时 signal 排队;signalfd 把 signal 变成 epoll 友好的 fd,跟事件循环干净集成。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。
用户 / 内核边界上 3 扇门是什么?
Syscall(你代码求)、interrupt(硬件要求)、exception(你代码 fault)。 3 个都原子地抬特权、切到内核栈、跳到固定 handler。 差别:syscall 和 exception 跟当前指令流同步;interrupt 异步,能在任何边界触发。
IDT 是什么、CPU 怎么用它?
Interrupt Descriptor Table —— 内存里 256 项的(handler、特权环、栈切换提示)元组数组, IDTR 寄存器指它。任何中断或 exception 时,CPU 用 vector 号索引这表跳到 handler。 Vector 0-31 是 CPU exception;32+ 软件可分配给硬件中断和现代 MSI。
解释 Linux 的 top-half / softirq 分裂。
硬件中断在当前 IRQ 线禁掉的状态下跑,所以必须很小 —— 否则阻塞下一次同类中断。 Top-half 就 ack 设备、排个 softirq。Softirq 在中断重新开的状态下跑真实工作。 NAPI 扩展:第一个 packet 后,驱动禁中断、轮询,高速率下摊销开销 (10/40/100 Gb/s NIC 必需)。
为啥从 signal handler 调 malloc 不安全?
handler 能在任何指令边界触发,包括同一进程在 malloc 调用中间、 allocator 锁持着。从 handler 调 malloc 想拿同一把锁 —— 死锁。 POSIX async-signal-safe 列表(~30 个函数)是 signal handler 里唯一合法可调的集合。 标准模式:设个 flag、返回;主循环处理。
signalfd 什么时候帮、什么时候不帮?
跑 epoll 驱动事件循环、想把 signal 当普通 readiness 事件处理时帮。 对 SIGKILL 或 SIGSTOP 没用(它们不能给 handler); 对 fault 类同步致命 signal 像 SIGSEGV 没用(内核强制立即送达,不排到 signalfd)。 经典用例:把 SIGTERM / SIGINT 转成主循环里的 clean shutdown 逻辑。
IRQ affinity 干什么、为啥重要?
IRQ affinity 把每个 IRQ 绑定到一组 CPU。Linux 默认散开;调参重要因为 (a)把 NIC 的 IRQ 在 CPU 间弹会破坏网络栈工作的 cache 局部性、 (b)NUMA 机器上你想 IRQ 在 NIC 那个 socket 附近、 (c)高 packet 速率你可能想每 CPU 一个 IRQ(RSS)让 softirq 能并行跑。 通过 /proc/irq/N/smp_affinity 设。
Code review 红旗
- Signal handler 干了不只设 flag 的事。调 stdio、malloc、任何锁的东西 —— 等着出竞态或死锁。把工作挪到主循环。
- 满是 syscall 的紧凑循环。每个 syscall ~150 ns。 看到每秒 10 万+,用 epoll、io_uring、readv/writev 批处理, 或者通过 eBPF/AF_XDP 把工作挪进内核。
- 服务器里没 SIGPIPE handler。写到 client 已关的 socket 默认通过 SIGPIPE 杀你进程。忽略,或者 send() 用 MSG_NOSIGNAL。
- 紧凑循环里 poll
/proc/stat。每次迭代一次 syscall + 解析开销。缓存值,或者用 perf_event_open 拿真便宜的计数器。 - 自定义
SIGCHLDhandler 不收割 child。默认动作是「忽略但自动收割」;装了 handler 又忘waitpid, 累积 zombie 进程直到进程表满。