DMA技术详解:工作流程、传输模式与实际应用场景
一文读懂 DMA工作流程、传输模式与典型应用场景汇总搞嵌入式或者做 Linux 驱动开发的朋友对 DMA 这个缩写应该不陌生——直接内存访问Direct Memory Access说白了就是让数据在外设和内存之间自己走不走 CPU 这道“收费站”。我第一次接触 DMA 是调一块 UART 外设波特率 921600一秒钟要收差不多 100KB 的数据用中断逐字节搬运CPU 占用率直接飙到 70%连主循环都跑不动了。换成 DMA 之后CPU 占用率掉到 5% 以内数据量翻倍也不慌。从那之后我就明白了DMA 不是“进阶技能”而是做高性能数据采集、通信、存储绕不开的“基础设施”。这篇文章我会把 DMA 的工作流程、传输模式、实际应用场景以及我这些年踩过的坑一次性讲清楚。内容包括 MCU比如 STM32、PY32F003 这种 Cortex-M 内核芯片上的 DMA 使用也会带一下现代存储和网络领域里的 DMA——比如 UFS DMA、分布式 DMA 这些更难懂的概念。不管你是刚接触单片机的学生还是已经在产品上被 DMA “疑难杂症”折磨过一两周的工程师这篇文章应该都能让你少走很多弯路。1. DMA 到底是什么核心工作流程拆解1.1 没有 DMA 的时候数据是怎么“搬”的先回到最基础的场景一个串口不断收到数据CPU 要是想把每个字节都拿下来传统做法是“轮询”或“中断”。轮询就是 CPU 反复去读状态寄存器看有没有新数据中断则是串口来一个字节CPU 就停下手里的事跑去把字节读走。这个流程听起来不复杂但问题在于数据搬运这件事本身没有任何计算含量CPU 却在为它消耗时钟周期和功耗。你想想一个 Cortex-M0 内核主频才 48MHz跑一轮中断的进出开销少说几十个周期如果在这期间频繁来数据CPU 大部分时间都花在“赶路”上了真正干正事的时间反而很少。频率越高、数据量越大这个问题越严重CPU 就成了系统的绝对瓶颈。所以 DMA 的核心设计思路就是把“搬运数据”这个简单、重复、没有智力的活从 CPU 手里剥离出来交给一个专门干这个的硬件模块。CPU 只需要在开始的时候告诉 DMA“你从哪里搬、搬到哪、搬多少”然后就可以去睡觉或者干别的了。搬完数据DMA 再发一个中断告诉 CPU“活干完了”。这就是 DMA 的整个工作模型理解了这个模型后面所有细节都好办。1.2 DMA 的工作流程配置、触发、搬运、完成一个标准 DMA 传输从启动到结束大致可以拆成四个阶段。第一阶段配置参数。你要明确“源地址”Source Address、“目标地址”Destination Address、“传输宽度”比如按字节还是按字传、“传输数量”总共传输多少个数据单元。这些参数放在 DMA 控制器的寄存器里。以 STM32 的 HAL 库为例你会填一个DMA_InitTypeDef结构体本质上就是把这些硬件寄存器值准备好。第二阶段触发源。DMA 不会自己凭空开始搬数据它必须有触发条件。有的是软件触发你写一个位就启动有的是硬件触发比如串口收到一个字节、ADC 转换完成、定时器事件到来外设会自动向 DMA 控制器发一个请求信号。触发源的选择直接影响后面“何时开始搬、搬多少”的行为很多人在这里理解偏了。第三阶段搬运。DMA 控制器拿到触发信号后会接管总线在源地址和目标地址之间建立一条数据传输通道按你配置的宽度和模式传数据。普通模式下DMA 每响应一次请求就搬一个或一组数据单元循环模式下DMA 会把传输次数计数归零一轮传完接着下一轮。第四阶段完成中断或者事件。当传输数量减到 0DMA 会置起一个中断标志。你可以选择在中断里去解析数据、切换缓冲区、启动下一轮传输等。整个过程中 CPU 只在配置阶段和收尾阶段干预中间完全解放。1.3 DMA 为什么能快它其实是把总线当“车道”了有人会问DMA 搬数据不也要经过总线吗为什么会比 CPU 搬更快答案很微妙。CPU 搬数据需要先取指令LDR/STR再通过寄存器中转一个字节往往要多个时钟周期DMA 控制器则是硬连线的状态机它知道自己该做什么不需要“取指-译码-执行”这一套流程一条总线周期就能完成一次数据搬移。换句话说DMA 是“直连”模式CPU 是“绕路”模式同样一段路程走高速的 DMA 当然更快。另外一个关键点在于并发能力。很多高端 MCU 和 SoC 内部总线架构是支持多主机Multi-Master的CPU 和 DMA 可以同时在总线上工作DMA 搬它的数据CPU 跑它的主循环或算法二者互不阻塞。这也是为什么高性能应用里数据通路几乎都让 DMA 包办的原因。2. 传输模式与关键参数从基础到进阶2.1 四种主流传输模式怎么选DMA 的传输模式市面上的 MCU 大同小异主流无非这四种普通模式Normal、循环模式Circular、乒乓模式Double-Buffer通常由软件结合多缓冲实现、以及突发/批量模式Burst。普通模式最简单配置好源地址、目标地址指定传输数量DMA 把这段数据搬完就停。适合一次性任务比如从 SPI Flash 读一页数据到 RAM或者把一串要发送的报文从内存搬到 SPI 发送寄存器。循环模式则是“搬完自动重来”传输数量会自动加载为初始值DMA 永不停歇。最适合连续数据流场景比如 ADC 连续采样、麦克风录音、UART 不固定长度的流式接收。循环模式加中断标志是很多实时性要求不高但数据量大的应用的标配。乒乓模式严格来说是软件设计模式利用了 DMA 的“完成中断”切换目标地址。第一块缓冲区在收数据第二块缓冲区的数据同时被 CPU 处理两块交替使用可以最大化吞吐。我在音频采集项目里经常用这种方案DMA 往 buf1 写写满了进中断把目标地址切到 buf2同时 CPU 处理 buf1 的数据。这样数据和计算完全流水线化CPU 永远不会等着“数据就绪”。突发模式Burst则是按块传输。DMA 会一次性在一个总线周期里抢到多轮总线控制权连续搬运若干数据单元而不是每次请求只搬一个就释放总线。这种模式适合总线带宽充裕、外设 FIFO 足够深的场景能显著减少总线切换开销但对总线仲裁和 FIFO 深度有要求。STM32 里对应的就是 DMA_Circular 配合 Burst 位设置UFS DMA 更是把 burst 发挥到了极致——它可能一次请求就搬运 64KB 甚至更大的数据块。2.2 “DMA continuous requests”是什么为什么要关掉它 by not在 STM32 的 DMA 配置里有一个选项叫DMA_CTRL_CURRENT或者更常见的“Continuous requests”连续请求。这个位专门用在“外设到内存”方向。举个例子ADC 使能了 DMA 循环模式每次 ADC 转换完成都会产生一次 DMA 请求。理论上ADC 完成一次转换DMA 搬一次数正好。但如果你开了 Continuous requestsDMA 控制器会认为外设持续不断有请求于是不等 ADC 真正转换完成就直接启动搬运搬出来的数据可能是旧的、过期的或者是 FIFO 里的残留数据。这个坑我见过不少人在电机电流采样上踩过——波形乱七八糟以为是 ADC 噪声其实是 DMA 连续请求没关掉。所以默认情况下外设触发型 DMA 不建议开 Continuous requests除非你非常清楚外设的 FIFO 深度和请求时序。对于内存到内存方向软件触发时也不需要开因为这个模式下 DMA 请求本来就由软件持续使能。这个选项是查看芯片参考手册时的必查项很多高级调试问题最后就出在一个Enable/Disable上。2.3 总线矩阵与分布式 DMA从 MCU 到 SoC 的视野扩展聊到“分布式 DMA”很多 MCU 开发者可能觉得陌生但其实你已经见过它的雏形了。STM32H7 系列就引入了“MDMA”Master DMA和“DMA”两级结构SDMMC、以太网、LTDC 这些高速外设甚至直接集成了专用 DMA。这种把 DMA 能力下放到外设模块内部的设计就是一种“分布式 DMA”思路——总线上不止一个 DMA 控制器而是每个高速外设都带自己的 DMA 引擎多条数据通道可以同时工作。在更大规模的 SoC 里比如手机主芯片、SSD 主控、网卡芯片上分布式 DMA 就更加普遍了。一个 PCIe 网卡可能内置 8 个 DMA 队列每个队列独立处理一个数据流一个 UFS 控制器里的 DMA 通道专门负责把 Flash 读出来的数据直接搬到系统内存。这样 CPU 只需要处理“元信息”真正的数据搬运全部由散布在芯片各处的 DMA 引擎并行完成。理解了这个概念再回头看 MCU 上那个小小的 DMA 外设你会发现它其实就是整个计算机体系里“数据搬运”思想的最小单元实现。3. 从 UART 到 ADC典型应用场景实操3.1 UART 接收不定长数据空闲中断 DMA 的经典方案串口接收不定长数据是 DMA 最经典、也最让新手头疼的场景。传统做法是接收一个字节进一次中断然后自己拼包遇到空闲超时判断一帧结束。这种方式在低速和少量数据下没问题但一旦波特率提高、数据频繁中断风暴会直接把系统打死。正确姿势是“DMA 搬运 空闲中断IDLE Interrupt判断”。思路是让 DMA 始终把数据从串口接收寄存器搬到内存的环形缓冲区开启接收完成中断同时开启 UART 的空闲中断。UART 在一段时间内没有收到新字节时会触发空闲中断。此时你只需要在空闲中断里去__HAL_DMA_GET_COUNTER这类函数看一下 DMA 还剩多少没搬就知道这次实际收到了多少字节。以 STM32 HAL 库为例核心代码大概是这样的// 假设 DMA 接收缓冲区为 rxBuf长度 RX_BUF_SIZE HAL_UART_Receive_DMA(huart1, rxBuf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 使能空闲中断然后在串口中断回调里判断空闲标志void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t len RX_BUF_SIZE - remain; // 本次接收长度 process_frame(rxBuf, len); } }这里有个小细节HAL_UART_Receive_DMA的Size参数是一次 DMA 搬运的总量搬满之后 DMA 会停除非开循环模式。所以如果你要长期接收不定长数据一般建议开启 DMA 循环模式这样__HAL_DMA_GET_COUNTER可以始终反映环形缓冲区的写入位置你只需要维护自己的读指针。这也是我在做 Modbus 协议栈比如 freemodbus 移植到 DMA 上时推荐的做法——接收帧时用 DMA 循环 IDLE 中断一次空闲中断就能拿到完整的一帧比逐字节超时判断干净太多。PY32F003 这类国产 Cortex-M0 芯片原理一模一样。但你得注意有些低端 MCU 的 DMA 控制器没有“字节数剩余寄存器”或者 DMA 中断标志需要手动清这些都可能导致你移植 STM32 代码时踩坑。我的建议是拿到一个不熟悉的芯片先翻 DMA 章节和数据手册确认这几个关键能力是否有循环模式、是否能查询剩余计数、是否能与 UART IDLE 组合使用。都具备的话实现方案基本可以照搬。3.2 STM32 HAL 库 ADC 单通道 DMA 多次采样连续采集的稳定姿势ADC 测电压、测电流如果只采集一次然后读寄存器精度和抗干扰能力都很差。行业里正常的做法是“过采样”也就是在短时间内连续采集多次取平均或者滤波。手动在 while 循环里反复启动 ADC、等转换完成、读结果CPU 会因此忙于等待。用 DMA 才是正解。场景配置如下ADC 开启连续转换模式连续扫描DMA 配置为循环模式目标缓冲区是一个数组adc_buf[64]每次 DMA 搬运一个通道的转换结果。这样 ADC 每完成一次转换DMA 就把结果搬到数组里数组满了自动从头开始覆盖。CPU 需要计算时直接读这个数组做平均即可完全不参与采集过程。HAL 库的典型配置片段ADC_HandleTypeDef hadc; DMA_HandleTypeDef hdma_adc; uint16_t adc_buf[64]; HAL_ADC_Start_DMA(hadc, (uint32_t*)adc_buf, 64); // 在主循环里取数据滤波 void process_adc(void) { uint32_t sum 0; for (int i 0; i 64; i) { sum adc_buf[i]; } uint16_t avg sum / 64; // avg 就是平滑后的 ADC 采样值 }注意几个关键点。第一DMA 目标数组元素类型必须和 ADC 分辨率匹配12 位 ADC 结果寄存器是 16 位那么数组就应该是uint16_t不能用uint8_t。第二DMA 驱动 ADC 时必须设置外设地址自增Peripheral Increment为关闭内存地址自增Memory Increment为开启否则所有数据都会写到同一个地址。第三如果 ADC 是多通道或者注入通道DMA 的缓冲区布局就变得复杂每个通道的结果会按序列顺序进入缓冲区你需要自己索引。还有一个经验过采样之后如果发现读数偏低或偏高先检查 DMA 传输的数据宽度。常见错误是把DMA_PDATAALIGN_BYTE和DMA_MDATAALIGN_HALFWORD混着设导致高低字节颠倒。这类问题不查寄存器光看现象很容易误判成传感器故障。3.3 UFS DMA 与存储控制器中的 DMA和 MCU 有什么联系UFSUniversal Flash Storage是手机上常见的闪存标准它内部也有 DMA 引擎。很多人从 MCU 转向做存储驱动或者嵌入式 Linux 的时候会觉得 UFS DMA 是另一个世界的技术其实本质一模一样主机 CPU 下发一条“读数据”命令UFS 控制器里的 DMA 负责把数据从 Flash 介质读到内存缓冲区传完之后再发一个完成中断。区别只在于UFS 的 DMA 要处理“硬件队列”支持多条命令排队执行地址映射更复杂往往涉及 IOMMU传输量大通常按块、按页为单位搬运而不是像 MCU 那样按字节。但要提醒的是UFS DMA 和 MCU DMA 有一个很大的思维差异地址转换。MCU 里 DMA 直接拿物理地址访问 SRAM而 Linux 系统的内存是虚拟内存驱动的 DMA 缓冲区需要做“DMA 映射”dma_map_single / dma_alloc_coherent。如果你在调试 UFS 驱动时发现 DMA 传输失败八成不是因为 UFS 本身而是因为 DMA 缓冲区没有正确对齐、没有 cache 一致性处理。这里要特别提到 cache 问题——DMA 写入内存后CPU 可能因为 Cache 还没失效而读到旧数据反过来 CPU 写完缓存后DMA 也可能把旧数据搬到外设去。解决方法是维护好 cache 的 clean/invalidate或者干脆用dma_alloc_coherent分配“一致性内存”。这些是 MCU 开发者转入 Linux 驱动开发时最容易踩的“隐形大坑”。3.4 DMA 测速软件与性能评估别凭感觉要实测网络上常有人搜“DMA 测速软件”其实很多场景指的不是某个专门软件而是通过代码测试 DMA 的数据吞吐量。比如在一段循环里反复触发内存到内存的 DMA 搬运测量搬完一定字节数的时间或者在 UART 收发场景里用 DMA 发送一包数据在逻辑分析仪上测量从触发到完成的耗时。对比同一个操作用中断和用 DMA 执行的时间差你就能直观体会到 DMA 的价值。我在项目里试过一个很简单的测速方法DMA 从一块 64KB 的源缓冲区搬到目标缓冲区在 DMA 搬运开始前翻转一个 GPIO搬运完成中断里再翻转回来用示波器直接测高电平时间。这种方式比软件计时更直观既能看到耗时还能观察中断延迟。实测在 72MHz 的 STM32F103 上DMA 搬 64KB 数据大约需要几百微秒级别而 CPU 用 memcpy 逐字节搬可能要 10ms 以上性能差距一目了然。当然测速不只是为了炫数字更重要的是验证你的配置是否正确。比如你发现 DMA 搬运时间比理论值大很多那就可能是总线冲突、Cache 未命中、DMA 优先级设置过低等原因。测速就是帮你定位这些硬件级性能瓶颈的起点。4. DMA 疑难杂症排查与调试心得4.1 串口 DMA 发送到底要不要等上一轮发完“DMA 串口发送需要等待上一轮数据发送完吗”这是我被问过最多的问题也是新手最纠结的问题。直接说结论大多数情况下需要等上一轮发送完成至少在修改下次要发送的数据缓冲区之前必须确保 DMA 已经把数据全部搬到了发送寄存器链上。如果你在 DMA 传输进行中直接修改发送缓冲区DMA 可能搬一半就搬到你改过的数据了结果就是一帧数据被“混血”前一半是旧数据后一半是新数据。更准确的做法是发送前查询 DMA 的“传输完成标志”Transfer Complete确认上一轮已经结束再调用下一次发送。或者使用 DMA 发送完成中断在中断里置一个标志位发送函数里轮询这个标志。HAL 库用户还可以用 HAL 提供的“锁”机制来实现排队发送但本质上都是“等上一轮发完”的思路。void uart_dma_send(uint8_t *data, uint16_t len) { // 等待上一次 DMA 发送完成超时保护很重要 while (!dma_tx_done_flag) { if (timeout TIMEOUT_MAX) break; } dma_tx_done_flag 0; memcpy(txBuf, data, len); HAL_UART_Transmit_DMA(huart1, txBuf, len); // 使用独立的 DMA TX 缓冲区 } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { dma_tx_done_flag 1; }这里的细节是发送缓冲区必须单独预留一份拷贝不能直接把应用层的数据指针交给 DMA 然后立刻返回。因为应用层的数据可能在 DMA 还没搬完时就被释放或修改了。很多线上 bug 就是这么来的表面看是 DMA 发送混乱实际是你动了 DMA 正在读的内存。4.2 常见故障速查DMA 疑难杂症排查表我在做 DMA 相关项目时整理过一张问题排查表几乎能覆盖九成以上的疑难杂症。一并列出来供参考现象可能原因排查方向DMA 只搬一次就停普通模式传输计数减到0没有重新启动改用循环模式或在完成中断里重启 DMA数据顺序错乱源/目标地址自增配置反了检查外设地址自增和内存地址自增外设方向通常不自增接收数据丢字节UART 溢出错误或 DMA 缓冲区太小检查ORE/FE错误标志增大缓冲区或改循环模式数据全是 0 或 0xFFDMA 配置成功但没真正触发检查触发源是否使能外设 DMA 请求是否打开数据高低字节对调数据宽度设置与寄存器宽度不匹配__HAL_DMA_GET_COUNTER对应宽度确认 8/16/32 位设置中断风暴频繁卡顿DMA 完成中断触发过于频繁或未正确清除标志确认中断回调里是否清除了 DMA 标志必要时用半传输中断DMA 搬运到一半 CPU 挂死总线冲突或 DMA 优先级设置不当调低 DMA 优先级或检查是否有总线占用导致死锁从操作系统里 DMA 失败缓冲区物理地址不连续/未做一致性映射改用dma_alloc_coherent或保证缓冲区物理连续这张表不是万能药但每次遇到“看起来匪夷所思”的 DMA 问题我都会先过一遍这张表再动手查寄存器。不少问题其实不是 DMA 本身而是初始化顺序、时钟使能、中断优先级这些小地方。4.3 调试 DMA 的几条实用经验调试 DMA 和调试普通软件不一样你不能单步跟踪一暂停外设数据就断了所以要学会用硬件手段“看穿”它。最推荐的是加 GPIO 脉冲打点法在 DMA 搬运开始前置高一个 GPIO完成中断里置低。这样示波器上就能看到 DMA 占用的总线时间也能确认中断是否按时触发。再就是善用__HAL_DMA_GET_COUNTER这类“读取剩余传输计数”的接口。很多时候你不需要用调试器暂停在空闲中断里读一次计数就知道 DMA 到底搬了多少数据从而判断是漏数据还是多数据。这个函数是调试 DMA 接收时的“第一利器”。我还想强调一个思路任何 DMA 驱动的首次调试都应该用“纯软件触发 固定数据模式”来做最小验证。不要一上来就连外设否则一旦出错你分不清是外设的问题还是 DMA 的问题。我一般的做法是配置好 DMA 从一段固定的const数组搬到另一段volatile数组软件触发一次然后比较两段数据是否一致。这一步通过后再挂接外设触发源。这样层层递进能大幅度缩短排查时间。4.4 关于 FreeModbus 与 DMA 的组合建议FreeModbus 是一个轻量级 Modbus 协议栈经常被移植到 MCU 上。它的默认串口驱动是中断逐字节收发但实际工业环境里波特率经常到 115200 甚至更高逐字节中断访问的负担不小所以很多人想把它换成 DMA。我的经验是DMA 接收 空闲中断是可行的但 FreeModbus 的帧间隔判断原本用的是接收超时定时器t35如果你用 DMA 空闲中断需要把它映射到帧结束检测上。更稳妥的方案是仍然保留定时器超时判断DMA 只负责把数据搬进环形缓冲区协议栈的vMBPortSerRx从缓冲区里取数据。这样改动最小也不容易破坏协议栈原有的状态机。还要注意Modbus RTU 是半双工通信发送和接收不能同时进行所以 DMA 的 TX 和 RX 通道要严格按照“发送时关闭接收中断接收时关闭发送请求”来切换。否则可能出现回音干扰把一帧完整的数据搅得乱七八糟。这类半双工协议 DMA 的坑比单纯做 UART 透明收发要隐蔽得多调试时多准备一台逻辑分析仪会省很多时间。结尾DMA 值得多花时间我个人的体会是DMA 是少数“一旦用对就再也回不去”的硬件能力。它不复杂核心思想就是一句话——“CPU 不是用来搬数据的”但真正用好它需要你对总线、缓存、外设触发源、中断优先级这些周边概念都有感觉。这些东西教科书上不会串起来讲只有亲手调过 UART DMA 丢帧、改过 ADC 过采样缓冲区、查过驱动里 cache 不一致的问题才会真正记住。如果你现在正被某个 DMA 问题卡住建议先把外设和 DMA 分开验证确认触发源和地址配置都对再一次加一个变量去查原因。别急着改代码很多时候问题出在你没看数据手册里某个默认值的注释上。希望这篇总结能帮你把 DMA 从“玄学”变成“工具箱里的常规武器”。