
DMA也就是Direct Memory Access直接存储器访问说直白点就是“数据搬运工”。在嵌入式开发里无论是串口收发、ADC采样、SPI刷屏还是文件系统读写DMA都是绕不开的基础设施。这篇汇总把DMA的工作流程、传输模式和典型应用一次性讲透。适合刚接触DMA的嵌入式新手也适合天天跟DMA打交道但偶尔卡在疑难杂症上的老手——比如“串口DMA接收不定长数据”“DMA串口发送要不要等上一轮发完”“HAL库ADC多通道DMA多次采样”这些高频问题后面都会给到具体解法。我的目标是让你看完之后拿到一个带DMA的外设需求能直接说出配置思路、代码框架和坑位在哪而不是对着参考手册翻半天。1. DMA 工作流程与核心原理1.1 DMA 到底解决了什么问题先想一个场景MCU通过串口接收一包1000字节的数据。如果不用DMACPU要一条一条地进接收中断把数据从数据寄存器搬到内存缓冲区。假设每个字节都触发一次中断光进中断、出栈、判断标志、搬运数据、更新指针这一套流程就会消耗大量CPU时间。高频通信场景下CPU忙到连业务逻辑都跑不动系统实时性直接崩掉。DMA出现后外设和内存之间的数据搬运完全交给DMA控制器。CPU只管下发“你从UART的DR寄存器搬500字节到buf这个地址”这条指令然后就去跑别的任务。搬运过程中每搬一个字节CPU都不用管等DMA搬完500字节触发一个传输完成中断CPU回来处理这包数据就行。用一个生活化类比CPU好比一个老板让你在楼下等快递。老板不可能一直站在楼下他要处理邮件、开会、写方案。DMA就是那个“前台小哥”快递来了他先代收放满一柜子或者到了约定时间才给老板打个电话汇报“货到了”。老板的精力不被打断整体效率自然高。1.2 一次完整 DMA 传输是怎么走的拆解一次DMA传输的完整流程我习惯把它分成人话能记住的四步配置阶段软件配置DMA通道——源地址、目的地址、数据宽度、传输方向、传输计数、优先级等。在STM32 HAL库场景下就是用HAL_DMA_Init加上HAL_UART_Receive_DMA这类接口初始化并启动。请求阶段外设产生DMA请求。比如串口收到一个字节硬件把DR寄存器填好同时触发DMA请求信号DMA控制器收到请求后并不立即占用总线而是先向总线仲裁器申请总线控制权。传输阶段DMA获得总线所有权把数据从源地址搬到目的地址。每搬一次传输计数寄存器减一。这里要注意总线仲裁的优先级高于CPU访问总线的优先级所以DMA传输期间会出现“CPU被插队”的现象。对实时性要求严格的任务DMA优先级要根据外设实时性需求来设置。完成阶段传输计数减到0DMA控制器产生传输完成中断如果有使能然后把总线控制权交还给CPU。之后可以关闭DMA或重新装载计数值进入下一轮传输。这四步对应到代码里就是“初始化”“启动”“回调”“重新装载”逻辑很清晰。1.3 为什么理解请求信号比记住寄存器更重要很多初学者背寄存器背得很痛苦因为寄存器太多了。我个人的经验是寄存器不用全记但要吃透“请求信号”这个机制。DMA是被动工作的它不会自己搬数据必须有外设或软件触发请求。串口收到数据发请求ADC转换完成发请求定时器触发发请求SPI发送缓冲区空发请求——请求的源决定了DMA在什么时机搬数据也就决定了整个数据流的时序。理解了请求机制你就理解了为什么DMA接收串口数据可以用“接收空闲中断”判断一帧结束因为DMA每搬一个字节外部请求就拉一次接收空闲时DMA就停在那里但UART的IDLE标志会被置起来。这个思路后面串口实战部分还会再展开。2. DMA 传输模式逐个拆解2.1 基本模式与循环模式场景差在很多DMA传输模式在不同芯片上命名略有差异但底层思路通用。以STM32为例最常碰的是BasicNormal模式和Circular循环模式。Basic模式搬完设定的计数值就停止要再次传输必须软件重新使能。适合“搬一次处理一次”的固定长度传输比如一次SPI发送一屏图像、一次ADC采集固定帧数。Circular模式搬完计数值后自动重装回初始值继续下一次传输源源不断。适合连续数据流比如连续采集传感器数据、无间隔的PCM音频流。这两者怎么选看你的业务是“一段一段处理”还是“持续不中断”。ADC连续采样几百个点做平均滤波就可以用Circular模式DMA自己在后台滚动填充缓冲区CPU只需要周期性地去读最新的数据。判断最新数据的位置可以用DMA的当前数据计数器如__HAL_DMA_GET_COUNTER算偏移也可以用双缓冲乒乓机制解决。2.2 突发传输与单次传输为什么带宽差这么多DMA还可以按每次总线占用时搬运多少数据来区分单次传输Single和突发传输Burst。单次传输每次总线事务搬1个数据单元突发传输一次总线事务可以搬4、8、16个数据单元比如INCR4表示地址递增突发4次。突发的好处很明显减少总线仲裁的开销抢占总线的次数变少带宽更高。代价是突发期间会长时间占用总线如果同时有高实时性中断需要低延迟访问内存可能会被拖累。另一个潜在问题是源/目的地址如果不在总线的对齐边界上突发传输可能触发错误Bus Fault或DMA传输错误标志。我的经验是普通串口DMA、ADC DMA用单次传输足够如果做摄像头图像采集、高速存储读写这类需要大规模搬运的场景再考虑突发模式而且先查芯片参考手册确认外设是否支持突发请求。2.3 存储器到存储器模式有点反直觉除了外设到内存、内存到外设这两种常见方向DMA还支持内存到内存Memory-to-Memory缩写Mem2Mem模式。在这种模式下DMA不再等待外设触发请求而是由软件启动后自动连续搬运直到计数值清零。适用场景最典型的是大块内存拷贝、内存填充、某种数据格式转换。比如你要把一帧YUV数据转成RGB或者把零散的几个数据包拼装成一个连续的bufferMem2Mem模式能做得很高效。但要注意因为Mem2Mem模式下DMA在搬运过程中基本占着总线不放搬运时间较长时对CPU实时性影响会比较明显。能用CPU memcpy搞定的短块复制就没必要非上DMA大块复制且系统允许短暂总线抢占才值得用Mem2Mem。关于流控制模式Flow Control如果外设支持可以让外设来当流控制方DMA只负责搬适合某些特殊协议外设。但大多数通用MCU场景流控制方都是DMA控制器本身一般用户不需要去改这个配置。3. 串口 DMA 实战接收不定长数据的三种方案串口DMA应该是大家问得最多的场景。热搜词里的“py32f003使用串口DMA方式接收通讯数据”“使用接收空闲中断判断接收线束”“串口DMA接收不定长数据”其实都指向同一个问题通信协议里一帧数据的长度往往是变化的DMA本身只能按固定计数值搬数据怎么知道“这一帧从哪开始到哪结束”3.1 空闲中断 DMA接收最常用组合思路是这样的DMA只管把串口收到的字节往内存缓冲区里搬你不用预先知道一帧有多长先把DMA接收缓冲区设大一点比如256字节。每收到一个字节DMA搬运一次计数减一。当一帧数据发完、串口线上出现空闲时UART硬件会置起接收空闲标志。这时你读一下DMA剩余计数器用“缓冲区总长度 - 剩余计数”就能算出实际收到的字节数。在STM32 HAL库下配置步骤大概是初始化UART开启IDLE中断使能UART DMA接收用HAL_UART_Receive_DMA(huart, rx_buf, BUF_SIZE)启动DMA接收在UART的IDLE中断回调里用__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)判断空闲标志然后清除标志计算实际接收长度并重新调用HAL_UART_Receive_DMA才能进行下一轮接收。参考代码框架伪代码实际芯片以你的HAL库版本为准#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 如果缓冲区被填满也会走到这里可按需处理 } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); // 暂停DMA接收 rx_len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 实际长度 // 处理这一帧数据 process_frame(rx_buf, rx_len); memset(rx_buf, 0, RX_BUF_SIZE); HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); // 重新启动 } HAL_UART_IRQHandler(huart1); }这里要强调几个细节调用HAL_UART_DMAStop暂停接收后要重新调用HAL_UART_Receive_DMA才能继续接收。实际项目中我建议在IDLE中断里把数据解析和重新启动的动作尽量精简别在中断里做耗时太长的协议解析。清IDLE标志要谨慎不同芯片系列清法不一样有的写0清不了需要读SR再写CR。看参考手册的“清除标志”章节别盲目照搬其他型号代码。如果使用DMA循环模式接收可以不调DMAStop直接读计数器算偏移这就是无缝接收方案但环形缓冲区的读写指针管理要额外处理复杂一点。3.2 DMA 串口发送要不要等上一轮发完这是热搜里一个非常典型的问题“DMA串口发送需要等待上一轮数据发送完吗”。我的答案是一定要确认上一轮发送真正结束再启动新的一轮发送。原因在于DMA发送时你告诉DMA“把这段内存的数据搬到UART的发送寄存器”DMA并不知道你后续会修改这段内存。如果你在上一次传输完成前就修改了buffer内容轻则发出去的数据错位重则出现数据竞争偶发性的问题很难查。在实际开发里我有三种常用处理方式发送完成后中断回调里置标志位。在HAL_UART_TxCpltCallback里把tx_busy_flag清掉发送前检查tx_busy_flag等前一次完成再发下一次。这是最稳妥的做法。使用双缓冲区交替使用两个发送buffer。DMA正在发送buffer A时你把新数据写入buffer B等A发送完成回调后再切换使用。这样不用“干等”吞吐率更高。如果芯片有DMA半传输/传输完成双中断还可以做成乒乓发送效果类似双缓冲。还要注意一种情况有些外设DMA发送的目标是寄存器地址数据源如果是flash里的常量字符串DMA可以正常读取但如果你用DMA发送之后又马上修改源数据就会踩坑。发送静态字符串没问题发送动态拼接缓冲区就要特别小心时序。3.3 环形缓冲区方案适合高频收发场景如果你做的项目串口通信频率很高一帧接一帧不停IDLE中断方案可能不够用因为每次空闲都要停一次DMA再重新启动切换期间如果有数据进来会丢。这时候可以用DMA循环模式接收配合环形缓冲区。关键点在于DMA循环模式下DMA写完缓冲区末尾后会自动回到开头继续写形成“环”。你要做的就是记录DMA当前写到哪了通过读计数器算写指针然后管理一个读指针。每次空闲中断或者定时轮询就把两个指针之间的数据取走处理更新读指针。代码上需要额外处理指针回绕、读指针追上写指针说明数据没来得及处理被覆盖等问题。这个方案虽然更复杂但在实际产品里非常常见尤其是Modbus、私有协议等持续通信场景。热搜里的“freemodbus dma”本质就是把Modbus的帧接收封装成DMA空闲中断或环形缓冲区驱动的串口驱动层上层协议解析不用关心底层是DMA还是中断方式。这种分层隔离的思路在工程上很值得推广——驱动层稳定上层业务改动就小。4. ADC 多通道 DMA 采样实践4.1 用DMA做ADC采样解决了什么ADC如果不用DMA常规做法是启动转换、等待转换完成标志、读数据寄存器。单通道还行多通道连续采样就痛苦了假设4个通道连续采100轮就是400次等待400次读取CPU几乎被占满。用DMA接管ADC数据搬运后ADC转换完成自动触发DMA请求DMA把结果写到内存数组里400个采样点采完CPU才收到一个完成中断。既省CPU又能保证采样数据的连续性不会因为中断延迟而丢采样点。对实时性要求较高的工业采集场景DMA连续采样几乎是标配。比如电机三相电流采样三个通道要尽可能同时刻采样DMA把结果连续搬到内存CPU在固定周期统一处理这种模式比“采样一个中断一次”要稳定得多。4.2 HAL库 ADC 多通道 DMA 配置步骤STM32 HAL库下ADC用DMA多通道采样有几个关键点ADC参数配置选择扫描模式Scan Mode使能、连续转换模式Continuous Conv Mode按需求选择、DMA连续请求DMA Continuous Requests务必使能。如果不使能DMA连续请求很多芯片上ADC采一轮就会停DMA收不到后续转换结果——这是热搜里“DMA continuous requests”的直接关联点。DMA配置数据宽度和外设寄存器保持一致16位数据就都用Half Word方向是外设到内存模式根据需求选单次或循环。数据缓冲区大小多通道场景下DMA缓冲区里每个通道的数据是交叉存放的比如通道0、1、2、3依次循环那么缓冲区长度至少是通道数×每通道采样次数。参考配置如下ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; uint16_t adc_buf[4][100]; // 4个通道各100次 void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; hadc1.Init.ContinuousConvMode ENABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.DMAContinuousRequests ENABLE; // 关键 hadc1.Init.NbrOfConversion 4; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; HAL_ADC_Init(hadc1); // 配置4个通道的rank和采样时间略 HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, 400); }转换完成后adc_buf[0]、adc_buf[1]、adc_buf[2]、adc_buf[3]依次是通道0、1、2、3第一次采样结果第101、102、103、104个元素是第二次采样结果以此类推。在处理数据时要按这个排列规律取数别想当然地认为同一个通道的数据会连续放在一起。4.3 多次采样的数据对齐与平均滤波热搜里“stm32 hal库adc单通道dma多次采样”这个场景做应用层时通常还要配合滤波。最简单的就是一阶平均滤波多次采样结果求和取平均抗噪声能力比单次采样好很多。但要注意ADC数据寄存器位宽和DMA搬运的数据宽度必须匹配。如果芯片ADC是12位数据寄存器是16位那DMA的PeripheralDataSize和MemoryDataSize都用Half Word16位缓冲区用uint16_t数组。有些芯片存在16位对齐问题一个ADC转换结果占两个字节DMA指针按8位计算时要除以2这种细节最容易让新手卡住。另外如果做的是电池电压检测这类低频采样用单次模式即可DMA每采完指定帧数就停CPU在完成中断里做平均滤波再把缓冲区清零重新启动。如果做的是传感器连续监测更建议用循环模式DMA持续滚动填缓冲区CPU用定时器周期性去取最新一批数据。两种模式的取舍本质上是“你要的是峰值数据还是持续趋势”的业务问题。5. 进阶视角UFS DMA、分布式 DMA 与性能验证5.1 UFS DMA 里的 DMA 有什么不同UFSUniversal Flash Storage场景下的DMA和我们MCU里的DMA不太一样但底层思想相通。UFS控制器负责管理和UFS设备之间的数据通路DMA则负责把主机内存里的数据和控制器缓冲区之间做搬运。Flash访问通常是按块进行的读写的性能瓶颈往往不在Flash本身而在主机侧的数据搬运效率。在移动平台或者存储控制器里DMA被设计成支持更大的突发传输、更深的队列、更灵活的地址映射配合命令队列、中断聚合等机制把存储IO的数据搬运开销降到最低。如果你做的是有存储需求的应用比如视频录像、大文件日志要特别关注DMA burst大小和通道数量的配置配置得不好会出现“明明外设性能很好实测吞吐率却上不去”的问题。举个具体例子视频录像要持续写入SD卡或UFS每帧数据量很大如果用普通中断搬运CPU在腾挪数据上消耗太多帧率稍有波动就会掉帧。DMA配合大缓冲区一次搬运一整帧CPU只负责在帧与帧之间的空隙做文件系统操作整条链路的吞吐率才能跑起来。5.2 分布式 DMA 是什么适合什么场景“分布式DMA”这个词在不同语境里含义不完全一样。在嵌入式多核/多外设场景里分布式DMA可以指多个DMA控制器分布在系统不同位置各自服务不同的外设集群减少单一DMA控制器的瓶颈在计算机网络/存储领域它也可能指RDMARemote DMA这类远程直接内存访问技术让网卡或存储设备直接把数据写入远端主机内存。普通MCU开发者不用被这个词吓到分布式DMA的底层还是“源地址-目的地址-搬运长度”这套模型只是多了跨节点、跨总线的地址映射和权限管理。真碰到这类需求重点先搞清DMA控制器的地址空间和总线拓扑再看各节点之间是否支持内存一致性。说到底DMA的“搬”字没变变的只是“从哪搬到哪”的复杂度。5.3 DMA 测速软件与性能验证方法热搜里有“dma测速软件”这个词。这类工具在PC平台常用用于测试磁盘、网络或设备驱动的DMA吞吐带宽嵌入式平台上一般没有现成“软件”而是自己写测试代码来测。我自己常用的方法在内存里申请两个缓冲区块用DMA的Mem2Mem模式搬一个大块数据比如1 MB同时用定时器计时算实际吞吐率。也可以对比CPU memcpy搬运同样大小数据的时间就能量化DMA的收益。uint32_t src_buf[1024]; uint32_t dst_buf[1024]; uint32_t start_tick, end_tick; float bandwidth_mbps; start_tick HAL_GetTick(); // 配置并启动Mem2Mem DMA长度1024*4字节 HAL_DMA_Start(hdma_mem2mem, (uint32_t)src_buf, (uint32_t)dst_buf, 1024); HAL_DMA_PollForTransfer(hdma_mem2mem, HAL_DMA_FULL_TRANSFER, TIMEOUT); end_tick HAL_GetTick(); bandwidth_mbps (4096.0f * 1000.0f) / ((end_tick - start_tick) * 1000.0f);要注意DMA搬运大块数据时会抢占总线如果同时跑着其他性能测试数据会有偏差。建议在测试时记录CPU的繁忙率避免把DMA对总线的占用误判为外设瓶颈。还有一个细节DMA实际测速时源和目的地址的对齐方式、缓存Cache策略都会影响结果地址没对齐可能导致性能骤降。6. 常见问题与排查技巧实录6.1 DMA疑难杂症速查表直接在项目里踩坑踩出来的经验整理成表现象常见原因解决办法DMA接收偶发丢字节缓冲区过小、未及时取走数据增大缓冲区或改用循环模式环形缓冲发送DMA后数据不对修改了DMA正在搬运的源数据加发送完成标志/双缓冲严格串行化读写ADC DMA只采一轮就停DMA连续请求未使能使能DMAContinuousRequestsDMA传输完成中断不触发中断优先级配置错误/未使能检查NVIC优先级和DMA中断屏蔽位搬运数据错位高低字节颠倒数据宽度配置不一致外设和内存均设为Half Word/WordDMA地址错误/总线错误地址未对齐或访问非法区域检查地址对齐和内存权限带了Cache后DMA数据是旧的缓存一致性问题使用一致性内存或做Cache Clean/InvalidateDMA触发频繁导致CPU卡顿优先级过高/突发过大降低DMA优先级缩小突发长度6.2 排查DMA问题的通用流程真遇到DMA问题别急着改代码瞎试。我一般按这个流程走读回寄存器状态。HAL库下可以把DMA的NDTR剩余计数、外设的状态寄存器都读出来打印。先从寄存器层面确认DMA有没有启动、计数有没有变化。这能快速判断“DMA压根没跑”还是“跑了但数据错了”。用逻辑分析仪或示波器观察外设信号。比如串口DMA接收丢字节先看波形上数据是否完整确认外设没问题再查DMA。缩小范围做最小复现。把业务逻辑砍掉只保留DMA外设的最小代码看问题是否复现。很多时候问题出在“DMA搬完数据后你又在下一次启动前改了缓冲区”而不是DMA本身有问题。检查Cache一致性。用了带Cache的MCU比如Cortex-M7DMA和CPU访问同一块内存时要特别注意。CPU读到的是Cache里的旧数据或者DMA读到的是Cache里还没刷新的脏数据。解决办法是用无缓存的DMA缓冲区或者搬运后显式Clean/Invalidate。6.3 几个容易踩的坑血泪教训第一个坑DMA中断里不要做耗时操作。虽然DMA完成中断很诱人但中断函数里千万不要做协议解析、打印日志之类的重活。把这些逻辑扔到主循环或实时任务里中断里只置标志位。第二个坑重新启动DMA前要确保上次传输完全结束。除了前面说的发送场景接收场景也有类似问题。有些芯片重新启动DMA前需要等通道完全停止比如查DMA_ISR的TEIF/TCIF标志粗暴地直接调启动接口偶发会出现第一次启动正常、第二次启动异常的情况。第三个坑外设配置和DMA配置的顺序。比如SPI DMA发送要先把SPI使能、DMA通道配置好再启动传输。顺序反了有些外设会直接出错。参考手册里都有明确的“使能顺序”照着来最稳妥。还有个小技巧把DMA缓冲区定义在独立的、地址对齐的区域。很多DMA要求源或目的地址对齐到数据宽度用uint8_t数组做16位DMA搬运时地址可能不对齐。改用uint16_t数组或者在C里用__attribute__((aligned(4)))强制对齐能避免不少奇奇怪怪的问题。最后再分享一个我自己的习惯拿到不懂的DMA问题时先把DMA配置从头到尾读一遍往往能发现问题如果确实查不出来就让它先跑起来用计数器读值、打印关键节点慢慢缩小范围。DMA这东西配置正确了它比谁都可靠配置错了它比谁都难查。把上面这些模式、标志和时序吃透了后面再碰到DMA相关需求基本就是水到渠成的事。