嵌入式DMA实战指南:从串口不定长接收到ADC多通道采样全解析
发布时间:2026/9/7 22:15:22 作者:尧图编辑部 阅读量:1,286

接手过一个小设备串口数据一多就丢帧中断里进进出出CPU 全在保存现场和恢复现场了业务逻辑反而被饿死。查了两天最后把收发全部改成 DMA中断频率直接降了两个数量级问题当场消失。那是我第一次意识到DMA 不是“一个可选优化项”而是嵌入式系统里绕不开的基础设施。这篇内容我打算把 DMA 这件事讲透——它到底在替谁干活、工作流程是怎样的、四种传输模式怎么选、串口接收不定长数据和 ADC 多通道采样这类典型场景怎么落地再把实际项目里容易踩的坑按“症状→根因→验证”的方式列出来。不管你是刚接触单片机的初学者还是被 DMA 疑难杂症折磨过的老手这篇都能给你一套可以直接抄作业的思路。1. 先搞清楚 DMA 到底在替谁干活1.1 一个丢帧事故引出的问题先说那个丢帧的项目。MCU 主频 72MHz外接传感器通过串口以 115200bps 往上报数据每 10ms 来一包 64 字节。一开始很简单直接在串口中断里把数据搬到 RAM解析完存进结构体看起来没什么毛病。但系统里还有一块 LCD 需要频繁刷新还有好几个定时器中断在跑 PID 运算。当 LCD 刷新和串口接收撞在一起的时候串口中断响应就会延迟一不小心就过冲overrun丢掉的字节再也找不回来。问题最严重的时候丢帧率能到 3%。我当时做了个粗略估算115200bps 意味着每个字节大约 86.8 微秒64 字节一包就是 5.5 毫秒。如果用中断接收每字节进一次中断中断里要压栈、读数据、清标志、出栈一次怎么也要几十微秒。数据密集的时候CPU 光处理中断就占了不少时间片更别提 main 循环里还有滤波算法。改用 DMA 之后串口接收变成“数据自己往内存里跑”CPU 只在 DMA 攒满一包或者触发空闲中断的时候才被叫醒一次。原来每秒要进几百上千次中断现在可能只需要几十次。这就是 DMA 存在的根本意义把数据搬运这种机械劳动从 CPU 手里拿走让 CPU 去干真正需要判断的事。1.2 DMA 工作流程拆解事件触发到完成中断的五步很多人觉得 DMA 就是把数据从 A 搬到 B知道怎么配寄存器就算会用了。其实要真正用好 DMA得把那套触发和握手流程吃透。一个完整的 DMA 传输生命周期大概是这样的外设产生事件串口收到一个数据字节、ADC 转换完成、定时器触发更新外设会置起一个 DMA 请求信号。这个信号通常对应外设寄存器里的某个标志位比如串口的 RXNE、ADC 的 EOC。DMA 控制器仲裁DMA 控制器收到请求后会查看当前通道的优先级配置。如果多个外设同时发起请求DMA 会按硬件优先级和软件配置的优先级做仲裁决定先服务谁。发起总线传输DMA 作为总线主设备向总线仲裁器申请总线控制权。拿到总线后它执行一次读-写操作从源地址把数据读到内部临时寄存器再写入目标地址。这个过程不需要 CPU 介入。地址和计数器更新每传输一个数据单元通常是一个字节或一个字DMA 会按配置自动递增源地址或目标地址同时把传输计数器减一。传输完成产生中断计数器归零后DMA 置起传输完成标志可以触发中断通知 CPU“数据准备好了”。关键点在于第 3 步里的“总线主设备”。DMA 在传输期间会占用总线所以 CPU 在同一时刻是访问不了内存的。这也是为什么很多人说“DMA 不占 CPU但占总线”。好在 DMA 搬运一次数据只占几个总线周期比 CPU 亲自搬要快得多——因为 CPU 搬数据还要执行指令、解析地址、处理异常而 DMA 是纯硬件的状态机在跑。1.3 CPU 搬运和 DMA 搬运的对比我经常用一个比喻CPU 亲自搬数据就像大厨自己洗菜切菜DMA 就是专门雇了个帮厨。大厨只需要说一句“把这几筐菜洗好切好放那边”帮厨就闷头干完了。大厨可以同时去炒菜、看火、调酱汁。但如果帮厨水平不行配置不对洗好的菜可能放错地方、切坏了、甚至根本没切。对比维度CPU 中断搬运DMA 搬运CPU 占用每个数据都要中断搬移占用高只在传输完成时中断一次响应延迟中断响应有延迟高负载下可能丢数据硬件搬运不依赖中断响应吞吐量受指令执行速度限制接近总线峰值带宽适用场景低频、少量、需要逐字节判断的数据高频、批量、数据格式固定的传输复杂程度简单直观容易排查配置项多踩坑点集中典型代表按键扫描、低速传感器读取串口收发、ADC 连续采样、内存拷贝、存储读写这张表里最值得留意的是最后两行。DMA 配置复杂、踩坑点多但它带来的收益在高负载场景下是不可替代的。问题在于什么场景才值得用 DMA我的判断标准很简单——如果外设产生数据的速率超过 1kHz或者每一包数据超过 16 字节优先考虑 DMA。低于这个量级中断处理完全够用强行上 DMA 反而增加调试成本。2. 传输模式选型普通、循环、突发别靠感觉选2.1 普通模式和循环模式的本质区别DMA 最基础的两个模式是普通模式Normal/One-shot和循环模式Circular。很多新手是“看别人用哪个我就用哪个”结果在接收不定长数据的时候翻车。普通模式下DMA 完成一轮传输计数器归零后就停住了。如果要再次传输必须软件重新设置计数器并使能。这个模式适合“一次性搬完一批数据”的场景比如把一段协议报文从内存搬到串口发送寄存器或者把 SPI Flash 里的一页数据搬到 RAM。循环模式则会在计数器归零后自动重载初始值继续传输。它最典型的应用是 ADC 连续采样和串口不定长接收。因为数据是源源不断产生的你不可能每来一批数据就停下来重新配置一次——那反而比中断还慢。选型时最容易犯的错误是把普通模式用在连续产生的数据上。比如有人用普通模式做 ADC 采样采集完一轮就停了后面全是无效数据还有人把循环模式用在一次性发送上结果 DMA 把缓冲区里的数据一遍又一遍地往外发停都停不下来。记住一句话数据源是持续产生的就用循环任务是一次性的就用普通。2.2 突发传输和 FIFO 的配合逻辑除了普通和循环高性能 DMA 还支持突发传输Burst和 FIFO 缓冲。这两个功能经常被忽略但在总线压力大的系统里是救命的。突发传输的意思是DMA 拿到一次总线控制权后连续传输多个数据单元而不是传一个就释放总线。这就像去快递站取件一次取 10 个包裹比跑 10 趟高效得多。突发传输能显著降低总线占用次数减少 DMA 和其他总线主设备比如 CPU、以太网 MAC的冲突。FIFO 则是 DMA 内部的一个小缓存。外设的数据先进入 FIFO攒到一定阈值后再突发搬运到内存。这个机制有两个好处一是可以缓解外设速度和内存速度不匹配的问题二是配合突发传输能把一次次零碎的小传输合并成高效的大块传输。配置 FIFO 阈值的时候要注意外设的数据宽度。比如外设是 8 位的FIFO 阈值设为半满4 字节会更合适如果是 32 位的外设可以设为半满或全满8 字节。阈值设得太高数据积压在 FIFO 里外设可能来不及清空缓冲区导致过冲阈值设得太低突发效果不明显。2.3 模式选型对照表模式传输方式适用场景注意点普通模式传输完成即停止内存拷贝、SPI 一次性读取、串口发送固定报文发送完成需要重新配置计数器和使能循环模式计数归零自动重载ADC 连续采样、串口不定长接收、定时器触发采集注意缓冲区覆盖语义需要配合空闲中断或半满中断突发模式拿到总线后连续搬多个数据高吞吐存储读写、UFS/eMMC 数据搬移、高速 USB需要 FIFO 配合外设要支持突发请求内存到内存模式无外设触发直接搬内存Memcpy 加速、图像数据搬移、协议栈缓冲拷贝没有硬件触发源需要软件启动这里要特别说一下“内存到内存模式”。很多 DMA 控制器支持把一块内存的数据搬到另一块内存不需要外设触发。这个功能对协议栈很实用——比如网络收到一包数据需要把头部和负载拷贝到连续缓冲区用 DMA 一条指令搞定比自己写 for 循环快很多。2.4 分布式 DMA 和多通道调度的优先级博弈现在的 MCU 基本都带多个 DMA 通道比如 STM32F1 有两个 DMA 控制器共 12 个通道一些新系列甚至更多。多个外设可以同时申请 DMA 服务这就是热词里说的“分布式 DMA”——每个外设都能配一个通道像多个帮厨同时干活。但通道多了就有优先级问题。DMA 控制器一般分硬件优先级通道编号小的优先和软件优先级可配置。如果你的系统里同时跑着串口接收、ADC 采样和 SPI 闪存读写建议把实时性要求最高的外设配成最高优先级。我个人踩过一个坑把 ADC 采样配成最高优先级串口接收配成低优先级。结果高频 ADC 采样一直在占用 DMA串口数据偶尔过冲。后来调换优先级把串口接收提为最高ADC 排在后面问题就没了。因为 ADC 本来就是循环采样偶尔延迟一两次采样不会出大事但串口数据丢了就是真丢了。优先级排序的原则很简单丢了会出事故的外设排前面丢几次能容忍的排后面。3. 串口 DMA 接收不定长数据空闲中断 Modbus 的实战组合3.1 为什么逐字节中断扛不住高速串口串口是单片机最常用的通信口也是最适合用 DMA 的场景之一。但很多人刚开始用 DMA 接收时只会做定长接收——DMA 收到固定 N 个字节后触发完成中断。可现实里串口数据很多时候是不定长的有时候来 5 个字节有时候来 80 个字节你怎么知道一帧数据什么时候结束最粗暴的做法是逐字节接收每收一个字节就判断是不是帧尾。这在低速、短报文场景下能用但波特率一旦拉高到 460800 甚至 921600每字节的中断间隔只有 10 微秒左右。如果你的 main 循环里有耗时的浮点计算中断稍微延迟一点就过冲。另一个做法是用定时器判断接收间隔每收到一个字节就重置定时器定时器超时说明帧结束了。这就是 Modbus 协议栈常用的 3.5 字符时间判断法。但是定时器中断仍然会频繁触发而且在系统负载高的时候定时器的精度也会受影响。真正优雅的方案是DMA 空闲中断IDLE。串口外设的 IDLE 标志会在“收到一个字节后、总线上一个字节时间内都没有新数据”时置起。也就是说你只需要在 DMA 把数据搬进内存的同时等 IDLE 中断告诉你“这帧数据结束了”剩下的事全都交给硬件。3.2 空闲中断判断帧结束的核心逻辑空闲中断的逻辑本身不复杂但它和 DMA 配合时有一个容易困惑的点IDLE 中断不是一个固定事件它可能在帧的中间出现。举个例子发送端先发 10 个字节停 0.5 个字节时间再发 20 个字节。如果 0.5 个字节时间小于“一个字节时间”IDLE 不会被触发30 个字节会被当成一帧收完。但如果发送端停的时间大于一个字节时间IDLE 就会在中间触发你会收到两个“半包”。所以在实际项目里通信双方要对帧间间隔有约定。要么发送端确保帧内数据连续、帧间间隔足够大要么接收端做“粘包处理”——先收进大缓冲区再自己根据协议帧头帧尾或长度字段切分。下面这段是基于 HAL 库风格的参考实现PY32F003 这类 M0 内核的单片机思路完全一致只是库函数名略有差异#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len 0; void uart_dma_init(UART_HandleTypeDef *huart) { // 开启 DMA 接收数据自动写入 rx_buf HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, RX_BUF_SIZE); // 使能空闲中断HAL 库在 ReceiveToIdle 里已默认开启 __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { // 收到一帧数据Size 是本次接收到的字节数 rx_len Size; // 在这里解析协议帧 process_rx_frame(rx_buf, rx_len); // 重新启动下一次接收注意要传新的缓冲区地址或重新指定长度 HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, RX_BUF_SIZE); }注意最后的“重新启动接收”。很多新手在这里少写了这句导致只收到第一帧数据就再也不触发回调了。因为 DMA 在普通模式下接收完指定长度就直接停止你要重新使能才能继续收下一轮。如果你把 DMA 配成了循环模式那缓冲区会被不断覆盖回调触发时你拿到的可能已经被新数据改写了——这就是下一章要讲的核心矛盾。3.3 PY32F003 串口 DMA 接收参考例程的适配思路PY32F003 是普冉半导体推出的 Cortex-M0 内核 MCU主打低成本低功耗。我用它做过一个小型传感器采集节点串口用 DMA 收数据效果很稳。它的 UART 外设也支持 IDLE 中断库函数的命名风格和 STM32 标准库接近。适配时只需要注意三点开启 DMA 时钟和外设时钟M0 内核没有独立的 DMA 中断优先级分组DMA 中断直接用 NVIC 配置即可。中断服务函数里要手动清 IDLE 标志。标准库或 LL 库里通常要读两次寄存器才能清掉 IDLE 标志具体做法是__HAL_UART_CLEAR_IDLEFLAG(huart)。缓冲区大小要根据最大帧长留足。比如协议最大一帧 64 字节缓冲区至少给 128 字节留出余量防止 DMA 写入越界。拿到的完整逻辑就是开机时启动一次ReceiveToIdle然后在回调里拿数据、解帧、重新启动接收。整个过程 CPU 只在“一帧数据完整到达”时被唤醒一次中断频率从每字节一次降为每帧一次。115200bps 下每帧 64 字节意味着原来每秒进 1280 次中断现在只需要进约 156 次中断每秒约 156 帧差不多降了一个量级。3.4 FreeModbus 里的 DMA 适配与 3.5 字符间隔再说说 FreeModbus。这个开源协议栈在工业现场用得非常多RTU 模式的帧间判断基于“3.5 个字符时间静默”。标准实现里用定时器做超时判断每收到一个字节就重置定时器定时时间到就认为一帧收完。如果用 DMA 接收需要想清楚一个问题IDLE 空闲中断的超时时间不等于 3.5 个字符时间。IDLE 是在“一个字节时间”内没有新数据就触发而 Modbus 要求的是“3.5 个字符时间”静默才结束。两者并不等价。那怎么办两个思路思路一用 DMA 接收配合较长的定时器做帧结束判断。DMA 负责把数据搬进缓冲区每收到一个字节就重置定时器。定时器设为 3.5 个字符时间超时后才进入协议处理。这样 DMA 减轻了搬数据的压力帧间判断还是交给定时器。思路二如果通信链路是“半双工一问一答”模式比如主站发请求、从站回响应可以不用 3.5 字符判断而是直接按接收完成 短延时确认来切分。大部分 Modbus 应用其实是这种主从模式用 DMA IDLE 就已经够用。我做过一个基于 FreeModbus 的从站设备最终采用的就是思路一。DMA 把报文搬进缓冲区一个 1ms 定时器做超时判断在回调里喂狗重置定时器超时后进入vMBPortSerialRxComplete处理。这样协议栈的接口不用改底层换成了 DMA性能和稳定性都上去了。4. DMA 发送要不要等上一轮这个经典问题的完整答案4.1 不等的话数据会被覆盖“DMA 串口发送需要等待上一轮数据发送完吗”是社区里被问烂了的问题。答案是取决于你对“上一轮发完”的定义以及缓冲区还能不能复用。最常见的情况是这样的你要用串口发一帧数据把数据放进tx_buf然后调用 DMA 发送函数。如果第一次发送的数据还没发完DMA 还在搬运你就往tx_buf里写了新数据那么 DMA 正在搬运的数据会被改掉发出的是新旧混合的乱码。很多芯片的串口 DMA 发送流程是软件把数据从内存搬到串口数据寄存器串口再移位发出去。如果内存里的源数据被改了而 DMA 还没搬完那后续搬运的字节就是新数据对端收到的帧就乱了。4.2 判断“发完”的三个信号要避免覆盖必须先搞清楚“发完”指的是哪个阶段。这里其实有三个不同的“完成”信号含义使用时机UART TXE 标志数据寄存器已空可以写入下一个字节逐字节发送时判断UART TC 标志最后一个字节已经移位发出线路空闲发送完成后拉高 RS485 方向控制引脚DMA 传输完成中断DMA 已经把所有数据从内存搬到了外设判断源缓冲区是否可复用这里有个坑DMA 传输完成并不等于串口发送完成。DMA 把数据搬到串口的发送数据寄存器但串口还要一位一位地往外移位发送。最后一个字节可能还在移位寄存器和发送移位器里此时你把 RS485 改成接收模式最后一个字节就被切掉了。所以如果你的系统用了 RS485 这类需要方向控制的接口最好在“串口 TC 标志”置位之后再去切换方向。具体做法是在 DMA 传输完成中断里打开串口 TC 中断TC 置位后在中断里切换方向。4.3 什么情况下可以不等说了半天要等是不是所有场景都必须等也不是。下面这几种情况下你完全可以不等双缓冲机制准备两个缓冲区DMA 在发 A 缓冲区的同时你往 B 缓冲区写下一帧数据。DMA 发完 A 后自动切到 B需要 DMA 支持传输完成切换或者你在完成中断里立刻启动下一次发送。这样发送和填数据可以并行不需要等待。发送频率远低于传输时间比如一帧 100 字节115200bps 下发送完大约 8.7 毫秒。如果你的业务频率是每 100 毫秒发一帧那随便等不等反正 DMA 早就发完了。硬件 FIFO 足够大一些芯片的串口带很大的发送 FIFODMA 把数据全部写进 FIFO 后即返回FIFO 会自己慢慢发出去。这种情况下DMA 完成中断就可以认为缓冲区可复用真正发送完成要看 FIFO 空标志。4.4 实际项目中的处理套路我自己的习惯是这样写一个uart_dma_send()函数内部维护一个“忙标志”volatile uint8_t uart_tx_busy 0; void uart_dma_send(UART_HandleTypeDef *huart, uint8_t *data, uint16_t len) { // 如果没在发送直接启动 if (uart_tx_busy 0) { uart_tx_busy 1; HAL_UART_Transmit_DMA(huart, data, len); } // 如果还在发送可以根据需要选择 // 1. 丢弃新数据并返回错误 // 2. 缓存到待发送队列等完成中断后自动发送 else { // 项目中我会把新帧放入环形队列这里省略 } } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { uart_tx_busy 0; // 如果有排队的数据在这里启动下一帧发送 }注意HAL_UART_TxCpltCallback只代表 DMA 搬运完成不代表串口物理层发送完成。如果你的项目用 RS485还需要再等 TC 标志才能翻转方向。一个通用做法是在 DMA 完成中断里做一次延时比如等 2~3 个字节时间再方向切换这样最简单也最保险。5. ADC 单通道 DMA 多次采样从缓存连续到软件滤波5.1 CubeMX 配置里最容易忽略的“DMA 循环模式”ADC 是最典型的“持续产生数据”的外设不用 DMA 的话CPU 要么在轮询里死等 EOC 标志要么频繁进中断读数据。高采样率下比如 100ksps每 10 微秒就要读一次 ADC 数据寄存器CPU 基本干不了别的了。用 DMA 的思路是ADC 转换完成自动触发 DMA把转换结果直接搬进内存数组。CPU 要做的事就是隔一段时间看一眼数组做一下平均滤波。CubeMX 配置里最容易忽略的是把 DMA 模式设成 Circular循环。如果设成 NormalADC 转换完一轮就停了后面的数据全丢了。很多新手在 CubeMX 里默认用 Normal结果发现只有第一次采样有数据后面全是 0。另外还有一个隐藏项ADC 连续转换模式Continuous Conversion要打开。如果只开扫描模式而不开连续转换ADC 转换完一轮也会停下。DMA 循环 ADC 连续转换这两个必须同时配置才能实现“后台不停采、数据自动进数组”的效果。5.2 缓存连续性问题DMA 一直写CPU 一直读热词里有一个“dma continuous requests”字面意思是 DMA 连续请求。这背后真正的问题是DMA 在循环模式下会持续不断地搬运数据而 CPU 同时也在读同一块缓冲区两边可能在同一个位置发生冲突。比如你配置 DMA 把 ADC 结果写入adc_buf[64]循环模式下一共 64 个点采集满后 DMA 自动回到adc_buf[0]继续写。如果你在主循环里正好读到adc_buf[0]而 DMA 此刻也准备写adc_buf[0]读到的数据就可能是“写了一半”的脏数据。解决这个问题有几种常见做法半满中断 全满中断DMA 支持传输过半中断和传输完成中断。当半满中断触发时说明前半段缓冲区已经稳定可以安全读取前半段当全满中断触发时说明后半段已经稳定可以读后半段。这样 CPU 永远只读“DMA 当前不在写”的那一半。双缓冲区Double Buffer像串口发送那样准备两个缓冲区DMA 写 A 时 CPU 读 BDMA 写 B 时 CPU 读 A。这种方式比半满中断更干净但需要 DMA 支持双缓冲功能。读之前关闭 DMA最简单粗暴但会丢失采样数据实时性要求高的场景不推荐。为了演示我以 STM32 HAL 库为例展示最常用的半满/全满中断处理方式#define ADC_BUF_LEN 64 uint16_t adc_buf[ADC_BUF_LEN]; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { // 前半段数据已经稳定可以处理 adc_buf[0] ~ adc_buf[31] process_adc_samples(adc_buf, ADC_BUF_LEN / 2); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { // 后半段数据已经稳定可以处理 adc_buf[32] ~ adc_buf[63] process_adc_samples(adc_buf[ADC_BUF_LEN / 2], ADC_BUF_LEN / 2); }这段代码的思路是DMA 写缓冲区的时候CPU 只去处理“已经写完”的那一半两边互不干扰。实际项目中核心电机的电流采样我就用的这个方案效果很好。5.3 软件滤波滑动平均和去极值平均的实现ADC 裸数据直接用的场景不多一般都要做软件滤波。DMA 循环采样带来的直接好处是滤波窗口的数据永远是最新的因为 DMA 一直在更新数组。两种最常见的滤波方法滑动平均连续取最近 N 次采样的平均值。N 越大滤波效果越平滑但响应越慢。适合噪声小、变化缓慢的信号比如温度传感器、电池电压。去极值平均把 N 次采样排序去掉最大值和最小值再对剩下的求平均。这个对脉冲噪声很有效比如电机启动时的尖峰干扰。滑动平均的实现很简单但要注意数据类型溢出。比如 12 位 ADC 最大值 409564 个点累加最大 262080已经超过 16 位无符号整数的上限。所以累加变量要用uint32_t求完平均再转回uint16_t。uint16_t moving_average(uint16_t *buf, uint8_t len) { uint32_t sum 0; for (uint8_t i 0; i len; i) { sum buf[i]; } return (uint16_t)(sum / len); }去极值平均稍微复杂一点需要排序。但 ADC 点数一般不大8~64 个用简单的选择排序完全没问题。排序本身也会消耗 CPU 时间所以这个滤波一般放在 DMA 半满/全满中断之后的主循环里做不要在中断里排序。5.4 延伸DMA 测速和存储场景里的 UFS DMA聊到 DMA 的性能市面上有一些“DMA 测速软件”原理无非就是让 DMA 反复搬运一大块内存记录搬运时间算出带宽。这个思路在嵌入式里也能直接用配置一个 GPIO在 DMA 传输前拉高、传输完成中断里拉低用逻辑分析仪测高电平持续时间就能算出实际吞吐量。这个方法我在调 UFS 存储驱动时用过。UFS 这类存储器的读写本质上也是 DMA 在做主控和 Flash 之间的数据搬移只不过 DMA 控制器集成在存储控制器内部。它的工作模式和 MCU 上外设 DMA 是同一个道理——只是总线宽度更宽、突发长度更长、队列更深。理解了 MCU 的 DMA再去看这些高吞吐场景思路是相通的。我在实际调试里测过一次MCU 内部 DMA 做内存到内存搬运72MHz 主频下搬运 4KB 数据耗时约几十微秒算下来带宽能达到几十 MBps 级别。当然实际瓶颈往往不在 DMA 本身而在源端和目的端的等待时间。6. DMA 疑难杂症的排查链路现象、根因、验证三步走6.1 高频问题症状表DMA 的配置项多踩坑点也就多。我把这些年遇到的、以及社区里高频出现的 DMA 疑难杂症整理成了一个症状对照表现象可能原因验证手段DMA 收不到数据寄存器配置没问题DMA 时钟没使能或外设请求映射错误检查 RCC 里的 DMA 时钟位核对 DMA 请求映射表收到一半数据就停了缓冲区长度配置错误或普通模式没有重新使能打印 DMA 剩余计数寄存器看是不是提前归零数据错位、顺序混乱外设数据宽度和 DMA 数据宽度不匹配核对 MSIZE/PSIZE 配置统一为 8 位或 16 位系统整体卡顿、明显变慢DMA 优先级过高一直占总线调低优先级开突发传输缩短占用时间发送数据偶尔丢失最后一个字节只判断 DMA 完成没等串口 TC加 TC 中断或延时后切换方向缓冲区被异常改写DMA 写入越界或循环模式和业务逻辑冲突加大缓冲区加上边界检查打印死机进入 HardFaultDMA 目标地址非法或长度超过缓冲区检查目标地址是否在 RAM 范围内带 D-Cache 的核数据不一致CPU 读到了 Cache 里的旧数据DMA 写的是真实内存对缓冲区执行 Cache 无效化Invalidate操作6.2 一个完整排查案例串口 DMA 收不到数据的根因定位拿最经典的“串口 DMA 收不到数据”来说。这个问题的排查链路我建议严格按下面的顺序走跳步容易白费功夫第一步确认外设本身在正常收数据。先关掉 DMA直接在主循环里轮询 RXNE 标志看串口能不能收到数据。如果这一步都收不到问题根本不在 DMA而在串口配置、引脚复用、电平转换。千万别先去翻 DMA 寄存器。第二步确认 DMA 通道映射正确。打开芯片参考手册的 DMA 请求映射表核对你的串口接收 DMA 请求挂在哪个通道上。比如 STM32F1 中 USART1_RX 映射到 DMA1 通道 5F4 系列可能就不一样了。映射错了外设永远触发不了 DMA。第三步确认 DMA 时钟已使能。这一步新手很容易漏。只开了外设时钟、没开 DMA 时钟代码看起来全对但 DMA 控制器根本没工作。检查方式很简单在初始化代码里打印 DMA 控制寄存器的值看都能不能读出来。第四步确认缓冲区地址和长度。很多库函数要求缓冲区地址按半字或字对齐。如果地址没对齐在某些核上会直接触发异常。还见过有人把uint8_t buf[64]传给一个要求uint32_t对齐的 DMA最后数据写乱套了。第五步确认中断回调真的被调用了。在回调函数第一行加一个 GPIO 翻转用示波器看引脚有没有电平变化。没有变化就查中断使能、NVIC 配置、回调函数注册。这块是最容易找不到北的地方因为你觉得“代码都写了”但中断根本没进。这套流程走完绝大多数“收不到数据”的问题都能定位出来。我把这个排查顺序贴在工位旁边团队里新人遇到 DMA 问题先按这个查一遍再问人。6.3 D-Cache 缓存一致性被忽视的大坑如果你用的芯片带 D-Cache比如 Cortex-M7、A 系列DMA 还有一个特有的坑缓存一致性问题。CPU 读数据的时候优先从 Cache 里读。如果 DMA 把外设数据写到了真实内存而 Cache 里还留着旧数据CPU 读到的就是旧数据。反过来如果 CPU 写了数据去发送数据可能还在 Cache 里没回写到内存DMA 去内存搬的时候拿到的就是空数据。解决办法是对缓冲区做 Cache 维护操作// 发送前把 CPU 写的数据从 Cache 回写到内存 SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); // 接收后让 CPU 重新从内存加载数据无效化 Cache 行 SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len);清 Cache 和无效化 Cache 是两个不同的操作方向完全相反千万别搞混。我见过有人把发送方向写成 Invalidate结果发出去全是乱码因为 Cache 里刚写的数据全被丢掉了。6.4 我这些年总结的 DMA 排查习惯最后分享几个我个人的排查习惯都是踩过坑之后固化下来的配置一律用库函数生成然后人工复查一遍。手写 DMA 寄存器虽然能加深理解但在复杂芯片上很容易漏配一两个位。我习惯先用 CubeMX 或厂商的代码生成器配置再逐个检查关键寄存器相当于“机器打底稿、人做校对”。调试时多打印剩余计数寄存器。DMA 的剩余计数寄存器比如NDTR能直观反映当前还有多少数据没搬完。怀疑 DMA 没启动就看它有没有变化怀疑数据被覆盖就观察它的节奏是否异常。这是 DMA 调试的“示波器”。中断回调里不要做复杂处理。很多人喜欢在 DMA 完成中断里直接做协议解析、滤波、写入 Flash这是大忌。中断里做复杂操作会拖长中断响应时间影响其他实时任务。正确做法是中断里只置一个标志位主循环或 RTOS 任务里轮询标志再处理。加一个“DMA 总开关”调试接口。在代码里留一个测试命令可以随时关闭 DMA、切换回中断模式。这样出问题时能快速二分定位——关 DMA 好了那问题在 DMA 配置关 DMA 还不行那问题在业务逻辑。这些习惯看着琐碎但真能省下大量排查时间。DMA 本身是一个很讲逻辑的外设只要配置正确它从来不会“灵异”。所有看似随机的问题背后都有一个确定的根因。找到那个根因剩下的就好办了。