第7章:异常与系统调用——用户态的边界
┌─────────────────────────────────────────────────────────────┐
│ 元数据卡 │
│ ├─ 主题:异常与系统调用 │
│ ├─ 难度: │
│ ├─ 前置要求:第3章(内存层次)、第5章(链接与加载) │
│ ├─ 核心概念:中断、陷阱、故障、终止、系统调用、用户态/内核态 │
│ └─ 预计阅读:35 min │
└─────────────────────────────────────────────────────────────┘你的进度
"地心的平静被一声警报打破了。你写的程序触发了系统异常——也许是除以零,也许是非法内存访问。从用户态到内核态,中间隔着一道不可见的墙。你即将穿越这道墙。"
你写了一个小程序,从文件读数据:
#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的深入应用
学完本章后,你应该能:
- 说清楚CPU上四种异常事件的定义和区别(中断 / 陷阱 / 故障 / 终止)
- 手绘一次系统调用的完整路径:用户代码 → libc 包装 → syscall 指令 → 内核处理 → 返回
- 解释用户态与内核态的切换代价从何而来
- 在Linux上用
strace/perf实证测量 syscall 开销 - 用 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() 的一生
// 用户代码
ssize_t n = read(fd buf 64);- 你的代码调用
read()——这是 glibc 的包装函数 - glibc 准备参数:将
fd、buf、count存入寄存器(rdi、rsi、rdx),系统调用号(0代表read)存入rax - 执行
syscall指令:
- 切换到 Ring 0(栈切换到内核栈)
- 跳转到
entry_SYSCALL_64入口
- 内核系统调用处理:
- 保存用户态寄存器到内核栈
- 验证参数合法性(指针检查、权限检查)
- 调用
ksys_read()→vfs_read()→ 文件系统层 - 真正从磁盘读取数据(可能引发缺页、DMA等)
- 准备返回:将结果写回
rax,恢复用户态寄存器 - 执行
sysret指令:切回 Ring 3 - glibc 拿到返回值,返回给你的 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。
#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 时,时间开销会飙升到微秒甚至毫秒级:
#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 这么慢?
- 上下文切换成本:保存/恢复寄存器、切换栈、切换页表(TLB 冲刷 )
- SMAP/SMEP 检查:内核必须验证用户传入的指针
- 分支预测失效:进入内核是完全不同的代码路径
- 缓存污染:内核代码和数据挤占 L1/L2 缓存
** 实际建议:** 批量操作永远优于多次小操作。用
read(fd buf 4096)一次读 4KB,不要read(fd buf 1)读 4096 次。
用 strace 看你的程序
$ 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 语言的信号处理
#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!
验收标准
读完本章后,请确认你能回答:
- 中断和陷阱的区别是什么?哪个是你主动触发的?
- 执行
syscall指令后,CPU 做了哪三件事? - 为什么
getpid()要 400ns 而一个加法只要 0.3ns? - 信号处理函数中为什么不能调用
printf()? - 段错误的幕后流程是什么?(谁检测→谁处理→谁负责终止)
常见卡点
| 卡点 | 真相 |
|---|---|
| "系统调用就是调了一个函数" | 不对。函数调用不切换特权级,syscall 会 |
| "信号会立即处理" | 不对。只在返回用户态前检查 |
| "缺页错误就是报错" | 不对。内核可能加载页面后重试,你的代码完全不知情 |
| "strace 是黑魔法" | 不对。它只是用 ptrace 系统调用拦截了所有 syscall |
现在不需要理解
- 中断描述符表(IDT)的具体结构 —— 硬件细节,需要时查 Intel 手册
- APIC 和中断控制器编程 —— 适合 OS 内核课程
ucontext和信号栈的细节 —— 协程实现时才需要io_uring如何绕过 syscall 开销 —— 第 14 章文件 I/O 会讲
旅人笔记
在我刚学计算机的时候,"系统调用"四个字就像一个黑盒魔法。调用
read()就能读到数据,至于中间发生了什么——不知道。直到有一天我意识到:CPU 也不知道你调用了 read。它只认识一条叫做
syscall的指令。glibc 把高层的 C 调用翻译成这条指令,而这条指令会触发一个陷阱异常,把控制权交给内核——就像你走进一家餐厅,不是直接去厨房做饭,而是叫服务员。服务员再去通知厨师。用户态就是"在餐桌上等",内核态就是"进了厨房"。每次系统调用都是在用户态和内核态之间做一次往返旅行。旅行的代价是几百纳秒、TLB 刷新、缓存污染。这就是为什么
read 1 byte比read 4096 bytes慢 4000 倍——你付的是旅费,不是运费。明白这件事之后,你再看高性能网络服务器、数据库引擎的代码,很多优化就一目了然了:能少出门就少出门,一次买够一周的菜。
→ 下一站预告
第8章:进程上下文——谁在运行你的代码
系统调用让你进入了内核。但进程到底是谁?一个"正在运行的程序"到底是什么?下一章我们聊聊 fork()、exec()、wait(),以及那个著名的写时复制(Copy-on-Write)机制。