搞嵌入式的,谁没被DMA坑过两回?我第一次调STM32串口DMA接收不定长数据,数据总是时不时多一帧、丢一帧,整整排查了两天才发现是空闲中断和DMA计数器读取顺序的问题。从那以后,我就特别在意"DMA到底是怎么一步一步把数据搬完的"——请求怎么发起、总线怎么仲裁、计数怎么递减、中断什么时候来,每一个环节都可能成为Bug的温床。
DMA,Direct 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转去写B,CPU处理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剩余的计数:
这个received就是本次空闲到来时,DMA已经写入缓冲区的有效字节数。uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_uart_rx); uint16_t received = sizeof(dma_rx_buf) - remain; - 处理数据:把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批量搬运ADC:HAL库单通道多次采样的配置实战
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启动后做校准,校准过程占用了ADC,DMA拿到的数据可能是校准期间的无效转换结果。
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连续请求与分布式DMA
5.1 UFS主机控制器里的描述符DMA
聊完MCU里的DMA,再往外看一眼,DMA在大系统里的形态要复杂得多。UFS存储协议里的DMA就是一个典型代表。
UFS主机控制器内部普遍集成了DMA引擎,用来把存储设备读出的数据快速搬到系统内存。这种DMA通常具备多队列、多描述符、突发传输等能力。它和MCU中DMA最大的区别在于,它支持"描述符链"机制:DMA控制器自己会从内存读取一个描述符表,描述符里写明了源地址、目标地址、数据长度,DMA处理完一个描述符后自动加载下一个,CPU只需要把一批描述符准备好,然后交给DMA去跑。
UFS里管这个叫PRDT(Physical 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,通道再多也是两个中枢;到了STM32H7,DMA1、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到SoC,DMA测速优化时,常有人用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搬运,三个测试都过了,再往上叠应用逻辑。别笑,这套东西看着简单,真能帮你把"板子有没有问题"和"代码有没有问题"快速分开,省下的调试时间远远超过写测试本身。