FEATURED · 精选文章

DMA如何通知CPU“干完了”?中断、轮询与完成队列解析

发布时间 / 2026/9/13 13:33:13
来源 / 创域科博编辑部
栏目 / 资讯中心
DMA如何通知CPU“干完了”?中断、轮询与完成队列解析 1. 先搞清楚一个 DMA 事务的完整生命周期1.1 一次 DMA 传输在硬件层面是怎么跑完的刚开始接触 DMA 的人往往把注意力全放在“怎么配置源地址、目的地址、传输长度”上等到数据真的能跑了才会被一个更基础的问题卡住DMA 把活干完了CPU 到底是凭什么知道的DMA 的完整生命周期其实包含三个阶段。第一个阶段是 CPU 发起传输把源地址、目的地址、传输字节数、突发长度这些参数写进 DMA 控制器的寄存器然后置位启动位或者写一次门铃寄存器。第二个阶段是 DMA 控制器自己接管总线从源地址读数据、往目的地址写数据这期间 CPU 完全被解放出来可以去跑别的任务。第三个阶段就是“收尾”DMA 控制器把最后一个字节写完之后需要让 CPU 知道这件事否则 CPU 永远不知道缓冲区里的数据已经“新鲜出炉”了。第三个阶段就是我们今天聊的主角。很多人会想当然地认为“DMA 做完后肯定触发一个中断然后中断处理函数里收数据”现实当然没这么简单。不同硬件平台、不同总线拓扑、不同性能需求的场景做“干完了”这件事的手段完全不同。而且这个环节出问题的概率远比配置 DMA 本身要高得多。1.2 为什么“干完了”这件事必须被精确表达先说一个最直接的场景。你在用串口 DMA 接收不定长数据DMA 被配置成接收模式数据到了就自动往内存里搬。问题来了如果发送方这次只发了 15 个字节可你把 DMA 的接收长度配置成了 256 字节DMA 什么时候才算“干完”如果只靠 DMA 传输完成中断那得等到 256 字节全部凑齐才触发而发送方下一次可能只发 20 字节导致接收缓冲区里的数据处理逻辑变得极其别扭。这个时候你会引入“空闲中断”——串口在一段时间内没有新数据进来就认为一帧收完了。这个组合拳在 STM32 等嵌入式平台上非常流行本质就是用“DMA 完成中断”处理满帧用“空闲中断”处理残帧。但如果 DMA 完成中断处理得不好帧边界就会错乱数据直接串位。反过来在高性能场景下比如网卡收发数据一秒钟可能有几十万甚至上百万个包每个包都可能对应一次 DMA 完成事件。如果每次完成都触发一次中断CPU 会被中断风暴淹没相当于 DMA 省下的 CPU 开销又原封不动地还了回去。这时候就得考虑中断聚合、轮询或者半轮询半中断的混合模式。可见“怎么告诉 CPU 我干完了”不是一个小问题而是直接决定系统吞吐量和延迟上限的关键设计。1.3 完成通知的两个方向设备主动说 vs CPU 主动看“设备告诉 CPU 我干完了”本质上只有两条路一条是设备主动喊另一条是 CPU 自己去看。设备主动喊最典型的形式就是中断。DMA 控制器完成传输后拉高某根中断引脚或者通过 MSI-X 机制往 CPU 写一个特定地址的中断消息CPU 收到后再进入中断处理流程。主动喊的好处是实时性好CPU 不用一直盯着坏处是高频场景下中断开销大。CPU 主动看就是轮询。CPU 循环读取 DMA 控制器的状态寄存器或者直接读内存里 DMA 描述符的完成标志位自己判断传输结束没有。轮询的好处是没有中断开销适合吞吐量极高的场景坏处是延迟受轮询间隔影响而且 CPU 被占住了。现实中大多数系统用的是混合方案。比如网卡的 NAPI 机制平时靠中断通知 CPU一旦发现中断频率太高就切换到轮询模式等数据包处理得差不多了再切回中断。这个思路在 DMA 完成通知的设计里同样适用。搞清楚这两种基本姿势之后我们再看硬件层面到底怎么实现。2. 设备侧“干完了”的几种表达方式2.1 经典方案完成中断Completion Interrupt完成中断是最经典、也是绝大多数硬件平台默认支持的 DMA 完成通知方式。DMA 控制器在完成一次数据传输后会置位状态寄存器里的某个完成标志位同时向中断控制器发出中断请求。CPU 响应中断后在中断处理函数里读取状态寄存器确认是哪个 DMA 通道完成了然后做后续处理。以 STM32 为例DMA 的每个通道都有自己的中断标志位比如传输完成标志 TCIF、半传输完成标志 HTIF、传输错误标志 TEIF。你可以单独使能这些中断也可以一起开。很多人不知道的是如果你在中断处理函数里不清除中断标志位这个中断会一直触发系统直接卡死在中断里。这听起来像是新手才会犯的错但我在帮别人排查问题时真的遇到过——他把 DMA 中断配置成电平触发又在中断函数里忘了清标志结果板子一启动就死循环。在 Linux 内核里DMA 完成中断对应的代码路径可以这样理解DMA 控制器驱动注册一个 irq handler当硬件中断触发时内核调用这个 handler驱动在 handler 里读取 DMA 通道的状态寄存器判断传输是否完成然后调用注册好的回调函数通知上层。这个回调函数里再决定是唤醒等待队列、往环形缓冲区里塞数据还是递交网络协议栈。2.2 进阶方案完成队列 门铃机制Completion Queue / Doorbell如果业务场景是“一次提交一批 DMA 描述符让 DMA 控制器连续处理”完成中断这种一对一的模型就不够用了。现代高性能设备普遍采用“提交队列Submission Queue 完成队列Completion Queue”的设计。这个设计有点像餐厅的点菜流程。CPU 把一批任务DMA 描述符写进提交队列然后写一次门铃寄存器告诉设备“有新任务了”。设备按顺序执行这些任务每完成一个就在完成队列里写一个完成项Completion Entry同时更新完成队列的尾指针。CPU 怎么知道完成队列有新东西两种办法一是设备触发一个完成中断二是 CPU 轮询完成队列的尾指针。这里的“门铃”是个很形象的说法。你按一次门铃设备就知道有活要干。关键是门铃寄存器的写操作触发的是设备的中断或轮询唤醒而不是每次提交描述符都打断设备。NVMe 协议就是这么做的网卡的 DMA 引擎也广泛采用了类似机制。完成队列的好处是批量处理一次中断可以捎带多个完成项处理完一批再来一批大大摊薄中断开销。我最初写网络驱动的时候对这种机制理解得不透彻老想着“为什么不能简单地在 DMA 完成中断里直接读数据”后来才明白在高速场景下一次中断处理一百个完成项和一个一个中断处理一百个完成项CPU 开销能差出一个数量级。2.3 轻量级嵌入式场景从 STM32 和串口 DMA 说起嵌入式平台和服务器平台的一个显著区别是嵌入式上的 DMA 控制器通常没那么复杂完成通知的手段也更直接一些。STM32 的 DMA 基本就是“完成中断 半完成中断 错误中断”三件套没有完成队列这种花活。但嵌入式有个很有趣的现象就是大家会用“DMA 空闲中断”的组合来解决不定长数据接收的问题。具体做法是DMA 配置成循环模式或正常模式使能接收通道同时使能串口的空闲中断。当发送方停止发送后串口会检测到总线空闲触发空闲中断。在空闲中断里你计算出当前 DMA 接收了多少字节通过读取 DMA 剩余传输计数值寄存器然后把这部分数据交给协议栈处理。这其实也是一种“完成通知”只不过通知的并不是 DMA 传输完成而是“一帧数据接收完成”。这里有个容易踩的坑DMA 在正常模式下传输完成后接收功能就停了如果这时候还有数据进来会直接丢失。所以实际项目里我更喜欢用循环模式Circular Mode让 DMA 一直处于接收状态然后利用空闲中断来切割帧。这时候 DMA 的“传输完成”中断用不用看需求。如果你是为了接收一整块固定长度的数据那必须用传输完成中断如果是不定长帧用空闲中断更合理传输完成中断反而会碍事。SPI 的 DMA 场景也类似。有人问“SPI 需要两个 DMA 吗”答案是看全双工还是半双工。SPI 全双工通信时发送和接收同时进行所以一个 DMA 通道负责从内存搬数据到 SPI 发送寄存器另一个 DMA 通道负责从 SPI 接收寄存器搬数据到内存这就是为什么常看到 SPI 同时配置 Tx DMA 和 Rx DMA。两边都完成后各自触发完成中断。但要注意SPI 的接收 DMA 完成中断触发时发送 DMA 可能还没完全结束因为 SPI 是边发边收的如果你的协议要求必须“发完所有字节才能处理接收数据”那就得等发送完成中断也到了再统一处理不然会拿到半截数据。2.4 中断聚合当“干完了”太多时怎么办当 DMA 完成事件非常频繁时一个一个中断实在扛不住硬件层面就有了中断聚合Interrupt Coalescing机制。简单来说设备不每完成一个 DMA 就立刻发中断而是攒一批或者等一小段时间再统一发一次中断。这样 CPU 一次中断处理就能消化掉多个完成事件大幅降低中断频率。中断聚合的参数一般是可以调的比如最大聚合条数、最大等待时间。设得太激进等太久会导致延迟增加设得太保守几乎不聚合又起不到省 CPU 的效果。做网络优化的人对这块应该非常熟悉——网卡的 interrupt moderation 参数调优本质就是在这两个目标之间找平衡。3. CPU 侧收到“干完了”之后要做什么3.1 中断从设备到 CPU 的完整路径说完设备侧我们把视角切到 CPU 侧。设备说“我干完了”之后这个信号是怎么一步步传到 CPU 并触发处理的传统的中断路径是设备把中断信号发给中断控制器比如 GIC、IO-APIC中断控制器经过优先级仲裁后选择一个 CPU 分发中断。CPU 收到中断后保存现场、跳到中断向量表执行中断处理程序。这个过程涉及模式切换、寄存器保存/恢复开销不小。现代高性能平台越来越多地使用 MSI/MSI-X 中断。MSI 的全称是 Message Signaled Interrupts设备不通过物理中断引脚而是直接向某个特定内存地址写一个数据这个写操作会被硬件解读为一次中断投递。MSI-X 更进一步允许一个设备映射多个中断向量每个队列可以分配独立的中断号这样多核 CPU 可以并行处理不同队列的 DMA 完成事件。这也是很多现代网卡实现多队列收包的基础每个队列一个 MSI-X 向量绑定不同 CPU 核心中断处理天然并行。从 DMA 完成通知的角度看MSI-X 的意义在于DMA 完成中断不再是一根线抢来抢去而是每个队列都有独立的“电话线”想打给哪个 CPU 都可以。你写驱动时绑定 CPU 核心irq affinity实际上就是在配置“这个设备干完了活优先通知哪个 CPU”。3.2 中断处理的下半部软中断、tasklet 还是 workqueueCPU 进了中断处理程序之后如果直接在中断上下文里做大量工作会阻塞其他中断甚至导致实时任务超时。所以 Linux 等操作系统把中断处理分成了上半部和下半部上半部只做最紧急的事——读状态寄存器、清中断标志、关中断然后登记一个下半部下半部在更宽松的环境里执行可以处理数据、唤醒进程、提交网络包。DMA 完成通知对应的下半部选择其实很有讲究。如果 DMA 完成后的处理很轻量比如只是把数据搬到另一个缓冲区、更新一下标志位用 tasklet 或软中断就行因为它们在原子上下文中执行不能被睡眠但调度开销小。如果 DMA 完成后的处理可能需要睡眠——比如要读取设备寄存器、要发消息给用户态进程、要等待某个锁——那就必须用 workqueue 或 threaded irq因为它们运行在进程上下文中允许睡眠。我在写一个 FPGA DMA 驱动时踩过这个坑一开始把 DMA 完成后的数据处理直接放在中断上下文里做结果那个处理函数里调用了某个可能睡眠的 API一旦触发就报“BUG: sleeping function called from invalid context”后来改成 workqueue 才稳定。这个问题的本质不是中断本身有问题而是我把“UCPU 收到完成通知后该做什么”搞错了。3.3 内存屏障和 Cache 一致性问题DMA 完成通知还有一个非常隐蔽的坑就算设备告诉 CPU“数据已经写到内存里了”CPU 读到的未必是最新的数据。这是因为 CPU 有自己的缓存Cache而 DMA 直接把数据写进内存时并不会经过 CPU 的 Cache。如果这个内存地址对应的缓存行还停留在 CPU 的 Cache 里并且被标记为脏DirtyCPU 读到的就是旧数据还有一种情况是 CPU 已经预取了这个地址的数据到 Cache 里DMA 更新了内存但 CPU 还是从 Cache 里头读照样读到旧数据。解决的办法和 DMA 的映射方式有关。如果用的是一致性的 DMA 映射比如 Linux 里的 dma_alloc_coherent驱动申请的内存本身就配置成了不可缓存或硬件自动保证一致性的形式CPU 读到的和 DMA 写进去的数据是一致的。如果用的是流式映射dma_map_single那就需要在 DMA 完成之后、CPU 读取之前调用 dma_unmap_single 或者 dma_sync_single_for_cpu 来保证数据同步。实际操作中很多人忘了这个同步步骤结果就是 DMA 明明显示完成了CPU 读到的却是一堆乱七八糟的数据或者缺了几十字节。这不是 DMA 传输错了而是没有正确地“同步完成通知”。4. 轮询模式当 CPU 选择自己盯着“干完了”4.1 轮询在哪些场景下反而更优中断虽好但架不住频繁。前面提过如果每秒有几百万个 DMA 完成事件每次中断处理哪怕只花 1 微秒CPU 也会被吃掉一大块。内存延迟通常只有几十到一百纳秒一次中断的开销可能要几微秒。所以在超高吞吐场景下轮询反而是更好的选择。网络领域的 NAPI 机制就是典型例子收包中断一进来CPU 先把中断关掉然后立刻切换成轮询模式一口气从硬件队列里把积累的数据包全部取出来处理等队列空了再重新开启中断。这个机制相当于把“设备告诉 CPU 干完了”这件事从“每个事件都喊一次”改成了“喊一次之后CPU 自己连续查看还有没有更多活”。DMA 轮询的本体操作其实很简单CPU 循环读取 DMA 控制器的状态寄存器或内存中的完成标志判断是否置位。关键问题有两个一是轮询间隔定多少二是 CPU 一直轮询会不会饿死其他任务。间隔太短CPU 占用高间隔太长延迟大。一般做法是把轮询放在一个内核线程里配合调度器自动调节频率。很多存储设备驱动就是用一个专用内核线程在 DMA 完成队列上轮询效果比中断驱动模式好不少。4.2 混合模式Event Index、INTx 和 MSI-X 的取舍纯中断和纯轮询都不完美所以出现了混合方案。最有代表性的是 VirtIO 的 Event Index 机制驱动和设备在内存中各维护两个索引值一个表示“设备可用的新数据到哪了”一个表示“驱动处理的完成项到哪了”。驱动可以动态决定是让设备发中断还是关掉中断自己轮询完全靠写 Event Index 字段来控制。简单说当驱动处理不过来时它可以把 Event Index 设成某个值告诉设备“等我处理到这里了再通知我”本质上是把中断频率和设备进度绑定而不是简单地等一批再发。这种精细控制让系统能够在低延迟和高吞吐之间平滑切换比粗暴地聚合中断优雅得多。在普通嵌入式平台上混合模式也很常见。比如你可以先开 DMA 完成中断处理正常情况但如果连续一秒钟内中断频率超过某个阈值就自动切换成轮询模式等数据流量降下来再切回中断。这个策略我在处理某个高速 ADC 数据采集项目时用过效果立竿见影CPU 占用率直接降了 40%。5. 实战DMA 完成通知故障的排查套路5.1 从“rk3588 eth failed to reset the dma”这类报错说起搜索热词里有一条“rk3588eth报failed to reset the dma”我猜很多人看到这个报错时一头雾水。这个报错通常发生在网卡驱动初始化或重置过程里内核在启动 DMA 引擎之前先做一次 DMA 复位但硬件没有按预期在超时时间内完成复位。核心问题虽然是 DMA 复位失败但背后往往牵连到完成通知机制——复位操作本身也是需要确认完成的。怎么排查先确认硬件和驱动版本很多 RK3588 网卡问题其实是因为设备树里 ethernet 的时钟配置不对导致 DMA 时钟没起来或者频率不对DMA 根本无法正常工作。其次确认电源域的供电状态DMA 模块断电或者处于待机状态复位操作也会超时。再有就是中断和复位共享同一套时钟域时如果中断配置错误导致驱动在 etc 状态里等不到复位完成也会报这个错。这种报错最常见的坑是“只看 error log 不去查硬件状态寄存器”。正确做法是打开内核的 devmem 工具直接读 DMA 控制器的复位状态寄存器看看是不是真的卡在某个位还是驱动读寄存器时序不对。我处理过的案例中有一半以上都是读寄存器时的地址偏移搞错了读到的是错误设备的寄存器。5.2 丢中断、卡死、数据错乱三类问题的定位方法DMA 完成通知出问题常见的表现无非三类中断丢了、系统卡死、数据错乱。丢中断的典型特征是DMA 明明配置了完成中断但 CPU 就是不进中断处理函数。这时候先用 cat /proc/interrupts 看这个中断号有没有触发计数如果完全没增长说明中断根本没到 CPU问题出在设备到中断控制器这一段。接着查中断是否被屏蔽、是否被注册成别的触发方式电平触发 vs 边沿触发。如果是共享中断还要确认注册中断时是否设置了 IRQF_SHARED否则会报“IRQ handler type mismatch”。系统卡死的典型特征是中断一直在触发但中断处理函数里有死循环或者长时间占用 CPU。可以在处理函数入口和出口加打印看是不是一进来就出不去。这个比较好查但有个隐藏很深的坑——中断处理函数里如果调用了可能导致睡眠的接口在原子上下文里会触发内核异常表现为随机性的系统卡死。数据错乱就更有意思了。DMA 完成了中断也触发了数据读出来却是乱的或者总是缺一段。优先怀疑缓存一致性问题调用 sync 函数或者换成 coherent 映射试试。其次是怀疑 DMA 描述符的链式管理出了问题完成中断触发时第二个描述符也许还没真正完成。解决办法是在中断处理里检查 DMA 控制器的“活动描述符指针”或者直接做完一轮 DMA 后重新配置下一轮确保时序对齐。5.3 排查清单速查表现象可能原因定位手段解决建议中断完全没有触发中断源没使能、触发方式不对、共享中断冲突cat /proc/interrupts 查计数检查 DMA 中断使能寄存器确认 IRQF_SHARED中断触发但处理函数不执行中断被上层屏蔽或 irq 无法唤醒 CPU打开中断号 trace 事件检查 irq affinity确认 CPU 在线状态DMA 传输完成但数据不对缓存一致性问题、内存屏障缺失读内存对照硬件状态寄存器使用 dma_alloc_coherent 或增加 sync 调用系统随机卡死中断处理里睡眠、死循环ftrace 抓函数调用栈下半部改 workqueue避免原子上下文复位 DMA 超时时钟没起、电源域未就绪、寄存器地址错误devmem 读状态寄存器对照 datasheet 检查时钟与电源配置一秒钟大量重复中断中断标志未清除打印中断处理函数出口清标志加内存屏障防止重入这张表是从实际调试中沉淀出来的基本覆盖了 DMA 完成通知问题的高频故障面。遇到类似问题时按表里的顺序逐项排除比漫无目的地改代码高效得多。6. 经验值拉满的避坑心得6.1 别在 DMA 完成回调里做重活这条我说过但值得再说一遍。DMA 完成回调尤其是直接在中断上下文里执行的那种一定不要做耗时操作。比如发网络包、读写文件、打印日志这些操作在中断上下文里轻则增加延迟重则直接系统崩溃。正确做法是在回调里快速记录“哪块数据准备好了”然后触发更底层的机制tasklet、workqueue 或软中断去慢慢处理。有个测试数据可以参考一个典型的 DMA 完成中断从硬件触发到进入处理函数大约 2 微秒到 5 微秒如果你在中断里打印一行日志可能就增加几十微秒掉几个包就再也追不上实时性要求了。我们做高速采集时连调试 printk 后都要立刻删掉否则根本跑不到理想速率。6.2 缓存一致性记得 sync 或使用 coherent 内存再强调一次缓存一致性。我见过太多人 DMA 收数据却没做一致性处理数据“看起来正常偶尔抽风”。这种随机性的 bug 最难查。如果你用的是流式 DMA 映射请务必在 CPU 读数据之前做一次 dma_sync_single_for_cpu在 CPU 写数据之后做一次 dma_sync_single_for_device。如果你用的是 coherent 映射虽然理论上不需要手动 sync但也要注意同一块内存不要同时被 CPU 和设备高频交替读写因为即使硬件保证一致总线仲裁的开销也很大。为什么会有这个问题你可以把 CPU 的 Cache 想象成一块记事板DMA 设备往内存里写数据相当于别人直接改了你办公桌上的文件但你的记事板还留着旧文件的复印件。不擦掉旧复印件你拿起文件看到的永远是旧版本。6.3 共享中断处理中容易被忽略的细节最后聊一个共享中断的细节。多个设备共用同一个中断号时中断处理函数里必须第一件事就判断这个中断是不是我这个设备触发的如果不是立刻返回 IRQ_NONE让内核继续找下一个 handler。很多新手写共享中断处理上来就直接读自己设备的 DMA 状态寄存器发现没置位就当做“无效中断”处理结果把别家设备的中断给吞了系统行为直接乱套。另外注册共享中断的时候一定要带 IRQF_SHARED 标志否则第二个设备注册同一个中断号时会被拒绝。我在某个项目中把一个编解码器和 DMA 控制器共享中断注册时忘了这茬结果驱动加载顺序一变就报错排查了半天才发现是共享标志没加。最后分享一点自己的调试习惯我调试 DMA 完成通知问题时习惯性地先确认硬件层面“完成”的信号到底有没有出来。手头有逻辑分析仪就抓中断引脚和分析仪一起看没有逻辑分析仪就在驱动里打时间戳记录“DMA 描述符完成时间”“中断处理函数入口时间”“数据处理完成时间”三个点一对比就能定位延迟花在哪段。很多时候你觉得是 DMA 通知慢其实慢在处理逻辑上而不是通知本身。这个习惯帮我淘汰过很多“伪 DMA 问题”也希望对你排查类似问题时有用。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻