嵌入式DMA驱动开发实战:从串口丢包到SPI/ADC高速采集与测速 1. 从一次串口丢包说起为什么DMA值得单独开一期做嵌入式驱动开发的人迟早会被一个场景教育串口波特率拉到 921600甚至更高主循环里还在跑别的任务结果接收缓冲区里的数据莫名其妙少几个字节或者发送到一半卡死。你盯着逻辑分析仪看波形时序完全正确数据就是丢了。这时候老手通常会问一句你用的是中断收发还是 DMADMADirect Memory Access直接存储器访问这个东西在单片机开发里属于那种不用也能跑用了就回不去的外设。它的本质是给数据搬运这件事开了一条专用通道让外设和内存之间、内存和内存之间的数据搬移不再需要 CPU 逐字节参与。CPU 只需要配置好源地址、目标地址、搬运长度和触发条件剩下的活交给 DMA 控制器搬完了再通过中断通知 CPU 一声。这一期之所以单独把 DMA 拎出来讲是因为它横跨了嵌入式驱动开发里几乎所有高速数据场景串口收发、SPI 高速通信、ADC 连续采样、定时器触发搬运、存储器到存储器的大块拷贝。你去看那些热搜词——串口 DMA、SPI DMA、ADC DMA、TIM DMA Burst、DMA 测速——背后其实是同一套机制在不同外设上的应用。把这套机制吃透你会发现很多之前觉得玄学的问题其实都有明确的因果链。这篇文章面向的是已经能跑通基本外设驱动、但对 DMA 还停留在照着例程改改能用阶段的开发者。我会从 DMA 的工作模型讲起把通道映射、传输模式、地址增量、数据宽度对齐、中断与标志位这些容易踩坑的点逐个拆开再结合串口、SPI、ADC 三个典型场景给出可复现的配置思路最后聊聊 DMA 测速这件事到底该怎么测、测出来的数字意味着什么。全程不堆砌寄存器手册的原文而是讲清楚每个配置项背后的意图。2. DMA 控制器的工作模型它到底在替 CPU 干什么2.1 一次搬运的完整生命周期要理解 DMA先得把一次传输这件事拆成几个阶段。以 STM32 系列的 DMA 控制器为例一次典型的传输包含这些环节请求触发外设比如 USART 的 RXNE 事件、ADC 的转换完成事件、TIM 的更新事件产生一个 DMA 请求信号。仲裁如果多个通道同时有请求DMA 控制器根据通道优先级决定先服务谁。取地址与取数据控制器从源地址读一个数据单元写到目标地址。地址更新根据配置决定源地址和/或目标地址是否递增。计数递减传输计数器减一直到归零。完成通知计数器归零时置位传输完成标志若使能了中断则触发中断。这个流程里CPU 唯一参与的就是最开始配置寄存器和最后响应中断。中间的数据搬移完全不占用 CPU 的指令周期这就是 DMA 的核心价值。你可以把它想象成一个专门的搬运工你告诉他从哪个货架搬到哪个货架、搬多少箱他就自己干活去了你该干嘛干嘛。2.2 通道与请求映射为什么你的 DMA 不触发新手最常遇到的第一个坑就是配置全对DMA 就是不工作。十有八九是通道映射搞错了。在 STM32F1、F4 这些经典系列上DMA 请求和外设之间的对应关系是硬连线的不是你想用哪个通道就用哪个。比如 USART1_TX 固定映射到 DMA1 的通道 4USART1_RX 固定映射到 DMA1 的通道 5你配成通道 1 就是白费劲。到了 STM32F7、H7、G0、G4 这些较新的系列引入了 DMAMUXDMA 请求复用器请求源可以通过寄存器灵活分配到任意通道自由度大了很多但也多了一层配置。很多人从 F1 转到 H7 之后习惯性地去找USART1 对应哪个通道结果发现手册里根本没有这张表——因为 H7 上任何通道都能接任何请求你要做的是在 DMAMUX 里把请求号和通道绑定。提示拿到一颗新芯片第一件事是翻参考手册的 DMA 请求映射表F1/F4或 DMAMUX 请求表F7/H7/G4。这张表决定了你的外设能不能用 DMA以及用哪个通道。别凭经验猜。2.3 传输模式单次、循环与连续请求DMA 的传输模式直接决定了它适合什么场景。常见的有三种单次模式Normal搬完设定的长度就停标志位置位需要重新使能才能再搬。适合一次性的大块数据传输比如把一段固件从 Flash 搬到 RAM。循环模式Circular计数器归零后自动重装周而复始。这是 ADC 连续采样和串口不定长接收的标配。ADC 在循环模式下配合定时器触发可以持续把转换结果写进缓冲区CPU 定期去取就行。连续请求Continuous Requests这个模式容易被忽略。在某些实现里它指的是 DMA 在一次请求后不等外设再次触发连续搬运直到计数器归零。它和循环模式的区别在于循环是搬完一轮再来一轮连续请求是一次触发搬完一整块。用错了会导致数据错位比如串口接收时本该每来一个字节搬一次结果配成连续请求DMA 会在第一个字节到来后一口气搬完整个缓冲区长度把后面的垃圾数据也读进来。这里有个经验串口接收永远用单次或循环绝不用连续请求存储器到存储器的大块拷贝才考虑连续请求。这个判断依据是数据产生的节奏——外设数据是来一个搬一个内存数据是一次性都在那儿。2.4 地址增量与数据宽度对齐问题引发的血案DMA 配置里有两个参数看着不起眼出错率却极高地址增量模式和数据宽度。地址增量决定搬运一个数据单元后地址指针是否自动加一。外设数据寄存器比如 USART-DR的地址是固定的所以外设侧地址增量必须关闭内存缓冲区是连续的所以内存侧地址增量必须打开。这个搞反了要么所有数据都堆在同一个内存地址上只保留最后一个字节要么外设寄存器地址乱跳直接跑飞。数据宽度更微妙。DMA 支持字节8 位、半字16 位、字32 位三种宽度源和目标的宽度可以不同但必须保证源宽度和目标宽度匹配或者至少目标宽度不小于源宽度。举个例子ADC 是 12 位精度通常配置成半字16 位传输你如果配成字节传输高 4 位就被截断了。反过来如果源是字节、目标是字DMA 会把多个字节拼成一个字写过去这在某些场景下是有意为之但大多数时候是配置错误。我见过一个真实的案例有人用 SPI DMA 读 FlashSPI 数据寄存器是 8 位的他配成半字传输结果每两个字节被拼成一个半字读出来的数据全乱了。排查了半天最后发现是数据宽度没对齐。这种问题的隐蔽性在于DMA 不会报错它只是忠实地按照你的配置搬运错的是配置本身。3. 串口 DMA从丢包到稳定的完整配置链路3.1 为什么高波特率下中断收发会丢包先算一笔账。假设串口波特率 9216008 位数据位、1 位停止位、无校验那么每个字节占 10 位传输一个字节需要 10 / 921600 ≈ 10.85 微秒。如果 CPU 主频 72MHzSTM32F103 的典型值一个指令周期约 13.9 纳秒10.85 微秒大约能执行 780 个指令周期。看起来挺宽裕问题在于中断响应不是即时的。中断有响应延迟进入中断后要保存现场、执行中断服务函数、恢复现场。如果中断服务函数里还做了数据处理、协议解析或者系统里还有更高优先级的中断在跑这个时间很容易超过 10 微秒。一旦超过下一个字节到来时上一个还没取走硬件溢出标志置位数据就丢了。DMA 把这个过程彻底解耦每个字节到来时DMA 控制器自动把它从 USART-DR 搬到内存缓冲区CPU 完全不用管。只有当一整块数据搬完单次模式或者缓冲区半满/全满循环模式配合半传输中断时才通知 CPU 处理一次。中断频率从每字节一次降到每缓冲区一次CPU 负载骤降。3.2 串口接收的两种 DMA 策略串口 DMA 接收有两种主流做法各有适用场景。定长接收配置 DMA 单次模式长度设为协议帧长。每收满一帧触发一次传输完成中断CPU 在中断里处理整帧数据。适合协议帧长固定的场景比如 Modbus RTU 的某些实现。缺点是如果实际数据长度不固定就得靠超时来判断帧结束。不定长接收 空闲中断这是更通用的方案。DMA 配成循环模式缓冲区开得足够大比如 256 字节同时使能串口的空闲中断IDLE。数据持续到来时 DMA 默默搬运总线空闲一个字节时间后触发 IDLE 中断CPU 在中断里读取 DMA 的剩余计数寄存器NDTR用缓冲区总长 - 剩余计数算出实际收到的字节数然后处理数据、重置 DMA 计数。这个方案的精髓在于用 IDLE 中断判断帧边界用 DMA 计数判断帧长度。两者配合既不用逐字节中断又能处理任意长度的数据帧。实测下来在 921600 波特率下连续接收这个方案稳如老狗。3.3 空闲中断方案的关键细节这个方案看着简单实操里有几个细节不注意就会翻车。第一DMA 计数重置的时机。处理完一帧数据后必须把 DMA 的 NDTR 重新设为缓冲区长度否则下一帧的计数就乱了。但重置之前要确保当前帧的数据已经处理完否则可能把新数据覆盖掉。稳妥的做法是在 IDLE 中断里先把数据拷贝出来再重置计数。第二缓冲区溢出保护。如果一帧数据超过了缓冲区长度DMA 循环模式会从头覆盖导致数据错乱。所以缓冲区要开得比最大帧长还大一些并且在软件层做长度校验。如果算出来的长度等于缓冲区长度说明可能溢出了要特殊处理。第三IDLE 中断的清除。IDLE 标志位需要先读 SR 寄存器再读 DR 寄存器才能清除顺序错了中断会反复触发。这个在参考手册里有明确说明但很多人第一次写会漏掉。下面是一段基于 STM32 HAL 库的接收初始化思路伪代码重点看配置逻辑// 串口 DMA 接收初始化 #define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_len 0; void uart_dma_rx_init(void) { // 1. 配置 DMA循环模式外设地址不增内存地址递增字节宽度 // 方向外设 - 内存 // 通道根据芯片手册选择 USARTx_RX 对应的通道 HAL_DMA_Start(hdma_usart_rx, (uint32_t)USART1-DR, (uint32_t)rx_buffer, RX_BUFFER_SIZE); // 2. 使能串口空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 3. 使能 DMA 请求 __HAL_UART_ENABLE_IT(huart1, UART_IT_RXNE); // 视实现而定 } // 空闲中断处理 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 清标志 // 计算接收长度 uint16_t remaining __HAL_DMA_GET_COUNTER(hdma_usart_rx); rx_len RX_BUFFER_SIZE - remaining; // 处理数据拷贝出来或置标志位给主循环 process_rx_data(rx_buffer, rx_len); // 重置 DMA 计数 __HAL_DMA_DISABLE(hdma_usart_rx); hdma_usart_rx.Instance-NDTR RX_BUFFER_SIZE; __HAL_DMA_ENABLE(hdma_usart_rx); } }注意不同 HAL 库版本对 IDLE 标志清除的封装不一样有的版本__HAL_UART_CLEAR_IDLEFLAG内部已经处理了读 SR 和 DR 的顺序有的需要手动写。移植代码时务必确认库版本。3.4 串口发送 DMA 的坑什么时候算发完了串口发送用 DMA 相对简单把数据丢给 DMA 就不用管了。但发完了这个判断有讲究。DMA 的传输完成中断只表示数据已经从内存搬到了串口的发送数据寄存器不代表数据已经从串口线上发出去了。如果这时候你立刻关闭串口或者进入低功耗最后几个字节可能还没发完就被掐断了。正确的做法是等串口的TCTransmission Complete标志而不是只看 DMA 的完成标志。TC 置位才意味着移位寄存器里的数据也发完了。在 HAL 库里HAL_UART_Transmit_DMA的回调函数触发时机是 DMA 完成如果你需要确保数据真正上线得额外等 TC 或者用HAL_UART_TxCpltCallback配合检查。这个坑在低功耗场景下特别致命。我见过一个项目发送完就进 STOP 模式结果对端收到的数据总是少最后两个字节查了很久才发现是没等 TC。4. SPI DMA 与 ADC DMA高速数据采集的两条路径4.1 SPI DMA 读 Flash 的配置要点SPI 用 DMA 的典型场景是读写外部 Flash、显示屏、高速传感器。SPI 是全双工的发送和接收同时进行所以通常需要两个 DMA 通道一个负责 TX内存到 SPI-DR一个负责 RXSPI-DR 到内存。配置上有几个关键点数据宽度必须和 SPI 数据帧格式一致。SPI 可以配成 8 位或 16 位数据帧DMA 的数据宽度要跟着走。8 位帧配字节传输16 位帧配半字传输。前面提到的那个读出来全乱的案例就是这里没对齐。TX 和 RX 的启动顺序。SPI 是全双工你发一个字节的同时就会收到一个字节。如果只启动 RX 不启动 TX时钟不会产生RX 永远等不到数据。所以要么同时启动两个 DMA要么先启动 RX 再启动 TX。HAL 库的HAL_SPI_TransmitReceive_DMA内部处理了这个顺序但如果你自己写寄存器操作就得注意。传输完成判断。SPI 的 DMA 完成标志同样只表示数据搬完了要等 SPI 的 BSY 标志清零才表示总线空闲。连续操作 Flash 时如果不等 BSY 清零就发起下一次传输可能导致时序错乱。4.2 ADC 连续采样DMA 循环模式的主场ADC 是 DMA 循环模式最经典的应用。配置思路是这样的ADC 配置成连续转换模式或者用定时器触发转换。使能 ADC 的 DMA 请求。DMA 配成循环模式外设地址固定ADC-DR内存地址递增半字宽度缓冲区长度等于通道数乘以采样深度。启动 ADCDMA 自动把每次转换结果写进缓冲区写满一轮自动从头开始。这样 CPU 完全不用管采样过程定期去缓冲区取数据做处理就行。如果是多通道扫描缓冲区里会按通道顺序排列数据比如 3 个通道、每个通道采 100 次缓冲区就是 300 个半字前 100 个是通道 1接着 100 个是通道 2以此类推。这里有个容易忽略的点ADC 的采样时间和 DMA 的搬运速度要匹配。如果 ADC 转换很快DMA 搬运跟不上就会发生溢出overrunADC 的数据丢失。STM32 的 ADC 有 overrun 标志一旦置位说明数据没被及时取走。解决办法是降低采样率、提高 DMA 优先级或者用定时器触发控制采样节奏。4.3 TIM DMA Burst定时器也能玩 DMA热搜词里有个STM32 的 TIM DMA Burst 应用这个稍微进阶一点。TIM 的 DMA Burst 模式允许定时器在特定事件比如更新事件、比较匹配时通过 DMA 批量修改定时器的多个寄存器。典型应用是生成复杂的 PWM 波形序列或者动态调整定时器参数。它的工作方式是DMA 把一组预定义的值搬到定时器的寄存器组比如 CCR1、CCR2、CCR3、CCR4定时器在每个周期自动加载这些值。这样就能在不占用 CPU 的情况下生成频率、占空比动态变化的波形。做电机控制、LED 调光、信号发生器的时候很有用。配置的关键是理解 TIM 的 DMA Burst 基地址和长度设置。基地址指向要修改的第一个寄存器长度决定一次搬运几个寄存器。这个模式相对小众但用好了能省下大量 CPU 开销。5. DMA 测速数字背后的真相5.1 测速到底在测什么DMA 测速这个词在搜索里出现频率很高但很多人对测的东西理解有偏差。DMA 测速通常测的是数据搬运的吞吐率单位是字节每秒或兆字节每秒。但这个数字受很多因素影响源和目标的类型内存到内存最快外设到内存受外设速度限制。数据宽度字传输比字节传输吞吐率高因为同样时间内搬的字节多。总线仲裁DMA 和 CPU 争抢总线时DMA 的实际带宽会下降。传输模式单次模式有启动开销循环模式连续搬运效率更高。所以脱离具体配置谈 DMA 速度是没有意义的。你测出来的数字必须连同配置一起说明才有参考价值。5.2 一个可复现的内存到内存测速方法内存到内存的 DMA 传输是最纯粹的测速场景因为它不受外设速度限制。测速思路准备两块内存区域源区填充已知数据目标区清零。配置 DMA 为内存到内存模式字宽度传输长度设为 N。在启动 DMA 前读取一个高精度计时器的计数值。在 DMA 传输完成中断里再读一次计数值。用传输字节数除以时间差得到吞吐率。用 STM32F407主频 168MHz实测内存到内存的字宽度传输吞吐率通常在 100MB/s 到 200MB/s 之间具体取决于总线负载和是否开了 Cache。如果测出来只有几 MB/s那多半是配置有问题比如用了字节宽度、或者每次传输长度太短导致启动开销占比过高。5.3 测速失败的常见原因DMA 测速失败也是个高频搜索词。失败通常表现为传输完成中断不触发、数据不对、或者速度异常低。排查顺序建议这样现象可能原因排查方法中断不触发通道映射错误、中断未使能、标志位未清除检查请求映射表、NVIC 配置、中断标志数据不对地址增量配反、数据宽度不匹配核对源/目标地址增量、宽度设置速度异常低传输长度太短、总线争抢、宽度太小增大传输块、提高 DMA 优先级、用字宽度传输卡死外设未使能 DMA 请求、时钟未开检查外设 DMA 使能位、RCC 时钟这个表基本覆盖了 90% 的 DMA 问题。遇到问题按这个顺序查比盲目改代码高效得多。5.4 测速工具与代码的取舍网上有些现成的 DMA 测速工具或代码直接拿来用之前要看清它的测试条件。有的工具测的是外设到内存的速度有的测的是内存到内存两者的数字差好几倍。还有的工具没有排除启动开销测短数据块时数字偏低。我的建议是自己写测速代码控制变量。固定数据宽度、固定传输长度、固定总线状态只改一个参数看变化。这样才能真正理解 DMA 的性能特性。用别人的工具测出来的数字如果不知道它的配置参考价值有限。6. 那些手册不会告诉你的 DMA 实战经验6.1 优先级不是越高越好DMA 通道优先级分四档最高、高、中、低。很多人遇到数据丢失就把优先级拉到最高但这会带来副作用高优先级通道会抢占低优先级通道的总线使用权如果高优先级通道频繁请求低优先级通道可能长时间得不到服务反而导致那边丢数据。正确的做法是按数据产生的紧迫程度分配优先级。串口接收这种来一个必须马上搬走的给高优先级ADC 采样这种有缓冲区兜底的给中或低优先级。让紧迫的先用总线不紧迫的排队等。6.2 中断里别做耗时操作DMA 传输完成中断里只做最必要的事置标志位、拷贝数据指针、重置计数。协议解析、数据运算、打印日志这些统统放到主循环里做。中断里耗时越长响应下一次 DMA 事件就越慢高波特率下照样丢数据。我习惯的做法是在中断里只更新一个状态机变量和缓冲区指针主循环轮询这个变量来决定是否处理数据。这样中断执行时间控制在几个微秒以内稳定性大幅提升。6.3 缓冲区对齐与 Cache 一致性在带 Cache 的芯片上比如 STM32F7、H7DMA 和 Cache 之间有一致性问题。DMA 直接访问内存不经过 Cache如果 CPU 刚往缓冲区写了数据数据可能还在 Cache 里没落到内存DMA 搬走的就是旧数据。反过来DMA 搬来的新数据CPU 读的时候可能读到 Cache 里的旧值。解决办法有两种一是把 DMA 缓冲区放在非 Cache 区域MPU 配置二是在 DMA 传输前后做 Cache 清理clean和无效化invalidate操作。H7 上这个问题特别突出很多人第一次用 H7 的 DMA 都会栽在这里数据时对时错查半天查不出来。提示带 Cache 的芯片用 DMA第一件事是确认缓冲区的一致性处理。要么配 MPU要么在传输前后加 Cache 维护操作。这个坑不踩一次很难记住。6.4 DMA 和低功耗的配合低功耗场景下用 DMA 要特别注意DMA 传输期间 CPU 可以进低功耗模式但 DMA 完成中断要能唤醒 CPU。如果配置成 STOP 模式DMA 可能也被停掉了传输会中断。具体哪些低功耗模式下 DMA 还能工作要看芯片手册的电源管理章节。一般来说SLEEP 模式下 DMA 可以继续工作STOP 模式下大部分 DMA 会停。7. 从能用到好用DMA 驱动的工程化思路把 DMA 跑通只是第一步真正在项目里用好它需要一些工程化的封装思路。统一的数据结构给每个 DMA 通道定义一个结构体包含缓冲区指针、长度、状态标志、回调函数。这样上层应用不用关心底层是哪个通道、哪个外设只管往结构体里填数据、读数据。状态机管理DMA 传输有空闲、传输中、完成、错误几个状态用状态机管理可以避免重复启动、竞争条件这些问题。特别是循环模式下要清楚当前处理的是哪一半缓冲区。错误处理DMA 也会出错比如传输错误TE、直接模式错误DME、FIFO 错误FE。这些错误标志要检查出错了要有恢复机制比如重新初始化通道、重置外设。很多例程不处理这些实际项目里一旦出错就卡死。可测试性把 DMA 的配置参数抽出来做成可配置的方便在不同场景下调整。测速代码、压力测试代码要能方便地接入验证配置的正确性。这套思路下来DMA 驱动就不再是照着例程改改的临时方案而是可以复用到多个项目的稳定组件。串口、SPI、ADC 的 DMA 配置虽然细节不同但骨架是一样的封装好了之后换个外设只是填不同的参数而已。最后分享一个我自己的习惯每次用新的芯片或新的 DMA 场景先写一个最小化的测速程序把内存到内存的传输跑通确认通道映射、中断、数据宽度这些基础配置没问题再往上叠加外设。这样出问题时排查范围小比一上来就搞复杂场景高效得多。DMA 这东西基础打牢了后面就是组合拳基础没打牢每个场景都是新坑。