中断与异常
#系统 · #中断 · #异常 · #硬中断 · #软中断 · #内核
深入理解中断、异常、系统调用、信号处理、调试器原理、时间实现与系统诊断。
1. 异常控制流总览
正常程序的执行是按指令顺序进行的("平滑控制流")。异常控制流(ECF) 是任何改变这种顺序流的事件。
mermaid
graph LR
subgraph 正常流
A[I1] --> B[I2] --> C[I3] --> D[I4]
end
subgraph 异常流
E[I1] --> F[I2] --> G[异常!] --> H[处理程序] --> I[返回I3]
end异常控制流发生在计算机系统的各个层次:
| 层次 | 异常类型 | 示例 |
|---|---|---|
| 硬件层 | 中断 | I/O 完成通知、定时器 |
| 操作系统层 | 上下文切换 | 进程切换 |
| 应用层 | 信号 | SIGINT (Ctrl+C) |
| 语言层 | 非本地跳转 | C 的 setjmp/longjmp, C++/Java 的 try/catch |
2. 为什么需要中断?
2.1 轮询 vs 中断
没有中断的世界 (轮询):
CPU 反复检查每个设备: "有数据吗?" → 即使 99% 时间空闲, CPU 也在空转
有中断的世界:
CPU 正常执行, 设备有数据时主动通知 CPU
→ CPU 利用率接近 100%, 响应及时 (微秒级延迟)中断的经济学:中断时 CPU 花费数十 ns 保存/恢复状态,但换来的是释放 CPU 去做有用的工作。典型网络场景下中断比轮询省 95% CPU。高频中断时可通过中断合并(Interrupt Coalescing) 和 NAPI(中断+轮询混合)优化。
3. 硬件异常
3.1 异常分类
mermaid
graph TD
EXCEPTION["异常"]
EXCEPTION --> ASYNC["异步异常<br/>(中断)"]
EXCEPTION --> SYNC["同步异常"]
ASYNC --> IRQ["I/O中断<br/>定时器中断"]
SYNC --> TRAP["陷阱<br/>(有意的)"]
SYNC --> FAULT["故障<br/>(可恢复)"]
SYNC --> ABORT["终止<br/>(不可恢复)"]| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | I/O 设备信号 | 异步 | 返回到下一条指令 |
| 陷阱 | 系统调用 | 同步 | 返回到下一条指令 |
| 故障 | 可恢复错误(如缺页) | 同步 | 可能返回到当前指令 |
| 终止 | 不可恢复错误(如硬件错误) | 同步 | 不返回 |
3.2 中断处理流程
1. 外设向中断控制器 (APIC) 发送中断信号
2. APIC 将中断传递给 CPU
3. CPU 完成当前指令后检查中断信号
4. 如果中断被启用 (IF标志=1):
a. 保存当前上下文 (SS, RSP, RFLAGS, CS, RIP)
b. 跳转到对应的中断处理程序 (通过 IDT)
5. 中断处理程序执行 → iretq 返回,恢复上下文3.3 IDT(中断描述符表)
x86-64 使用 IDT 存储中断向量:
| 向量号 | 用途 |
|---|---|
| 0 | 除法错误 (#DE) |
| 3 | 断点 (#BP) - int3 |
| 6 | 无效操作码 (#UD) |
| 8 | 双重故障 (#DF) |
| 13 | 一般保护故障 (#GP) |
| 14 | 缺页 (#PF) |
| 32-255 | 外部中断和系统调用 |
- 向量 0-31:Intel 保留的异常
- 向量 32-255:用户定义的中断
3.4 APIC(高级可编程中断控制器)
现代系统使用 APIC 替代传统的 8259A PIC:
┌──────────────┐
IRQ0 ───────→│ │
IRQ1 ───────→│ I/O APIC │──→ Local APIC (每核) ──→ CPU
... │ (南桥/芯片组) │
IRQn ───────→│ │
└──────────────┘- Local APIC:每个 CPU 核心一个,接收中断并传递给核心
- I/O APIC:收集外部设备中断,路由到目标 Local APIC
4. 系统调用
4.1 系统调用机制
系统调用是用户态程序请求内核服务的标准接口。
mermaid
sequenceDiagram
participant User as 用户程序
participant Kernel as 内核
User->>User: 设置参数 (rax=调用号, rdi,rsi,rdx...)
User->>Kernel: syscall 指令
Note over User,Kernel: 硬件: 保存 RIP/RFLAGS → 切换到内核态
Kernel->>Kernel: 通过系统调用表分派
Kernel->>Kernel: 执行处理函数
Kernel->>User: sysret 指令
Note over User,Kernel: 硬件: 恢复 RIP/RFLAGS → 回到用户态
User->>User: 处理返回值 (rax)4.2 x86-64 系统调用指令
| 指令 | 说明 |
|---|---|
syscall | 64位快速系统调用(特权级3→0) |
sysret | 从系统调用返回(特权级0→3) |
int 0x80 | 32位兼容系统调用(较慢,已不推荐) |
4.3 系统调用号与参数
调用号放在 %rax,参数在寄存器中传递:
| 参数 | 寄存器 |
|---|---|
| 调用号 | %rax |
| 第1参数 | %rdi |
| 第2参数 | %rsi |
| 第3参数 | %rdx |
| 第4参数 | %r10 (注意不是 %rcx,%rcx 被 syscall 指令破坏了) |
| 第5参数 | %r8 |
| 第6参数 | %r9 |
| 返回值 | %rax |
4.4 Linux 系统调用表
| 调用号 | 名称 | 功能 |
|---|---|---|
| 0 | read | 读文件 |
| 1 | write | 写文件 |
| 2 | open | 打开文件 |
| 3 | close | 关闭文件 |
| 9 | mmap | 内存映射 |
| 12 | brk | 调整堆大小 |
| 57 | fork | 创建进程 |
| 59 | execve | 执行程序 |
| 60 | _exit | 退出进程 |
| 231 | exit_group | 退出所有线程 |
5. 调试器是怎么实现的
5.1 int3 与硬件断点
调试器设置软件断点的核心原理:将目标地址的指令首字节替换为 0xCC(int3 指令)。
asm
; 原始指令
mov $0x42, %rax
; 设置断点后 →
int3 ; 0xCC 替换了第一个字节
; 程序执行到此处 → int3 异常 → 内核发送 SIGTRAP 给调试器
; 调试器恢复:换回原始字节 → 单步执行 → 重新设 0xCC硬件断点(DR0-DR3):不修改代码!将地址写入 CPU 调试寄存器,只有 4 个断点,但零开销。
5.2 ptrace:调试器的系统调用
c
// 用 ptrace 实现极简调试器
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv) {
pid_t child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 声明被调试
execve(argv[1], &argv[1], NULL); // 执行目标程序
}
// 父进程: 调试器
int status;
waitpid(child, &status, 0); // 子进程 exec 后自动停住
while (WIFSTOPPED(status)) {
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, child, NULL, ®s);
printf("RIP = 0x%llx, RAX = 0x%llx\n", regs.rip, regs.rax);
// 单步执行
ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
waitpid(child, &status, 0);
}
return 0;
}
// 设置断点: 保存原始数据,写入 0xCC
long set_breakpoint(pid_t pid, void *addr) {
long original = ptrace(PTRACE_PEEKDATA, pid, addr, NULL);
long trap = (original & ~0xFF) | 0xCC;
ptrace(PTRACE_POKEDATA, pid, addr, (void*)trap);
return original; // 返回原始数据,用于恢复
}
// GDB 的断点实现就是这样的!6. 时间是怎么实现的
6.1 定时器硬件
| 定时器 | 精度 | 用途 |
|---|---|---|
| PIT (8253/8254) | ~1ms | 传统定时器,基本废弃 |
| RTC (实时时钟) | 1s | 墙钟时间,电池供电 |
| HPET (高精度事件定时器) | ~100ns | 高精度定时,多媒体 |
| TSC (时间戳计数器) | CPU周期级别 | 现代主流,rdtsc |
| Local APIC Timer | ~ns 级 | 每核独立定时器,调度器用 |
| ARM Generic Timer | ~ns 级 | ARM 架构定时器 |
6.2 TSC(时间戳计数器)
c
#include <x86intrin.h>
#include <stdio.h>
// rdtsc: 读取 CPU 周期数
static inline uint64_t rdtsc() {
unsigned int lo, hi;
__asm__ volatile("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi << 32) | lo;
}
// 测量一段代码的执行周期数
uint64_t measure_cycles() {
uint64_t start = rdtsc();
// 要测量的代码...
volatile int x = 0;
for (int i = 0; i < 1000; i++) x += i;
return rdtsc() - start;
}
// 3GHz CPU → 1 cycle ≈ 0.333ns → 纳秒级精度!
// 可靠性检查: 现代 CPU 都有 invariant TSC (所有核心同步)
// cat /proc/cpuinfo | grep constant_tsc6.3 Linux 的高精度时间接口
c
#include <time.h>
// clock_gettime: 纳秒级精度
void get_precise_time() {
struct timespec ts;
// CLOCK_MONOTONIC: 单调递增 (不受系统时间调整影响)
clock_gettime(CLOCK_MONOTONIC, &ts);
// CLOCK_REALTIME: 墙钟时间 (可被 NTP 调整)
clock_gettime(CLOCK_REALTIME, &ts);
// CLOCK_THREAD_CPUTIME_ID: 当前线程的 CPU 时间
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts);
printf("%ld.%09ld 秒\n", ts.tv_sec, ts.tv_nsec);
}
// 转换为微秒:
static inline uint64_t now_us() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000;
}6.4 现代定时器:timerfd
c
#include <sys/timerfd.h>
// timerfd 可以和 epoll 一起使用!
int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
struct itimerspec spec = {
.it_value = {1, 0}, // 首次: 1 秒后
.it_interval = {0, 500000000} // 之后: 每 0.5 秒
};
timerfd_settime(tfd, 0, &spec, NULL);
uint64_t expirations;
read(tfd, &expirations, sizeof(expirations)); // 阻塞等待定时器触发
printf("timer fired %lu times\n", expirations);7. 进程上下文切换
7.1 触发时机与过程
- 时间片耗尽(定时器中断)
- 当前进程阻塞(等待 I/O、信号量、锁)
- 高优先级进程就绪(抢占)
- 进程主动让出(
sched_yield())
mermaid
sequenceDiagram
participant P1 as 进程A (运行中)
participant Kernel
participant P2 as 进程B
Note over P1: 时间片耗尽/阻塞
P1->>Kernel: 进入内核态 (中断/syscall)
Kernel->>Kernel: 保存A的上下文 (task_struct)
Kernel->>Kernel: 选择下一个进程 (schedule())
Kernel->>Kernel: 切换页表 (CR3)
Kernel->>Kernel: 恢复B的上下文
Kernel->>P2: 返回用户态
Note over P2: 进程B继续执行7.2 上下文切换开销
| 操作 | 开销 |
|---|---|
| 寄存器保存/恢复 | ~0.1μs |
| 调度决策 | ~0.2-0.5μs |
| CR3 切换 + TLB flush | ~0.5-2μs |
| Cache 预热(换进程后) | 可能数百周期 |
TL;DR: 进程切换的核心开销不是寄存器操作,而是TLB flush和Cache 冷启动。
8. 信号(Signal)
8.1 信号的概念
信号是操作系统向进程发送的软件中断通知。信号处理是异步的。
8.2 常见信号
| 信号 | 编号 | 默认行为 | 说明 |
|---|---|---|---|
| SIGINT | 2 | Term | Ctrl+C |
| SIGQUIT | 3 | Core | Ctrl+\ |
| SIGKILL | 9 | Term | 不可捕获/忽略 |
| SIGSEGV | 11 | Core | 段错误 |
| SIGPIPE | 13 | Term | 向关闭的管道写入 |
| SIGALRM | 14 | Term | alarm() 定时器 |
| SIGTERM | 15 | Term | 优雅终止 |
| SIGCHLD | 17 | Ignore | 子进程状态变化 |
| SIGSTOP | 19 | Stop | 暂停(不可捕获) |
| SIGTSTP | 20 | Stop | Ctrl+Z |
8.3 信号处理流程
mermaid
sequenceDiagram
participant Kernel
participant Process
Kernel->>Process: 发送信号 (设置待处理位)
Note over Process: 继续正常执行
Note over Kernel: 进程从内核态返回用户态时
Kernel->>Kernel: 检查待处理信号
alt 有待处理信号
Kernel->>Process: 调用信号处理函数
Process->>Kernel: 处理函数返回
Note over Kernel: 恢复被中断的代码
end8.4 阻塞与未决信号
- 未决(Pending):已发送但尚未处理的信号
- 阻塞(Blocked):被阻塞的信号不会递送,保持在 pending 状态
- 每个信号类型只有一个 pending 位,同类信号不排队
8.5 信号处理的安全实践
c
// 信号处理函数中安全使用的函数非常有限 (async-signal-safe)
// 不安全: printf, malloc, fprintf — 这些不是可重入的!
// 安全: write, _exit, signal, sem_post
volatile sig_atomic_t flag = 0; // 信号安全的标志变量
void sigint_handler(int sig) {
write(STDOUT_FILENO, "Interrupted\n", 12);
flag = 1; // 设置标志,主循环检测
}
int main() {
// 推荐 sigaction 而非 signal(可移植,行为明确)
struct sigaction sa;
sa.sa_handler = sigint_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
sigaction(SIGINT, &sa, NULL);
while (!flag) pause(); // 等待信号
return 0;
}
// 避免信号的竞态条件:正确等待子进程
void child_handler(int sig) {
int old_errno = errno;
pid_t pid;
// 用 while 而非 if:多个 SIGCHLD 可能被合并!
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0)
printf("reaped child %d\n", pid);
errno = old_errno;
}9. 非本地跳转
9.1 setjmp / longjmp
c
#include <setjmp.h>
jmp_buf env;
void func() {
if (setjmp(env) == 0) {
// 首次调用,设置跳转点
} else {
printf("异常恢复\n"); // longjmp 后回到这里
}
}
void another_func() { longjmp(env, 1); } // 跳回 setjmp9.2 使用场景与注意事项
使用场景:C 语言中模拟 try/catch、实现协程调度器、深层嵌套函数中的错误恢复。
注意事项:longjmp 不会触发 atexit 注册的函数;非 volatile 局部变量值可能不确定;不能跨已释放的栈帧。
10. 通过中断模式诊断系统问题
10.1 /proc/interrupts — 中断分布
bash
cat /proc/interrupts
# 关键指标:
# LOC (Local Timer): 应该各核均衡。不均衡→某核被隔离或负载不均
# NET_RX (网卡接收): 应该均匀散布。集中在 CPU0 → 需启用 irqbalance
# NMI: 大量 NMI → 硬件故障(ECC 内存错误),查看 dmesg
# 查看中断亲和性
cat /proc/irq/<IRQ_NUMBER>/smp_affinity_list
# 手动绑定网卡中断到特定核心
echo 0c > /proc/irq/123/smp_affinity # 0c=1100 → CPU2,310.2 软中断(SoftIRQ)监控
bash
cat /proc/softirqs
# TIMER: 定时器软中断 SCHED: 调度器
# NET_RX: 网络接收 — 过高说明网络负载重,可能是网卡中断被限在少数核
# 解决: echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus10.3 中断模式诊断表
| 异常模式 | 可能原因 | 排查方向 |
|---|---|---|
| LOC 在单核激增 | 该核心被隔离或负载不均 | /proc/irq/*/smp_affinity |
| NET_RX 集中于单核 | 网卡未配置 RSS | ethtool -l eth0 查看队列数 |
| 大量 NMI | 硬件故障 (ECC 内存错误) | dmesg, mcelog |
| 频繁 RES (重调度中断) | 过多进程频繁切换 | vmstat, pidstat -w |
| 系统调用次数飙升 | 某进程循环中调用 syscall | strace -c -p <PID> |
10.4 综合诊断命令
bash
# 总体:查看 cs (上下文切换) 和 in (中断数)
vmstat 1
# 中断来源分析
watch -n1 'cat /proc/interrupts | head -20'
# 中断开销统计
perf stat -e cycles,instructions,task-clock \
-e irq_vectors:local_timer_entry \
-e irq_vectors:reschedule_entry sleep 5
# 哪个进程产生大量系统调用
strace -c -p <PID>11. 中断优化
11.1 中断亲和性(IRQ Affinity)
将网卡中断绑定到特定核心,提升 Cache 命中率(中断处理 + 数据处理在同一核心)。
11.2 中断合并(Interrupt Coalescing)
bash
ethtool -c eth0 # 查看当前设置
ethtool -C eth0 rx-usecs 50 # 最多延迟 50μs
ethtool -C eth0 rx-frames 32 # 或累计 32 个包11.3 NAPI:中断 + 轮询混合
Linux NAPI (New API):
1. 包到达 → 硬件中断 → ISR 禁止本中断 → 调度 NAPI poll
2. 软中断 poll(): 一次性处理多个包 (轮询, 无中断)
3. 没有包了 → 重新启用中断
效果: 高负载自动切换轮询(更高吞吐),低负载用中断(更低延迟)12. 调试工具速查
| 工具 | 用途 |
|---|---|
strace | 跟踪进程的系统调用 |
kill -l | 列出所有信号 |
/proc/PID/status | 查看信号的 blocked/pending 状态 |
dmesg | 查看内核中断和异常日志 |
perf stat | 统计上下文切换次数 |
/proc/interrupts | 查看各核心的中断计数 |
/proc/softirqs | 查看软中断统计 |
参考
- CSAPP 第8章:异常控制流
- Intel SDM Vol.3 - Interrupt and Exception Handling
- Linux
man 7 signal
登录后即可发表评论 👇