嵌入式DMA完全指南:从工作原理到串口、ADC实战踩坑
发布时间:2026/9/7 4:01:00 作者:尧图编辑部 阅读量:1,286

搞嵌入式的谁没被DMA坑过两回我第一次调STM32串口DMA接收不定长数据数据总是时不时多一帧、丢一帧整整排查了两天才发现是空闲中断和DMA计数器读取顺序的问题。从那以后我就特别在意DMA到底是怎么一步一步把数据搬完的——请求怎么发起、总线怎么仲裁、计数怎么递减、中断什么时候来每一个环节都可能成为Bug的温床。DMADirect Memory Access直接存储器访问。说直白点就是给CPU雇了一个专门搬数据的搬运工。串口、ADC、SPI这些外设和内存之间的大量数据搬移如果全靠CPU在中断里一个字节一个字节地搬那CPU基本什么都别干了。DMA允许外设和内存之间直接建立数据传输通道搬完数据后打断CPU一下说声活干完了CPU再去处理。这篇文章我会把DMA的工作流程、传输模式以及串口、ADC、存储和分布式系统中的典型应用场景系统梳理一遍重点聊聊我实际调过的串口不定长接收、HAL库ADC单通道DMA多次采样、FreeModbus兼容这类场景里踩过的坑。不管你是刚开始接触MCU的新手还是已经在和DMA搏斗的老手这篇都值得当作一份参考手册。1. 先看DMA凭什么替CPU干活请求、仲裁、搬运、中断一条线1.1 没有DMA之前每个字节的搬运都是对CPU的一次打断先回到没有DMA的时代。假设串口以115200波特率接收数据每个字节大约87微秒。传统中断方式下每收到一个字节CPU都要经历一次完整的中断流程检测到RXNE标志、产生中断请求、保存现场、读取串口数据寄存器、写入内存缓冲区、恢复现场。也就是说CPU每87微秒就被打断一次。如果数据量很小这不痛不痒但如果有三四个串口同时工作、ADC又高频采样、SPI还在刷屏CPU的大部分算力就消耗在现场保存-现场恢复这种纯粹的上下文切换里真正跑业务逻辑的时间被严重压缩。很多人一开始不理解觉得读一个寄存器、写一个字节能有多慢单看确实不慢怕的是次数。一个系统里每秒几万次、几十万次的数据搬运每次都要中断进出累积起来的开销非常可观。这还没算中断嵌套、临界区保护带来的额外损耗。1.2 DMA的底层思路从寄存器搬运变成总线搬运DMA的思路是绕过CPU的通用寄存器让DMA控制器本身作为总线上的一个主设备直接发起读改写操作。也就是说搬运数据这件事不再经过外设寄存器→CPU累加器→内存这条路而是变成外设寄存器→DMA内部缓存→内存。这里有一个必须拎清楚的点DMA控制器不是简单地替CPU执行几条指令它是独立于CPU运行的总线主设备。CPU要做的只是在搬运开始前把任务参数配置好源地址、目标地址、数据长度、传输方向、传输宽度、模式然后给DMA发一个启动信号。后面的重复读写由DMA控制器自己的状态机完成。打个比方。CPU是工厂老板数据是货箱内存是仓库。不用DMA时每来一箱货老板都要放下手头的文件、站起来、走到门口、把箱子搬进仓库、再坐回办公桌全程亲力亲为。用DMA相当于雇了一个专职叉车司机。老板只需要提前交代把这100箱搬到A仓库叉车司机自己搬搬完了按一声铃通知老板验收。DMA就是那个任劳任怨、搬完还会主动汇报的叉车司机。1.3 一次DMA传输的完整流程拆解一次典型的DMA传输从外设发起到完成链路大概是这样的外设产生DMA请求。比如串口收到一个字节RXNE置1的同时硬件逻辑会向对应DMA通道发出请求信号。DMA控制器接收请求读取当前的配置寄存器解析出源地址、目标地址、传输方向和剩余传输计数。DMA申请总线控制权。这一步非常关键因为总线上同时可能有CPU、其他DMA通道、甚至其他总线主设备在访问内存必须根据优先级和仲裁策略排队。DMA拿到总线后执行一次数据搬运从源地址处读出数据放进自己的内部暂存然后写入目标地址。对于外设到内存方向这一步等于是把外设数据寄存器里的数据读出来写到内存缓冲区。传输计数器NDTR减1。如果计数不为0回到第1步等待下一次外设请求如果计数到0则置起传输完成标志并根据配置产生中断。CPU在中断服务程序里做后续逻辑处理。需要提醒的是第5步有个常见的理解误区DMA并不是一口气搬完所有数据的。除非配置成突发模式否则每一次搬运对应一个外设请求搬运一个单位的数据然后等待下一个请求。所以DMA传输的总时长本质上还是由外设产生数据的节奏决定的。1.4 配置DMA之前必须搞懂的传输宽度和地址对齐DMA传输宽度有8位、16位、32位三种常见配置。源端和目标端的宽度可以不同但这里藏着很多初学者容易踩的坑。以串口为例串口数据寄存器是8位的所以DMA方向是外设8位→内存8位配置成字节宽度就行。ADC转换结果是12位或16位DMA方向通常是外设16位→内存16位配置成半字宽度。如果源宽度和目标宽度不一样DMA控制器需要FIFO配合做数据拼接或拆分配置复杂度会高不少。我踩过一次很典型的坑SPI读Flash ID的时候把DMA配成字节宽度去读半字寄存器结果读回来的数据整体错位ID怎么都对不上。后来老老实实按数据寄存器宽度配置成半字问题立刻消失。所以拿到一个新外设第一步永远是查参考手册里数据寄存器的位宽和DMA请求映射表搞清楚这个外设挂在哪个DMA控制器的哪个通道上而不是照抄别的工程的配置。另外DMA缓冲区地址的对齐问题也要留意。部分DMA控制器要求内存缓冲区地址按字对齐比如双缓冲模式下两个缓冲区的地址都要按特定边界对齐。如果不对齐轻则数据错乱重则硬件产生总线错误。2. DMA传输模式怎么选单次、循环、突发、双缓冲2.1 普通模式Normal搬完设定数量就停普通模式是最基础的模式。DMA完成设定的传输计数后产生传输完成事件然后通道自动关闭不再响应外设的新请求。想再次传输必须手动重新设置传输计数并重新使能DMA通道。适合普通模式的场景很明确数据长度固定的传输比如一次性读取固定长度的传感器数据、EEPROM/Flash页读写、固定长度串口报文接收。新手在普通模式下最容易犯的错是在串口接收场景里直接依赖DMA传输完成中断来判断一帧数据到了。如果每帧长度固定还好一旦变成不定长协议一帧数据还没把DMA的NDTR耗尽下一帧又来了整个计数逻辑全部乱套。结果就是数据错位、丢包、多包混在一起。所以不定长接收不要指望普通模式后面CH3会细说。2.2 循环模式Circular搬完自动掉头适合连续采样循环模式是DMA最强、也最容易出问题的模式。传输计数到达0后不需要CPU参与地址计数器自动回到起始地址传输计数自动恢复初始值DMA继续等待下一次外设请求。整个过程完全无感CPU彻底不用管数据搬运这件事。循环模式的典型场景有两个一是ADC连续采样DMA不断把转换结果写入内存数组二是串口DMA接收外设持续发数据DMA持续往接收缓冲区里写配合空闲中断就可以判定每一帧的边界。循环模式的好处是缓冲永远在线坏处是数据会被覆盖。如果CPU处理数据的速度赶不上DMA写入的速度旧数据被新数据覆盖整个缓冲区的信息就废了。所以循环模式必须配合半传输中断或者传输完成中断以半缓冲为单位做流水线处理。把缓冲区切成两半DMA写前一半的时候CPU处理后一半等到半传输中断到了再交换角色。这就是经典的双缓冲思想也是后面ADC实战里要讲的重点。2.3 突发传输与FIFO性能选项高速外设更依赖突发模式下DMA一旦获得总线控制权可以连续传输一组数据比如4个、8个甚至16个节拍传输完毕才释放总线。这样做的好处是减少了总线仲裁和切换的次数让外设能获得一个相对稳定的高吞吐路径。突发模式在UFS、SDIO、以太网这类高速外设里几乎是标配。它的本质是一次谈判多批搬运省去了频繁握手消耗的时间。不过这也带来一个副作用总线上DMA长时间占用CPU可能被饿到响应变慢。好在大部分总线仲裁都有轮询或优先级抢占策略实际影响取决于芯片设计。MCU的DMA也有FIFO选项。FIFO可以缓冲数据完成源和目标宽度的匹配也能在突发模式下平滑数据流。但配置FIFO时需要额外关注阈值设置阈值设得太高FIFO容易满外设可能等待阈值设得太低突发传输的批量搬运优势又发挥不出来。2.4 双缓冲模式两片缓冲区轮流接活双缓冲模式的目标很纯粹A缓冲区DMA正在写B缓冲区CPU正在处理处理完了交换指针DMA转去写BCPU处理A。两者在时间上错开天然解决了覆盖和等待的矛盾。STM32的DMA双缓冲不是简单配两个NDTR就完事的而是利用Memory0/Memory1两个地址寄存器配合传输完成中断切换当前目标地址。CPU处理完一个缓冲后要将当前缓冲区的所有权交还给DMA同时确认另一块缓冲区的数据已经被妥善处理完。我的建议是初学者不要一上来就上双缓冲代码逻辑容易绕晕。先把循环模式半传输中断跑通理解缓冲区分半处理的节奏再去接触双缓冲模式会顺很多。四种模式怎么选我整理过一个非常简单的对照逻辑传输长度固定传完就拉倒普通模式外设持续采样/接收CPU不定期取数据循环模式高速外设需要稳定大带宽突发FIFO数据量大、CPU处理慢要求不丢数双缓冲或循环半满中断3. 串口DMA实战发送等待、不定长接收与FreeModbus兼容3.1 串口DMA发送到底要不要等上一轮发完答案是要等。而且等的不是调用了发送函数这个动作完成而是DMA已经把缓冲区里的数据全部搬到串口数据寄存器这件事完成。道理很简单。串口DMA发送的流程是你把缓冲区首地址和长度交给DMA之后DMA会在串口数据寄存器为空的时候自动从缓冲区取数据填入发送移位寄存器。这个搬运是异步的和CPU主线完全并行。如果你在DMA还没搬完之前就修改缓冲区内容或者再次调用新的DMA发送上一帧数据的后半部分就可能被改成新内容或者直接和新发送的数据混在一起。具体判断方式有两种。第一种是查询轮询HAL_UART_GetState等状态从HAL_UART_STATE_BUSY_TX脱离再发起下一次发送。第二种更推荐用中断回调注册UART_TxCpltCallback在这个回调里才能释放缓冲区、准备下一帧数据。这里有一个特别坑的细节HAL_UART_Transmit_DMA函数返回HAL_OK只代表DMA启动成功绝不代表数据发送完了。所以不能把函数返回值当成发送完成信号。发送完成标志只在TxCpltCallback里才有效。我在实际工程里的做法是维护一个发送状态枚举IDLE、SENDING、DONE。要发新帧前先看状态如果是SENDING就直接挂起或者丢弃等DONE了再切到发送流程。这套状态机可以完美避免上一帧还没发完就启动下一帧的问题。3.2 不定长接收的核心矛盾与三种解法串口协议里定长是少数不定长是常态。但DMA有一个根深蒂固的矛盾你必须在启动传输前告诉它要搬多少字节。如果不知道长度DMA该怎么工作业内大致有三种解法。解法A固定帧长。协议约定每帧固定N个字节用DMA传输完成中断作为帧边界。简单粗暴但改协议的成本比较高不适合灵活性强的场景。解法B帧结束符。接收端解析数据发现特定字符比如0x0D 0x0A就认为一帧结束。问题在于二进制协议里结束符可能出现在数据中间需要做转义处理CPU参与度会上去。解法C串口空闲中断DMA循环模式。串口在接收完一个字节后如果线路保持空闲超过一个字节时间没有新的起始位硬件会置起IDLE标志产生空闲中断。空闲中断天然的语义是线上的数据告一段落正好对应一帧数据接收完毕。配合DMA循环模式既不用知道帧长也不用担心缓冲区写满是当前最主流的方案。我强烈推荐方案C。下面就直接给它一个可以照抄的实现框架。3.3 串口空闲中断DMA的标准实现步骤以STM32 HAL库为例配置流程是这样定义DMA接收缓冲区建议按协议允许的最大帧长来定义。比如最大帧256字节就定义uint8_t dma_rx_buf[256]。启动前清一次接收缓冲然后调用HAL_UART_Receive_DMA(huart, dma_rx_buf, sizeof(dma_rx_buf))让DMA进入循环接收。手动使能空闲中断__HAL_UART_ENABLE_IT(huart, UART_IT_IDLE)。这一步大家最容易忘很多人的DMA接收能拿到数据但永远触发不了空闲中断就是漏了这行。编写串口中断服务函数。在UART_IRQHandler里判断IDLE标志是否置起。如果置起先读取DMA剩余的计数uint16_t remain __HAL_DMA_GET_COUNTER(hdma_uart_rx); uint16_t received sizeof(dma_rx_buf) - remain;这个received就是本次空闲到来时DMA已经写入缓冲区的有效字节数。处理数据把dma_rx_buf里的received个字节拷贝到协议解析缓冲区置一个标志位让主循环去处理。__HAL_UART_CLEAR_IDLEFLAG(huart)清除空闲标志等待下一帧。我用这个方案在PY32F003上也做过一版参考例。PY32F003是小资源封装MCU但串口和DMA的用法和STM32基本一致核心区别只在外设时钟使能和引脚配置。代码迁移成本很低关键是理解空闲中断定帧边界、DMA循环模式做缓冲这个组合拳。3.4 空闲中断里最容易翻车的三个细节以及FreeModbus的兼容问题先说三个细节。细节一先读NDTR再清IDLE标志。如果你先把IDLE标志清了再去读计数器极端情况下新数据已经到了计数器已经变化你算出来的长度就是错的。所以正确顺序必须是读计数器→计算长度→拷贝数据→清IDLE标志。细节二IDLE中断里不要做耗时操作。中断里如果做协议解析、printf、内存拷贝大块数据一旦超过下一帧数据的到达间隔帧序就乱了。正确做法是中断里只做数据搬家和置标志把真正的协议解析放到主循环。细节三半包和粘包问题。空闲中断判断的是线路空闲不是协议帧结束。如果两个协议帧之间间隔太短可能被当成一帧如果一帧被拆成两段发送又会被当成两帧。所以协议层还需要自己的帧头校验和长度字段来兜底。再说FreeModbus。FreeModbus处理RTU帧时有一个严格的时序要求3.5个字符时间内没有新数据就认为一帧结束。如果用DMA接收就不要依赖DMA传输完成中断来判断帧边界而应该保留FreeModbus自己的定时器机制让超时中断来触发帧处理。DMA在这里只负责把数据搬进缓冲区定时器负责划定边界。我自己实测下来这样既保留了DMA低中断频率的优点也不会破坏Modbus的帧间隔语义。4. 用DMA批量搬运ADCHAL库单通道多次采样的配置实战4.1 单通道DMA多次采样的HAL库配置流程STM32 HAL库做ADC单通道DMA多次采样最典型的姿势是ADC连续转换DMA循环传输内存数组。配置步骤初始化ADC分辨率为12位开启连续转换模式。这里说清楚连续转换模式是DMA能持续搬运的前提。如果只开单次转换ADC只转换一轮DMA搬运完一轮数据后通道也会停。配置DMA循环模式数据宽度为半字内存地址自增。定义采样数组比如uint16_t adc_buffer[256]。先做ADC校准再调用HAL_ADC_Start_DMA(hadc, (uint32_t *)adc_buffer, 256);配置完成后ADC每转换完一个通道DMA就自动把转换结果写入数组下一个位置。数组写满255后DMA回绕到数组开头继续写。CPU完全不用管转换这件事需要用数据时直接读数组做平均或滤波。这里有个很容易踩的坑忘了在CubeMX里开启ADC的连续转换模式CONTINUOUS_CONV。这种配置下DMA只搬一次数据就再也不动了现象表现为数组只有第一个值有效后面全是0。排查这个坑其实很快但新手很容易在DMA配置上翻来覆去找半天。另一个坑ADC校准必须在启动DMA之前完成。如果在DMA启动后做校准校准过程占用了ADCDMA拿到的数据可能是校准期间的无效转换结果。4.2 多通道扫描模式与数据不对齐的坑多通道扫描模式下ADC按扫描序列依次转换多个通道DMA按转换完成顺序把结果写入数组。这里要注意DMA写入数组的顺序严格对应转换完成的先后顺序不是你想当然的通道号顺序。最容易出问题的场景是中途修改了扫描序列或者启动了注入通道。比如你原本扫描了CH0、CH1、CH2三个通道DMA写数组的顺序是[CH0, CH1, CH2]循环。某次优化时你加了一个CH3到扫描序列里但DMA缓冲区的处理逻辑还按三个通道一组的格式去解析整个数组的下标对应关系全部错位。解决思路是不要在中途随意改动扫描序列或者在协议解析层用每个通道的绝对序号来索引数据不要用数组下标%这种偷懒的相对位置公式。我在实际项目里把扫描序列固定写死任何改动都走配置版本管理倒逼自己不要在运行期动态调整ADC扫描序列。4.3 半传输中断把采样缓冲变成流水线蓄水池如果ADC采样率很高比如32K采样率、持续采集波形CPU就不能等转换完一整批再去取数据否则缓冲会被覆盖。这时候就要用前面提到的半传输中断循环模式。具体做法缓冲区定义成2的整数倍比如1024个半字。DMA传输一半、也就是512个点的时候触发半传输中断传到满、也就是1024个点时触发传输完成中断。在两个中断回调里分别处理前512点和后512点的数据。由于DMA是循环模式处理完1024点后DMA自动回绕不会停下来等CPU。这样一来缓冲区分成了两块DMA写一块、CPU处理另一块时间上完全错开不丢数据也不需要频繁进中断。这个思路其实和串口的环形缓冲是同一个原理但很多人容易忘记半传输中断这个好东西导致要么缓冲区开很大、要么频繁进传输完成中断两头不讨好。5. 放大视野UFS DMA、DMA连续请求与分布式DMA5.1 UFS主机控制器里的描述符DMA聊完MCU里的DMA再往外看一眼DMA在大系统里的形态要复杂得多。UFS存储协议里的DMA就是一个典型代表。UFS主机控制器内部普遍集成了DMA引擎用来把存储设备读出的数据快速搬到系统内存。这种DMA通常具备多队列、多描述符、突发传输等能力。它和MCU中DMA最大的区别在于它支持描述符链机制DMA控制器自己会从内存读取一个描述符表描述符里写明了源地址、目标地址、数据长度DMA处理完一个描述符后自动加载下一个CPU只需要把一批描述符准备好然后交给DMA去跑。UFS里管这个叫PRDTPhysical Region Descriptor Table。它的好处是CPU一次性可以下发大量IO请求而不是每搬一段数据都来干扰一次。这其实是一种更高级的DMA设计模式——用描述符驱动来降低CPU的参与频率。理解了这个模式再回头看MCU里简单的寄存器配置DMA思路会不一样。5.2 分布式DMA一个芯片里多个DMA引擎各自干活分布式DMA的意思是DMA控制器不止一个而是分散在系统各个子系统内部。USB控制器有自己的DMA引擎以太网控制器有自己的DMA引擎显示控制器也有自己的DMA引擎各搬各的数据互不干扰。这样设计的好处很明显一是避免所有DMA请求都挤到一个中央DMA控制器造成总线瓶颈二是各个子系统可以并行搬运数据吞吐量大幅提升三是每个DMA引擎可以针对自己外设的特点做定制优化比如以太网DMA专门处理描述符环显示DMA专门做二维地址搬运。MCU里也能看到这种思想。STM32F4只有一个DMA1和一个DMA2通道再多也是两个中枢到了STM32H7DMA1、DMA2、BDMA、MDMA分工明确有管低速外设的、有管内存到内存高速搬运的。MDMA甚至可以看成DMA的DMA它由普通DMA触发负责把数据从SRAM搬到外部存储器形成两级流水线外设→DMA→SRAM→MDMA→大容量外部存储。5.3 DMA连续请求模式与应用场合DMA连续请求对应英文里的DMA continuous requests。简单理解就是DMA请求信号在配置好之后保持有效DMA控制器不需要等外设一个一个地请求而是连续发起总线传输直到传输计数归零。它适合数据流一旦启动就要稳定持续的场景比如音频流播放、显示控制器刷屏、高速数据采集。连续请求模式本质上是把性能发挥到极致但也意味着DMA对总线的占用时间更长。设计时要注意总线仲裁策略给CPU留出足够的带宽否则可能出现CPU被DMA挤兑、系统响应变慢的情况。另外从MCU到SoCDMA测速优化时常有人用IO翻转加示波器来估算DMA实际传输效率简单但好用。6. DMA疑难杂症排查实录六个我踩过的坑6.1 先读NDTR还是先清IDLE标志前面已经提到过一次但值得单独再讲一遍因为它太隐蔽了。出问题的代码长这样__HAL_UART_CLEAR_IDLEFLAG(huart); uint16_t remain __HAL_DMA_GET_COUNTER(hdma_uart_rx); uint16_t received RX_BUF_SIZE - remain;看起来没什么问题但如果你运气不好在清完IDLE标志后、读NDTR计数器前串口正好收到下一个新字节DMA的计数器会先减1你算出来的received就少了一个字节。这是真正的随机性Bug时有时无很难复现。正确顺序是反过来uint16_t remain __HAL_DMA_GET_COUNTER(hdma_uart_rx); uint16_t received RX_BUF_SIZE - remain; __HAL_UART_CLEAR_IDLEFLAG(huart);先锁定数据长度再清标志准备下一帧。这一行代码的顺序差异我见过有人调了一个星期。6.2 DMA中断优先级配置不当导致偶发丢包另一个经典案例系统里同时跑USB、LED点阵刷新、两路串口。串口DMA的传输完成中断优先级设得很低主循环稍微繁忙一点DMA传输完成中断就被其他中断顶住迟迟得不到处理。最要命的是中断里如果还要做协议解析处理时间一长下一帧数据可能已经把DMA缓冲区覆盖了表现出来就是偶发性的丢包、错位。我最后的解决方案是串口DMA接收的传输完成中断和串口空闲中断都调到较高优先级中断回调里只做数据拷贝和置标志绝不做协议解析。协议解析放主循环用状态机处理。这样调整之后丢包率直接降到0。6.3 带Cache的MCU上DMA与D-Cache的一致性问题这个问题在Cortex-M7、Cortex-M35P这类带D-Cache的MCU上特别典型。DMA直接把外设数据写进内存之后CPU去读的时候有可能命中的是Cache里的旧数据导致读出来的数据不是DMA刚刚写进去的。反过来CPU往内存写数据后启动DMA发送DMA可能读到的是Cache还没刷到内存的旧数据。处理办法有三种把DMA缓冲区所在内存区域配置为MPU的不可缓存区。适合DMA频繁操作的共享缓冲性能损失可控。每次DMA接收前调用SCB_CleanDCache接收完成后再调用SCB_InvalidateDCache。关掉整个D-Cache。性能影响太大不推荐。我第一次在H7上调DMA接收时数据死活不对示波器看波形没毛病后来才意识到是Cache一致性在里面捣鬼。从那以后写H7工程的第一天就把MPU配置好不然后面所有DMA外设都会轮流出事。6.4 缓冲数组别放栈上、地址要按边界对齐最后一个坑特别基础但很多人还是会犯DMA缓冲区定义成局部变量。局部变量在栈上栈地址受函数调用深度和编译器分配影响不稳定而且栈空间一般不大动辄256字节的缓冲区放栈上很容易栈溢出。正确做法是定义成全局变量或静态变量最好再加上对齐属性__ALIGNED(32) static uint8_t dma_rx_buf[256];有些DMA控制器对缓冲区地址有对齐要求比如双缓冲要求两个缓冲区地址按块大小对齐。用__ALIGNED显式声明可以在编译期就保证对齐省得运行业出诡异问题。说到排查给一个通用思路遇到DMA数据不对先查地址、长度、模式、宽度四个基本参数再查中断优先级、Cache一致性、标志位操作顺序。绝大多数DMA疑难杂症都能在这七条里找到答案。最后分享一个我自己的调试习惯每换一块开发板第一件事不是写业务逻辑而是写一版最小的DMA回环测试。串口发送DMA环回、ADC单通道DMA采样、定时器触发DMA搬运三个测试都过了再往上叠应用逻辑。别笑这套东西看着简单真能帮你把板子有没有问题和代码有没有问题快速分开省下的调试时间远远超过写测试本身。