中断与异常处理:从硬件中断到软中断

中断(Interrupt)是操作系统最核心的机制之一。无论是键盘输入、磁盘 I/O 完成,还是定时器到期,系统都依赖中断来打破 CPU 的顺序执行流,让内核能够及时响应外部事件。理解中断的完整链路——从硬件信号到内核处理,再到 Linux 的下半部机制——是掌握操作系统底层原理的关键一步。

中断(Interrupt)是操作系统最核心的机制之一。无论是键盘输入、磁盘 I/O 完成,还是定时器到期,系统都依赖中断来打破 CPU 的顺序执行流,让内核能够及时响应外部事件。理解中断的完整链路——从硬件信号到内核处理,再到 Linux 的下半部机制——是掌握操作系统底层原理的关键一步。

一、中断的基本概念

1.1 硬件中断与软件中断

硬件中断(Hardware Interrupt)由外部设备产生,例如网卡收到数据包、硬盘读写完成、用户按下了键盘。这些事件通过物理信号线通知 CPU:“我有事情需要你处理。”

软件中断(Software Interrupt)则是由 CPU 执行软件指令触发的,属于程序主动发起的同步行为。最典型的例子是系统调用:int 0x80syscall 指令会触发一个软件中断,使用户态进程陷入内核态。此外,CPU 执行除零操作、访问非法内存时产生的异常,本质上也是一种特殊的软件中断。

1.2 同步中断与异步中断

同步中断(Synchronous Interrupt)与当前执行的指令流严格相关:它在 CPU 执行某条指令之后立刻发生,并且可以在代码中预估到发生位置。异常(Exception)和系统调用都属于同步中断。例如,执行 div 指令时除数为零,CPU 会立即抛出 #DE(Division Error)异常。

异步中断(Asynchronous Interrupt)与当前指令流无关,由外部事件随机触发。硬件 IRQ 就是典型的异步中断。网卡何时收到一个数据帧是不可预测的,CPU 只能通过中断通知被动接收。

1.3 可屏蔽中断与不可屏蔽中断

可屏蔽中断(Maskable Interrupt)可以通过设置 CPU 的 EFLAGS 寄存器中的 IF(Interrupt Flag)位来关闭。当 IF = 0 时,CPU 忽略绝大多数外部中断请求,这在进入临界区时非常重要。Linux 内核通过 local_irq_disable()local_irq_enable() 宏来操作本 CPU 的中断屏蔽位。

不可屏蔽中断(NMI, Non-Maskable Interrupt)则无法被屏蔽,通常保留给最紧急的硬件故障场景。x86 上 NMI 的典型用途包括:内存奇偶校验错误、看门狗超时、硬件调试。NMI 通常是致命错误的信号,内核往往只能记录现场后触发恐慌(panic)。

二、x86 中断架构

2.1 中断描述符表(IDT)

x86 架构使用中断描述符表(Interrupt Descriptor Table, IDT)来管理所有中断和异常的处理入口。IDT 是一个包含 256 个条目的数组,每个条目可以是中断门(Interrupt Gate)、陷阱门(Trap Gate)或任务门(Task Gate)。

当中断或异常发生时:

  1. CPU 根据向量号作为索引查找 IDT;
  2. 读取对应门描述符中的段选择子和偏移量;
  3. 切换到内核栈(如果是用户态下发生);
  4. 跳转到 ISR(中断服务例程)入口执行。

Linux 内核在启动早期通过 trap_init()idt_setup_apic_and_irq_gates() 等函数构建 IDT,将每个向量号映射到对应的汇编入口 entry_64.S 中的处理代码。

2.2 中断向量号的分配

x86 预留了 256 个中断向量(0~255),用途如下:

向量号范围用途
0 ~ 31CPU 保留异常(Faults/Traps/Aborts)
32 ~ 127外部硬件 IRQ(由 I/O APIC 或 PIC 映射)
128系统调用(int 0x80,历史遗留 / 兼容用途)
129 ~ 238扩展 IRQ(APIC 模式下的额外向量)
239本地 APIC 的 Spurious Interrupt
240 ~ 255本地 APIC 内部使用 / Linux 内部 IPI

常见的 CPU 异常包括:0 号除零错误(#DE)、6 号非法操作码(#UD)、13 号通用保护故障(#GP)、14 号页故障(#PF)、8 号双重故障(#DF)。其中页故障是操作系统内存管理的核心——当进程访问未映射或权限不足的页时触发,内核在页故障处理程序中完成按需分配、写时复制(COW)、换页等关键逻辑。

2.3 从 PIC 到 APIC 的演进

在单处理器时代,x86 使用两片级联的可编程中断控制器(PIC, Programmable Interrupt Controller,Intel 8259A)。主 PIC 管理 IRQ0IRQ7,从 PIC 管理 IRQ8IRQ15,两片通过 IRQ2 级联,总共 15 条可用中断线。PIC 的缺点是:所有中断必须通过 PIC 汇总到 CPU 的一个引脚,无法利用多核并行处理。

现代系统已全面转向高级可编程中断控制器(APIC)。每个 CPU 核心包含一个本地 APIC(Local APIC),系统中还有一个或多个 I/O APIC 负责接收外部设备的中断请求。I/O APIC 收到中断后,根据内部重定向表(Redirection Table Entry)决定将中断投递给哪个(或哪些)本地 APIC。这直接支持了多处理器中断分发IRQ Affinity 配置。

对于没有物理中断线连接的设备(如通过 PCI-E 连接的设备),x86 提供了一种更现代的中断投递方式——消息 signaled 中断(MSI 和 MSI-X)。设备不通过物理线 assert 中断,而是向一个特定的内存地址写入一条特殊消息,由总线桥接器转发给本地 APIC。MSI 相比传统线中断有三个关键优势:

  • 可扩展性:MSI 支持最多 32 个向量,MSI-X 支持多达 2048 个;
  • 抖动低:无需轮询中断信号线的电平变化,纯消息传递延迟更小;
  • 每条中断独立:传统共享 IRQ 需要 ISR 逐一查询是哪个设备触发,MSI 天然独享向量。

三、中断处理流程

3.1 从设备到内核的路径

当一个外部设备需要 CPU 处理时,完整的中断链路如下:

  1. 设备通过内部逻辑或 MSI/MSI-X 消息向中断控制器(I/O APIC)发送请求;
  2. I/O APIC 根据 RTE 配置,通过 APIC 总线向目标 CPU 的本地 APIC 投递中断;
  3. 本地 APIC 在 CPU 的 IRR(Interrupt Request Register)中设置对应位;
  4. 当 CPU 完成当前指令且中断未被屏蔽时,从 IDT 中取出对应 ISR 入口;
  5. 进入内核态,执行 do_IRQ() 或类似的通用分发函数,最终调用设备注册的具体 ISR。

3.2 上半部与下半部的分离

Linux 将中断处理分为两个部分:上半部(Top Half)和下半部(Bottom Half)。

上半部即真正的 ISR(Interrupt Service Routine),运行在中断上下文中,此时当前 CPU 通常会屏蔽同一条 IRQ 线(甚至所有本地中断)。ISR 必须极快地完成以下工作:

  • 向中断控制器发送 EOI(End of Interrupt)确认;
  • 关闭设备的中断请求(或清中断标志);
  • 拷贝关键数据到内存缓冲区;
  • 标记或调度下半部执行。

上半部的设计原则是"越快越好"——因为 ISR 执行期间,CPU 要么完全关中断,要么至少屏蔽了本条 IRQ,丢失后续中断的风险随时间线性增长。

下半部负责"重活":解析数据包、磁盘 I/O 完成后的后处理、唤醒等待的进程等。下半部可以在未来某个更安全的时间点执行,此时中断是开启的,甚至可以运行在进程上下文中(如 workqueue)。

这种分离的本质原因是:中断上下文不允许睡眠。如果 ISR 中去申请一个需要等待的锁或分配可能触发回收的内存,就可能因为无法调度而导致系统死锁或丢中断。

四、Linux 下半部机制详解

Linux 内核经过多年演进,提供了若干下半部实现,它们在使用场景上有所侧重。

4.1 softirq

softirq 是 Linux 中优先级最高的下半部机制,在内核启动时静态分配了 10 种类型(如 HI_SOFTIRQTIMER_SOFTIRQNET_TX_SOFTIRQNET_RX_SOFTIRQBLOCK_SOFTIRQ 等)。

softirq 的特点:

  • 同类型的 softirq 可以在多个 CPU 上并行执行(需要处理函数自己实现锁保护);
  • 在不可眠的中断上下文中执行,因此也不能阻塞或访问用户空间;
  • 通常在 ISR 返回前或 ksoftirqd 内核线程中被触发和执行。

软中断通过 raise_softirq() 发起,由 do_softirq()__do_softirq() 执行。如果软中断局部爆发导致当前 CPU 一直忙于处理软中断,内核会将其"迁移"到专属的 ksoftirqd 内核线程中执行,避免用户进程被饿死。

4.2 tasklet

tasklet 建立在 HI_SOFTIRQTASKLET_SOFTIRQ 之上,提供了更友好的动态分配接口。相比 softirq,tasklet 的一个重要约束是:同个 tasklet 实例不会同时在两个 CPU 上运行。这意味着 tasklet 处理函数不需要额外处理跨 CPU 的互斥,简化了驱动编写。

tasklet 的使用示例:

#include <linux/interrupt.h>

static void my_tasklet_handler(unsigned long data)
{
    struct my_device *dev = (struct my_device *)data;
    /* 处理接收到的数据 */
    process_rx_buffer(dev);
}

static DECLARE_TASKLET(my_tasklet, my_tasklet_handler, (unsigned long)&my_dev);

static irqreturn_t my_isr(int irq, void *dev_id)
{
    /* 上半部:清除中断,读取数据到缓冲区 */
    disable_device_irq(dev_id);
    copy_data_to_buffer(dev_id);

    /* 调度下半部 */
    tasklet_schedule(&my_tasklet);
    return IRQ_HANDLED;
}

4.3 workqueue

workqueue 是"下半部的下半部"——它将工作项放入队列,由内核 worker 线程在进程上下文中执行。这意味着 workqueue 的处理函数可以睡眠、可以持有信号量、可以访问大块内存而不用担心栈溢出。

workqueue 的典型使用场景包括:复杂的 I/O 后处理、需要与用户空间交互的任务、长时间运行的设备状态更新。

#include <linux/workqueue.h>

static void my_work_handler(struct work_struct *work)
{
    struct my_device *dev = container_of(work, struct my_device, work);
    /* 运行在进程上下文,可以睡眠 */
    mutex_lock(&dev->mtx);
    process_and_notify(dev);
    mutex_unlock(&dev->mtx);
}

static DECLARE_WORK(my_work, my_work_handler);

static irqreturn_t my_isr(int irq, void *dev_id)
{
    schedule_work(&my_work);
    return IRQ_HANDLED;
}

内核提供了多种 workqueue 变体,如 system_wq(默认共享队列)、system_highpri_wq(高优先级)、system_freezable_wq(可冻结),驱动也可以通过 alloc_workqueue() 创建专用队列以控制并发度和执行属性。

4.4 Threaded IRQs

从 Linux 2.6.30 开始,内核引入了 threaded IRQ 机制:驱动可以通过 request_threaded_irq() 注册中断时,同时提供一个主 ISR(handler)和一个线程处理函数(thread_fn)。主 ISR 执行极少量工作(通常只确认中断),然后内核自动唤醒一个与该 IRQ 绑定的内核线程,在线程上下文中执行 thread_fn

Threaded IRQ 的核心优势在于:线程处理函数运行在进程上下文中,因此可以睡眠,可以使用阻塞锁,并受完全调度策略控制。这对于现代设备驱动非常有用,因为许多设备的中断处理逻辑天然需要睡眠等待。

irqreturn_t irq_handler(int irq, void *dev_id)
{
    /* 检查是否是我们的设备产生的中断 */
    if (!device_has_interrupt(dev_id))
        return IRQ_NONE;

    /* 关闭设备中断,通知核心唤醒线程 */
    mask_device_irq(dev_id);
    return IRQ_WAKE_THREAD;
}

irqreturn_t irq_thread_fn(int irq, void *dev_id)
{
    struct my_device *dev = dev_id;
    process_interrupt(dev);
    unmask_device_irq(dev);
    return IRQ_HANDLED;
}

request_threaded_irq(irqnum, irq_handler, irq_thread_fn,
                     IRQF_SHARED, "my_driver", dev);

4.5 下半部机制对比

机制执行上下文能否睡眠并发性使用场景
softirq中断上下文多 CPU 并行网络收发、块设备等高吞吐子系统
tasklet中断上下文同实例串行,不同类型并行驱动开发的通用下半部
workqueue进程上下文可以由 worker 池调度复杂后处理、需阻塞的操作
threaded IRQ进程上下文可以按 IRQ 独立线程需睡眠的设备中断处理

驱动开发的一般原则是:优先使用 request_threaded_irq(),因为可睡眠的上下文显著降低了死锁风险;仅在性能极端敏感的网络或块设备路径上直接操作 softirq。

五、中断上下文与进程上下文的区别

理解中断上下文(Interrupt Context)与进程上下文(Process Context)的差异,对编写正确的内核代码至关重要。

5.1 中断上下文的核心特征

  • 无关联进程:中断发生时,CPU 可能正在执行任何进程(甚至空闲线程)。ISR 不属于任何用户进程,因此 current 指针虽然有效,但指向被中断打断的进程——通常不应基于它做决策。
  • 不允许睡眠:中断上下文不是通过调度器进入的,也不能通过调度器退出。如果 ISR 调用可能休眠的函数(如 kmalloc(GFP_KERNEL)mutex_lock()),内核会触发 BUG: sleeping function called from invalid context 并可能崩溃。
  • 无法访问用户空间:中断上下文没有对应的用户态内存映射,直接访问用户地址会导致故障。
  • 栈空间很小:中断使用每个 CPU 的小栈(4KB 或 8KB),过深的函数调用或局部大数组会导致栈溢出。内核代码中常见 alloca 限制和 VLA 禁用都是为了保护中断栈。

5.2 为什么这些限制存在

中断处理不是由调度器编排的,而是 CPU 硬件强制跳转。如果 ISR 可以睡眠,那么睡眠前的栈现场、寄存器现场就变得难以保存和恢复——本质上需要再实现一套"进程"切换机制,而非利用现有的调度框架。将这些限制较重的任务推迟到进程上下文中执行(workqueue / threaded IRQ),是一种优雅的分而治之。

六、IRQ 亲和性与负载均衡

现代多核服务器中,合理分配中断到不同 CPU 是优化性能的关键。Linux 提供了完整的 IRQ affinity 机制。

6.1 通过 /proc 配置亲和性

/proc/irq/default_smp_affinity 定义了新建 IRQ 的默认 CPU 亲和掩码。对于具体 IRQ N,文件 /proc/irq/N/smp_affinity 控制该中断允许投递的 CPU 集合。掩码是一个十六进制位图: bit 0 对应 CPU0,bit 1 对应 CPU1,以此类推。

例如,将 IRQ 128 限制在 CPU 2 和 3 上处理:

echo "0x0C" > /proc/irq/128/smp_affinity

某些系统使用 /proc/irq/N/smp_affinity_list 提供更直观的 CPU 列表接口:

echo "2-3" > /proc/irq/128/smp_affinity_list

6.2 NUMA 感知与中断均衡

在 NUMA 架构下,让处理中断的 CPU 与设备所在的 NUMA 节点一致,可以显著减少跨节点内存访问延迟。Linux 的 irqbalance 守护进程(或较新的内核内置均衡逻辑)会自动分析中断负载,并尝试将高吞吐中断分散到不同核心,同时尊重 NUMA 拓扑。

对于网络密集型应用,业内常采用一种极端优化:将网卡的中断固定到特定 CPU 核心,并将处理该数据的用户态线程绑定到同一核心。这就是网卡的 RPS/RFS(Receive Packet Steering / Receive Flow Steering)和 XPS(Transmit Packet Steering)所要解决的问题。它们在软件层面补充了硬件队列的不足,让数据包从进入网卡到被用户态处理的全过程都在同一个核心上完成,最大化缓存局部性。

6.3 实时场景中的 isolcpus

对于硬实时(hard real-time)负载,调度器抖动是最大的敌人。isolcpus 启动参数可以将某些 CPU 从内核的通用调度域中隔离出来,使得普通进程不会被调度到这些核心上运行。配合 IRQ affinity 将这些核心排除在所有外部中断之外,可以为用户态实时线程提供近乎零抖动的执行环境。

# 将 CPU 2 和 3 隔离,不做一般调度,不接收中断
linux isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3

七、实践:观察和编写中断

7.1 通过 /proc/interrupts 观察中断分布

/proc/interrupts 是理解系统中断行为最直接的工具。每一行对应一条 IRQ,每一列对应该中断在各个 CPU 上的触发次数。

cat /proc/interrupts

输出示例:

           CPU0       CPU1       CPU2       CPU3
  0:         34          0          0          0   IO-APIC    2-edge      timer
  1:          3          0          0          0   IO-APIC    1-edge      i8042
  8:          1          0          0          0   IO-APIC    8-edge      rtc0
  9:          0          0          0          0   IO-APIC    9-fasteoi   acpi
 12:          4          0          0          0   IO-APIC   12-edge      i8042
 16:      12345      67890      11111      22222   PCI-MSI   327680-edge  ens33-rx-0
 16:      12345      67890      11111      22222   PCI-MSI   327681-edge  ens33-rx-1

从输出中可以直观判断:某条 IRQ 是否只由单个 CPU 处理(存在热点),或者是否被 MSI-X 分散到多个队列。如果你修改了 smp_affinity,运行一段时间后再读 /proc/interrupts,就能看到计数分布的变化。

7.2 查找设备对应的 IRQ

除了 /proc/interrupts/sys 文件系统也提供了结构化的设备-IRQ 映射信息:

# 查看某 PCI 设备的 IRQ
cat /sys/bus/pci/devices/0000:00:03.0/irq

# 查看网卡中断的亲和性配置
cat /proc/irq/128/smp_affinity_list

# 查看网卡接收队列映射
ls /sys/class/net/eth0/queues/
cat /sys/class/net/eth0/queues/rx-0/rps_cpus

对于 MSI-X 设备,每个队列都有独立的 IRQ 编号,可以通过 /sys/class/net/<iface>/device/msi_irqs/ 目录查看。

7.3 用 perf 监控 CPU 中断处理

perf 工具可以深入观察 CPU 花在中断/软中断上的时间:

# 统计各 CPU 的软中断分布
perf stat -C 0,1,2,3 -e irq:irq_handler_entry -a sleep 10

# 采集中断栈
perf record -e irq:irq_handler_entry -ag sleep 5
perf script

# 查看硬件中断周期
perf stat -e cycles,instructions,kvm:kvm_entry,kvm:kvm_exit -a sleep 5

7.4 编写一个简单的中断处理内核模块

下面是一个基于 GPIO 中断的简单内核模块骨架,展示了从 request_irqfree_irq 的完整生命周期。实际 GPIO 号和平台相关,请根据目标板卡调整。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/interrupt.h>
#include <linux/gpio.h>

#define GPIO_IRQ_PIN  25
static int irq_num;

static irqreturn_t my_gpio_handler(int irq, void *dev_id)
{
    printk(KERN_INFO "myirq: GPIO interrupt triggered!\n");
    /* 实际驱动中应调度 tasklet 或 workqueue */
    return IRQ_HANDLED;
}

static int __init myirq_init(void)
{
    int ret;

    if (!gpio_is_valid(GPIO_IRQ_PIN)) {
        pr_err("Invalid GPIO\n");
        return -ENODEV;
    }

    ret = gpio_request(GPIO_IRQ_PIN, "myirq_gpio");
    if (ret) {
        pr_err("Failed to request GPIO\n");
        return ret;
    }

    ret = gpio_direction_input(GPIO_IRQ_PIN);
    if (ret) {
        gpio_free(GPIO_IRQ_PIN);
        return ret;
    }

    irq_num = gpio_to_irq(GPIO_IRQ_PIN);
    ret = request_irq(irq_num, my_gpio_handler,
                      IRQF_TRIGGER_FALLING,
                      "myirq_driver", NULL);
    if (ret) {
        gpio_free(GPIO_IRQ_PIN);
        pr_err("Failed to request IRQ\n");
        return ret;
    }

    pr_info("myirq: Loaded, IRQ %d\n", irq_num);
    return 0;
}

static void __exit myirq_exit(void)
{
    free_irq(irq_num, NULL);
    gpio_free(GPIO_IRQ_PIN);
    pr_info("myirq: Unloaded\n");
}

module_init(myirq_init);
module_exit(myirq_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Simple GPIO Interrupt Example");

编译并加载后,用 dmesg 观察触发日志,用 cat /proc/interrupts 核对该 IRQ 的计数变化。


总结

中断是操作系统与外部世界交互的神经系统。从 x86 IDT 的硬件向量设计,到 Linux 内核将 ISR 拆分为上半部和下半部的软件工程智慧,再到 softirq、tasklet、workqueue 和 threaded IRQ 形成的多层次异步处理体系,每一层设计都体现了对响应延迟系统吞吐的权衡。

理解中断上下文不能睡眠的本质原因,掌握 IRQ affinity 的调优手段,以及熟练使用 /proc/interruptsperf 观测中断行为,是进阶系统开发与性能调优的基本功。在多核与 NUMA 架构已经成为主流的今天,让正确的中断到达正确的 CPU,往往比优化一行用户态代码带来的收益更高。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace