资讯动态

嵌入式DMA详解:从数据搬运原理到串口、ADC应用与调试

发布时间:2026/9/7 20:51:40 来源:尧图企业网站定制
做嵌入式开发这几年“数据搬运”这四个字几乎每天都在打交道。串口收数据、ADC采样、SPI读传感器哪一样不是在搬数据搬得不好CPU就要被中断淹没搬得好了CPU只做最后一步分析和处理。而DMADirect Memory Access直接存储器访问正是干这个的。这篇文章我把DMA的工作流程、传输模式、典型应用场景还有调试中踩过的坑一次性聊透。无论你是刚学STM32想搞明白串口DMA接收不定长数据的新手还是被ADC多通道采样、UFS DMA这类问题折腾过的老手都能从这里找到能直接抄作业的思路。1. DMA 到底是什么从“数据搬运工”说起1.1 为什么偏偏是DMA如果没有DMA外设要往内存放数据只能靠两种方式轮询和中断。轮询就是CPU不停去问外设“你有没有数据”浪费CPU大量时间中断稍微好一点外设有数据了通知CPUCPU进中断服务函数里搬一个字节再出来。波特率不高还好一旦数据吞吐量上去了比如100万波特率的串口每秒10万字节每字节一次中断CPU基本就耗在进出中断和现场保护上了其他任务全被卡死。DMA的思路很简单CPU别管数据搬运了找一个专门的硬件来干这个活。这个硬件就是DMA控制器。CPU只需要把源地址、目的地址、搬运多少个数据告诉DMA控制器说一声“开工”然后就干自己的事。数据搬完了DMA控制器拉一下中断通知CPU“活干完了。”这就是DMA的基本工作模式。类比一下CPU就像一个全栈工程师既要写代码又要做设计。搬数据这种事一直是亲自搬每次一个箱子从A仓库搬到B仓库搬完一个还要等下一个箱子。突然来了个DMA这个专职物流专员CPU只需要说一句“把A仓库的一百个箱子搬到B仓库”然后就可以去写代码了物流专员自己搬搬完汇报一下。你说这种设计香不香。1.2 DMA核心工作流程拆解DMA完整的工作流程一般来说分这么几步第一步软件配置。CPU配置DMA控制器的源地址、目的地址、传输方向、数据宽度、传输计数、是否地址增量、中断使能等参数然后启动DMA。第二步外设触发或软件触发。大多数DMA传输不是DMA自己主动跑的而是被“事件”踢一脚才动。比如串口接收寄存器收到一个字节DMA检测到这个事件申请总线控制权。第三步总线仲裁。如果同一时刻有多个DMA请求DMA控制器内部按固定优先级和可编程优先级仲裁选中一个通道。第四步单次数据传输。选中的DMA通道握有总线控制权从源地址读数据写到目的地址。数据宽度可以是字节、半字或字。第五步更新控制信息。传输计数器减一如果配置了地址增量源地址和目的地址会加对应的步长。然后DMA回到等待状态等待下一次触发。第六步计数器归零后结束。如果是一次性Normal模式DMA会停在那里同时置传输完成标志可产生中断通知CPU。如果是循环模式计数器会自动重新装载到初值继续等下一次触发形成连续循环。这个流程听着简单但有几个点特别容易误解。第一DMA搬一个数据要占用总线。如果CPU同时也要访问内存二者会竞争总线。多数MCU都设计了合理的仲裁把DMA访问安排在CPU总线空闲周期但如果数据传输量特别大、总线繁忙CPU确实会感觉到“卡顿”。第二DMA不是免费的它需要初始化配置占用RAM缓冲区、占用一个中断。数据量小的搬运任务用DMA反而得不偿失因为配置开销可能比中断处理还大。1.3 DMA控制器的关键寄存器不同的MCU实现细节略有差别但DMA控制器基本都有这几类核心寄存器寄存器作用配置时要注意什么源地址寄存器存放数据来源地址外设到内存时填外设数据寄存器地址而不是寄存器里的值目的地址寄存器存放数据目的地地址内存到外设时填外设寄存器地址注意地址空间是否可访问传输计数寄存器记录待传输数据个数每传一次减一注意是“次数”不是“字节数”控制寄存器决定方向、数据宽度、地址增量、优先级、模式方向和增量最容易配错状态寄存器存放传输完成、半传输、传输错误等标志中断里用完要把标志清掉否则会一直误触发配置时核心就是把这几个寄存器填对。曾经有人调DMA半天不出数据最后发现是源地址寄存器里填的是外设寄存器的值而不是外设寄存器的地址这类细节真的值得注意。2. 传输模式怎么选Normal、Circular还是Burst2.1 正常模式与循环模式的选择逻辑最常用的两套模式是Normal正常和Circular循环。Normal模式就是一次性搬运计数器从初值减到0DMA停止。典型场景批量存储数据比如把传感器读回来的1024个字节一次性搬进Flash。或者你在SD卡里读了一个块的数据到内存这种任务一次结束不需要DMA一直跑。Circular模式是无限循环的DMA搬满一个缓冲区之后自动回到缓冲区起始位置继续搬。典型场景ADC连续采样、串口不定长数据接收。数据不停地进来DMA永远不停止CPU只需要在合适的时候去缓冲区里取数据。理解Circular模式最好的方式就是环形缓冲区DMA负责不停往缓冲区里写CPU按照DMA的进度去读两边各管各的互不打扰。我实际见过不少人把这两种模式搞混。有人一直用Normal模式接收串口不定长数据每次收完一帧数据都要软件重新启动一次DMA启动晚了就丢数据。也有人一直用Circular模式做一次性内存拷贝DMA搬完一轮还在那里无限重传完全停不下来最后缓冲区越写越乱。所以选模式之前先问自己数据是一次性的还是持续的一次性用Normal持续用Circular。2.2 三种传输方向与数据宽度DMA传输方向在大多数MCU上有三种外设到内存、内存到外设、内存到内存。外设到内存是最常见的串口接收、ADC采样、SPI读数据都用它。外设数据寄存器当作源地址RAM缓冲区当作目的地址。内存到外设负责把数据从内存发出去。比如串口发送、DAC输出、SPI写屏。源地址是RAM缓冲区目的地址是外设寄存器的地址。内存到内存则是把一块内存的数据搬到另一块内存比如把暂存区的内容搬到帧缓冲区。这种传输一般由软件触发也就是不需要外设事件CPU一配置完DMA就自己跑。要注意的是内存到内存每搬一个数据都要占用总线对实时性影响比外设到内存更大尤其是搬运数据量特别大的时候系统里其他对时间敏感的任务很容易被挤掉所以能不用就不用。数据宽度则是每次搬运的“车厢大小”。一般支持字节、半字、字三种。判断标准很简单外设数据寄存器有多宽就选多宽。串口寄存器一字节选字节ADC数据寄存器16位选半字USB专用的缓冲区如果按32位组织选字。如果源和目的的数据宽度不一致很多MCU的DMA控制器的行为会相当微妙有的会丢弃多余位有的会把数据零扩展。不要轻易让源和目的的数据宽度不一致除非你非常清楚具体芯片的行为否则后患无穷。我就见过一位同事把串口的8位数据宽度配成16位结果每个接收字节后面多出一个零字节整个协议解析直接乱掉。2.3 突发传输、连续请求和优先级有些DMA支持突发传输一次总线仲裁连续搬运多个数据比如4字节、8字节、16字节。有的人觉得突发传输就是快但实际上突发传输的意义主要是减少总线仲裁的次数搬运大块数据时确实能降低总线开销。另外突发传输要求地址对齐如果没对齐有些芯片直接不干活有些则是发送错误异常。所以用突发传输之前确认缓冲区地址是否对齐否则踩坑踩到怀疑人生。连续请求DMA continuous requests在一些MCU的资料里翻译成“持续请求”或“连续请求”。普通模式下DMA要等外设的触发信号才动作。开启连续请求后DMA不需要等外设触发自己会持续产生传输请求一路搬到底。这种模式比较适合内存到内存的批量初始化比如把一整个帧缓冲区快速清零。但是注意一旦忘记关掉连续请求DMA会一直霸占总线的访问带宽其他任务就会明显变慢。这个坑我有一次排查了快两天最后DMA停不下来原因就是连续请求位被不小心打开了。多个DMA通道同时有请求时优先级决定谁先搬。每个通道一般有可编程优先级配置相同优先级时按硬件固定的初始优先级裁决。配置优先级的原则是实时性要求高的通道优先比如音频数据吞吐量大的通道也尽量给高优先级比如高速串口。2.4 配传输参数时最容易忽略的细节几个小的细节出问题的时候极难排查。第一地址增量。外设到内存时外设地址通常不增量因为外设就一个寄存器内存地址必须增量否则数据全写到一个位置。忘了给内存地址开增量是新手最常见的错误之一表现就是缓冲区第一个字节一直在变后面全是空的。第二缓冲区大小和传输计数之间的关系。DMA传输计数器记录的是“次数”不是“字节数”。每次搬一个半字传100次就是200字节。缓冲区数组定义成多少、DMA计数器填多少一定要先算清楚。我习惯在代码里用宏定义统一管理比如#define BUF_SIZE 128所有地方引用同一个宏就不会出现数组大小和计数不一致的问题。第三缓存一致性。如果MCU带D-Cache比如Cortex-M7DMA搬运的数据从外设进内存之后CPU读到的可能还是Cache里的旧数据。这个时候需要在DMA传输完成中断里做Cache Clean/Invalidate。没有Cache的设备不用管但有Cache不看这一条写出来的程序就是偶尔灵偶尔不灵非常诡异而且这种bug复现率还不稳定特别难定位。第四编译器优化等级。DMA缓冲区如果没加volatile修饰在某些优化等级下编译器可能把读缓冲区的代码重排或优化掉导致你明明看到DMA在搬数据程序逻辑却总是用旧值。缓冲区声明成volatile或者用内存屏障宏都能解决。3. 典型应用场景串口、ADC与存储里的DMA3.1 串口DMA接收不定长数据空闲中断是关键串口接收是最常见、也最能体现DMA价值的场景尤其是不定长数据。传统方式每收一个字节触发一次接收中断软件判断是不是一帧结束这种方式在数据量大时CPU压力极大。DMA方案的基本思路是让DMA把收到的字节持续搬进一个大缓冲区CPU不参与中间过程等检测到一帧数据结束再统一处理。问题来了怎么知道一帧数据结束嵌入式圈子里最常用的方案是“接收空闲中断”。很多串口外设都有空闲检测功能当总线上连续一段时间没有新的起始位就认为线路空闲了触发空闲中断。对应到DMA接收就是DMA还在循环搬运空闲中断一响说明对方发完了一帧这时候CPU去看DMA当前还剩多少个计数用缓冲区总大小减去当前计数就能算出这一帧的实际长度。这个方案在STM32 HAL库和PY32F003这种Cortex-M0平台上的配置思路完全一致。PY32F003这类入门级MCU虽然资源有限但串口的空闲中断加DMA功能不少型号都是具备的直接照着抄就能用。初始化代码大体上是这样的先开启串口RX的DMA方向外设到内存模式用循环模式缓冲区定义足够大。然后使能串口空闲中断。之后就不用管了数据一直在DMA搬运。空闲中断触发后回调函数里去读DMA计数寄存器算出本次数据长度然后处理这一帧。关键代码骨架我贴一下HAL库版本#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; void uart_rx_dma_init(void) { __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); } void HAL_UART_IDLECallback(UART_HandleTypeDef *huart) { uint16_t remain, len; remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); len RX_BUF_SIZE - remain; // 这时 rx_buf[0] 到 rx_buf[len-1] 就是一帧数据 process_frame(rx_buf, len); }这里把DMA半传输中断关掉是经验之谈不定长接收时半传输中断基本用不上留着反而增加复杂度。实际项目中坑也不少。第一如果一帧数据长度正好等于缓冲区大小或者数据流一直不停空闲中断不会触发这时候需要额外机制比如配合定时器超时或者用半传输中断辅助处理。第二循环模式下DMA地址会回绕如果上一帧数据跨越缓冲区结尾处理的时候要特别小心否则拿到的是拼错位的数据。稳妥做法是配合DMA半传输中断做双缓冲数据落在前半段时处理前半段落在后半段时处理后半段。3.2 ADC多通道DMA连续采样配置ADC采样是另一个典型的DMA应用场景尤其是多通道连续采样。如果不使用DMA每个通道转换完成都要进中断取一次数据再做下一次转换一旦通道数多、采样频率高中断开销非常可观。用DMA之后ADC每转换完一个通道结果就自动搬到内存数组里对应的位置CPU只等整批数据就绪再处理统计计算。STM32 HAL库的配置一般是这样先配置ADC为扫描模式、连续转换然后启动DMA数据目的地址指向自己定义的数组。比如两个通道各采10次缓冲区数组大小就是20用半字类型。代码骨架#define ADC_CH_NUM 2 #define SAMPLE_COUNT 10 uint16_t adc_buf[ADC_CH_NUM * SAMPLE_COUNT]; void adc_dma_init(void) { // ADC1 配置为扫描模式、连续转换 // DMA配置为循环模式、外设到内存、半字 HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, ADC_CH_NUM * SAMPLE_COUNT); }这里有个细节DMA启动时第三个参数传的是“总共要搬运的样本数”不是“每个通道的采样次数”。我见过有人把两个概念搞混导致缓冲区只用了一半另一半永远是零。另外多通道DMA采样时数据的排列顺序一般和ADC扫描顺序一致也就是先第一个通道的结果再第二个通道的结果。如果你想的是“第一个通道采10次再第二个通道采10次”这种分块排列那是错的DMA给出的通常是一轮一轮交错排列处理统计时要按通道间隔取数。首采数据不稳定也是一个老问题。ADC刚上电或者刚切换通道后的第一次采样经常不准多通道扫描时尤其明显。我一般会在统计时丢弃第一批数据或者让DMA缓冲区留一点余量等第二个完整周期再拿来做平均值。别小看这一点采样不准导致后续控制抖动的案例我遇到过好几起。3.3 UFS DMA存储子系统里的搬运工串口和ADC属于微控制器层面的DMA存储领域里的UFS DMA则是另一种形态。UFS即通用闪存存储在手机、平板和车载存储里很常见。UFS协议栈内部有一套自己的DMA引擎负责主机内存和UFS设备队列之间的数据搬运。主机侧要发起一次读写操作系统会把命令描述符、数据传输描述符组织到内存里然后由UFS主机的DMA引擎批量搬给UFS设备控制器。它解决的问题和单片机里一模一样数据量太大不能依赖CPU逐个搬运。分布式DMA则是把这些引擎分散在系统的多个节点每个节点都管好自己的本地搬运任务减少单一中心DMA引擎的瓶颈。嵌入式工程师如果未来接触多核、大存储的芯片这个概念会越来越常碰到。但不管形态怎么变DMA的本质仍然是“用硬件搬运、按描述符工作、完成之后发通知”理解了应用处理器层面这一套看到UFS控制器里的DMA同样不会陌生。顺带说一句DMA这个缩写在不同行业里的含义不一样。硬件领域说的是Direct Memory Access。但在某些工程管理模式里DMA可能指别的含义比如权责转移。所以看到有人讨论“结构工程师的工作流程DMA”也别急着代入内存搬运先确认上下文再聊。做技术最怕张冠李戴名词对不上讨论半天全白搭。4. 常见问题与排查技巧实录4.1 串口DMA发送要不要等上一轮发完结论再明确一点要等而且等的是串口的TC标志不是DMA的完成标志。串口发送数据时实际上有两个环节DMA把数据从内存搬到串口的发送数据寄存器串口硬件再把发送数据寄存器里的数据通过移位寄存器一位一位发出去。DMA的发送完成中断只是说“数据都搬进发送数据寄存器了”但这时候移位寄存器可能还有最后一个字节没发完。如果立即启动新一轮DMA发送新的数据可能覆盖还没发出去的数据效果就是数据错乱。稳妥的写法是用串口的TCTransmission Complete标志判断上一次真正发送完毕再启动下一轮DMA。HAL库参考写法while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET); HAL_UART_Transmit_DMA(huart1, buf, len);还有一个细节HAL库的HAL_UART_Transmit_DMA接口内部会清除TC标志所以在调用前等待TC标志置位是安全的。如果用的是寄存器级别的代码也要注意先清标志再启动新的DMA否则残余的标志状态会干扰下一次传输的状态判断。4.2 DMA连续请求模式的正确打开方式“DMA连续请求”这个模式在不同芯片上有不同的叫法但是思路类似普通DMA是等外设触发的被动模式连续请求是DMA自己主动产生请求的自触发模式。什么时候用这种自触发模式可以用在内存到内存的批量搬运上。比如你要把一块缓冲区全部清零或者把一张图片数据在内存里复制一份配置好源地址、目的地址、传输长度开启连续请求DMA就一直搬搬完这轮才停。配置这样一个任务只需要一条CPU指令后续就不用管了。什么时候别用外设到内存的正常接收场景千万别开。一旦开了DMA根本不理会外设有没有新数据会一直重复读取外设寄存器往内存里写数据全乱套。而且这种自触发模式如果配置成循环模式DMA会永远忙个不停系统其他任务全部卡顿。我调试时曾遇到过DMA一直在跑、CPU几乎被拖垮的现象最后把连续请求位一关整个世界清净了。4.3 DMA疑难杂症排查方法论调了几年DMA我总结了一个排查套路遇到问题先别乱改代码按顺序查。第一步查时钟。DMA控制器和外设的时钟必须都打开而且如果外设时钟没开外设寄存器读出来全是不确定值DMA会从错误地址搬数据。这个级联问题经常伪装成“DMA配置错误”。第二步查配置。源地址、目的地址、地址增量、数据宽度、方向逐一对照寄存器手册确认。尤其注意外设寄存器地址是地址不是值填充的时候用取地址符号别直接填数据值。第三步查中断和处理函数。DMA传输完成中断有没有正确清标志半传输中断和传输完成中断的优先级配置有没有问题还有DMA错误中断有没有触发。传输错误标志如果长期没人清DMA会一直卡在错误状态。第四步查缓存一致性。带D-Cache的芯片必须处理Cache一致性否则就是“数据看起来没变”的灵异事件。我之前遇到过一版程序在开了Cache加速之后串口DMA收到的数据永远是旧值清掉Cache就好了就是这个原因。第五步查缓冲区地址。有些芯片DMA不能访问全部内存区域或者要求缓冲区地址按数据宽度对齐。比如半字传输要求地址偶数对齐字传输要求4字节对齐。缓冲区声明成uint16_t数组有时地址不对齐用__attribute__((aligned(4)))指定一下更稳妥。这五步基本上能解决九成的DMA问题。剩下的那一成大概率是芯片参考手册里某个特定模式的隐藏限制这个时候不要猜直接查勘误表。4.4 Modbus与DMA自由协议和DMA的矛盾最后聊一个不太常被提到的坑Modbus RTU配DMA。Modbus RTU协议要求两帧之间必须有3.5个字符时间的间隔接收方靠这个间隔来判断一帧结束。如果用DMA加空闲中断来接收看似完美但实际上有个隐患3.5个字符时间在高波特率下非常短有的芯片的空闲中断检测粒度根本来不及在这个时间内可靠触发。更麻烦的是Modbus帧内部如果刚好有一段比较长的电平静止区间空闲中断可能提前触发把一帧拆成两半。我在实际项目中处理Modbus RTU和DMA的组合一般是两种思路要么不用DMA直接用串口接收中断加定时器超时判断一帧结束虽然CPU占用多一点但协议时序可靠要么必须用DMA时DMA只负责把数据搬到缓冲区帧边界判断交给定时器定时器超时了才认为一帧结束再取DMA缓冲区里的数据。这个方案牺牲了一点实时性换来了协议解析的可靠性对于一个工业总线协议来说非常值得。我在实际调DMA的过程中最大的体会是DMA本身并不复杂复杂的是“你以为DMA配置对了但它偏偏不出数据”的那一刻。遇到这种情况别急着怀疑硬件拿起参考手册把寄存器配置、时钟、中断、缓存一致性一项项排过去大多数问题都能水落石出。最后再分享一个我自己的习惯DMA缓冲区全用volatile修饰并且声明地址对齐凡是涉及DMA的代码宁可多写几行防御性初始化也别等着线上出bug再头疼。希望这篇能把DMA聊透省去你一周的翻手册时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价