DMA中断原理:完成、错误与向量

你想想看,DMA 只管搬数据,搬完了总得有人知道吧?这就是中断的作用。DMA 控制器在关键节点上踩一脚刹车,发个信号给 CPU,告诉它“活干完了”或者“出问题了”。

我个人习惯把 DMA 中断分成三类:完成中断、错误中断,以及承载它们的中断向量。这三者互相配合,构成了 DMA 与 CPU 的沟通机制。

核心观点:没有中断的 DMA 就像没有铃铛的快递员——你不知道包裹啥时候到,也不知道中途有没有摔坏。

一、完成中断

说白了,完成中断就是 DMA 通知 CPU:“我这趟传输搞定了,数据已经就位,你过来检查吧。” 触发时机通常有这几个:

  • 单次块传输(Block Transfer)结束
  • 链式描述符列表全部执行完毕
  • 循环模式下,缓冲区满或半满

我在调优网络包处理的时候遇到过一个问题:完成中断来得太频繁,CPU 光忙着响应了,机器吞吐量反而往下掉。后来我才意识到,中断不是越频繁越好。你想想看,CPU 每次进中断都要保存上下文、查描述符、清标志位,一套下来好几百个时钟周期就没了。

我的经验:批量传输场景下,尽量启用批量完成中断(比如传输完 4 个 block 才发一次中断),或者用延时中断定时器做防抖。压力测试时盯着 /proc/interrupts 看,如果每秒中断数超过 10 万次,就得考虑合并中断了。

完成中断处理函数里,一般要干这几件事:

  1. 读取 DMA 状态寄存器,确认传输结果
  2. 清除中断挂起位(写 1 清零)
  3. 更新驱动里的描述符环形队列指针
  4. 通知上层协议栈数据已可用
/* 典型完成中断处理伪代码 */
void dma_done_irq_handler(int irq, void *dev_id) {
    struct dma_device *dev = dev_id;
    u32 status = readl(dev->regs + DMA_STATUS);
    if (status & DMA_DONE) {
        // 清除完成标志
        writel(DMA_DONE, dev->regs + DMA_STATUS);
        // 推进描述符队列
        dev->completed_desc++;
        // 唤醒等待数据的进程
        wake_up_interruptible(&dev->wait_queue);
    }
}

注意:有些 DMA 控制器在清除完成标志之前,不会响应下一次传输。所以必须先清标志,再后续处理。我曾经因为顺序搞反,导致中断风暴——清完状态之后又触发了新的完成中断,递归处理差点让系统 hang 死。

二、错误中断

错误中断比完成中断麻烦多了。它告诉 CPU:“出事了,但我也不知道该怎么办,你来擦屁股。” 常见错误类型有这些:

错误类型 触发原因 处理方式
总线错误 DMA 访问非法地址、设备未响应 记录地址、停止通道、复位 PCIe 链路
传输超时 数据总线一直没准备好,超出计数器上限 超时复位、重新发起握手
数据对齐错误 源地址或目的地址不满足对齐要求 返回负值、驱动尝试软件修正或报错
描述符错误 链表中出现无效指针或非法配置 停止 DMA、打印描述符内容、排查内存损坏

我在项目里遇到最头疼的是总线错误。那次 FPGA 端的 DMA 在传输过程中突然拉低了 TREADY,主机这边的 DMA 控制器等了 256 个时钟就超时了,触发了错误中断。但错误标志还留在寄存器里,不清掉的话,整个 DMA 通道就直接废了。所以错误中断处理的第一件事,永远是“停通道→读错误详情→清标志→复位通道”。

避坑指南:我曾经在错误中断里只做了打印日志,没停 DMA 通道。结果 DMA 一直在错误的地址上读写,把内核堆栈都冲碎了。嗯,从那以后我要求所有错误处理函数必须调用 dma_ctrl(dev, DMA_TERMINATE_ALL, 0) 先停止当前所有传输。

/* 错误中断处理示例 */
void dma_err_irq_handler(int irq, void *dev_id) {
    struct dma_device *dev = dev_id;
    u32 err_status = readl(dev->regs + DMA_ERR_STATUS);
    pr_err("DMA error on channel %d: status=0x%x\n", dev->chan, err_status);
    // 终止当前传输
    dma_ctrl(dev, DMA_TERMINATE_ALL, 0);
    // 清除所有错误标志
    writel(err_status, dev->regs + DMA_ERR_STATUS);
    // 复位通道状态机
    writel(1, dev->regs + DMA_CH_RESET);
    // 通知驱动程序重新初始化
    schedule_work(&dev->recovery_work);
}

三、中断向量

中断向量就是 DMA 中断在中断控制器里的“身份证号”。CPU 收到中断信号后,得通过查向量表才知道是哪个设备在叫。说白了,中断向量是一个物理中断号(IRQ number),每个 DMA 通道或者每个 DMA 控制器通常有一个独立的向量。

在 x86 平台上,DMA 中断可能会被映射到 MSI(Message Signaled Interrupt)向量上。ARM 平台则用 GIC(Generic Interrupt Controller)的 SPI 或 PPI 来分配。中断向量的编号和亲和性配置直接相关——你把中断号配给哪个 CPU 核心,那个核心就会处理对应的 DMA 完成或错误中断。

关键点:中断向量本身不决定性能,但它决定了中断发往哪个 CPU。选错了核心,缓存 miss 率能翻好几倍。

你可以在 /proc/interrupts 里看到每个 DMA 中断的触发次数和 CPU 分布:

$ cat /proc/interrupts | grep dma
 196:     123456     98765    0    0  IR-PCI-MSI 512000-edge  ioat-msix
 197:     111222    333444    0    0  IR-PCI-MSI 512001-edge  ioat-msix

左边的 196、197 就是中断向量号。右边的 ioat-msix 表示这是 Intel I/OAT DMA 驱动注册的中断名字。每一列对应一个 CPU 核心,数字是该核心处理的中断次数。如果发现只有一个核心在承受所有中断,那就需要做中断亲和性调整了——这部分我们后面几章重点讲。

小技巧:查看某个 DMA 通道的中断向量号,可以用 cat /sys/bus/pci/devices/0000:00:04.0/irq 直接拿到 IRQ 号。调试的时候特别好用。

好,到这里你应该能感受到完成中断、错误中断和中断向量各自扮演的角色了。下面这张图把它们的互动关系画出来了:

DMA中断处理流程 DMA控制器 完成事件 错误事件 中断逻辑 完成中断 错误中断 中断向量 IRQ号:196 MSI-X向量 中断控制器 GIC / APIC 路由表 CPU核 中断处理 亲和性配置 关键路径:DMA完成/错误事件 → 中断逻辑 → 中断向量 → 中断控制器路由 → CPU处理 亲和性决定了中断向量在中断控制器路由表中绑定的目标CPU核心

从图里你可以看到,完成中断和错误中断在中断逻辑里产生后,统一被映射到一个中断向量(可能是同一个,也可能是不同的 MSI-X 向量)。中断向量携带一个 IRQ 号,中断控制器根据亲和性设置把这个信号路由到某个指定 CPU 上。CPU 再调用我们前面写的处理函数。

记住一句话:中断向量是桥梁,亲和性是方向盘。桥梁本身不会堵车,但方向盘打歪了,所有车全挤到一个 CPU 上,那性能就完蛋了。