Skip to content

中断与异常 ​

#系统 · #中断 · #异常 · #硬中断 · #软中断 · #内核

深入理解中断、异常、系统调用、信号处理、调试器原理、时间实现与系统诊断。


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 系统调用指令 ​

指令说明
syscall64位快速系统调用(特权级3→0)
sysret从系统调用返回(特权级0→3)
int 0x8032位兼容系统调用(较慢,已不推荐)

4.3 系统调用号与参数 ​

调用号放在 %rax,参数在寄存器中传递:

参数寄存器
调用号%rax
第1参数%rdi
第2参数%rsi
第3参数%rdx
第4参数%r10 (注意不是 %rcx,%rcx 被 syscall 指令破坏了)
第5参数%r8
第6参数%r9
返回值%rax

4.4 Linux 系统调用表 ​

调用号名称功能
0read读文件
1write写文件
2open打开文件
3close关闭文件
9mmap内存映射
12brk调整堆大小
57fork创建进程
59execve执行程序
60_exit退出进程
231exit_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, &regs);
        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_tsc

6.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 常见信号 ​

信号编号默认行为说明
SIGINT2TermCtrl+C
SIGQUIT3CoreCtrl+\
SIGKILL9Term不可捕获/忽略
SIGSEGV11Core段错误
SIGPIPE13Term向关闭的管道写入
SIGALRM14Termalarm() 定时器
SIGTERM15Term优雅终止
SIGCHLD17Ignore子进程状态变化
SIGSTOP19Stop暂停(不可捕获)
SIGTSTP20StopCtrl+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: 恢复被中断的代码
    end

8.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); }  // 跳回 setjmp

9.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,3

10.2 软中断(SoftIRQ)监控 ​

bash
cat /proc/softirqs
# TIMER: 定时器软中断    SCHED: 调度器
# NET_RX: 网络接收 — 过高说明网络负载重,可能是网卡中断被限在少数核
# 解决: echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus

10.3 中断模式诊断表 ​

异常模式可能原因排查方向
LOC 在单核激增该核心被隔离或负载不均/proc/irq/*/smp_affinity
NET_RX 集中于单核网卡未配置 RSSethtool -l eth0 查看队列数
大量 NMI硬件故障 (ECC 内存错误)dmesg, mcelog
频繁 RES (重调度中断)过多进程频繁切换vmstat, pidstat -w
系统调用次数飙升某进程循环中调用 syscallstrace -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查看软中断统计

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记