DMA技术全解析:从串口不定长接收到ADC多通道采样
搞嵌入式的朋友一定遇到过这种场景串口一帧数据几十个字节用中断一个字节一个字节收每来一个字节CPU就跳进中断收完一帧CPU已经被打断了上百次ADC连续采样几百个点用轮询方式CPU整天啥也别干了就忙着读转换结果。这些痛点的标准解药就是DMADirect Memory Access直接存储器访问。DMA说白了就是让硬件自己搬数据搬完了通知CPU一声整个搬运过程CPU不参与可以直接去干别的活。这篇文章我会把DMA真正讲透从DMA控制器的工作流程、四种传输模式到串口DMA不定长接收的经典方案、STM32 HAL库ADC多通道多次采样的参考实现再到DMA性能测试、UFS DMA、分布式DMA这类进阶话题最后聊一聊实际开发中我踩过的坑。不管你是刚接触单片机的新手还是被DMA疑难杂症折磨过的老手这篇应该都能给你一些参考。1. DMA从头讲到尾一次完整的数据搬运背后发生了什么1.1 DMA到底是什么为什么比中断还香DMA的中文全称是直接存储器访问核心价值一句话就能概括在外设和内存之间建立一条不经过CPU的数据通道让数据自己把自己搬过去。嵌入式开发里CPU是最贵的资源中断虽然比轮询省事但每个字节都要打断CPU一次频繁的中断会严重挤压主流程的执行时间在实时性要求高的系统里还可能造成任务超时。DMA的好处可以归结为三点。第一是释放CPU这个最直观数据搬运交给DMA硬件CPU可以同时处理协议栈、UI或者其他任务。第二是降低功耗DMA搬运时CPU可以进入休眠状态只在传输完成中断时醒来对电池供电设备特别友好。第三是提高吞吐率DMA的突发传输模式可以在一次总线授权中连续搬运多个数据效率远高于CPU逐字节的取指-读写循环。很多人会问中断不也能完成数据搬运吗区别在于中断是CPU被动响应的DMA是硬件主动完成的。中断方式下每个字节都要经过压栈、跳转、处理、出栈这一整套流程有效代码可能只有几条指令剩下的全是上下文的开销。DMA方式下CPU只需要在最开始配置一次通道最后收一个完成中断中间的重复劳动全部由硬件完成省掉的这部分时间在高速通信场景里非常可观。1.2 DMA的完整工作流程拆解DMA工作流程看起来简单但很多细节决定了它能不能稳定工作。一个完整的DMA传输通常分成五个阶段。第一阶段是初始化。CPU要先配置DMA通道的源地址、目的地址、传输方向、数据宽度、传输长度和优先级。注意这里的地址在大多数MCU里必须是总线地址而不是逻辑地址配置错了轻则数据不对重则总线错误。还要确认缓冲区大小是否和传输长度匹配缓冲区不够大是DMA越界写内存的常见原因。第二阶段是请求触发。DMA是一个被动设备它不会自己跑必须有外设或软件发请求才动。外设请求是最常见的比如串口接收寄存器非空时外设向DMA控制器发出请求信号内存到内存模式下则是软件把DMA的请求位置1来启动一次传输。理解这一点很重要它决定了DMA的启动顺序先启动DMA再等外设数据和先来数据再启动DMA最终效果完全不同。第三阶段是总线仲裁。系统总线上同时挂着CPU、DMA和其他主设备DMA发起传输之前要先得到总线使用权。这个仲裁过程由总线矩阵完成通常DMA的优先级高于CPU但不同芯片的仲裁策略有差异。设计时要避开一个误区DMA优先级高并不代表任何时候都快在总线繁忙时DMA反而会加重总线负载。第四阶段是数据搬运。DMA控制器每次搬一个数据单元源地址和目的地址按照配置自动递增、固定或者递减把数据从源搬到目的同时内部的传输计数器减一。搬完一次后再次发出总线请求直到计数器归零。这就是普通模式的完整过程。这个阶段的长短直接取决于总线的时钟频率和数据宽度搬运宽度从8位改成32位理论上时间能缩短到四分之一。第五阶段是结束处理。计数器归零后DMA控制器置位传输完成标志如果开启了传输完成中断就会触发中断通知CPU。这里有个容易忽略的动作如果配置了自动重载DMA会把计数器恢复成初始值通道继续等待下一次请求如果没有通道就处于禁用状态需要CPU重新使能才能再次使用。项目里如果发现DMA只工作一次就停了先查是不是没开循环模式或者没有重新使能通道。1.3 DMA请求的仲裁机制与优先级多路DMA请求同时到来时谁先搬这就要看DMA控制器的仲裁机制。绝大多数MCU的DMA通道都有可编程优先级比如STM32的DMA有低、中、高、最高四个级别同级别通道再按通道号决定先后。优先级不是越高越好我曾经在一个项目里把显示缓冲区的DMA优先级调到最高结果串口接收出现了偶发丢字节原因就是高优先级的DMA频繁占用总线串口的DMA请求排队时间过长。后来把两个任务优先级平级问题就消失了。调试时可以先用逻辑分析仪抓总线活动看看请求频率再定优先级而不是无脑全拉最高。还有一点容易被忽略DMA请求的使能开关一定要在初始化外设之前配置好。很多芯片在使能外设的同时就会产生首个请求如果DMA通道还没准备好这次请求就丢了导致整个通信从第一个字节就开始错位。我自己踩过这个坑当时查了半天寄存器最后发现是初始化顺序的问题。把顺序调整成DMA先初始化好并使能通道再使能外设之后问题就彻底消失了。2. 四种传输模式怎么选外设到内存、内存到外设、内存到内存、循环模式2.1 外设到内存与内存到外设串口、ADC、DAC的标配外设到内存模式是使用最频繁的模式典型的场景是串口接收和ADC采样。外设数据寄存器收到一个完整数据后产生请求DMA把数据搬运到指定的缓冲区。外设到内存模式下的地址配置有讲究外设地址一般不递增内存地址按数据类型递增。如果外设地址被配置成递增DMA就会去相邻的寄存器里拿数据数据完全乱套。内存到外设模式刚好相反典型场景是串口发送、DAC输出和SPI发送。比如串口发送一个字符串DMA从内存缓冲区把每个字节搬到串口数据寄存器中CPU只管往缓冲区里放数据然后启动一次DMA传输。要注意的是内存到外设模式下外设本身发起请求的时机决定了搬运节奏。串口发送模式下是发送数据寄存器为空时才请求DMADMA才给一个数据节奏是由外设时钟控制的突发效果没有内存到内存模式那么集中。这两种方向的选择核心判断依据是谁主动谁被动。数据从外设产生需要及时拿走就走外设到内存数据在内存里准备好需要及时送出去就走内存到外设。配置时只要把源地址和目的地址填对方向选对再配合外设的事件触发基本不会出大问题。2.2 内存到内存模式谨慎使用内存到内存模式有很多人忽略其实它是测试DMA性能和做内存拷贝的好帮手。它的特点是传输不依赖外设请求由软件启动后DMA就连续占用总线把一块内存的数据搬到另一块内存。在STM32的HAL库里配置DMA为内存到内存模式后调用HAL_DMA_Start_ITDMA就会启动搬运。这个模式有个明显的坑因为不需要外设请求DMA会不断抢占总线如果你在这时候开了高优先级中断中断响应可能会被DMA拖慢。而且内存到内存模式下CPU和DMA同时要访问总线时仲裁结果可能让CPU长时间拿不到总线对实时性有影响。实际项目中我很少用DMA做普通内存拷贝只有大数据量的图像帧拷贝或者Flash缓存搬移才值得。CPU的memcpy在数据量不大时反而更快因为启动DMA本身也有开销几百字节以下不一定划算这个权衡需要测一下才知道。2.3 循环模式处理连续数据流的法器循环模式Circular Mode可以说是DMA的灵魂功能。在普通模式下传输完成后通道就停了循环模式下传输计数器归零后自动重载初值通道一直保持使能持续响应外设请求。这就非常适合处理无固定长度的连续数据流比如ADC连续采样、串口不定长接收、麦克风采集。以串口不定长接收为例循环模式加环形缓冲区是经典组合。DMA不断把串口收到的字节写入缓冲区写到最后自动回头从头写CPU在处理数据时也通过读指针追赶写指针两个指针一比较就知道有没有新数据。这个方案的核心优势是CPU不用频繁进中断数据攒到一定量再统一处理对系统整体性能的提升非常明显。循环模式看起来美好但要小心缓冲区的读写竞争。如果CPU的读指针追上了DMA的写指针可能会读到半新的数据如果DMA的写指针追上CPU的读指针新的数据就会覆盖还没来得及处理的数据。实际工程中要么保证处理速度足够快要么使用双缓冲切换策略。双缓冲的思路是DMA在缓冲A和缓冲B之间来回切换一个缓冲在接收另一个缓冲在处理两边互不干扰代价是需要两个缓冲区的大小刚好是一帧数据的最大长度。2.4 传输方向之外的关键参数数据宽度、突发传输和地址递增选择传输模式时还要设置数据宽度和突发长度。数据宽度指DMA每次搬运的数据单位常见的有字节、半字和字三种搬运双方的数据宽度可以不一致有些DMA还支持自动打包和拆包。比如一个8位的外设数据寄存器要搬到32位的内存缓冲区DMA会在内部做数据对齐。突发传输类似快递批量取件一次总线授权连续搬运4个、8个或者16个数据单元能大幅减少总线仲裁次数提升传输效率。但突发传输在高负载总线上会长时间占用总线反而影响其他主设备。小系统里2个或4个单位的突发长度比较稳妥先跑通功能再根据性能测试结果调整。地址递增设置体现了DMA的灵活性源地址和目的地址都可以单独配置为递增、递减或者固定。搬运外设数据到连续内存时外设地址固定、内存地址递增搬运内存中的一张表到外设时需要反向处理。经验不足的同学设计缓冲区时没有对齐会导致DMA性能下降特别是32位DMA要求缓冲区首地址4字节对齐否则会触发总线错误或者拆分传输。在C语言里可以用__attribute__((aligned(4)))声明缓冲区保证对齐这个习惯我建议从第一个DMA项目开始就养成。