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核心处理的全流程:
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中。中断上下文不能睡眠,不能获取信号量,不能做太多内存操作。记住:中断处理是越快越好,不是越全越好。