1. 先别被缩写吓到,DMA解决的是CPU“打杂”问题
DMA,全称Direct Memory Access,直接存储器访问。这个名字听起来很技术,但它的核心目标非常直接:让CPU从繁重的数据搬运工作中解放出来。
想象一个场景:你的单片机(比如STM32)需要通过串口(USART)接收一长串数据。如果没有DMA,CPU需要像一个“快递员”一样,不断地从串口的数据寄存器里把一个个字节“搬”到内存里。每搬一个字节,CPU都要停下手中的计算工作(比如处理传感器数据、更新屏幕显示),去处理这个“搬砖”的杂活。数据量一大,CPU就几乎被“打杂”占满了,真正要干的“正事”(算法、逻辑)就卡住了。
DMA就是一个专门负责“搬砖”的“小工”。你只需要告诉它:从哪个地方(外设寄存器,如USART->DR)搬数据,搬到哪个地方(内存数组),搬多少。然后CPU就可以去干别的了,DMA会默默地在后台完成所有数据的搬运工作。搬完了,它还可以“举手”(触发中断)告诉CPU:“老板,活干完了,数据都放好了,你来处理吧。”
所以,对于新手来说,理解DMA最关键的一点是:它不帮你计算,它帮你搬运。它的价值在于提升系统效率,让CPU专注于核心计算任务,尤其适合处理大量、连续的数据流,比如:
- 串口(UART/USART)通信:接收GPS模块的NMEA长报文,或者发送大量的调试信息。
- ADC采样:连续采集音频、电压信号,需要将海量采样点存入内存。
- SPI/I2C通信:读写大容量Flash(如W25Q128)、驱动TFT屏幕(如LVGL使用DMA加速图形刷新)。
- I2S音频:播放或录制音频数据流。
很多新手纠结于DMA的配置细节,却忽略了它要解决的“为什么”的问题。先建立这个“CPU解放者”的认知,后面的通道、传输模式、中断就都好理解了。
2. 理解DMA的“工作流程”:控制器、通道与传输模式
在动手配置代码之前,我们需要把DMA这个“小工”的工作机制搞清楚。这能让你在遇到问题时,知道该检查“工作指令”的哪一部分。
2.1 DMA控制器与通道:谁在干活,走哪条路?
以STM32为例,它内部有一个或多个DMA控制器。每个控制器管理着多条“通道”(Channel)。你可以把控制器想象成一个“劳务公司”,通道就是它派出去的“不同专业的工人”。
通道与请求:每个通道通常被“固定分配”或“可配置”给一个或多个外设。例如,STM32F4的DMA1的通道4,可能被分配给USART1的发送请求。这意味着,当USART1需要发送数据时,它会向DMA控制器“下单”,控制器就会指派通道4这个“专送USART1快递的工人”去干活。在CubeMX或标准库配置中,为外设选择正确的DMA通道是第一步,选错了,DMA就收不到“订单”,不会工作。
仲裁器:如果一个控制器有多个通道同时接到“订单”(比如USART1要收数据,ADC1要传数据),谁先干?这由仲裁器根据优先级(软件可配置)决定。对于新手,通常保持默认优先级即可。
2.2 DMA的“工作清单”:源、目标、数量和模式
配置DMA,本质上就是给这个“工人”写一份清晰的“工作清单”。这份清单主要包含:
- 源地址(Source Address):数据从哪里搬?是外设的数据寄存器地址(如
&USART1->DR),还是内存中的一个数组地址(&sourceBuffer)? - 目标地址(Destination Address):数据搬到哪里去?是内存地址,还是外设寄存器地址?
- 传输数量(Data Number):一共要搬多少“件”?这个“件”可以是字节(Byte)、半字(HalfWord,2字节)或字(Word,4字节),取决于外设数据宽度。
- 传输方向(Direction):
- 外设到内存(Peripheral-to-Memory):最常见,如串口接收。源是USART->DR,目标是内存数组。
- 内存到外设(Memory-to-Peripheral):也很常见,如串口发送。源是内存数组,目标是USART->DR。
- 内存到内存(Memory-to-Memory):这是DMA一个强大的功能,可以在不占用CPU的情况下,在内存内部大规模拷贝数据。很多芯片(如STM32)的DMA都支持此模式。
2.3 核心工作模式:单次、循环与双缓冲
这是理解DMA高效处理数据流的关键。
- 单次模式(Normal Mode):DMA按照你指定的数量搬完一次后,就自动停止,需要CPU重新配置才能启动下一次。适合处理确定长度的单次数据块。
- 循环模式(Circular Mode):这是处理连续数据流的利器。DMA搬完指定数量的数据后,会自动将源/目标地址和传输计数器重置为初始值,然后重新开始搬运,周而复始。比如用于ADC连续采样,采样数据会被源源不断地填入一个固定大小的内存缓冲区(数组)中,覆盖旧数据。CPU只需要定期来缓冲区里读取最新数据即可。
- 双缓冲模式(Double Buffer Mode):可以看作是循环模式的“增强版”。它使用两个缓冲区(Buffer0和Buffer1)。当DMA正在向Buffer0填充数据时,CPU可以安全地处理Buffer1中的数据;当Buffer0填满,DMA会自动切换到向Buffer1填充,同时CPU可以切换到处理Buffer0。这完美避免了CPU和DMA同时访问同一块内存区域可能带来的数据一致性问题,在高速数据流处理(如I2S音频、摄像头数据)中至关重要。STM32的很多外设DMA支持直接配置双缓冲。
注意:很多新手在配置串口不定长接收时,喜欢用“单次模式+DMA传输完成中断”,但这要求你知道数据的确切长度。更常见的做法是“循环模式+串口空闲中断(IDLE)”,DMA一直开着,当串口总线空闲一段时间(即一帧数据结束)时,触发中断,此时通过计算DMA已搬运的数据量,就能知道这一帧不定长数据有多长。这就是“HAL库串口空闲中断加DMA”这个热词的典型应用场景。
3. 从零配置:以STM32 HAL库串口DMA收发为例
理论说再多,不如动手调一遍。我们以STM32CubeMX和HAL库为环境,配置USART1使用DMA进行发送和接收。这是最经典、最常用的场景。
3.1 环境准备与CubeMX图形化配置
- 硬件:任意一款STM32开发板(如STM32F103C8T6,STM32F407VET6等),确保USART1引脚(PA9/PA10)连接正确。
- 软件:安装STM32CubeMX和对应的IDE(Keil MDK或STM32CubeIDE)。
- CubeMX配置步骤:
- 选择芯片:创建新工程,选择你的具体型号。
- 开启USART1:在
Pinout & Configuration标签页,找到USART1,选择Asynchronous(异步模式)。引脚PA9(TX)和PA10(RX)会自动配置。 - 配置DMA:
- 切换到
DMA设置页。 - 点击
Add添加DMA请求。 - 对于USART1_TX(发送):
DMA Request: 选择USART1_TX。Mode: 选择Normal(单次模式)。因为发送通常由CPU主动触发一次。Direction:Memory To Peripheral(内存到外设)。Priority: 默认Low或Medium。
- 对于USART1_RX(接收):
DMA Request: 选择USART1_RX。Mode:强烈建议选择Circular(循环模式),为不定长接收做准备。Direction:Peripheral To Memory(外设到内存)。Priority: 可以比TX高,确保数据不丢失。
- 切换到
- 生成代码:配置时钟树(通常用内部或外部晶振,保持默认即可),在
Project Manager设置好工程名、路径和IDE,然后点击GENERATE CODE。
3.2 代码编写:启动、发送与接收处理
CubeMX生成的代码搭建了框架,我们还需要添加业务逻辑。
// 1. 定义缓冲区 #define RX_BUFFER_SIZE 256 uint8_t txBuffer[] = "Hello DMA!\r\n"; uint8_t rxBuffer[RX_BUFFER_SIZE]; // DMA循环接收缓冲区 volatile uint16_t rxLen = 0; // 接收到的数据长度 uint8_t rxDataTemp[RX_BUFFER_SIZE]; // 用于临时存放处理的数据 // 2. 在main()初始化后,启动串口DMA接收 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); // 启动串口DMA循环接收,数据会持续填入rxBuffer HAL_UART_Receive_DMA(&huart1, rxBuffer, RX_BUFFER_SIZE); // 3. 开启串口空闲中断(IDLE),用于检测一帧数据接收完成 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); while (1) { // 主循环处理其他任务,接收完全由DMA+中断在后台完成 // 例如,可以定时发送数据 HAL_UART_Transmit_DMA(&huart1, txBuffer, sizeof(txBuffer) - 1); // 发送字符串(不含结尾\0) HAL_Delay(1000); } } // 4. 重写串口空闲中断回调函数 void HAL_UART_IdleCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 暂时关闭DMA,安全地计算接收数据长度 __HAL_DMA_DISABLE(huart->hdmarx); // 计算本次接收到的数据长度 // 公式:设定的缓冲区大小 - DMA当前剩余未传输的数据量 rxLen = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx); if (rxLen > 0) { // 1. 将DMA缓冲区数据拷贝到临时处理缓冲区 memcpy(rxDataTemp, rxBuffer, rxLen); // 2. 处理数据 (例如,解析指令、打印等) // user_handle_data(rxDataTemp, rxLen); // 3. 处理完后,可以将处理结果通过DMA发送出去 // HAL_UART_Transmit_DMA(&huart1, response, responseLen); } // 重新配置DMA接收计数器,并启动DMA,准备接收下一帧数据 // 注意:因为之前关闭了DMA,需要重新设置传输数量并启动 __HAL_DMA_SET_COUNTER(huart->hdmarx, RX_BUFFER_SIZE); __HAL_DMA_ENABLE(huart->hdmarx); // 清除空闲中断标志位 __HAL_UART_CLEAR_IDLEFLAG(huart); } }代码关键点解析:
- 启动接收:
HAL_UART_Receive_DMA启动了DMA循环接收,数据会源源不断写入rxBuffer,写满后从头覆盖(循环模式)。 - 空闲中断:
UART_IT_IDLE中断在一帧数据结束后(总线空闲)触发。在回调函数里,我们计算DMA已经搬运了多少数据到缓冲区,这就是一帧数据的长度。 - 安全操作:在
IdleCallback中,先__HAL_DMA_DISABLE再计算长度,是为了防止在计算过程中DMA仍在修改缓冲区导致数据不一致。计算处理完后,必须重新设置计数器并启用DMA。 - 发送:
HAL_UART_Transmit_DMA非阻塞,调用后立即返回,CPU可以去干别的,DMA负责发送完成。
3.3 验证与调试:如何判断DMA在工作?
- 发送验证:连接串口助手,上电后应能每秒收到一次“Hello DMA!”。同时,用调试器单步运行,会发现
HAL_UART_Transmit_DMA调用后程序立刻继续执行,不会卡住,证明发送是DMA在后台完成的。 - 接收验证:在串口助手向单片机发送一串数据(如“12345”)。在
HAL_UART_IdleCallback函数内设置断点,或者通过printf将rxDataTemp和rxLen打印出来。你应该能看到正确接收的数据和长度。 - 资源监控:在调试模式下,观察CPU利用率(如果IDE支持)。在进行大量数据收发时,对比使用DMA和不用DMA(轮询或中断方式)的CPU负载差异,能直观感受到DMA的优势。
4. 进阶、排错与不同平台的DMA思想
当你掌握了基础用法,就会遇到更复杂的需求和问题。同时,DMA的思想在各种平台(如GD32、FPGA、Zynq)上是相通的。
4.1 常见问题排查链路(避坑指南)
DMA不工作,或者数据不对,别急着怀疑人生,按这个顺序查:
第一步:查“订单”和“工人”
- 通道映射对吗?这是最常出错的地方。回头检查CubeMX或你的初始化代码,USART1_RX/TX是否分配到了正确的DMA和通道上?查对应芯片的参考手册《DMA请求映射表》。
- DMA时钟开了吗?在STM32的
RCC配置中,必须使能对应的DMA时钟(如__HAL_RCC_DMA1_CLK_ENABLE())。CubeMX通常会自动生成。
第二步:查“工作清单”
- 地址对齐对吗?如果外设数据宽度是16位(如某些ADC),那么内存缓冲区地址最好2字节对齐。
HAL库通常能处理,但自己分配缓冲区时要注意。使用__ALIGNED(4)等关键字修饰缓冲区数组。 - 传输数量(Counter)重置了吗?在单次模式发送完成后,或像上面例子中在空闲中断里重新启动接收前,必须重新设置传输数量。
HAL_DMA_Start或__HAL_DMA_SET_COUNTER。 - 缓冲区溢出?循环接收模式下,如果CPU处理数据的速度跟不上DMA接收的速度,新数据会覆盖旧数据。确保你的处理逻辑足够快,或者使用双缓冲(Double Buffer)。
- 地址对齐对吗?如果外设数据宽度是16位(如某些ADC),那么内存缓冲区地址最好2字节对齐。
第三步:查“完成报告”
- 中断开了吗?如果你使用了DMA传输完成中断(
HAL_UART_TxCpltCallback/HAL_UART_RxCpltCallback),需要在CubeMX中开启DMA的全局中断(NVIC Settings),并实现对应的回调函数。 - 中断标志清除了吗?在中断服务函数或回调函数中,要确保清除了相应的DMA或UART中断标志位,否则会连续进入中断。
- “发送完成”判断对吗?这是热词“DMA传输最后一个字节后 怎么判断串口已发送完成?”的答案。对于UART,DMA传输完成只意味着数据从内存搬到了UART的发送数据寄存器(TDR)。但UART硬件还需要时间把TDR里的数据一位一位地通过TX线发出去。所以,DMA传输完成 != 串口发送完成。如果需要精确知道串口物理发送完毕(例如才能关闭RS485的发送使能),需要等待UART的
TC(传输完成)标志位置位,而不是DMA完成中断。
- 中断开了吗?如果你使用了DMA传输完成中断(
第四步:查“数据一致性”
- 内存访问冲突?CPU和DMA同时访问同一块内存区域,如果没有正确处理缓存(Cache)或使用双缓冲,会导致数据错乱。对于Cortex-M7内核(如STM32H7)或高性能平台,需要特别注意数据缓存(D-Cache)的一致性问题,在DMA传输前后可能需要调用
SCB_CleanDCache_by_Addr或SCB_InvalidateDCache_by_Addr函数。
- 内存访问冲突?CPU和DMA同时访问同一块内存区域,如果没有正确处理缓存(Cache)或使用双缓冲,会导致数据错乱。对于Cortex-M7内核(如STM32H7)或高性能平台,需要特别注意数据缓存(D-Cache)的一致性问题,在DMA传输前后可能需要调用
4.2 不同平台的DMA实现思想
- GD32 / RT-Thread:GD32作为STM32的兼容/替代品,DMA使用方式几乎一样。在RT-Thread等RTOS中,使用DMA通常结合信号量、消息队列。例如,在DMA完成中断中释放信号量,通知RT-Thread的线程去处理数据,实现高效的多任务通信。
- FPGA实现DMA:在FPGA中,DMA控制器通常需要你用硬件描述语言(如Verilog)自己设计。它包括状态机控制、地址生成器、总线主设备接口等。FPGA的DMA更底层,灵活性极高,可以实现定制化的高速数据通路,比如直接从摄像头传感器接口(如DVP/MIPI)搬运数据到DDR内存。
- Zynq AXI DMA:Xilinx Zynq SoC的DMA基于AXI总线协议。它通常包含两个独立的通道(MM2S:内存到流, S2MM:流到内存),通过VDMA(Video DMA)还能处理视频帧。在FreeRTOS下,你需要调用Xilinx提供的XDMA库函数进行配置,并处理好中断与任务同步。它的配置比单片机复杂,但吞吐能力也强得多。
- Linux下的DMA:在如RZ/N2L这类MPU上,DMA驱动通常由内核提供。应用层通过设备文件(如
/dev/dma)或特定的驱动API(如dmaengine)来申请和配置DMA通道,实现用户空间缓冲区与设备间的高效数据传输。
4.3 性能考量与高级应用
- DMA测速:如何衡量DMA带来的性能提升?可以对比测试:用for循环CPU拷贝1MB数据的时间 vs 使用内存到内存DMA拷贝的时间。同时用逻辑分析仪或示波器监测IO引脚翻转,可以精确测量DMA传输耗时。
- 与其它技术结合:
- LVGL + DMA:在刷新TFT屏幕时,使用DMA将显存(Frame Buffer)中的数据搬运到LCD的GRAM或SPI接口,可以极大降低CPU负载,实现流畅的图形界面。
- SPI + DMA:读写SPI Flash(W25Q128)时,使用DMA可以大幅提升连续读写速度。配置时注意SPI的数据帧格式(8位/16位)与DMA的宽度匹配。
- ADC + DMA + 双缓冲:实现高速连续采样无遗漏的经典方案。DMA循环模式配合双缓冲,ADC永不停止,CPU总有一个完整、稳定的缓冲区可供处理。
DMA不是一个孤立的技术点,它是嵌入式系统优化“数据流”的核心工具。从理解“解放CPU”这个初衷开始,通过标准外设(如UART)上手实践,再逐步深入到内存管理、缓存一致性、多任务同步和不同硬件平台的实现,你就能真正掌握这把提升系统性能的利器。记住,配置时多想想你给DMA的“工作清单”是否清晰完整,出问题时按照“订单-清单-报告-数据”的链路去排查,大部分难题都能迎刃而解。