Skip to content

第7章:异常与系统调用——用户态的边界


plaintext
┌─────────────────────────────────────────────────────────────┐
│ 元数据卡 │
│ ├─ 主题:异常与系统调用 │
│ ├─ 难度: │
│ ├─ 前置要求:第3章(内存层次)、第5章(链接与加载) │
│ ├─ 核心概念:中断、陷阱、故障、终止、系统调用、用户态/内核态 │
│ └─ 预计阅读:35 min │
└─────────────────────────────────────────────────────────────┘

你的进度

"地心的平静被一声警报打破了。你写的程序触发了系统异常——也许是除以零,也许是非法内存访问。从用户态到内核态,中间隔着一道不可见的墙。你即将穿越这道墙。"

你写了一个小程序,从文件读数据:

c
#include <fcntl.h>
#include <unistd.h>

int main() {
 int fd = open("data.txt" O_RDONLY);
 char buf[64];
 read(fd buf 64);
 write(STDOUT_FILENO buf 64);
 close(fd);
 return 0;
}

编译、运行,一切正常。但你有没有停下来想过一个问题——

调用 read() 的时候,到底发生了什么?

不是你从教科书上背的那句"陷入内核",而是实实在在的硬件和操作系统层面的全过程:你的程序怎么"暂停"的?内核怎么接手的?它凭什么能访问磁盘而你不能?它回来以后,你怎么恢复的?

这一章,我们就揭开这层纱。


你的任务

本章分层

  • 必读:异常控制流(ECF)的概念;用户态 vs 内核态的根本区别;系统调用(如 read())的完整路径;用 strace 观察程序行为
  • 选读:四种异常的区别;系统调用与普通函数调用的性能差异测量
  • 进阶:信号的生命周期(pending→delivered→handled);异步信号安全的编程约束 本章不会要求你掌握
  • 中断描述符表(IDT)的具体硬件结构
  • APIC 和中断控制器编程
  • 信号处理函数中 volatile sig_atomic_t 的深入应用

学完本章后,你应该能:

  1. 说清楚CPU上四种异常事件的定义和区别(中断 / 陷阱 / 故障 / 终止)
  2. 手绘一次系统调用的完整路径:用户代码 → libc 包装 → syscall 指令 → 内核处理 → 返回
  3. 解释用户态与内核态的切换代价从何而来
  4. 在Linux上用 strace / perf 实证测量 syscall 开销
  5. 用 C 注册一个信号处理器,解释信号传递与捕获的机制

遭遇战——"段错误"究竟是谁干的?

先看一个 C 程序员的日常噩梦:

c
int main() {
 int *p = NULL;
 *p = 42; // 
 return 0;
}

运行输出:

Segmentation fault (core dumped)

"段错误"本身只是一个打印消息,真正的问题是:谁检测到了这个错误?谁杀了你的程序?

答案是 CPU + OS 合作

异常控制流(ECF)

正常情况下,CPU 按顺序取指执行,这条路径叫控制流。但有些事件会打断这个流:

  • 你按下了键盘按键
  • 你的程序除以了零
  • 你的网卡收到一个数据包
  • 你的程序调用了 read()

这些事件都会触发 异常控制流(Exceptional Control Flow ECF)。CPU 通过一张异常表(Exception Table) —— 本质是一张从异常号到处理函数地址的跳转表——来分发异常事件。

** 洞见:** CPU 只有两种活着的方式——要么在执行你的代码(用户态),要么在执行异常处理代码(内核态)。没有第三种状态。

四种异常

类别原因异步/同步返回行为
中断 (Interrupt)来自I/O设备的信号异步始终返回到下一条指令
陷阱 (Trap)有意的异常(如 syscall)同步始终返回到下一条指令
故障 (Fault)潜在可恢复的错误(如缺页)同步可能返回到当前指令重新执行
终止 (Abort)不可恢复的错误(如硬件故障)同步不返回

中断是异步的——网卡、磁盘、键盘在""你不知情""时发起。CPU 执行完当前指令后,检查中断引脚,发现有挂起中断就跳转。

陷阱是你主动叫来的——这就是系统调用的本质。

故障是可以挽救的——缺页故障会加载页面后重新执行那条访存指令。段错误则是故障处理程序放弃抢救的结果。

终止——没得商量,直接 abort。


获得技能——解剖一次系统调用

用户态 vs 内核态

CPU 通过特权级别来隔离操作系统和用户程序。在 x86-64 上:

  • Ring 3:用户态——不能执行特权指令(如 cli 关中断),不能直接访问硬件,不能访问内核页表
  • Ring 0:内核态——全权限

你的程序就是在 Ring 3 的"牢笼"里运行。想读磁盘?想发网络包?想创建新进程?对不起,请通过系统调用。

一条 read() 的一生

c
// 用户代码
ssize_t n = read(fd buf 64);
  1. 你的代码调用 read() ——这是 glibc 的包装函数
  2. glibc 准备参数:将 fdbufcount 存入寄存器(rdirsirdx),系统调用号(0 代表 read)存入 rax
  3. 执行 syscall 指令
  • 切换到 Ring 0(栈切换到内核栈)
  • 跳转到 entry_SYSCALL_64 入口
  1. 内核系统调用处理
  • 保存用户态寄存器到内核栈
  • 验证参数合法性(指针检查、权限检查)
  • 调用 ksys_read()vfs_read() → 文件系统层
  • 真正从磁盘读取数据(可能引发缺页、DMA等)
  1. 准备返回:将结果写回 rax,恢复用户态寄存器
  2. 执行 sysret 指令:切回 Ring 3
  3. glibc 拿到返回值,返回给你的 C 代码

整条路径上,你以为只打了个电话,其实内核在背后跑了几百到几千条指令。

c
#include <unistd.h>
#include <stdio.h>

int main() {
 /* 实际上最内层就是这样在裸跑 */
 long ret;
 /* syscall 指令的 C 内联版本 */
 asm volatile(
 "mov $0 %%rax\n" /* read 的 syscall 号 */
 "mov $0 %%rdi\n" /* fd = stdin */
 "lea %1 %%rsi\n" /* buf */
 "mov $4 %%rdx\n" /* count */
 "syscall\n"
 "mov %%rax %0\n"
 : "=r"(ret)
 : "m"(buf)
 : "rax" "rdi" "rsi" "rdx"
 );
 return 0;
}

** 洞见:** 所谓"系统调用"本质上就是执行了一条 syscall 指令,同时触发了"陷阱"异常。它不是函数调用,而是一场 CPU 级别的状态切换政变


常见陷阱——syscall 到底有多贵?

很多人背过"系统调用慢",但到底慢多少?我们自己量。

实验1:getpid() 与普通函数调用的对比

getpid() 是一个极轻量的系统调用——内核只返回一个缓存的值,不涉及I/O。

c
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <time.h>

static inline long now_ns() {
 struct timespec ts;
 clock_gettime(CLOCK_MONOTONIC &ts);
 return ts.tv_sec * 1000000000L + ts.tv_nsec;
}

int main() {
 pid_t pid;

 /* 空循环基准 */
 long start = now_ns();
 for (int i = 0; i < 1000000; i++) {
 /* 空操作 */
 __asm__ volatile("");
 }
 long base = now_ns() - start;

 /* getpid 循环 */
 start = now_ns();
 for (int i = 0; i < 1000000; i++) {
 pid = getpid();
 }
 long elapsed = now_ns() - start;

 printf("空循环: %ld ns (总量)\n" base);
 printf("getpid x1M: %ld ns (总量)\n" elapsed);
 printf("每次 getpid ≈ %ld ns\n" (elapsed - base) / 1000000);

 return 0;
}

典型输出(Linux 5.x x86-64):

空循环: 3210000 ns (总量)
getpid x1M: 408720000 ns (总量)
每次 getpid ≈ 405 ns

一次 getpid() ≈ 405 ns。对比一个普通的函数调用(~1-3 ns),差了 两个数量级

实验2:read() 从磁盘读取

当 syscall 真正涉及 I/O 时,时间开销会飙升到微秒甚至毫秒级:

c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <time.h>

int main() {
 char buf[1]; /* 每次读一个字节,最大化 syscall 比例 */
 int fd = open("/dev/zero" O_RDONLY);

 long start = now_ns();
 for (int i = 0; i < 1000000; i++) {
 read(fd buf 1);
 }
 long elapsed = now_ns() - start;

 printf("1M read(1字节): %ld ns\n" elapsed);
 printf("每次 read ≈ %ld ns\n" elapsed / 1000000);

 close(fd);
 return 0;
}

典型输出:

1M read(1字节): 952180000 ns
每次 read ≈ 952 ns

为什么 syscall 这么慢?

  1. 上下文切换成本:保存/恢复寄存器、切换栈、切换页表(TLB 冲刷 )
  2. SMAP/SMEP 检查:内核必须验证用户传入的指针
  3. 分支预测失效:进入内核是完全不同的代码路径
  4. 缓存污染:内核代码和数据挤占 L1/L2 缓存

** 实际建议:** 批量操作永远优于多次小操作。用 read(fd buf 4096) 一次读 4KB,不要 read(fd buf 1) 读 4096 次。

strace 看你的程序

bash
$ strace -c ./a.out

输出示例:

% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
 42.13 0.001432 477 3 read
 28.94 0.000984 82 12 mmap
 15.22 0.000517 258 2 openat
 8.71 0.000296 37 8 mprotect
 2.42 0.000082 41 2 close
 1.15 0.000039 39 1 write
 0.83 0.000028 28 1 execve
 0.59 0.000020 10 2 fstat
 0.01 0.000000 0 1 arch_prctl
100.00 0.003398 32 total

每一列都在告诉你:你的程序把时间花在了哪里。


进阶:信号处理是系统编程的高级话题。以下内容面向已经掌握系统调用基础、想深入了解异常控制流的读者。

通关挑战——信号处理

异常不止于系统调用。还有一类特殊的 ECF——信号(Signal)

信号是 OS 发给进程的"软件中断"通知。你可以注册一个函数来处理它。

C 语言的信号处理

c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void handler(int sig) {
 /* 注意:信号处理函数中只能调用 async-signal-safe 的函数 */
 const char *msg = "你不用按 Ctrl+C 了,我帮你收着\n";
 write(STDOUT_FILENO msg 35);
}

int main() {
 signal(SIGINT handler); /* 注册 SIGINT(Ctrl+C)处理器 */

 printf("按 Ctrl+C 试试,我不退\n");
 while (1) {
 pause(); /* 等待信号 */
 }
 return 0;
}

运行效果:

按 Ctrl+C 试试,我不退
^C你不用按 Ctrl+C 了,我帮你收着
^C你不用按 Ctrl+C 了,我帮你收着
^C你不用按 Ctrl+C 了,我帮你收着
^\[\Quit (core dumped) /* Ctrl+\ 发出了 SIGQUIT,默认行为是终止 */

进阶:信号的生命周期涉及内核调度和进程状态管理,是系统程序员才需要掌握的细节。

信号的生命nding)"状态

等待(pending) │ ▼ 进程下次被调度时,内核在返回用户态之前检查 pending 位图 递达(delivered) │ ▼ 默认动作或者注册的 handler 处理


**关键事实**:信号不是立即处理的。内核只在进程从内核态返回用户态的路径上检查信号。这意味着如果在内核中卡住了(比如不可中断的 I/O),信号会等到那之后才处理。

### 危险的信号处理函数

信号处理函数有一个极其致命的特点:**它可以打断你的主程序的任何位置。**

```c
#include <stdio.h>
#include <signal.h>

int counter = 0;

void handler(int sig) {
 counter++; /* 假如主程序正好在写 counter... */
}

int main() {
 signal(SIGUSR1 handler);

 /* 安全隐患!counter 可能被信号处理器并发修改 */
 if (counter == 0) {
 /* 此时信号来了,handler 改了 counter */
 /* 回来之后 counter 已经不是 0 了,但条件已经通过了 */
 do_something();
 }
 return 0;
}

** 实战规则:** 信号处理函数里只做三件事——设置 volatile sig_atomic_t 标志、写 pipe 通知、_Exit()。其他都要在主循环中异步安全地处理。不要用 printf!不要用 malloc!不要用 mutex_lock


验收标准

读完本章后,请确认你能回答:

  1. 中断和陷阱的区别是什么?哪个是你主动触发的?
  2. 执行 syscall 指令后,CPU 做了哪三件事?
  3. 为什么 getpid() 要 400ns 而一个加法只要 0.3ns?
  4. 信号处理函数中为什么不能调用 printf()
  5. 段错误的幕后流程是什么?(谁检测→谁处理→谁负责终止)

常见卡点

卡点真相
"系统调用就是调了一个函数"不对。函数调用不切换特权级,syscall 会
"信号会立即处理"不对。只在返回用户态前检查
"缺页错误就是报错"不对。内核可能加载页面后重试,你的代码完全不知情
"strace 是黑魔法"不对。它只是用 ptrace 系统调用拦截了所有 syscall

现在不需要理解

  • 中断描述符表(IDT)的具体结构 —— 硬件细节,需要时查 Intel 手册
  • APIC 和中断控制器编程 —— 适合 OS 内核课程
  • ucontext 和信号栈的细节 —— 协程实现时才需要
  • io_uring 如何绕过 syscall 开销 —— 第 14 章文件 I/O 会讲

旅人笔记

在我刚学计算机的时候,"系统调用"四个字就像一个黑盒魔法。调用 read() 就能读到数据,至于中间发生了什么——不知道。

直到有一天我意识到:CPU 也不知道你调用了 read。它只认识一条叫做 syscall 的指令。glibc 把高层的 C 调用翻译成这条指令,而这条指令会触发一个陷阱异常,把控制权交给内核——就像你走进一家餐厅,不是直接去厨房做饭,而是叫服务员。服务员再去通知厨师。用户态就是"在餐桌上等",内核态就是"进了厨房"。

每次系统调用都是在用户态和内核态之间做一次往返旅行。旅行的代价是几百纳秒、TLB 刷新、缓存污染。这就是为什么 read 1 byteread 4096 bytes 慢 4000 倍——你付的是旅费,不是运费。

明白这件事之后,你再看高性能网络服务器、数据库引擎的代码,很多优化就一目了然了:能少出门就少出门,一次买够一周的菜。


下一站预告

第8章:进程上下文——谁在运行你的代码

系统调用让你进入了内核。但进程到底是谁?一个"正在运行的程序"到底是什么?下一章我们聊聊 fork()exec()wait(),以及那个著名的写时复制(Copy-on-Write)机制。

Built with VitePress | Software Systems Atlas