
搞嵌入式的谁没被DMA绕晕过几回刚学单片机的时候总觉得DMA是个玄学明明中断就能收数据为什么非要搞个“直接内存访问”后来真正上手调串口不定长接收、ADC多通道连续采样才明白DMA不是锦上添花而是刚需。这篇就围绕DMA的工作流程、传输模式、典型应用场景和调试心得展开内容覆盖从Cortex-M0到高性能SoC的常见用法也把DMA发送要不要等上一轮完成、空闲中断判断接收结束这类高频问题一次性说清楚。不管你是刚接触DMA的初学者还是已经被DMA疑难杂症折磨到怀疑人生的老手这篇都值得花十分钟看完。我有信心看完之后你再遇到DMA相关问题至少知道该往哪个方向排查。1. DMA到底在解决什么问题1.1 为什么不能全靠CPU搬运数据先想一个最朴素的场景串口波特率115200去掉起始位停止位理论上一秒大概能收11520字节折合每字节约86.8微秒。看着频率不高但如果每收一个字节都进一次中断CPU要做的事情远不止读一个寄存器现场压栈、读状态寄存器、判断错误标志、读数据寄存器、把数据存入数组、更新下标、判断是否收到完整帧、最后出栈返回。这一套下来几十个周期就没了。在115200波特率下勉强能撑住但要是换成1M波特率、四路串口同时收发再叠加ADC采样、PWM控制、显示刷新CPU迟早被搬运工作拖垮。DMA的本质就是把这个“从外设数据寄存器搬到内存”的动作外包出去。你可以把CPU想象成厨师DMA就是传菜员。厨师负责炒菜传菜员负责把菜端到对应桌台如果每炒一个菜都得厨师亲自跑一趟后厨早就瘫痪了。DMA控制器是一个独立的总线主设备它能在不占用CPU执行流水线的情况下按照你预先配置好的“源地址、目的地址、传输长度”把数据从A点搬到B点。CPU只需要在传输开始前交代清楚任务在传输完成后收到一个中断通知中间整个过程CPU可以该干嘛干嘛。在实际项目中DMA带来的收益除了降低CPU占用率还有两个容易被忽略的点一是降低系统功耗因为CPU可以在DMA搬运期间进入休眠或低功耗模式二是提高数据搬运的确定性尤其是在ADC连续采样、高速通信这些场景下如果靠CPU逐次响应外部请求响应延迟的抖动会导致采样间隔不均匀而DMA的搬运节奏是硬件保证的不存在中断延迟抖动的问题。1.2 DMA在系统总线里到底是什么角色要理解DMA得先把它的“身份”搞明白。在MCU内部的总线架构里CPU、DMA控制器、Flash、SRAM、各类外设都挂在总线矩阵上。普通外设比如UART、SPI、ADC属于总线从设备它们只能被动地被CPU访问而CPU和DMA控制器属于总线主设备可以主动发起读写操作。DMA控制器本身也是一个主设备只不过它很“专一”只会做搬运这一件事。DMA控制器内部有多个通道每个通道相当于一个独立的搬运任务描述器。以常见的STM32系列为例DMA1有7个通道DMA2有5个通道具体型号有差异每个通道都可以配置为响应某个特定外设的DMA请求。比如串口1的接收请求可能映射到DMA1通道5ADC1的转换完成请求可能映射到DMA1通道1。不同系列、不同厂商的MCUDMA通道映射表不一样这是很多人踩坑的第一站配置完DMA没反应先别怀疑代码逻辑查一下通道映射表很可能外设请求和DMA通道根本就没对上。还有一点必须摆正心态DMA只负责搬运不负责加工。它不会帮你把字节拼接成帧不会帮你做均值滤波更不会判断数据协议对不对。DMA搬来的数据最终还是要靠CPU去解析。所以理解了这一点之后你对DMA的期望值应该更务实它解决的是“数据从哪到哪”的搬运问题而不是“数据怎么处理”的算法问题。2. DMA的工作流程与内部机制2.1 一次完整DMA传输的四个阶段DMA传输看似简单内部其实是分阶段的理解这几个阶段对排查问题非常重要。第一阶段是请求阶段。外设在特定事件发生时会向DMA控制器发出DMA请求信号。比如UART接收寄存器非空RXNE、ADC转换完成EOC、定时器更新事件都可能触发DMA请求。这个信号是硬件层面的握手信号不需要软件干预。有些外设还可以配置为DMA突发请求一次发出多个数据传输请求。第二阶段是仲裁阶段。当多个DMA通道同时收到请求或者DMA请求和CPU访问总线的需求发生冲突时总线仲裁器会按照优先级决定谁先获得总线控制权。DMA通道本身有优先级设置高、中、低此外还有一个硬件优先级编号当软件优先级相同时编号小的通道优先。很多人在多路DMA同时工作的时候发现某一路数据总是丢失多半就是优先级配置不合理导致的。第三阶段是传输阶段。DMA控制器获得总线控制权后会发起一次读操作从源地址读取数据再发起一次写操作把数据写入目的地址。每完成一次读写传输计数器就减一。计数器从初始值递减到0这一轮DMA传输就结束了。这里有个细节读写各占一个总线周期所以DMA传输并非“瞬间完成”高速外设配置DMA时要考虑总线占用率。第四阶段是结束阶段。当传输计数器归零DMA控制器会置位传输完成标志位如果使能了传输完成中断就会触发DMA中断。在普通模式下传输完成后通道自动禁用想要再次传输必须重新设置计数器并重新使能通道在循环模式下计数器归零后自动重载初值通道保持使能状态继续响应下一次请求。2.2 关键寄存器与描述符解读DMA编程的本质就是在配置一堆寄存器。不同厂商的寄存器命名有差异但核心概念差不多。以STM32的寄存器为例CPAR外设地址寄存器存放外设数据寄存器的地址比如USART1-DR。CMAR内存地址寄存器存放内存缓冲区的首地址比如一个uint8_t buf[64]数组名。CNDTR传输数量寄存器存放要传输的数据个数单位取决于数据宽度配置。CCR配置寄存器存放传输方向、数据宽度、地址增量、循环模式、优先级等配置。这里我特别想提醒一个新手常犯的错CNDTR寄存器是实时变化的它在传输过程中不断递减所以你在调试的时候去读这个寄存器读到的是一个动态变化的数不是初始值。很多人在中断里想通过读CNDTR来判断剩余数据量结果发现数值跳来跳去就开始怀疑是不是DMA坏了。其实读CNDTR判断剩余传输量是DMA调试的常规操作但要选对读取时机要么在传输完成后读此时为0要么在外设事件触发的瞬间去读反映的是“当前剩余量”。在部分高端MCU里DMA不是简单寄存器配置而是使用描述符Descriptor链表。每个描述符里包含源地址、目的地址、传输长度、控制配置等信息多个描述符可以串成链表一个传完自动装载下一个。这对实现多缓冲、二维传输、乒乓缓冲非常有用但概念上仍然是“配置搬运任务”这件事只是载体从寄存器变成了内存里的数据结构。3. DMA传输模式与关键参数深入解读3.1 普通模式、循环模式与突发模式怎么选普通模式Normal下DMA完成设定的传输数量后自动停止通道使能位被硬件清零。这种模式适合“一次性搬运”的场景比如把传感器的一包数据搬到内存、把内存里的一帧显示数据搬到外设。但要注意普通模式传输完成后想再次使用必须重新设置传输计数器CNDTR并重新使能通道这个动作遗漏了DMA就不会响应第二次请求。循环模式Circular下传输计数器归零后自动重载初值DMA通道始终保持使能状态。这种模式特别适合连续数据流场景ADC连续采样、串口持续接收、音频数据流等。我最早体会到循环模式的威力是在做ADC四通道连续采样的时候。如果没有循环模式每次DMA传完4个数据就得重新手动启动中间延迟一长采样数据就断了。用了循环模式之后DMA自动把四通道数据循环写入一个数组CPU随时去数组里读最新一轮采样值非常省心。突发模式Burst则是另一种维度普通模式下每次DMA请求只搬一个数据单元突发模式下一次请求会连续搬运固定数量的数据比如4个、8个、16个。突发模式的优势是减少了总线仲裁次数提高传输效率适合高速外设和大块数据传输但代价是突发传输期间会持续占用总线可能导致CPU取指被阻塞。网络上常说的“continuous requests”连续请求也跟这个相关在部分存储控制器和DMA引擎里连续请求模式允许DMA在配置好的一整块传输区间内保持请求信号有效而不是每搬一个字节就重新发起一次请求这样做能显著提高数据吞吐率。3.2 数据宽度、地址增量与传输方向搭配DMA的数据宽度分字节8位、半字16位、字32位源和目的的数据宽度可以不一致硬件会自动对齐。但实际使用中我强烈建议源和目的数据宽度保持一致除非你非常清楚自己在干什么。比如ADC的12位数据存在32位数据寄存器里通常配置为字宽度如果配置成半字搬出来的数据就是错位的。地址增量这个参数很容易理解配置了地址增量后每次传输完一个数据单元对应地址会按数据宽度自动增加不配置地址增量地址保持不变。典型应用场景是外设到内存外设地址不增量每次读同一个数据寄存器内存地址增量依次写入缓冲区内存到外设内存地址增量依次读缓冲区外设地址不增量每次写同一个发送寄存器内存到内存两边都增量实现数组拷贝。传输方向有三种外设到内存、内存到外设、内存到内存。前两种比较好理解第三种在MCU里也很实用比如把Flash里的一段数据搬到RAM或者做RAM内部缓冲区拷贝。这种模式需要注意内存到内存传输是软件触发的配置完DMA后要手动置位使能位启动传输而外设相关的DMA传输是靠外设请求触发的。搞不清这三个参数的搭配关系是很多DMA“搬运出来的全是乱码”的根源。我调试DMA的时候习惯先把通道配置表打印或者用表格列出来看一眼源地址是否增量、目的地址是否增量、数据宽度是多少、方向和预期是否一致80%的配置错误一眼就能看出来。3.3 优先级、总线矩阵与性能的权衡DMA通道的优先级设置表面上是软件配置项实际上影响的是整个系统的总线分配策略。假设你同时开启了串口接收DMA、ADC采集DMA、SPI发送DMA如果三者的通道优先级都设为高那么当多个请求同时发生时按照硬件通道编号顺序裁决编号小的优先级反而更高。这会导致低编号通道的数据始终先被处理高编号通道的数据可能出现延迟如果高编号通道的外设FIFO很小就会丢数据。所以多路DMA并行工作时优先级分配的原则是数据最容易丢失的通道给最高优先级。比如串口接收硬件FIFO浅数据来了不及时搬走就可能覆盖应该给高优先级而内存到内存搬运晚几个周期无伤大雅可以给低优先级。还要知道DMA传输并非零成本。DMA每搬一个数据单元都要占用一次次总线带宽。如果系统里有个高速外设DMA在持续搬运大量数据CPU和DMA同时在抢总线极端情况下CPU指令执行会变慢。这就是为什么有些项目里开了DMA之后原本正常的实时控制突然出现时序抖动——不是DMA功能有问题而是总线带宽被挤占了。遇到这种情况可以考虑降低外设的DMA传输速率或者调整通道优先级让关键任务先走。4. 六大典型应用场景实操拆解4.1 串口DMA接收不定长数据空闲中断是关键串口接收不定长数据是DMA应用里最具代表性的场景之一。早期我做过一个项目单片机通过串口和上位机通信数据帧长度可变最短可能只有3个字节最长接近200字节。用传统逐字节中断接收波特率一高中断频繁到CPU都没空干别的事用DMA接收又面临一个核心问题DMA怎么知道一帧数据什么时候结束答案就是用空闲中断IDLE Interrupt。思路是这样的配置串口接收使用DMA把DMA设为循环模式数据不断写入接收缓冲区同时使能串口空闲中断。当串口总线上出现一个字节宽度的空闲电平时硬件就认为一帧数据结束了触发空闲中断。在空闲中断处理函数里读取DMA当前剩余的传输计数比如CNDTR用缓冲区总长度减去剩余计数就是本次收到的数据长度。以我常用的PY32F003这类Cortex-M0内核MCU为例本例同样适用STM32系列核心配置流程如下配置UART为DMA接收模式选择UART的RX DMA通道方向为外设到内存。配置DMA目的地址为接收缓冲区首地址源地址为UART数据寄存器地址数据宽度为字节。设置传输长度为缓冲区长度的整数倍选择循环模式。使能UART空闲中断IDLEIE位。初始化后启动DMA传输之后缓冲区就由DMA“看护”着每来一个字节自动写入。在UART空闲中断中执行以下逻辑判断是否真的产生了一次空闲事件读取DMA计数寄存器算出本次接收长度清空闲标志然后把数据交给协议解析层。这里有个非常容易踩的坑空闲中断标志的清除顺序。以STM32为例标准做法是“先读状态寄存器再读数据寄存器”来清除IDLE标志否则标志清不掉中断会一直触发。很多新手在这里卡了很久明明数据接收正常但代码一直进空闲中断就是这个原因。收到不定长数据后如果缓冲区的数据还没来得及处理下一帧就来了循环模式的DMA会把旧数据覆盖掉。因此实际项目中我建议采用“双缓冲”思路配置两个大小相同的缓冲区DMA交替写入或者在前台处理完数据、把缓冲区标为可复用之前先暂停DMA通道处理完再恢复。这个细节处理不好高负载下会出现数据错帧而且极难排查。4.2 ADC多通道采集解放CPU的经典用法ADC多通道采集配合DMA是我在课堂上讲得最多的一个案例也是很多初学者第一次感受到DMA价值的场景。假设STM32的ADC1要采集四通道模拟量开启扫描模式和连续转换模式每次转换完成都会产生EOC事件如果不使用DMA每个EOC事件都必须由中断处理去读取数据寄存器否则下一个通道的转换结果会覆盖当前结果数据就丢了。有了DMA流程变成ADC每次转换完成触发一次DMA请求DMA把数据寄存器里的值搬到一个内存数组里。四通道对应数组的四个元素循环往复。CPU只需要定时去数组里拿最新数据即可完全不用关心每个采样事件。配置有个关键点ADC数据寄存器是32位的但有效数据可能只有12位或16位DMA的数据宽度应该配置为字32位对应内存缓冲区定义为uint32_t adc_buf[4]。如果配置为半字DMA每次只搬低16位地址递增步长也变成半字数组和通道对应关系就会错乱。还有一个经验多通道DMA采样的顺序由ADC扫描序列的通道顺序决定。修改扫描序列时务必从序列1开始配置不要试图只改后面几个通道很多芯片会忽略不连续修改的效果。比如你想改为采集CH2、CH5、CH3必须重新把序列1设置为CH2、序列2设置为CH5、序列3设置为CH3。至于热词里提到的“STM32 HAL库ADC单通道DMA多次采样”操作也很简单调用HAL_ADC_Start_DMA(hadc, (uint32_t*)adc_buf, N)把DMA配置为循环模式N是采样次数。如果N大于1DMA会持续把多次采样结果写入数组想要均值滤波就在DMA传输完成或半传输中断里对数组做累加平均。需要注意的是HAL库的DMA回调函数执行频率比较高不要在回调里做浮点运算或耗时操作否则会导致采样数据覆盖不及时。4.3 DMA串口发送到底要不要等上一轮发送完这个问题的答案是要看情况但多数情况下必须等。群里经常有人问“DMA串口发送需要等待上一轮数据发送完吗”答案不能简单地用“要”或者“不要”来回答得先想清楚你等的到底是什么。DMA发送流程一般是CPU把待发送的数据放到内存缓冲区配置DMA源地址为缓冲区地址目的地址为串口数据寄存器地址传输长度为数据长度启动DMA然后DMA自动把缓冲区里的数据逐个搬到数据寄存器串口外设再把数据移位发送出去。这个流程里有两个“完成”概念容易混淆DMA搬运完成和数据真正从串口发完。DMA搬运完成意味着内存缓冲区的内容全部写到串口数据寄存器了数据发完意味着移位寄存器里的最后一位已经移出此时串口的TXE和TC标志会置位。对于RS485这种需要控制收发方向的总线必须在TC标志置位后再关闭发送使能否则最后一个字节会被截断。回到问题本身。如果你准备重新修改源缓冲区并启动下一次DMA发送而上一次DMA还在搬运中那么你修改缓冲区内容会影响还没搬完的数据导致发送内容错乱。所以必须等上一次DMA传输完成确认方法有两种一是读DMA通道的传输完成标志TCIF二是启用DMA发送完成中断在中断里置一个标志位发送新数据前检查这个标志位。还有一个细节如果用普通模式DMA传输完成之后通道自动禁用如果你发现第二次启动不管用先检查是不是上一次传输完成后没有重新设置CNDTR和使能位。很多人说“DMA发送第二次就卡死”原因往往就在这里。4.4 协议栈与DMA的结合以FreeModbus为例FreeModbus这类协议栈对接收时序要求比较严格。以Modbus RTU为例帧与帧之间要求有3.5个字符时间的静默间隔接收方需要基于字节间隙来判断帧的边界。如果直接用逐字节中断接收实现帧间隔检测比较自然但要是直接用DMA把整帧数据搬进缓冲区怎么判断一帧完整到达就成了问题。我的做法是分层处理DMA负责把数据收进缓冲区不负责判断帧边界帧边界的判断交给空闲中断或者一个定时器。如果MCU支持UART空闲中断可以用空闲中断来模拟3.5字符时间的判断如果不支持可以开一个定时器在每次收到任意字节时重置定时器定时器超时对应3.5个字符时间就认为当前帧结束。FreeModbus的移植层里接收回调函数改成从DMA缓冲区读取数据而不是从串口数据寄存器逐字节读取。发送侧也有讲究。Modbus RTU在RS485总线上是半双工的往外发数据之前要拉高发送使能DE/RE发完之后再拉低。如果把发送交给DMA你必须在“DMA搬运完成”这个时刻去切换方向。但我前面说过DMA搬运完成不等于串口发送完成所以严谨的做法是在DMA传输完成中断里再读取串口的TC标志等TC置位后关闭发送使能。这样能避免最后一字节在RS485线上被截断Modbus主站就不会频繁报CRC错误。4.5 高性能场景从UFS DMA到分布式DMA热词里有“ufs dma”和“分布式dma”这两个概念虽然偏高端但嵌入式工程师多少应该了解一下因为你迟早会遇到。UFSUniversal Flash Storage是一种存储接口手机上用得比较多。UFS控制器内部有DMA引擎负责在主机内存和UFS设备之间搬运数据。这里面的DMA不再只是把串口寄存器搬到内存那么初级而是涉及到描述符环、多队列、中断聚合等复杂机制。UFS DMA的性能直接决定了手机读写速度所以存储厂商对DMA的调优非常重视。分布式DMA则指在一个复杂的SoC里不再只有一个DMA控制器而是多个DMA引擎分布在不同的总线域或子系统里各自掌管一部分外设的数据搬运。比如一个子系统负责音频一个子系统负责网络一个子系统负责显示每个子系统内部有自己的DMA各管一摊互不干扰。这种架构的核心优势是并行度高避免所有数据搬运请求都挤在一个DMA控制器上同时也降低了大系统中跨总线访问的延迟。理解这些高端概念对我们做MCU开发也有启发DMA的通道、请求映射在不同芯片上解决方案完全不同不要拿着一种芯片的DMA经验框死所有芯片。遇到新平台先找参考手册的DMA章节和DMA请求映射表比瞎试代码靠谱得多。5. DMA疑难杂症与排查避坑手册5.1 常见DMA问题速查表为了让你调试时能快速定位问题我把这些年遇到的DMA故障整理成了一张速查表基本覆盖了90%的DMA异常情况。现象可能原因排查思路DMA搬出来的数据全是0DMA通道未真正使能或外设未产生请求源地址配置错误检查DMA通道使能位、外设DMA请求使能位单步调试看CNDTR是否在变数据搬了一半就停止普通模式传输完成自动禁用突发传输遇到总线错误确认CNDTR初始值查看DMA状态寄存器是否有传输错误标志串口DMA接收数据错位数据宽度配置错误或地址增量配置漏了核对源地址是否增量、内存地址是否增量、数据宽度是否和外设寄存器匹配空闲中断反复触发IDLE标志未清除DMA没有及时读取数据寄存器按“先读SR后读DR”顺序清IDLE标志检查DMA是否真的在搬运DMA发送第二次失效普通模式未重新设置CNDTR、未重新使能通道在启动函数里先清标志、设置传输计数、再使能DMAADC多通道数据全是同一通道的ADC扫描序列配置不正确或DMA目的地址没有增量重新配置扫描序列确认DMA目的地址增量使能DMA和CPU同时访问总线时系统卡顿DMA优先级过高持续占用总线降低DMA通道优先级或给CPU访问预留更高总线优先级5.2 厂商芯片的坑勘误表才是第一手资料有网友提到“bat32mcu的DMA通道详解以及bug”这让我想起一个很普遍的现象不同厂商的DMA实现多多少少都有一些“隐藏特性”。有的芯片DMA通道和外设的映射关系比较特殊有的芯片存在多个DMA通道共享触发源的问题还有的芯片在某些低功耗模式下DMA不能正常工作。这些问题在芯片的参考手册里未必写得很明显但勘误表Errata Sheet里通常会提到。所以我有个习惯拿到一款新MCU先翻两样东西——参考手册的DMA章节和芯片勘误表。哪怕手册已经读过多遍勘误表也要扫一遍尤其是里面关于DMA、低功耗、复位相关的条目。很多“DMA疑难杂症”其实不是你的代码问题而是芯片本身的设计限制早看到勘误表能帮你省下好几天的排查时间。另外别忘了DMA和D-Cache的交互问题。在带缓存Cache的高性能MCU上比如Cortex-M7内核CPU通过Cache访问内存DMA却是直接访问物理内存两者看到的数据可能不一致。CPU往发送缓冲区写了数据DMA去读时可能读到旧数据DMA往接收缓冲区写了数据CPU去读时可能读到Cache里的旧内容。解决办法是在CPU写发送缓冲后执行Cache Clean操作在CPU读接收缓冲前执行Cache Invalidate操作。很多人第一次把代码从M3/M4移植到M7平台上发现DMA突然“不灵了”多半就是Cache一致性问题。5.3 让DMA代码更健壮的几个编码习惯最后分享几个让我少走弯路的编码方法都是实际项目里调出来的经验。第一统一封装DMA启动函数。不要在业务代码里零散地配置DMA寄存器。建议每个DMA通道对应一个启动函数内部固定顺序关闭DMA通道、清所有标志、设置源地址、设置目的地址、设置传输计数、配置模式、重新使能通道。顺序固定能避免遗漏某一步也方便统一加日志。第二中断回调里别干重活。DMA传输完成中断的触发频率可能很高尤其是循环模式下如果配置为每次传输完成都中断CPU可能被频繁打扰。回调里尽量只做置标志、存计数、清标志这几个轻量级操作真正的数据处理放到主循环里做。否则中断嵌套、长时间关中断会引发一系列优先级相关的诡异问题。第三共享缓冲区加上volatile修饰。DMA和CPU共享的数据缓冲区在C语言层面建议声明为volatile避免编译器优化导致CPU读取不到最新数据。有些优化级别下编译器会把人写的代码“聪明”地改成寄存器操作结果数据根本没更新到内存。这个坑看起来低级但真踩过的人才知道有多难受。第四调试DMA先看计数器。不管问题表象是什么第一件事永远是读CNDTR或者等效的传输数量寄存器。这个值能告诉你DMA到底有没有启动、搬了多少、停在哪一步。我甚至会把CNDTR的读取值通过串口打印出来再配合逻辑分析仪看外设波形基本能把问题定位到配置错误、请求丢失、总线错误这几类里。第五验证DMA性能可以用“内存到内存”模式。想测DMA的实际搬运速度配置一个内存到内存的DMA传输在目标缓冲区填充特定数据用GPIO翻转或定时器统计搬运完成时间就能粗略算出DMA带宽。很多“DMA测速软件”的原理也类似只不过把数据源换成了更复杂的存储介质。最后再分享一个小技巧我个人调试DMA这几年最深的感受是DMA本身并不复杂复杂的是它和外设、总线、中断、缓存之间的各种交互。很多问题单看DMA寄存器发现不了得结合外设状态一起看。所以我调试的时候习惯把串口的SR寄存器、DMA的状态寄存器、CNDTR计数器三个值一起打印出来看到这三个值的变化规律问题基本就水落石出了。你可以试试这个办法比对着代码干想要高效得多。