4. 中断机制:INTx中断、MSI/MSI-X中断、中断亲和性与负载均衡

中断,是外设跟CPU打招呼的方式。没有中断,CPU就得轮询——那效率低得没法看。

PCIe设备的中断,经历了三代演变:从古老的INTx,到MSI,再到现在的MSI-X。我刚开始做驱动时,还在用INTx,后来项目要求高性能,才真正体会到MSI-X的好处。这一章,咱们就把这三种中断掰开揉碎了讲清楚。

4.1 INTx中断:传统的中断方式

INTx是PCI时代的遗产。它用四根边带信号线——INTA#、INTB#、INTC#、INTD#——来传递中断。说白了,就是拉低一根线,告诉CPU有活干了。

INTx的核心特点:

  • 电平触发,不是边沿触发。这意味着中断处理完之前,信号线必须一直保持有效状态。
  • 共享中断。多个设备可以共用同一根INTx线。嗯,这既是优点也是坑。
  • 一个设备最多支持4个中断源(INTA~INTD)。

我曾经踩过的坑:INTx是电平触发,中断处理程序必须确认确实有中断请求,否则会陷入无限中断循环。我见过一个同事,中断处理函数里没做状态检查,结果CPU直接被打满,系统卡死。排查了一整天,最后发现是INTx的共享中断导致误触发。

在PCIe配置空间中,INTx中断通过以下寄存器配置:

配置空间偏移 寄存器名称 位域 说明
0x3C Interrupt Line [7:0] 中断向量号,由BIOS/OS分配
0x3D Interrupt Pin [7:0] 1=INTA#, 2=INTB#, 3=INTC#, 4=INTD#

你想想看,INTx最大的问题是什么?是共享。多个设备共享一根中断线,中断来了,你得一个个设备去查,谁发的?这就是所谓的「中断风暴」。在高吞吐场景下,INTx基本扛不住。

4.2 MSI中断:消息 signaled 中断

MSI(Message Signaled Interrupt)的出现,解决了INTx的共享问题。它不是靠拉信号线,而是通过写一个特定的内存地址(Message Address)和消息数据(Message Data)来触发中断。

说白了,MSI就是一次内存写操作。设备往指定地址写一个32位数据,中断控制器收到后,直接路由到CPU核心。不需要轮询,不需要共享线。

我个人习惯:新项目能上MSI就上MSI,别用INTx。MSI的延迟比INTx低一个数量级,而且每个设备可以有自己的中断向量,不会互相干扰。

MSI的配置在PCIe能力结构(Capability Structure)中:

// MSI Capability Structure (偏移 0x50 附近)
struct msi_cap {
    u8  cap_id;      // 0x05 (MSI Capability ID)
    u8  next_ptr;    // 下一个Capability指针
    u16 msi_ctrl;    // 控制寄存器
    u32 msg_addr;    // 消息地址 (32位)
    u16 msg_data;    // 消息数据 (16位)
    // 如果支持64位地址,还有高32位地址
};

MSI控制寄存器的关键位域:

位域 名称 说明
[0] MSI Enable 使能MSI中断
[3:1] Multiple Message Capable 设备支持的中断数量(2的幂次)
[6:4] Multiple Message Enable 实际分配的中断数量
[19:17] Per-vector Masking Capable 是否支持每个向量独立屏蔽

MSI有个限制:最多只能支持32个中断向量(2^5)。对于大多数设备够用了,但如果你做的是NVMe SSD或者万兆网卡,32个可能就不太够。

4.3 MSI-X中断:灵活的中断方案

MSI-X是MSI的升级版。它把中断向量表搬到了设备的BAR空间中,每个中断向量可以独立配置消息地址和消息数据。这意味着什么?意味着你可以有上千个中断向量,每个队列一个中断,每个CPU核心一个中断。

MSI-X vs MSI 核心差异:

  • MSI-X支持最多2048个中断向量,MSI最多32个。
  • MSI-X每个向量独立配置地址和数据,MSI所有向量共享一个地址。
  • MSI-X向量表在BAR空间中,可动态调整;MSI在配置空间中,固定分配。
  • MSI-X支持每个向量独立屏蔽,MSI需要额外能力位。

MSI-X的配置结构:

// MSI-X Capability Structure
struct msix_cap {
    u8  cap_id;      // 0x11 (MSI-X Capability ID)
    u8  next_ptr;
    u16 msix_ctrl;   // 控制寄存器
    u32 table_offset;// 向量表偏移(BAR索引 + 偏移)
    u32 pba_offset;  // Pending Bit Array偏移
};

// 每个MSI-X向量表项(16字节)
struct msix_table_entry {
    u64 msg_addr;    // 消息地址(64位)
    u32 msg_data;    // 消息数据(32位)
    u32 vector_ctrl; // 向量控制(bit 0 = Mask)
};

我在项目中遇到过一个问题:MSI-X向量表在BAR空间中,如果BAR空间映射的是设备内部寄存器,写向量表时要注意字节序和对齐。有些设备要求64位地址必须8字节对齐,否则会触发Unsupported Request。

4.4 中断亲和性与负载均衡

中断来了,交给哪个CPU核心处理?这就是中断亲和性(IRQ Affinity)要解决的问题。

默认情况下,中断可能随机分配到任意核心。但实际场景中,我们希望:

  • 网卡中断绑定到专门处理网络协议栈的核心。
  • NVMe中断绑定到处理存储I/O的核心。
  • 避免多个高吞吐中断挤在同一个核心上。

Linux下,中断亲和性通过 /proc/irq/{irq_num}/smp_affinity 控制。这是一个位掩码,每一位代表一个CPU核心。

# 查看中断号
cat /proc/interrupts | grep "my_device"

# 将中断号 48 绑定到 CPU 0 和 CPU 2
echo "5" > /proc/irq/48/smp_affinity
# 5 = 二进制 0101,表示 CPU0 和 CPU2

我建议:在驱动初始化时,通过 irq_set_affinity_hint() 设置亲和性提示。这样用户空间工具(如 irqbalance)会优先按照你的提示分配。但别强制绑定,给用户留调整空间。

负载均衡,说白了就是让多个中断均匀分布到多个核心上。Linux的irqbalance守护进程会自动做这件事。但有些场景下,自动均衡不如手动配置:

  • DPDK应用:需要把中断全部绑定到非数据面核心。
  • 实时系统:关键中断必须绑定到隔离核心。
  • NUMA架构:中断最好绑定到离设备最近的NUMA节点上的核心。

下面这张图,展示了从设备触发中断到CPU核心处理的全流程:

PCIe中断处理流程与亲和性配置 PCIe设备 触发中断 MSI/MSI-X 内存写事务 中断控制器 路由到目标核心 CPU核心(通过smp_affinity绑定) CPU 0 处理中断 亲和性掩码: 0x01 CPU 1 处理中断 亲和性掩码: 0x02 CPU 2 处理中断 亲和性掩码: 0x04 CPU 3 处理中断 亲和性掩码: 0x08 💡 关键点: • INTx:电平触发,共享中断线,适合低吞吐场景 • MSI/MSI-X:内存写事务触发,每个中断独立路由,支持亲和性绑定 • 亲和性配置:通过 /proc/irq/{irq}/smp_affinity 控制中断到CPU的分配

4.5 实际驱动中的中断注册

在Linux驱动中,注册中断的典型流程:

static int my_pcie_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    int ret;
    int irq;

    // 1. 使能设备
    ret = pci_enable_device(pdev);
    if (ret) return ret;

    // 2. 设置DMA掩码
    ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));
    if (ret) return ret;

    // 3. 尝试MSI-X,不行就MSI,再不行就INTx
    ret = pci_alloc_irq_vectors(pdev, 1, 4, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_INTX);
    if (ret < 0) {
        dev_err(&pdev->dev, "Failed to allocate IRQ vectors\n");
        return ret;
    }

    // 4. 获取中断号并注册处理函数
    irq = pci_irq_vector(pdev, 0);
    ret = request_irq(irq, my_interrupt_handler, 0, "my_device", dev);
    if (ret) {
        pci_free_irq_vectors(pdev);
        return ret;
    }

    // 5. 设置亲和性提示(绑定到CPU 0)
    irq_set_affinity_hint(irq, cpumask_of(0));

    return 0;
}

我曾经踩过的坑:在注册中断之前,一定要确保设备不会产生中断。否则中断处理函数还没注册,中断就来了,系统直接panic。我的习惯是:先pci_enable_device,然后立即禁用设备中断(写设备寄存器),等所有中断资源分配完毕,最后再使能中断。

中断处理函数的设计,也有讲究:

static irqreturn_t my_interrupt_handler(int irq, void *dev_id)
{
    struct my_dev *dev = (struct my_dev *)dev_id;
    u32 status;

    // 1. 读取中断状态寄存器
    status = ioread32(dev->bar0 + INT_STATUS_REG);

    // 2. 检查是否真的是本设备的中断
    if (!(status & INT_PENDING)) {
        return IRQ_NONE;  // 不是我的中断,返回
    }

    // 3. 清除中断标志(重要!)
    iowrite32(status, dev->bar0 + INT_CLEAR_REG);

    // 4. 处理中断(如果是MSI-X,可以在这里区分队列)
    if (status & TX_DONE) {
        handle_tx_completion(dev);
    }
    if (status & RX_READY) {
        handle_rx_packet(dev);
    }

    return IRQ_HANDLED;
}

我建议:中断处理函数里尽量少做事。把耗时操作放到tasklet或workqueue中。中断上下文不能睡眠,不能获取信号量,不能做太多内存操作。记住:中断处理是越快越好,不是越全越好。