1. DMA 到底是什么:一次讲清楚它的核心逻辑
很多做嵌入式开发的朋友,第一次接触 DMA 是在用 STM32 的时候。串口接收、ADC 采样、SPI 刷屏,只要数据量一大,CPU 就被中断淹没了。后来有人告诉你“用 DMA 吧”,你一查手册,看到一堆 Direct Memory Access、通道、请求映射、循环模式这些词,头就大了。
我先把 DMA 的本质用一句话说清楚:DMA 是一条让外设和内存之间直接搬运数据的专用通道,搬运过程中 CPU 不需要逐字节参与,只需要在开始和结束时被通知一声。
打个比方,你开了一家小餐馆,CPU 就是老板。传统模式下,每一桌客人点菜,老板都要亲自去后厨下单、端菜、结账,客人一多老板就忙不过来。DMA 相当于你请了一个专门的传菜员,客人点完菜之后,老板只需要告诉传菜员“把这几桌的菜从后厨端到对应桌子”,传菜员自己一趟一趟跑,老板可以继续干别的事,等传菜员全部端完了再跟老板说一声“搞定了”。
这个比喻基本能覆盖 DMA 的核心工作方式,接下来我会从原理到应用,把 DMA 的完整链路拆开讲清楚。
1.1 DMA 为什么需要存在:CPU 的效率困境
先看一个很常见的场景。你用 STM32 的串口接收一批数据,假设一次收 1KB。如果用传统的中断接收方式,每收到一个字节,串口外设就会触发一次中断,CPU 要停下来保存现场、跳到中断服务函数、把数据从数据寄存器搬到内存数组、再恢复现场。1KB 数据就是 1024 次中断,CPU 大量时间都耗在进出中断的过程中。
实际上,串口波特率还不算高,如果是 ADC 连续采样、SD 卡读写、以太网接收这种高速场景,CPU 被占用的比例会非常可怕。算一笔简单的账:假设系统主频 168MHz,一次完整的中断进出需要大约 40 个时钟周期(这已经是比较乐观的估计了),那么 1024 次中断光中断开销就是 40960 个周期,这还不算中断服务函数里实际搬数据的时间。如果把 CPU 时间去干 PID 算法、显示刷新、按键扫描,效率会高得多。
DMA 就是为了解决这个效率困境而生的。它把“搬运数据”这个重复劳动从 CPU 手里接管过来,CPU 只需要做两件事:一是配置 DMA 传输的参数,二是等 DMA 传输完成后处理数据。
1.2 DMA 的核心组成:从硬件视角看传输链路
要真正理解 DMA,不能只看软件层面的函数调用,得知道硬件上都有哪些部件在协作。
一个完整的 DMA 传输链路,通常包含这几部分:
- DMA 控制器:这是 DMA 的“大脑”,负责管理所有 DMA 通道的传输请求、优先级仲裁、传输状态。在 STM32 上它一般是独立的外设,挂在总线矩阵上。
- DMA 通道:每条通道对应一个或多个外设请求源,可以理解为一条独立的“传送带”。不同通道可以配置不同的传输方向、数据宽度、优先级。
- 外设请求源:串口、ADC、定时器、I2C、SPI 这些外设在特定事件发生时,会向 DMA 控制器发出传输请求。比如串口收到一个字节,就会产生一个“接收数据请求”。
- 存储器地址和外设地址:DMA 搬运数据的两个端点。一个通常是外设的数据寄存器地址(比如 USART_DR),另一个是内存地址(比如数组的首地址)。
- 传输计数器:记录还需要传输多少个数据单元,每传完一个就减一,减到零就表示传输完成。
- 总线仲裁器:当多个 DMA 通道同时请求传输时,由仲裁器决定谁先谁后。
这里要特别强调一个点:很多人写代码的时候只关心调用了哪个 DMA 函数,却忽略了外设侧的配置。实际上 DMA 能不能正常工作,一半取决于 DMA 控制器的配置,另一半取决于外设是否开启了 DMA 请求功能。比如你要用串口 DMA 接收,必须在串口初始化时使能 USART 的接收 DMA 请求,光配置 DMA 控制器是没用的。
2. DMA 的工作流程:从触发到完成的完整生命周期
DMA 的工作流程看起来只有“配置-启动-完成”三步,但每一步里面都有不少细节。我按照实际开发中会经历的完整流程来拆解。
2.1 DMA 工作流程的六个阶段
一个标准的一次性 DMA 传输(非循环模式),完整生命周期是这样的:
- 外设事件触发请求:外设产生了某个事件,比如串口收到了一字节数据、ADC 完成了一次转换、定时器更新事件发生。这个事件会转化为 DMA 请求信号,发送给 DMA 控制器的对应通道。
- DMA 控制器仲裁:如果同时有多个通道请求传输,DMA 控制器会根据通道优先级进行仲裁。优先级高的先响应,同级的话通道号小的优先(不同芯片实现略有差异)。
- 源地址读取:DMA 控制器从源地址读取一个数据单元。源地址可能是外设数据寄存器,也可能是内存数组。
- 目的地址写入:DMA 控制器把读到的数据写入目的地址。目的地址可能是内存数组,也可能是外设数据寄存器。
- 地址更新与计数递减:根据配置,DMA 会自动决定源地址和目的地址是递增、递减还是固定不变,同时传输计数器减一。
- 传输完成判断:如果计数器减到零,DMA 产生传输完成中断(如果使能了中断),并自动禁用该通道。如果是循环模式,计数器重载,继续下一轮传输。
从软件角度看,这个流程只需要三个步骤:初始化 DMA 通道参数、使能外设的 DMA 请求、启动 DMA 传输。后续的数据搬运全部由硬件完成。
2.2 DMA 传输方向:为什么说“方向”是个坑
DMA 支持三种传输方向,但在不同芯片、不同外设上,这三种方向的配置方式差异很大,这也是新手最容易踩坑的地方。
- 外设到内存:典型场景是串口接收、ADC 采样、I2C 接收。外设数据寄存器是源,数组是目的。
- 内存到外设:典型场景是串口发送、DAC 波形输出、SPI 发送。数组是源,外设数据寄存器是目的。
- 内存到内存:典型场景是内存拷贝。两个内存地址互传,常用于在 RAM 内部搬数据。
这里有个大坑:内存到内存的 DMA 传输,不是所有外设请求源都不需要。在 STM32 上,内存到内存模式是软件触发的,不需要外设请求,配置好之后直接启动 DMA 通道就行。但在某些芯片上,内存到内存模式需要选择一个外设请求源来触发,如果你没搞清楚就照抄别人的代码,很可能会发现 DMA 根本启动不了。
还有一个更隐蔽的问题:外设到内存方向,外设地址通常是固定不变的(因为数据寄存器地址就一个),但内存地址需要自动递增。而在内存到外设方向,内存地址递增,外设地址固定。如果你的地址递增模式配置反了,数据会全部写到同一个位置,最后一次写入覆盖前面的数据。
2.3 DMA 传输模式:单次、循环、乒乓与离散传输
DMA 的传输模式直接决定了你的代码架构,我逐个说一下。
单次传输模式(Normal Mode):传输计数器从初始值减到零之后,DMA 通道自动禁用,需要软件重新设置计数器并启动才能再次传输。这种模式适合一次性的数据搬移,比如开机把一段数据从 Flash 拷到 RAM。
循环传输模式(Circular Mode):传输计数器减到零后自动重载初始值,DMA 通道始终处于使能状态。这种模式特别适合 ADC 连续采样、串口不定长接收。数据会不断写入环形缓冲区,你可以通过 DMA 的中断回调或者轮询 DMA 当前计数值来判断新数据来了多少。
乒乓传输模式(Ping-Pong Mode):这是循环模式的一种变体,使用两块缓冲区交替接收数据。当 DMA 写满缓冲区 A 时,自动切换到缓冲区 B 写入,同时通知 CPU 处理缓冲区 A 的数据;写满 B 后又切回 A。这样 CPU 处理数据的时间和 DMA 采集数据的时间完全重叠,不会出现数据覆盖的问题。在高速 ADC 采集、音频处理场景中非常实用。
离散式传输 / Scatter-Gather(SG)模式:这是一种更高级的 DMA 模式。标准 DMA 传输要求源地址和目的地址分别是连续的内存块,而 Scatter-Gather 模式允许你定义一组“传输描述符”,每个描述符指定一个小的传输任务(源地址、目的地址、传输长度),DMA 自动按顺序执行这一组描述符。这样做的好处是不需要先做内存拷贝把分散的数据集中到连续缓冲区,直接按描述符搬运即可。Linux 下很多驱动程序就是依赖硬件的 Scatter-Gather 能力来减少内存拷贝开销的。
在实际项目里,串口 DMA 接收通常会配合空闲中断和环形缓冲区使用。数据不是按固定长度到达的,DMA 循环模式把数据不停写入缓冲区,串口空闲中断告诉你“这一帧数据接收完了”,你再从缓冲区里把这一帧数据提取出来处理。
3. DMA 的传输模式与关键参数配置实战
这一部分我以实际开发中最常见的场景为例,带着大家一步步配置 DMA。选型上以 STM32 为主(因为用的人最多),但原理是通用的,其他芯片完全可以对照理解。
3.1 传输的参数到底该怎么定:数据宽度、地址递增与突发传输
配置 DMA 时,有几个参数是必须明确的,我先逐个讲清楚。
数据宽度(Data Width):DMA 每次搬运一个数据单元的大小,可选字节(8 bit)、半字(16 bit)、字(32 bit)。这个参数必须和源/目的的数据宽度匹配。比如 ADC 的 12 位采样结果,存储在 16 位寄存器里,那数据宽度就选半字。串口发送的数据是字节流,就选字节。如果你把数据宽度配错了,搬出来的数据要么被截断,要么多了垃圾数据。
这里有一个需要特别注意的点:外设数据宽度和内存数据宽度不一致时,能否转换?答案是大部分 DMA 控制器支持外设宽度与内存宽度不同。比如外设是 8 位的寄存器,内存是 32 位的数组,DMA 可以把 4 个字节拼成一个字写入内存。但这种模式使用时要非常小心,不同芯片的行为不完全一致,建议没有特殊需求就保持一致。
地址递增(Address Increment):控制 DMA 每次传输后源地址和目的地址是否自动加一。原则很简单:如果数据是连续存储的,就递增;如果源或目的固定在一个寄存器上,就不递增。最常见的是外设到内存方向:外设寄存器固定,内存地址递增。
突发传输(Burst Transfer):DMA 每次仲裁获得总线后,连续传输多少个数据单元再释放总线。支持 1、4、8、16 等不同突发长度。突发传输可以提高总线利用率,因为减少了仲裁次数。但要注意,突发传输通常要求源/目的地址按突发长度对齐,如果你的缓冲区地址没有对齐,可能触发 DMA 传输错误(DMA Transfer Error)。
优先级(Priority):同一 DMA 控制器下多个通道同时请求时的响应顺序。合理设置优先级很重要。比如 ADC 连续采样用于电流环控制,那这个通道就应该给最高优先级,否则在突发高负载下采样间隔抖动会影响控制性能。
3.2 一次完整的串口 DMA 收发配置过程
我以 STM32 + HAL 库为例,演示一个完整的串口 DMA 收发配置过程。这个例子已经过实际验证,大家可以作为参考。
第一步,在 CubeMX 里启用串口的 DMA 请求。找到 USART 的 DMASettings,添加 TX 和 RX 两个 DMA 通道。TX 方向是 MemoryToPeripheral,RX 方向是 PeripheralToMemory。两个通道的模式都选 Normal,优先级根据需求设置。
第二步,在代码初始化中启动 DMA 接收:
uint8_t rx_buffer[256]; uint8_t tx_buffer[128]; // 启动 DMA 接收,接收 256 字节后触发完成回调 HAL_UART_Receive_DMA(&huart1, rx_buffer, 256);第三步,发送数据时也走 DMA:
HAL_UART_Transmit_DMA(&huart1, tx_buffer, len);这样配置完成后,CPU 不再被串口中断打扰。接收端收满 256 字节后,HAL 库会调用HAL_UART_RxCpltCallback,你在这个回调里处理数据即可。
但实际项目中很少有恰好接收固定长度数据的场景。更实用的是结合串口空闲中断来做不定长接收。原理是:DMA 循环接收写缓冲区,串口空闲中断表示总线上一段时间没有新数据,即一帧数据结束了,此时可以从缓冲区提取数据。
CubeMX 开启串口空闲中断的方法是:在 NVIC 设置中使能 USART global interrupt,然后在代码中先调用HAL_UART_Receive_DMA,再调用__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)开启空闲中断。在串口中断回调里检测空闲标志:
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart) { // 其他处理... if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart); // 计算当前 DMA 接收了多少新数据,进行处理 } }这里有个细节:空闲中断不是 HAL 库默认帮你处理好的,你需要在串口中断服务函数里自己判断和清除标志位。具体做法一般是在HAL_UART_IRQHandler之后追加自己的判断逻辑。
3.3 基于 DMA 计数值计算接收数据长度
在循环接收模式下,怎么知道 DMA 已经往缓冲区里写了多少新数据?这需要读取 DMA 控制器的当前计数值。HAL 库提供了获取剩余未传输计数的函数。
我用一个实际场景说明:DMA 缓冲区大小 256 字节,循环模式,已经接收了 100 字节,此时 DMA 的计数器值应该是 256 - 100 = 156。等到接收了 200 字节,计数器值是 256 - 200 = 56。注意,如果已经接收了 300 字节,计数器会从 256 重新开始递减,当前值是 256 - (300 - 256) = 212。
所以在代码中,计算当前已接收数据量的公式是:
uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t received = rx_buffer_size - remain;但要正确处理环形缓冲区的边界,不能让新旧数据互相覆盖。一般做法是维护一个 last_pos 变量记录上一次处理的位置,根据最新 received 和 last_pos 之间的关系计算新数据长度,然后更新 last_pos。
AC 实际项目中,如果处理不及时导致 DMA 缓冲区写满,新的数据会覆盖旧数据,因此缓冲区大小和处理速度必须匹配。这种问题在高速连续传输场景下尤其突出。
3.4 DMA 的中断管理:完成中断与错误中断
DMA 传输完成会产生完成中断,传输错误会产生错误中断。在 HAL 库中,完成中断对应HAL_DMA_TC_Callback或外设的 RxCplt 回调,错误中断对应HAL_DMA_ErrorCallback。
很多初学者只关注完成中断,忽略了错误中断。实际使用中,错误中断常常能帮你快速定位问题。比如地址未对齐导致的总线错误、非法地址访问、传输被异常中止等,都会触发错误中断。建议在开发阶段把错误回调函数打印出当前的 DMA 状态寄存器和错误标志,定位问题会快得多。
另外要特别注意中断服务函数的耗时。DMA 完成中断虽然比逐字节中断频率低,但在高采样率下触发频率依然可能很高。比如 1MHz 采样率下,每次采样触发一次完成回调,那主循环就别想干别的了。这种情况下考虑启用乒乓模式或者用循环模式批量处理数据。
4. 典型应用场景拆解:从 STM32 到高性能处理器的实际用法
DMA 的应用范围非常广,从单片机到应用处理器,从简单的串口收发到复杂的多通道采集系统。我挑几个典型场景细致拆解。
4.1 串口 DMA:最基础也最常用的场景
串口 DMA 是目前嵌入式开发中使用最多的 DMA 场景,核心价值在于解决两个问题:一是发送长数据时阻塞 CPU,二是接收频繁数据时中断太密。
串口 DMA 发送:直接调用HAL_UART_Transmit_DMA即可。需要特别注意:DMA 发送是异步的,如果你发完立即改变缓冲区内容,DMA 可能还没搬完。所以要么等传输完成回调后释放缓冲区,要么用互斥机制保护。另外,连续调用两次 DMA 发送时,需要等上一次传输完成,否则会返回 HAL_BUSY。
有朋友问“串口 DMA 发送需要等待上一轮数据发送完吗”,我的答案是:如果发送内容无关紧要、可以丢弃,那可以不等待,直接重新配置缓冲区发送。但不能直接复用同一个缓冲区,否则数据会被新内容覆盖,出现乱码。更稳妥的做法是维护一个发送队列,把待发送的数据丢进队列,DMA 发送完成后再从队列取下一段。
串口 DMA 接收:固定长度和不定长两种。固定长度用HAL_UART_Receive_DMA收满触发回调;不定长则配合空闲中断,前面已经讲过原理。这里再补充一个常见问题:接收过程中如果发生了错误(比如帧错误、噪声错误),DMA 会停止接收。HAL 库在错误回调里会调用HAL_UART_DMAStop,之后你需要重新启动 DMA 接收。很多项目出现“串口收了一会儿就再也不收了”的问题,就是因为没有处理这个错误恢复流程。
4.2 ADC 多通道采集 DMA:数据如何保持通道对应关系
ADC 多通道使用 DMA 是另一个高频场景。用 STM32CubeMX 配置 ADC 多通道 DMA 采集时,最常见的问题是:通道数据顺序乱了。
数据乱掉的本质原因是:ADC 的“注入通道”和“规则通道”转换顺序不同,多通道规则转换时是轮流转换各个通道,DMA 按照转换完成顺序把结果写入缓冲区。如果你的通道扫描顺序和 DMA 缓冲区索引的对应关系没弄清楚,拿到数据后就会张冠李戴。
举个例子,你配置了 ADC 通道 0、1、2、3,扫描顺序就是 0→1→2→3。DMA 写入缓冲区的顺序就是:第一次转换结果对应通道 0,第二次对应通道 1,以此类推。这个顺序是固定的,你要做的就是把缓冲区索引和通道号对应起来。
但问题往往出在“时序”上。如果 ADC 各通道的采样时间设置不当,多通道扫描一次的总时间可能超过你的预期。DMA 在循环模式下的写入是和转换同步进行的,只要 DMA 缓冲区大小恰好等于通道数,就不会乱。
实际使用中另一个大坑是:ADC 数据寄存器的数据宽度与 DMA 配置不匹配。STM32 很多 ADC 的转换结果是 12 位或 16 位,存储在 32 位的数据寄存器里。如果你在 CubeMX 里配置 DMA 数据宽度为 Byte,问题就来了:每次 DMA 传输只搬低 8 位,数据自然不对。正确做法是根据具体芯片来选择(STM32F4 一般选择 Half Word 或 Word),这个细节如果不对,采集到的数据完全没法用。
以 GD32E230 为例,网上很多人反映 ADC + DMA 数据紊乱,我排查过类似问题,常见原因包括:DMA 通道选择错误(GD32 的 DMA 请求映射和 STM32 不完全一样)、ADC 校准未完成就启动转换、DMA 传输完成中断标志未及时清除。排查这类问题,建议先关掉 DMA 中断,只观察缓冲区数据,再逐步开启功能,缩小问题范围。
4.3 PWM + DMA:用 DMA 生成任意波形
PWM DMA 的核心思路是:定时器利用 DMA 来更新比较寄存器,从而在 PWM 输出引脚上产生一个任意形状的波形,这个过程中 CPU 不需要每周期都去操作寄存器。
实现方法大概是:定时器产生更新事件,触发 DMA 把内存里的波形表搬到定时器的比较寄存器。每个 PWM 周期,DMA 自动把下一个波形值写入寄存器,输出引脚就跟着波形表变化。通过调整波形表的内容,你可以输出正弦波、三角波、任意自定义波形,不需要软件实时计算。
这个方案适合 LED 呼吸灯效果、音频播放(通过 PWM 输出模拟音频)、步进电机的复杂控制波形等场景。
需要注意的点:波形表的更新频率决定了输出波形的频率和精度。如果定时器频率是 1MHz,波形表有 100 个点,那输出波形频率就是 10kHz。波形表放在内存里,DMA 配置为循环模式,每次定时器更新事件到来就搬一个数。内存到外设方向,外设地址固定为比较寄存器,内存地址递增。
还有一个技巧:如果需要更高分辨率的输出,可以采用 PWM 硬件定时器 + DMA 的“有限状态机”思路,让 DMA 同时搬运多路波形数据到多个通道的比较寄存器,实现多路波形同步输出。
4.4 I2C、SPI、UFS 等高速外设的 DMA 传输
串口之外,I2C、SPI 在高速通信中同样离不开 DMA。
SPI DMA:SPI 是主从同步通信,主设备提供时钟,同时收发数据。使用 DMA 可以显著减少 CPU 干预。常见场景是 SPI Flash 读写、LCD 屏幕刷新。大屏刷屏时,一帧图片数据量可到几百 KB,用传统方式逐字节发送会卡到怀疑人生,DMA 发送则非常轻松。
SPI DMA 要注意的细节:一是 SPI 的收发通常是并行的,即读操作也需要发送数据来产生时钟。DMA 读的时候需要同时配置发送 DMA 和接收 DMA,发送端发送一个无意义字节(比如 0x00 或 0xFF)来产生时钟,接收端接收真正的数据。二是 SPI 的 NSS 片选控制通常还是需要 CPU 参与,DMA 传输前后要手动拉低拉高片选。
I2C DMA:I2C 传输有地址阶段、控制阶段、数据阶段,DMA 通常只负责数据阶段,地址和控制部分仍需 CPU 配置。因此 I2C DMA 的逻辑比 SPI DMA 复杂。实际使用中,I2C 速率最高也就几 Mbps,除非数据量很大,否则 DMA 的价值相对没那么显著。但如果你的 I2C 挂了多个传感器,需要连续读取大量数据,DMA 还是能省不少 CPU 时间。
UFS DMA:UFS(Universal Flash Storage)是手机等领域常用的存储标准,它内部自带 DMA 引擎,用于在存储芯片内部搬运数据。这和我们在 MCU 上说的 DMA 略有区别,但核心思想一致:减少 CPU 介入,提高数据传输效率。UFS 的多队列、命令处理等机制,其实也是围绕 DMA 传输来设计的。
4.5 AXI 总线下的 DMA 传输:高性能处理的必经之路
在更高性能的嵌入式处理器(比如 Xilinx Zynq、RK3588、树莓派 CM4 这类 ARM + FPGA 或 ARM SoC 平台)上,DMA 通常会挂载在 AXI 总线上,比如 AXI DMA、AXI VDMA。
AXI DMA 与 MCU 上的 DMA 核心区别在于:AXI DMA 支持更大的数据宽度(64 位甚至 128 位)、更高的传输带宽,并且通常支持 Scatter-Gather 模式。FPGA 逻辑可以直接通过 AXI DMA 与 ARM 核共享大块内存,数据的流向可以是 FPGA 内部逻辑 → AXI DMA → DDR,或者反过来。
在使用这类平台时,常会遇到类似“RK3588 以太网报 failed to reset the DMA”这样的错误。这种问题往往不是 DMA 本身损坏,而是驱动初始化顺序、时钟未开启或 DMA 控制器处于忙状态未被复位导致。排查思路一般是:确认 DMA 控制器的时钟和复位信号是否正常、驱动是否与硬件版本匹配、DMA 描述符是否存在异常。注意不要上来就怀疑硬件坏了,先看日志里的上下文。
另外一个关键差异:MCU 上的 DMA 一般由外设直接触发,而 SoC 上的 DMA 往往是通过描述符驱动的,软件准备好描述符、写 DMA 控制器的描述符地址、然后使能 DMA,硬件自动从内存中加载描述符并执行,因此描述符的地址对齐要求、内存分配方式(一致/非一致)非常重要。分配不一致内存时注意 Cache 一致性问题,否则 DMA 读到的数据可能是缓存里的旧数据。
4.6 分布式 DMA 与多核场景
“分布式 DMA”这个词在工业和网络领域也经常出现,通常指的是多个 DMA 引擎分布在不同的总线段或不同的处理器核心附近,各自负责本地的数据传输,降低跨总线传输的竞争。
在 AMP(非对称多处理)或者 SMP(对称多处理)架构下,每个核可能有自己的 DMA 资源。分布式 DMA 的好处是:多个 DMA 引擎可以并行搬运不同方向的数据,不会都挤在同一条总线上。比如一个核的串口 DMA 不影响另一个核的 SD 卡 DMA,数据传输的并发度更高。
对我们开发者来说,分布式 DMA 意味着在用多核芯片时,需要特别留意 DMA 请求归属和中断路由的配置,确认是哪个核负责处理这个 DMA 的中断和错误。
5. 常见报错与问题排查:我在实际项目中踩过的坑
这一节把我在各类 DMA 调试中遇到的典型问题汇总一下,这些问题在论坛上也经常被讨论,做个系统性总结。
5.1 问题速查表
我把常见的 DMA 故障按“现象-可能原因-排查方法”整理成表格,方便大家快速定位。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| DMA 传输不启动 | 外设 DMA 请求未使能 | 检查外设寄存器是否开启对应 DMA 请求 |
| DMA 通道未正确映射到外设 | 对照芯片参考手册检查 DMA 通道映射表 | |
| 内存地址未对齐 | 按数据宽度对齐缓冲区地址 | |
| 数据全部相同 | 地址递增未配置 | 检查内存/外设地址递增开关 |
| 数据宽度配置错误 | 检查源和目的的数据宽度 | |
| 数据错位/紊乱 | ADC 通道顺序和缓冲区索引不匹配 | 检查 ADC 扫描顺序与 DMA 缓冲区对应关系 |
| DMA 优先级配置不当导致数据丢失 | 提高该通道优先级 | |
| 接收一段时间后停止 | DMA 错误中断未处理 | 使能错误中断并打印状态寄存器 |
| 外设错误(如帧错误、噪声错误) | 处理表达错误标志并重新启动 DMA | |
| 指定长度接收不触发回调 | 传输计数器设置错误 | 检查传给 DMA 的长度值是否正确 |
| DMA 中断未使能 | 检查 NVIC 和 DMA 中断配置 | |
| 串口发送乱码 | DMA 缓冲区被改写 | 等待上一次发送完成再复用缓冲区 |
| 未等待 TC 标志 | 使用事件标志或完成回调 | |
| DMA 总线错误 | 地址访问无效区域 | 检查地址是否在合法内存范围内 |
| Cache 一致性问题 | 使用 Cache 管理函数对缓冲区执行 Clean/Invalidate | |
| 系统性能反而下降 | DMA 优先级设置过高抢占 CPU | 合理降低优先级或使用突发传输 |
5.2 易错点与排查技巧
这里分享几个经验性的排查技巧,它们在实际调试中帮过我大忙。
技巧一:先排查外设侧,再排查 DMA 侧。我见过太多人在 DMA 配置上反复折腾,最后发现是外设的中断标志没清除。DMA 请求到不了 DMA 控制器,配置再好也没用。调试 DMA 问题时,先用调试器读外设状态寄存器,确认外设确实产生了 DMA 请求。
技巧二:打印 DMA 状态寄存器。大部分芯片的 DMA 控制器都有状态寄存器,里面包含传输完成标志、错误标志等。调试时把状态寄存器打印出来看,能快速判断 DMA 到底卡在哪个环节。HAL 库可以通过__HAL_DMA_GET_FLAG读取这些标志。
技巧三:先关闭 DMA 中断,用轮询方式验证基本传输功能。很多 DMA 传输失败的根因是中断处理问题,而不是 DMA 本身。你可以暂时关闭中断,用轮询标志位的方式去验证数据是否搬运正确。数据对了,再开中断,这样就把问题排查范围缩小了一半。
技巧四:用固定的简单数据测试。调试 DMA 发送时,可以先发送一个简单的数组(比如 1、2、3、4...255),然后查看接收端的数据。如果数据完全一致,说明传输链路没问题;如果有规律性错乱,大概率是地址递增或数据宽度的问题。
技巧五:配置 DMA 接收时,记得启用传输完成中断和错误中断。开发阶段这两个中断都很重要,尤其是在调试初期。上线后再根据功耗需求调整。
5.3 Cache 一致性问题
在带有 Cache 的高性能处理器上使用 DMA,一个绕不开的问题是 Cache 一致性。DMA 直接读写内存,而 CPU 读写内存时可能经过 Cache 缓存。两者看到的数据可能不一致,导致 DMA 读到的数据是过期的,或者 CPU 读到的 DMA 数据是缓存里的旧内容。
解决方法很简单:
- 在 DMA 写内存之前,CPU 需要对缓冲区做 Cache Clean,确保缓冲区数据回写到物理内存。
- 在 DMA 写完之后、CPU 读之前,需要做 Cache Invalidate,使 CPU 缓存失效,强制从内存重新读取。
- 在某些 SoC 上,你还可以在设备树或驱动里将 DMA 缓冲区标记为一致性内存(coherent memory),驱动框架会自动帮你处理。
这里有一个实际的经历:我在一个 Zynq 平台上调试以太网 DMA 时,发现网络包内容偶尔是错的,排查了半天,最后发现是 DMA 接收缓冲区的 Cache 没有做 Invalidate 操作。加上之后问题就消失了。这个坑希望大家少踩。
6. 工具选型与配置流程:从标准化配置到快速验证
DMA 的配置方式,不同平台差别很大。在 MCU 上,直接寄存器操作不够直观,最常用的是 HAL 库或者 CubeMX 图形化配置;在 SoC 和 Linux 环境下,则是通过 DTS(设备树)配置在驱动的 DMA 引擎框架中。
很多朋友问我:DMA 配置是用 CubeMX 自动生成好,还是手写代码好?我的观点是:开发阶段建议用 CubeMX 生成基础配置,这样外设请求映射、时钟、中断这些容易搞错的地方不会出错。但是真正做产品时,你可能需要手调一些细节,比如优先级、缓冲区地址对齐、错误处理逻辑,这些是 CubeMX 生成代码无法完全覆盖的。
6.1 CubeMX 配置 DMA 的标准步骤
以 STM32F407 + ADC 多通道 DMA 为例,标准配置步骤:
- 在 Analog 分类下打开 ADC1,开启 Scan Conversion Mode,选择通道数。
- 在 ADC1 的 DMA Settings 里点击 Add,选择 ADC1 作为 DMA 请求源。
- DMA 模式选 Circular,数据宽度选 Half Word(12 位 ADC 结果在 16 位寄存器中)。
- 内存地址递增开启,外设地址不变。
- 在 NVIC 中使能 DMA 中断。
- 生成代码后,在 main.c 中启动 ADC DMA 采集:
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buffer, 4);这里有一点要特别提醒:HAL_ADC_Start_DMA的第一个参数是 ADC 句柄,第二个参数是存放结果的数组地址,第三个参数是传输个数(不是字节数)。设置成 4 意味着每次扫描完成 4 个通道后产生一次 DMA 完成中断。如果你的缓冲区是 uint16_t 类型的数组,它会在每次转换完成后把当前 4 个通道的数据都存进数组。
6.2 UART DMA 在 CubeMX 中的配置要点
串口 DMA 在 CubeMX 中的配置也不复杂,重点在于区分发送和接收的 DMA 通道。
- 在 USART 的 DMA Settings 中点击 Add,选择 USART_TX 和 USART_RX 分别添加。
- TX 的 DMA 模式选择 Normal,RX 的 DMA 模式建议选择 Circular(如果做不定长接收的话)。
- 在 NVIC 中使能 USART 全局中断和 DMA 中断。
- 生成代码后,主程序里先调用
HAL_UART_Receive_DMA(&huart1, rx_buffer, buffer_size)开始接收。
如果你做的是“DMA 串口发送需要等待上一轮数据发送完吗”这类问题的验证,可以在代码中加一个简单的状态标志,在HAL_UART_TxCpltCallback中置位,发送前检查该标志,或者直接用一个互斥量来防重入。
6.3 Linux 设备树中的 DMA 配置
在 Linux 环境下,DMA 配置一般通过设备树描述。以 Zynq 平台的 AXI DMA 为例,一个简单的设备树节点通常包含下面这些信息:
axi_dma_0: dma@40400000 { compatible = "xlnx,axi-dma-1.00.a"; reg = <0x40400000 0x10000>; dma-channels = <1>; #dma-cells = <1>; dma-channel@40400000 { interrupts = <0 29 4>; xlnx,datawidth = <0x40>; }; };驱动通过标准的 DMA engine API 来请求 DMA 通道、提交传输描述符、等待传输完成。在这个框架里,我们一般不需要关心底层 DTS 的具体格式,只要保证设备树能正确描述 DMA 控制器的寄存器地址、中断号、数据宽度即可。
如果你在调试以太网驱动时看到类似 “failed to reset the DMA” 的报错,优先怀疑设备树或驱动中的 DMA 通道资源配置问题,尤其是中断号和寄存器地址是否正确。
7. 补充:热词引申的扩展场景
前面大量篇幅都在讲 MCU 场景,但 DMA 的应用远不止这些。结合一些搜索热词,我再补充几个大家容易关心的方向。
7.1 无 DMA 时的替代方案与取舍
如果你的芯片没有 DMA,或者 DMA 资源被其他外设占用完了,也不是完全没有办法。
最简单的替代方案是“逐字节轮询+中断”,直白但低效。如果数据量不大、速率不高,这种方式完全够用。比如串口波特率 9600,每秒不到 1KB 数据,中断接收毫无压力。但如果是高速 ADC 连续采样,那就必须想办法腾出 DMA 资源。
还有一种方案是用定时器中断配合总线读取。比如定时器每 10 微秒触发一次中断,在中断里读取 ADC 转换结果。这种方式能实现和 DMA 相似的功能,但 CPU 开销明显更大。
选型阶段就考虑 DMA 资源比较稳妥。很多 32 位 MCU 的 DMA 通道数是有限的(比如 8 或 16 条通道),如果项目里串口、ADC、SPI、定时器都要用 DMA,就要先规划好通道分配,避免做到最后发现 DMA 不够用。
7.2 双核与多核平台上的 DMA 分配
在多核平台上,DMA 资源的分配要遵循一个原则:数据产生和消费尽可能在同一核附近,避免跨核跨总线传输。比如你用双核芯片,一个核负责采集传感器数据并使用 AI 算法计算,那就把 ADC DMA 和相关数据处理都安排在这个核上,另一个核负责通信和外设交互,用另一个 DMA 通道。这样能最大限度地发挥多核并行优势。
7.3 从 DMA 到 DMA Proxy
在分布式系统里,还有 DMA Proxy 这个概念。通常指的是一个模块或服务,代理背板/总线上其他设备的 DMA 传输请求。它的存在意义在于:不是所有设备都能访问系统主内存,有些协处理器或 FPGA 的地址空间受限,需要通过一个 DMA Proxy 来代表这些设备完成内存访问。
这对我们普通应用开发者来说相对少见,但在工控、通信设备、定制化硬件系统中可能会遇到。理解思路即可:DMA Proxy 是连接受限设备和系统内存之间的桥梁。
7.4 从 DMA 到数据流处理的应用
本质上,DMA 是一种典型的“以空间换时间”的架构。它牺牲了 DMA 控制器的硬件资源,换来了 CPU 的大量时间。这个思想应用在实时操作系统、音视频采集、高速数据采集等场景,都能带来立竿见影的效果。
举个例子,一个语音采集系统,麦克风通过 I2S 接口接入 MCU。I2S 把音频数据连续不断地送进来,如果用中断方式接收,每个采样点都要打断 CPU 一次,很可能导致音频数据卡顿。配合 DMA 加上双缓冲(乒乓模式),一个缓冲区在被 DMA 填充的同时,另一个缓冲区的音频数据可以被音频算法处理,两者完全并行,系统负载和实时性都得到保障。
这类“DMA + 乒乓缓冲 + 处理流水线”的架构,在音频、视频、通信基带等领域非常通用,我个人强烈建议做嵌入式的朋友掌握这个套路。
8. 写在最后:DMA 调试的第一个动作与一次排查实录
最后分享一个我最近实际调试 DMA 的案例,希望能帮你少走弯路。
有一次我在调试一块板子,串口 DMA 接收数据时,上位机发 100 字节,有时候能收到 90 个,有时候收到 100 个,有时候收到 102 个,完全没有规律。一开始我怀疑是 DMA 配置有问题,反复检查优先级、地址、宽度,都没发现异常。
后来我打开了串口错误中断的状态标志,发现 UART 的状态寄存器里置了 OR(Overrun Error)标志。这意味着串口接收寄存器在 DMA 还没来得及把数据搬走时又收到了新数据,发生了溢出。根本原因不是 DMA 配置,而是 DMA 请求优先级不够高,在高负载情况下没能及时响应串口请求,或者串口接收中断和 DMA 之间产生了竞争。
我把 DMA 优先级从 Low 提到 High,并且确认串口接收中断优先级低于 DMA 的传输完成中断后,数据就稳定了。事后回头看,如果一开始就检查状态寄存器和错误标志,这个问题的定位时间能缩短一半以上。
我个人的习惯是:DMA 调试出现异常,第一个动作永远是读状态寄存器,而不是改代码。状态寄存器最能直接反映硬件层面的问题,先看硬件再看软件,定位效率会高很多。
DMA 是个很基础但非常重要的外设,值得你在实践中反复接触。踩过几个坑之后,你会发现它是一个非常可靠的工具,只要配置正确、缓冲区管理合理,性能提升非常可观。希望这篇文章能帮你在 DMA 的路上少走弯路。