1. DMA到底是什么?为什么每个嵌入式开发者都绕不开它
如果你在调试串口收发、SPI读写Flash或者驱动LCD屏幕时,被CPU频繁进入中断、处理数据搬运搞得焦头烂额,导致主程序卡顿、实时性变差,那么你大概率需要深入了解DMA这个“外挂”了。DMA,全称Direct Memory Access,直接存储器访问。这个名字听起来有点抽象,但它的作用非常直接:让数据在内存和外设之间“自动”搬家,完全不需要CPU这个“总经理”亲自插手。
想象一下,你(CPU)是一家公司的总经理,每天要处理各种决策(执行算法、逻辑判断)。现在,仓库(内存)和生产线(外设,如串口)之间有一批原料(数据)需要频繁搬运。如果每次搬运都需要你亲自跑一趟,那你基本就没时间干正事了。DMA就像一个你雇来的专业搬运队(DMA控制器),你只需要在第一次告诉它:“从A仓库的X号货架,搬100箱货到B生产线的Y号入口,搬完了叫我一声。” 之后,你就可以回去开会了。搬运队会自己完成所有重复性的体力活,只在全部搬完或者遇到问题时才通知你。这样一来,你的工作效率(系统性能)就大大提升了。
在嵌入式领域,DMA的应用无处不在。从最基础的串口(USART)DMA收发,到高速的SPI读写外部存储器(如W25Q128),再到音频处理的I2S DMA传输,以及图形显示的LCD(如LVGL使用DMA2D加速),DMA都是提升系统吞吐量、降低CPU负载、保证实时性的关键角色。理解了DMA,你就能从“资源紧张”的开发者,进阶为能游刃有余设计高效系统的工程师。
2. DMA核心原理与工作模式深度拆解
要驾驭DMA,不能只停留在“自动搬运”的概念上,必须深入其内部工作机制。这能帮助你在遇到复杂场景(如多外设共用DMA、环形缓冲、双缓冲)时,做出正确的设计和排错。
2.1 DMA控制器的基本架构
以常见的STM32系列微控制器为例,其DMA控制器通常包含多个独立的通道(Channel)。你可以把每个通道看作一条独立的“搬运流水线”。一条流水线在同一时间只能为一个“客户”(即一个外设或内存区域)服务。这些客户包括ADC、SPI、I2C、USART、定时器等的外设寄存器。
关键组件解析:
- 源地址寄存器(Source Address):告诉DMA数据从哪里来。可以是内存地址(如数组
buffer的首地址),也可以是外设数据寄存器地址(如USART1->DR)。 - 目标地址寄存器(Destination Address):告诉DMA数据搬到哪里去。同样可以是内存或外设地址。
- 数据数量寄存器(Data Number):设定本次传输要搬运多少“单位”的数据。单位大小由数据宽度决定。
- 数据宽度(Data Width):设定每次搬运操作的数据大小,通常是字节(8位)、半字(16位)或字(32位)。必须注意源和目的的数据宽度设置要匹配,否则会导致数据错乱。例如,从32位宽的内存搬数据到8位宽的串口数据寄存器,通常需要设置源数据宽度为字节,或者使用DMA的“打包(Packing)”功能(如果支持)。
- 传输模式(Transfer Mode):
- 外设到内存模式:典型应用是串口DMA接收。源地址是
USART->DR,目标地址是用户定义的数组。串口每收到一个字节,就会向DMA控制器发出一个请求,DMA随即将该字节从串口数据寄存器搬到目标数组。 - 内存到外设模式:典型应用是串口DMA发送。源地址是存放待发送数据的数组,目标地址是
USART->DR。DMA将数据逐个搬到串口,串口硬件自动将其发送出去。 - 内存到内存模式:这是DMA能力的一个扩展,不涉及外设。可以用于高效地拷贝、填充大块内存数据,比如初始化显示缓冲区或进行数据块搬移,速度远高于CPU用循环操作。
- 外设到内存模式:典型应用是串口DMA接收。源地址是
2.2 关键工作模式:普通模式 vs 循环模式
这是DMA配置中最容易混淆也最重要的概念之一,直接决定了数据搬运的持续性和缓冲区的管理方式。
普通模式(Normal Mode):
- 行为:DMA在完成设定的数据数量(
Data Number)传输后,会自动停止,并产生一个传输完成中断(如HAL_DMA_XferCpltCallback)。 - 应用场景:发送或接收一段固定长度的数据。例如,通过串口发送一条已知长度的指令报文,或者接收一个固定格式的数据包。
- 注意事项:在普通模式下,一次传输完成后,DMA通道会被禁用。如果你想再次启动传输,必须重新配置数据数量并启动DMA通道。对于持续不断的流式数据(如音频流、持续传感器数据),普通模式就不太合适,因为你需要不断重启DMA,会产生间隙。
- 行为:DMA在完成设定的数据数量(
循环模式(Circular Mode):
- 行为:DMA在完成设定的数据数量传输后,不会停止,而是自动将源/目标地址重置为初始值,数据数量重置为初始值,然后重新开始新一轮传输,如此周而复始。
- 应用场景:处理连续、不间断的数据流。这是实现“环形缓冲区(Ring Buffer)”或“双缓冲(Double Buffer)”的硬件基础。
- 应用实例:串口DMA接收未知长度的数据。你可以设置一个足够大的数组作为接收缓冲区,并启用循环模式。DMA会不停地往这个数组里填数据,写满后就从头开始覆盖。你的主程序或中断服务程序只需要定期去检查这个缓冲区里有多少新数据(通过计算DMA当前写入位置),然后取走处理即可。这完美解决了“串口DMA接收完成中断只触发一次”的困扰,因为循环模式根本没有“完成”的概念,只有“半满”或“溢出”的事件需要关注。
2.3 中断与标志位:如何知道DMA干完活了?
DMA控制器在工作过程中会产生多种事件,并通过中断或标志位通知CPU。合理利用这些事件是高效编程的关键。
- 传输完成中断(Transfer Complete Interrupt):当DMA完成了全部
Data Number个数据的传输时触发。在普通模式下,这标志着一次搬运任务结束;在循环模式下,这标志着完成了一轮缓冲区填充。 - 半传输完成中断(Half Transfer Complete Interrupt):当DMA完成了一半
Data Number个数据的传输时触发。这是实现双缓冲(Double Buffer)技术的核心机制。- 双缓冲原理:假设你设置了一个包含N个元素的数组作为DMA目标,并开启了半传输和传输完成中断。DMA开始搬运。
- 当它搬完前N/2个数据(填满了缓冲区前半部分)时,触发半传输中断。此时,CPU可以安全地处理这前半部分的数据,因为DMA正在向后半部分写入。
- 当DMA搬完后N/2个数据(填满了整个缓冲区)时,触发传输完成中断。此时,CPU可以处理后半部分数据,而DMA在循环模式下会回头从数组开头重新写入,覆盖前半部分。
- 如此交替,CPU和DMA各自操作不同的半区,实现了无锁、高效的数据流水线,避免了处理数据时和DMA写入的数据冲突。这在处理I2S音频流、摄像头数据时非常有用。
- 传输错误中断(Transfer Error Interrupt):当发生配置错误(如访问非法地址)时触发。
- 直接模式错误中断(Direct Mode Error Interrupt):在某些特定配置下发生错误时触发。
实操心得:在STM32的HAL库中,使用
HAL_DMA_Start_IT()启动带中断的DMA传输后,需要重写对应的回调函数(如HAL_DMA_XferCpltCallback)来处理这些事件。务必注意中断的优先级设置,尤其是当DMA服务于高速外设(如SPI Flash)时,如果DMA中断被低优先级任务长时间阻塞,可能导致数据丢失或外设错误。
3. 典型场景实战:串口DMA收发全解析
串口通信是嵌入式开发中最基础也最常用的功能,结合DMA可以极大解放CPU。下面我们以STM32为例,拆解USART DMA收发的完整流程和关键细节。
3.1 串口DMA发送:如何可靠地发送一长串数据
目标:通过DMA将内存中的一个字符串或数据包发送出去。
配置步骤(以STM32CubeMX/HAL库为例):
- CubeMX图形配置:
- 使能USART和对应的DMA通道(Stream/Channel)。
- 在DMA设置中,为USART_TX添加一个DMA请求。
- 模式选择:
Normal(普通模式,发送完即停)。 - 方向:
Memory To Peripheral。 - 数据宽度:根据需求选择,通常外设(USART->DR)是Byte,内存侧也选Byte。
- 关闭循环模式。
- 代码实现:
// 1. 定义发送缓冲区 uint8_t tx_buffer[] = "Hello, DMA!\r\n"; uint16_t tx_len = sizeof(tx_buffer) - 1; // 去掉字符串结尾的'\0' // 2. 启动DMA发送 HAL_UART_Transmit_DMA(&huart1, tx_buffer, tx_len); // 3. 在传输完成中断回调函数中处理后续事宜 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { // 发送完成,可以点亮一个LED,或者准备下一包数据 // 注意:在普通模式下,此时DMA已停止,缓冲区可以安全复用 } }
关键问题与技巧:
- 如何判断串口DMA发送真正完成?这是一个高频问题。
HAL_UART_TxCpltCallback回调表示DMA已经把最后一个字节从内存搬到了USART的数据寄存器(DR)。但是,USART的移位寄存器可能还在发送这最后一个字节!如果此时你立即关闭串口、进入低功耗模式或复用TX引脚,会导致最后一个字节发送不完整。- 解决方案:在传输完成回调中,不要立即进行敏感操作。可以等待USART状态寄存器中的
TC(Transmission Complete)标志置位。HAL库提供了HAL_UART_GetState()函数,但更直接的方法是使用while((huart1.Instance->SR & USART_SR_TC) == 0);进行短暂等待(几个时钟周期)。更优雅的做法是利用USART本身的TC中断。
- 解决方案:在传输完成回调中,不要立即进行敏感操作。可以等待USART状态寄存器中的
- 发送过程中能否修改缓冲区?在DMA传输进行中,绝对不能修改源地址指向的缓冲区内容,否则发送出去的数据将是不可预知的。如果需要发送动态数据,应确保在启动DMA前准备好完整数据,或者使用双缓冲机制。
3.2 串口DMA接收:如何高效接收不定长数据
这是DMA应用的难点和重点。核心思路是:循环模式 + 空闲中断(Idle Interrupt)。
配置步骤:
- CubeMX图形配置:
- 使能USART和对应的DMA通道(Stream/Channel)。
- 在DMA设置中,为USART_RX添加一个DMA请求。
- 模式选择:
Circular(循环模式)。 - 方向:
Peripheral To Memory。 - 数据宽度:
Byte。 - 开启DMA传输完成中断(用于计算数据,非必需)。
- 在USART配置中,开启空闲中断(Idle Interrupt)。
- 代码实现原理:
// 定义接收缓冲区 #define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; // 在main初始化部分启动DMA接收 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); // 注意:这里长度是缓冲区总大小,因为工作在循环模式 // 全局变量记录状态 volatile uint16_t dma_last_pos = RX_BUFFER_SIZE; // 上次读取的位置
空闲中断处理: 串口空闲中断在检测到RX线上超过一个字符帧的时间(如10个位时间)没有新数据时触发。这是判断“一帧数据结束”的利器。
// 在stm32fxx_it.c的中断服务函数中 void USART1_IRQHandler(void) { // ... 其他中断处理 if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLE_FLAG(&huart1); // 清除空闲中断标志 // 计算本次接收到的数据长度 uint16_t current_pos = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // __HAL_DMA_GET_COUNTER 返回DMA通道中剩余未传输的数据量 // 用总大小减去剩余量,得到当前已写入的位置 uint16_t receive_len = 0; if(current_pos >= dma_last_pos) { receive_len = current_pos - dma_last_pos; } else { // 缓冲区已卷绕(wrap around) receive_len = RX_BUFFER_SIZE - dma_last_pos + current_pos; } if(receive_len > 0) { // 处理从 rx_buffer[dma_last_pos] 开始的 receive_len 个字节数据 process_received_data(&rx_buffer[dma_last_pos], receive_len); } // 更新上次读取位置 dma_last_pos = current_pos; } // ... 其他中断处理 }避坑指南:
- 缓冲区大小:循环缓冲区的大小要足够容纳两帧最大可能的数据包,防止在处理一帧数据时,新来的数据覆盖了还未处理完的旧数据。
- 数据覆盖:上述代码中,如果
process_received_data函数处理速度太慢,而新数据源源不断,dma_last_pos可能会被DMA的当前写入位置追上,导致部分新数据被当作“未处理”数据重复处理,或者更糟,未处理的数据被覆盖。在高流量场景下,需要设计更健壮的流控或使用双缓冲。- DMA计数器复位:在极少数情况下(如通信错误后恢复),可能需要停止并重启DMA接收。重启前,务必先调用
__HAL_DMA_GET_COUNTER获取并保存当前位置,然后调用HAL_UART_DMAStop(),再重新配置缓冲区地址和数据长度,最后调用HAL_UART_Receive_DMA()重启。顺序错误可能导致数据混乱。
4. 进阶应用与疑难杂症排查
掌握了基础用法后,面对更复杂的系统需求,你需要了解DMA的一些高级特性和常见问题。
4.1 多外设共享DMA通道
像STM32F103这类资源紧张的芯片,多个外设(如USART1_TX, USART2_RX, SPI1_TX)可能共享同一个DMA通道(Channel)。这意味着同一时间,只能有一个外设使用该通道。
管理策略:
- 分时复用:在代码层面严格管理,确保在使用一个外设的DMA前,该通道没有被其他外设占用。使用前先
HAL_DMA_Stop()停止可能存在的传输,然后重新配置源/目标地址、数据量等参数,再为新外设启动DMA。 - 状态标志:为每个使用共享DMA通道的外设设计一个状态标志(如
busy),在启动DMA前检查。 - 硬件限制核查:仔细查阅芯片参考手册的DMA请求映射表,明确哪些外设固定映射到哪些通道,避免冲突设计。
4.2 内存到内存传输
这是DMA一个非常高效的功能,常用于初始化大块内存(如清零显存)或拷贝数据。
// 使用HAL库进行内存到内存拷贝 uint32_t src[100], dst[100]; // ... 填充src数据 HAL_DMA_Start(&hdma_memtomem_dma_channel, (uint32_t)src, (uint32_t)dst, 100); // 等待传输完成 HAL_DMA_PollForTransfer(&hdma_memtomem_dma_channel, HAL_DMA_FULL_TRANSFER, HAL_MAX_DELAY);注意:内存到内存传输不涉及外设请求,通常需要手动启动并等待完成。它的优先级可能低于外设触发的DMA请求。
4.3 DMA传输中的常见问题与排查技巧
问题:DMA传输不启动,或者只传输一部分数据。
- 检查1:外设时钟和DMA时钟是否使能?这是最容易被忽略的。在CubeMX生成的代码中,通常会帮你使能,但如果是手动注册代码,务必确认
__HAL_RCC_DMAx_CLK_ENABLE()和对应外设的时钟已开启。 - 检查2:DMA通道与外设的映射是否正确?对照数据手册,确认你使用的DMA通道(Stream)确实支持该外设的请求。
- 检查3:外设是否已配置为产生DMA请求?例如,对于USART发送,需要使能
USART_CR3寄存器中的DMAT位;对于接收,需要使能DMA_R位。HAL库的HAL_UART_Transmit_DMA()函数内部会设置这些位。 - 检查4:缓冲区地址对齐问题?如果源/目标地址是内存地址,且数据宽度设置为字(32位),那么该地址最好是4字节对齐的。非对齐访问在某些芯片上可能导致传输错误或效率低下。可以使用
__ALIGNED(4)关键字来定义数组。
- 检查1:外设时钟和DMA时钟是否使能?这是最容易被忽略的。在CubeMX生成的代码中,通常会帮你使能,但如果是手动注册代码,务必确认
问题:DMA传输完成中断不触发。
- 检查1:DMA中断是否使能?使用
HAL_DMA_Start_IT()或HAL_UART_Transmit_DMA()(其内部调用了带中断的启动)才能产生中断。 - 检查2:NVIC中断控制器是否配置正确?在CubeMX中需要勾选对应的DMA通道全局中断,并设置优先级。在代码中,对应中断服务函数(如
DMA1_Channel1_IRQHandler)需要正确实现,并调用HAL_DMA_IRQHandler()。 - 检查3:传输完成中断标志(TCIF)是否被清除?在中断服务函数中,HAL库会处理标志位的清除。但如果自己编写中断服务程序,务必在退出前清除相应的标志位(
__HAL_DMA_CLEAR_FLAG),否则会连续进入中断。
- 检查1:DMA中断是否使能?使用
问题:数据错乱,收到或发送的数据不对。
- 检查1:数据宽度(Data Width)是否匹配?这是最常见的原因。确保内存端和外设端的数据宽度设置一致。例如,从32位宽的缓冲区发送到8位宽的串口,如果DMA配置为每次传输32位,那么一次操作会搬4个字节到串口数据寄存器,但串口硬件只会取最低的8位,导致数据顺序错乱。此时应配置为每次传输8位,或者使能DMA的“字节打包”功能(如果支持)。
- 检查2:字节序(Endianness)问题?当数据宽度大于8位时,需要注意CPU的字节序(大端/小端)。DMA通常按照内存中的字节顺序原样搬运。如果你用DMA发送一个16位的整数
0x1234,在内存中可能是0x34(低地址),0x12(高地址)(小端模式)。DMA会先搬0x34到外设,再搬0x12。你需要确认接收端是否能正确解析这种顺序。 - 检查3:缓冲区在传输过程中被意外修改?确保在DMA传输期间,没有其他代码(包括中断)修改源或目标缓冲区。可以考虑使用
const定义发送缓冲区,或将缓冲区定义为volatile并配合关中断等保护措施。
问题:使用双缓冲时,数据出现重叠或丢失。
- 检查:中断处理逻辑是否正确?在半传输中断(HT)中,你处理的是缓冲区的前半部分;在传输完成中断(TC)中,你处理的是后半部分。两个中断的处理函数必须非常快,并且要确保在处理一个半区时,DMA正在写入另一个半区。绝对不能在中断处理函数中进行长时间的操作(如打印大量日志),否则DMA可能已经写满了当前半区并开始覆盖你正在处理的半区。如果处理耗时,应将数据拷贝到另一个临时缓冲区,然后快速退出中断,在主循环中处理。
5. 不同芯片平台与生态下的DMA应用要点
DMA是一个通用概念,但在不同厂商、不同架构的MCU上,其具体实现和API各有不同。
- STM32 (HAL/LL库):生态最完善,CubeMX图形化配置极大简化了初始化。重点掌握
HAL_DMA_Start(),HAL_DMA_Start_IT(), 以及各种Callback函数。注意LL库提供更底层的寄存器操作,对时序要求苛刻时可能用到。 - GD32:与STM32高度兼容,但仍有细微差异。例如,在GD32中使能USART的DMA接收,可能需要额外操作一些寄存器位。需要仔细阅读对应型号的参考手册。
- N32 (国民技术):同样采用ARM Cortex-M内核,DMA控制器设计与STM32类似,但外设寄存器地址和位定义不同,需使用其官方提供的SDK。
- Zynq (FPGA+ARM):这里的DMA通常指AXI DMA IP核,用于在FPGA可编程逻辑(PL)和ARM处理系统(PS)的内存之间进行高速数据传输。配置更为复杂,涉及Linux驱动、设备树(Device Tree)、用户空间API(如
read/write)或XDMA驱动。核心是理解Scatter-Gather(SG)模式,它允许DMA传输不连续的内存块。 - ESP32:其DMA通常与I2S、SPI等外设绑定,用于音频、显示屏数据传输。例如,驱动WS2812/WS2811灯带时,会利用I2S的DMA模式产生精确的PWM时序信号。
- FreeRTOS与DMA:在RTOS环境下使用DMA,需要特别注意资源共享和临界区保护。例如,多个任务可能都想使用同一个DMA通道进行内存拷贝。通常使用互斥锁(Mutex)来保护DMA通道的访问。另外,DMA传输完成中断服务程序(ISR)中,应使用
FromISR版本的RTOS API(如xSemaphoreGiveFromISR)来通知任务,而不是在ISR中进行复杂处理。
工具与调试技巧:
- 逻辑分析仪:是调试DMA相关时序问题的终极利器。你可以同时抓取外设的时钟线、数据线以及DMA相关的控制信号或GPIO翻转信号,直观地看到DMA请求、响应和数据搬运的时序关系。
- 调试器与内存观察:在IDE(如Keil, IAR, STM32CubeIDE)中设置断点,观察DMA控制寄存器的值(如CNDTR-剩余数据计数)、源/目标地址寄存器的变化,以及缓冲区内存内容的变化。
- GPIO翻转:在DMA传输开始、中断触发等关键位置,用一条空闲的GPIO口线进行电平翻转,然后用示波器观察,可以粗略测量代码执行时间和DMA传输耗时。
DMA不是一个一学就会、一用就灵的黑盒子。它需要你清楚地知道数据流从哪里来、到哪里去、以何种形式搬运、何时开始、何时结束。每一次成功的DMA应用,都是你对芯片架构、外设特性和系统数据流理解的一次深化。从最简单的串口收发开始,逐步尝试SPI Flash读写、ADC多通道扫描、LCD刷新,你会逐渐体会到这个“沉默的搬运工”带来的巨大性能红利。