做嵌入式驱动这几年,我发现自己跟 DMA 打交道的次数,比跟任何外设寄存器打交道的次数都多。串口收发、SPI 刷屏、ADC 连续采样、定时器触发批量搬数,背后全是这个“硬件搬运工”在干活。如果你还在用中断一个字节一个字节地收数据,那这篇第 6 期经验整理,大概率能帮你把 CPU 占用率降一个数量级。内容适合正在调串口、SPI、ADC 驱动的新手,也适合被缓存一致性、中断标志、通道冲突这些坑折磨过的老手。我会把 DMA 驱动的设计思路、配置流程、代码模板,以及我踩过的坑一次讲清楚。
1. 先看整体:DMA 驱动开发的设计思路与选型判断
1.1 DMA 的本质是一个“不占 CPU 的搬运工”
讲 DMA 之前,先想一个问题:CPU 是干什么的?除了做运算,它还得当“快递员”——把外设寄存器的数据搬到内存,再把内存数据搬回外设。这个搬运动作本身不产生任何计算价值,纯粹是开销。中断方式下,每个字节来一次都要打断 CPU,现场保存、压栈出栈、判断标志、拷贝数据,代价极高。DMA 干的事,就是把这个“快递员”的角色交给一个硬件专用通道,它自己完成源地址、目的地址、搬运长度的配置,搬完触发中断告诉 CPU 一声就行。这意味着,CPU 在搬运期间可以继续干正事。
为什么要强调这个?因为很多新手在驱动开发时容易陷入一个误区:能跑就行,不关心中断频率。等到系统任务多了,CPU 在 115200 波特率的串口上被中断打爆,才发现问题严重。以一个波特率 115200、8N1 格式的串口为例,每秒约有 11520 字节,如果用中断方式每字节进一次中断,每秒要被中断打断一万多次,再算上每一次中断的进出场开销,CPU 的有效利用率会明显下降。而用 DMA 搬运,把一整包数据一次性搬进内存,CPU 全程不参与搬运,只处理整包数据到达的事件,效果天差地别。
这其实也回答了“什么时候该用 DMA”的问题:只要你有“大量数据需要在外设和内存之间搬移”的场景,都应该优先考虑 DMA。典型的包括:串口不定长帧接收和高速发送;SPI 与外部 Flash、显示屏、SD 卡之间的批量读写;ADC 多通道连续采样;定时器触发 DMA 批量更新多个 PWM 通道的占空比。反之,如果只是偶尔搬几个字节、实时性要求也不高,强行上 DMA 反而会增加配置复杂度和调试成本。判断标准很简单:单位时间内数据量越大,中断频率越高,DMA 越值得上。
1.2 驱动开发的整体决策流程
我做 DMA 驱动时,一般不会一上来就抄代码,而是按下面的顺序过一遍:
- 先看数据手册里的 DMA 请求映射表,确认用的外设事件对应哪条 DMA 通道/流,避免两条外设抢同一通道。
- 确定搬运方向:外设到内存、内存到外设、还是内存到内存。
- 确定模式:单次传输还是循环模式。循环模式适合 ADC 连续采样这类永远不停的外设;单次模式适合一次性搬完一包数据就停的场景。
- 确定数据宽度:字节、半字(16 位)、还是字(32 位),与外设寄存器宽度、FIFO 配置必须匹配。
- 选择中断:传输完成中断、半传输中断、错误中断分别怎么用,以及中断优先级要不要高于某些任务。
- 考虑缓冲区管理和缓存一致性,尤其在 Cortex-M7 或 Cortex-A 这类带 Cache 的平台上。
- 测试验证:先小包测试,再压到满负荷,用示波器或逻辑分析仪量时间戳,确认实际吞吐和中断负载。
这套流程看着繁琐,但它能挡住绝大多数运行期才暴露的鬼问题。下面几节我会结合串口、SPI、ADC、定时器这几个最常见场景,把每一步拆开讲。
2. 串口 DMA 接收:从“莫名丢包”到“稳定收包”的关键细节
2.1 不定长数据:空闲中断才是串口 DMA 的灵魂
串口 DMA 接收,严格说不难,难在“怎么知道一帧数据结束了”。串口协议千奇百怪,有固定长度的,有带帧头帧尾的,也有纯不定长的。对于不定长数据,工程上最实用的方案就是“DMA 循环接收 + 空闲中断”。数据到达时,DMA 负责把字节从串口的接收寄存器搬进缓冲区,CPU 完全不被打扰;当总线上出现一个字节间隔(即空闲)时,串口会产生空闲中断,此时 CPU 才去读 DMA 剩余计数器,算出真正接收到的字节数,然后处理这一帧。
这个方案我在 STM32F1、F4、H7 以及 ESP32 上都验证过,核心逻辑一致。以 STM32 HAL 库为例,代码骨架是这样:
#define RX_BUF_SIZE 512 static uint8_t uart_rx_buf[RX_BUF_SIZE]; void uart_dma_rx_start(UART_HandleTypeDef *huart) { // 开启 DMA 循环接收,CPU 不参与搬运 HAL_UART_Receive_DMA(huart, uart_rx_buf, RX_BUF_SIZE); // 开启空闲中断 __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); } // 串口中断里判断空闲标志 void uart_irq_handler(UART_HandleTypeDef *huart) { if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) { // 先清空闲标志,注意 HAL 库不同版本清法不同 __HAL_UART_CLEAR_IDLEFLAG(huart); // DMA 剩余计数器的值,就是缓冲区里尚未被搬走的字节数 uint16_t remained = __HAL_DMA_GET_COUNTER(huart->hdmarx); uint16_t received = RX_BUF_SIZE - remained; // 此时 uart_rx_buf[0..received-1] 就是一帧完整数据 handle_frame(uart_rx_buf, received); } HAL_UART_IRQHandler(huart); }注意几个容易翻车的细节。
第一,空闲中断必须在 DMA 接收开启之后再去使能。如果反过来,数据还没开始接收,空闲中断却被置位,你会在中断里读到一个长度为 0 的“假帧”。第二,清空闲标志的方式跟芯片系列相关。有些系列直接清 IDLE 标志位就行,但有些系列需要先读状态寄存器再读数据寄存器才能清干净,建议查对应参考手册,别把一个系列的工程代码复制到另一个系列就不管了。第三,计数器是 DMA 的“剩余数”,不是“已接收数”。很多人第一次看会搞反,导致每帧长度都算错。用缓冲区总长度减去剩余计数,才是本次实际收到的字节数。第四,如果一帧数据正好填满整个缓冲区,空闲中断还没来得及产生,DMA 计数器已经归零,这一帧就会因为“没有空闲”而不被处理。解决办法是把缓冲区设得比实际最大帧长更大,或者配合半传输中断做乒乓切换。我的习惯是:缓冲区长度设为最大帧长的 2 倍以上,必要时再加一个“DMA 传输完成中断”做兜底。
2.2 乒乓缓冲:让数据处理和 DMA 搬运并行起来
这里顺便聊聊乒乓缓冲。如果你认真做串口 DMA,一定会遇到这个问题:DMA 在往里写缓冲区,而你在中断里读同一个缓冲区,两个动作在不该碰头的地方碰了头,数据就花了。乒乓缓冲的思路是准备两块缓冲区,A 和 B。DMA 搬数据时,CPU 处理另一块;等 DMA 把当前这块搬满了(或半满中断触发),就切换,CPU 处理刚刚被搬满的那块,DMA 继续往另一块写。两块缓冲区轮流被读写,就像两个房间轮流住人,永远不会互相踩脚。
具体到串口场景,最简单的实现就是用 DMA 的半传输中断和传输完成中断分别标记前半段/后半段已经填满。假设缓冲区大小 1024 字节,DMA 写满前 512 字节触发半传输中断,CPU 处理 Buf[0..511];DMA 写满后 512 字节触发传输完成中断,CPU 处理 Buf[512..1023]。DMA 是循环模式,所以这个流程会不断重复。这样即使一帧的数据长度小于 512 字节,也能被及时处理,不会因为等待“空闲中断”而延迟一整个帧周期。
这里给一个简化版的乒乓处理骨架:
#define BUF_SIZE 1024 #define HALF_SIZE 512 static uint8_t rx_buf[BUF_SIZE]; void uart_dma_rx_start(void) { HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE); // 使能半传输中断和传输完成中断 __HAL_DMA_ENABLE_IT(huart1.hdmarx, DMA_IT_HT); __HAL_DMA_ENABLE_IT(huart1.hdmarx, DMA_IT_TC); } void dma_rx_irq_handler(void) { if (__HAL_DMA_GET_FLAG(huart1.hdmarx, DMA_FLAG_HT)) { __HAL_DMA_CLEAR_FLAG(huart1.hdmarx, DMA_FLAG_HT); process_data(rx_buf, HALF_SIZE); // 前半段已就绪 } if (__HAL_DMA_GET_FLAG(huart1.hdmarx, DMA_FLAG_TC)) { __HAL_DMA_CLEAR_FLAG(huart1.hdmarx, DMA_FLAG_TC); process_data(rx_buf + HALF_SIZE, HALF_SIZE); // 后半段已就绪 } }但提醒一句:乒乓缓冲的“帧边界”不一定跟缓冲区边界对齐。如果你在跑 Modbus、自组协议这类有明确帧结构的应用,不能直接拿半满标志当帧边界,得配合帧头帧尾或超时判断。乒乓解决的是“数据并发覆盖”问题,不是“帧协议解析”问题。我在实际项目里常用方案是:数据到达时先按乒乓策略写入一个大环形队列,协议解析单独跑一个任务从环形队列里消费,两个问题解耦,非常好用。
2.3 ESP32 串口 DMA 的差异点
热词里提到 ESP32 的串口 DMA 接收,这里单独说两句。ESP32 的 UART 外设本身带有硬件 FIFO,官方推荐的 DMA 接收写法跟 STM32 的循环 DMA 不完全一样。ESP-IDF 的uart_driver_install里可以配置UART_RX_DMA模式,驱动内部会维护环形缓冲区,应用层直接调用uart_read_bytes读走数据。它更类似“驱动帮你做好了缓冲管理和事件通知”,你不需要像 STM32 那样手工清 DMA 标志和计算计数器。
用 ESP32 时有一个跟发送端相关的坑:接收端启用 DMA 后,发送端并不需要改变发送时序,正常按照原协议发送即可,DMA 驱动会按字节流把数据搬进环形缓冲。但如果发送端连续发送的数据包很大,而你这边 DMA 缓冲太小,数据就可能被截断或丢弃。解决办法是合理设置uart_config里的缓冲区大小,并且在事件循环中及时读取数据,让环形缓冲腾出空间。不少人拿 STM32 的习惯去写 ESP32,结果被“DMA 接收不到数据”卡住很久,其实不是 DMA 坏了,是 IDF 的事件处理和缓冲策略没配对。
3. SPI DMA 与 ADC DMA:高速采样的两个“刚需”场景
3.1 SPI DMA:刷屏、读写 Flash、驱动 ADC 都靠它
SPI 是嵌入式里最常用的高频外设接口,它的特点就是“快”。很多 MCU 的 SPI 时钟能跑到 18MHz、36MHz、甚至上百 MHz。这时候如果用“发送一个字节,等发送完成中断,再发下一个字节”的轮询或中断方式,你会发现大部分时间都浪费在等待上,外设吞吐根本跑不满。DMA 一出场,直接把一组待发送的数据交给 DMA 控制器,SPI 移位寄存器每准备好一个数据,DMA 就自动填充下一个,发送完成后再给一个完成中断,CPU 全程零参与。
以用 SPI DMA 驱动一个 LCD 屏为例:刷一屏 240x320、16 位色深的图片,数据量是 2403202,约 150KB。如果普通方式在主循环里一个字节一个字节往 SPI 数据寄存器写,几帧下来主循环基本没法干别的。用 DMA,CPU 只需要把一包颜色的起始地址和数据长度告诉 DMA,然后就可以去跑其他任务,搬完了收到完成事件再收尾。刷屏帧率能提升 30%~50%,关键是 CPU 占用率大幅下降,系统整体响应更快。
写 SPI DMA 驱动时,几个要点:
- 通信前先确认 SPI 的 DMA 请求映射到哪条通道/流。STM32F103 这类 Cortex-M3 的 DMA1/DMA2 和外设的映射关系在参考手册里有一张大表,比如 SPI1_RX 固定走 DMA1_Channel2、SPI1_TX 固定走 DMA1_Channel3。两个外设不能同时占用同一条通道,所以设计外设分配时要提前查表。
- 发送方向要设置成内存到外设,源地址是内存缓冲区、目的地址是 SPI 的数据寄存器地址,而且目的地址要固定,不自动增加;源地址按数据宽度递增。很多人配置反了,导致发出去的全是同一个字节。
- 如果 SPI 外设和 DMA 都支持 FIFO,可以合理配置 FIFO 阈值,减少总线占用。不同芯片差异很大,但大方向是 FIFO 能缓冲一定深度,容忍总线仲裁延迟,避免 SPI 因等待数据出现欠载。
- 大块数据传输时还要注意 DMA 传输长度限制。有些 DMA 的计数寄存器只有 16 位,最大单次传输 65535 个数据单元,超过需要拆包或者用循环模式。
下面是一个用 HAL 库发送数据的典型代码:
// 发送前先把数据准备好 uint8_t spi_tx_buf[1024]; // 调用 DMA 发送,CPU 立即返回 HAL_SPI_Transmit_DMA(&hspi1, spi_tx_buf, len); // 在发送完成回调里做后续动作 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { // 发送完成,可以去处理下一包或拉高片选 } }有人会问:SPI DMA 发送时,片选信号怎么处理?正常做法是发送前拉低片选,发送完成回调里拉高片选,而且要确保片选拉高的动作发生在最后一个字节真正移出 SPI 之后。用HAL_SPI_Transmit_DMA时,完成回调在最后一个数据被送入 SPI 移位寄存器时会触发,但移位寄存器可能还在往外送,所以有的场景下需要加一个极小延时或查询 SPI 忙标志,再拉高片选,否则最后一个字节容易丢。这个坑我踩过不止一次。
3.2 ADC 连续采样:为什么必须 DMA
ADC 连续采样可以理解成一个“永不间断的测量仪”。要以 1kHz、10kHz 甚至更高的采样率连续采集电压,每次采样结果都进中断让 CPU 搬运,中断负载会高到不可接受。DMA 在这里的价值是:ADC 每转换完一个通道,触发 DMA 请求,DMA 把结果自动搬到内存数组里,数组可能存了几百上千个采样点,CPU 按自己的节奏去读这批数据做滤波、分析、显示。
多通道 ADC 的情况更明显。比如 STM32 的 ADC1 用扫描模式采集 8 个通道,转换顺序由规则组定义,DMA 按规则把每个通道的结果依次放到目标数组。你只需要把数组大小设为“通道数 × 采样次数”,比如 8 通道采集 100 轮,就是 800 个半字长度的数组,DMA 放进来的顺序天然对应通道顺序。
这里要特别注意 DMA 循环模式和数据对应关系。假设你配置了 ADC 扫描 8 个通道,目标数组长度 8,DMA 用循环模式,那么每转换完一轮,8 个通道的数据就依次刷新一遍。如果你把数组长度设成 8×N,DMA 就会把 N 轮数据连续存放,直到存满或循环覆盖。读取时要知道:第 i 个采样点对应第 i%8 个通道。这种对应关系不提前想清楚,滤波算法全都会因为数据错位而跑飞。
安富莱 AD7606 之类的板卡和模块也是同样的逻辑。AD7606 是 8 通道同步采样的高速 ADC,外部通常用并行接口或 SPI 接口跟 MCU 通信,强调同步采样和高速吞吐。如果走 SPI,MCU 端一般就是 SPI DMA 接收:AD7606 转换完成后通过 BUSY 引脚通知 MCU,MCU 再发起 SPI 读取,DMA 把 8 个通道的数据一次性搬进内存。驱动里 BUSY 引脚的中断优先级、SPI 时钟速度、以及 DMA 缓冲区大小是三个关键设计点。BUSY 中断要够快才能保证采样同步精度,SPI 时钟要够高才能在采样间隔内搬完数据,DMA 缓冲区要够大才能缓存多次采样结果。我做这类采集时,会先用一个固定频率的方波源接在其中一个通道上,验证 DMA 搬运的数据顺序是否正确,再用逻辑分析仪看波形确认最终结果。
3.3 Continuous Requests 与 TIM DMA Burst:定时器触发批量搬运的玩法
热词里的“DMA continuous requests”和“TIM DMA Burst”属于 DMA 的高级玩法,很多人在基本收发调通之后才开始碰。先说 continuous requests:在很多 MCU 里,外设触发 DMA 的请求模式可以配置成单次请求或连续请求。单次请求模式下,外设发一次请求,DMA 搬一个数据就停了;连续请求模式下,外设持续保持请求有效,DMA 会一直搬运,直到预设的传输计数耗尽。对于 ADC 这类持续产生数据的外设,用 continuous requests 可以保证数据不断被搬走,不会因为请求模式的差异而卡住。
TIM DMA Burst 是另一个很实用的用法。定时器不仅能产生 PWM,还能通过 DMA 批量更新自己的多个寄存器。最典型的应用是多路电机或多路 LED 控制:你需要同时更新 4 路 PWM 的占空比,传统做法是连续往 CCR1、CCR2、CCR3、CCR4 寄存器里写值,中间要间隔、要判断,更新速率上不去。DMA Burst 模式把这一串寄存器写入变成一个突发传输:内存里放一个数组,比如[1500, 3000, 4500, 6000],定时器溢出事件触发 DMA,DMA 按地址递增把数组依次写入 CCR1、CCR2、CCR3、CCR4,一气呵成,占空比更新延迟极低,而且所有通道是同一时刻生效的。
使用 TIM DMA Burst 有个关键点:DMA 的目的地址是“当前突发需要更新的那个寄存器”,它不会写在程序里写死的某个 CCR 上,而是通过 DMA 的突发配置和地址递增规则访问定时器的寄存器空间。因此,你得仔细看定时器寄存器地址映射和 DMA 突发长度配置,确保“起始地址 + 突发长度”所覆盖的范围就是你想更新的那批寄存器。配置错位是最常见的失败原因:明明开启了 Burst,寄存器却没更新,或者更新了不该更新的寄存器,导致 PWM 输出异常。标准参考做法是先只更新一个寄存器,验证 Burst 基本流程,再扩展到多寄存器,别一上来就整 4 路。
4. DMA 测速与验证:怎么证明你的驱动真的快了
4.1 从计数器和时间戳读吞吐
说完场景,聊聊热词里的“DMA 测速”。很多人把 DMA 配置好,能收能发,就以为万事大吉,但实际吞吐到底达到没有、CPU 有没有真正被解放,必须通过测速来验证。DMA 测速最直接的办法就是测实际搬运时间。
以串口 DMA 接收为例:你设定了 1Mbps 波特率,理论上每秒最多接收约 100KB 数据,实际能不能达到?最简单的方法是让上位机以固定频率发送一大包数据,你在收到完整数据包之后读系统 tick,计算包间的平均间隔。还可以利用 DMA 的剩余计数器做一个“瞬时接收速率”估算:收到 N 字节的数据,记录开始时间和结束时间,以 1 秒为窗口统计总量,就能算出真实吞吐。比如上位机连续发 100 包,每包 1024 字节,统计这批数据的接收耗时,用字节数除以时间,就是实际接收速率。
SPI DMA 测速同样可以做。在 SPI 从设备端用 GPIO 翻转法:开始发送前置高一个测试引脚,发送完成回调里拉低,用逻辑分析仪测量这个高电平的脉宽。这个脉宽就是 DMA 搬完一包数据的实际时间。举个例子:SPI 时钟 36MHz,单字节需要 8 个时钟周期,理论上发送 1024 字节最快约 1024*8/36MHz,约 227.6μs。如果你的 DMA 传输完成脉宽测出来是 300μs,差距 30% 以上,那就要查 SPI 配置、DMA 数据宽度、是否有额外的等待或中断开销了。理论值和实测值对照,是驱动性能调优最靠谱的方法。
4.2 用示波器和逻辑分析仪抓细节
我自己做驱动验证时,很少只依赖代码里的统计变量,因为软件统计本身也可能有偏差。更可靠的办法是用一个复用引脚配合 GPIO 翻转来标记关键事件:DMA 开始搬运时拉高一脚,DMA 完成中断里拉低一脚,双通道示波器同时看 SPI 时钟和这个标记引脚,就能清楚看到“搬运期间有没有时钟空洞”“片选是不是在最后一个字节之后才拉高”。这些细节在高速下非常关键。
再说一个常见误判:DMA 测速失败并不总代表 DMA 配置错。有时候是上位机发送节奏太快、缓冲区溢出;有时候是中断优先级太低,DMA 完成中断迟迟得不到响应;有时候是缓存一致性问题导致 DMA 写的数据 CPU 读不到,测速程序等待超时。所以遇到测速失败,先不要急着改 DMA 配置,按“缓冲是否溢出 -> 中断是否响应 -> 数据是否可见 -> 配置是否正确”的顺序排查,往往更快。我后来做了一套通用的“GPIO 标记 + 逻辑分析仪”验证模板,大多数 DMA 问题都能在 10 分钟内定位到具体环节。
5. 这几个 DMA 问题,够你排查一晚上
5.1 缓存一致性导致 DMA 数据“看不见”
在带 Cache 的芯片上,比如 Cortex-M7 的 STM32F7/H7、Cortex-A 系列,DMA 访问内存是绕过 CPU 缓存的,这就引出一个非常经典的问题:DMA 往内存写数据,CPU 因为 Cache 命中去读内存里的旧值;或者 CPU 往内存写数据,DMA 搬运时搬走的却是 Cache 里的旧内容。这就是缓存一致性问题。
解决思路分两步:发送前,要把待发送的缓冲区数据从 Cache 写回内存,用SCB_CleanDCache或SCB_CleanDCache_by_Addr;接收后,要告诉 CPU 这片区域已经被外部更新,需要作废缓存条目重新从内存读,用SCB_InvalidateDCache_by_Addr。而且 DMA 缓冲区必须按 32 字节对齐,不同内核要求的对齐长度不同,M7 一般是 32 字节,否则失效操作可能波及相邻数据。国产很多 M7 内核芯片的坑也集中在这里,配置时一定要看芯片厂商的 Cache 操作建议。
5.2 DMA 通道冲突
STM32F103C8T6 这类芯片的 DMA 通道是固定的,某个外设事件可能和另一个外设的事件在同一个通道上竞争。热词里“stm32f103c8t6 dma 同道”指的就是这个问题:USART1_TX 走 DMA1_Channel4,而某个定时器的更新事件也可能映射到 DMA1_Channel4 之类的通道,一旦多个外设映射到同一个 DMA 通道,就会出现“改了 A 外设的配置,B 外设反而不工作了”的灵异现象。解决方法没有捷径:设计外设资源时查清楚请求映射表,保证每个 DMA 通道只被一个外设占用。如果通道实在不够,可以研究一下 DMA 空闲时隙复用的思路,但复杂度会显著上升,除非项目必要,否则调整外设资源或换引脚更划算。
5.3 方向配置错误导致数据“全是同一个字节”
DMA 方向配置错了,表现非常诡异。比如 SPI 发送,你把源地址设成了 SPI 数据寄存器,目的地址设成了内存,DMA 以为自己是接收,结果发送出去的全是同一个值,内存里还会被写入一堆随机数据。排查时要先把源地址、目的地址和递增/固定模式一项项过:外设到内存时,外设寄存器地址固定不变,内存地址递增;内存到外设时反过来,内存源地址递增,外设目的地址固定。很多人栽在“把源地址设成固定”上,一次性把同一个值发几万个字节。
5.4 传输完成标志被误读
DMA 的标志位跟外设标志不是一回事。比如 DMA 传输完成标志是 TCIF,而串口的发送完成标志是 TC,名字很像,作用完全不同。有人在串口中断里读到串口 TC 标志后,去清 DMA 的 TCIF,清完发现标志还在,或者把 0 误判成完成,导致逻辑错乱。建议写驱动时把不同外设的标志位名写清楚,别省那几个字符。
5.5 半传输中断和传输完成中断没配合好
前面提到乒乓缓冲依赖半传输和传输完成两个中断。这俩中断的触发时机很关键:半传输中断是 DMA 搬运了总长度一半时触发,传输完成中断是搬完所有数据后触发。如果你只开了完成中断,却需要搬运完成前就处理前一半数据,就会浪费等待时间;如果你只开了半传输中断,后面一半数据永远不会被处理。新手最常见的错误是:循环模式下,传输完成标志不断循环触发,但没清理标志或没重新使能 DMA,第二次循环就卡在第一个触发点。正确做法是在中断服务里清理对应标志、必要时重新使能传输,保证 DMA 处于“搬运-中断-清理-继续搬运”的正常循环中。
5.6 初始化顺序不对导致第一包正常、后续错乱
DMA 驱动的初始化顺序也有讲究。我见过一个很怪的问题:SPI DMA 发送第一包数据正常,第二包开始数据错乱,查了半天,原因是 SPI 的 DMA 请求还没使能,程序就把 DMA 配置好了,导致 DMA 的第一次请求发生在错误的时间点。正确顺序一般是:先初始化外设和对应的 DMA 请求映射,再启动 DMA 传输。在 HAL 库里,通常是调用HAL_UART_Receive_DMA、HAL_SPI_Transmit_DMA这类启动函数,由它内部完成外设和 DMA 的联动配置,不要在它之前手动乱开 DMA。
5.7 常见问题速查表
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| 数据字节错位 | 数据宽度、地址递增配置不对 | 查 DMA 数据宽度和递增设置 |
| 只收到一帧后再也不收 | 循环模式未开,或传输完成标志未清 | 查控制寄存器 CIRC 位、中断清标志 |
| CPU 读到的数据是旧值 | 缓存一致性问题 | Clean/Invalidate DCache,对齐缓冲区 |
| 外设之间互相干扰 | DMA 通道冲突 | 查请求映射表 |
| 发送的一直是同一个字节 | 方向配置或目的地址固定设置错误 | 查方向、递增模式 |
| 一帧满缓冲区时不触发处理 | 缓冲区长度等于帧长,空闲中断未产生 | 扩大缓冲区或加完成中断兜底 |
这些问题我都实际遇到过,而且大概率你也会遇到。记住一个原则:先怀疑自己的配置,再用最小复现排除配置之外的因素。把问题拆成一个最小工程,往往比在一堆业务代码里瞎猜快得多。
6. 写在最后:DMA 驱动开发,我觉得最重要的是这件事
做 DMA 驱动这些年,我最大的体会是:DMA 本身不难,难的是你跟整个系统的配合。你不仅要会配寄存器、写回调、清标志,还要理解数据流、中断时序、缓存一致性,甚至要会看逻辑分析仪上的波形。真正的高手不是把 DMA 用得多花哨,而是能在一开始就预估到传输方向、缓冲策略、中断优先级这些架构级问题。第 6 期聊到这里,下一期我大概率会写中断嵌套和任务调度相关的内容,毕竟 DMA 搬完数据后,真正的业务竞争才刚开始。如果你也在调 DMA 卡了很久,试试把问题拆成“数据从哪来、到哪去、谁搬、搬完谁处理、何时处理”五步,很多问题自己就清晰了。