嵌入式DMA实战:从串口丢包到SPI/ADC高速采集的完整指南 1. DMA到底解决了什么问题从一个串口丢包案例说起做嵌入式驱动开发绕不开的一个话题就是DMA。很多人第一次接触DMA是因为串口接收大量数据时丢包了。我印象很深早年做一个工控采集板MCU用STM32F103串口波特率115200上位机每200ms下发一帧128字节的命令。一开始用中断接收单字节进中断结果跑起来偶尔丢几个字节尤其是系统里还有定时器中断和SPI刷屏的时候。后来改成DMA接收问题立刻消失。这个经历让我彻底理解了DMA存在的意义它不是“性能优化选项”而是很多场景下的“必需品”。DMA全称Direct Memory Access直接存储器访问。它的核心价值在于数据搬运这件事不需要CPU一个字节一个字节地参与。你可以把它想象成一个专门的“搬运工”CPU只需要告诉它“从哪个外设搬到内存的哪个位置、搬多少”然后CPU就可以去干别的事了搬完了搬运工再通知CPU一声。没有DMA的时候串口每收到一个字节都要打断CPU一次CPU从寄存器读走再写到内存缓冲区有了DMA外设和内存之间直接建立通道CPU被彻底解放出来。这一期要聊的就是嵌入式驱动开发中DMA的完整实战经验。从原理到配置从串口DMA到SPI DMA从测速验证到问题排查我会把踩过的坑和总结出来的方法都摊开讲。适合正在学STM32、正在做嵌入式Linux驱动、或者面试被DMA八股文折磨过的朋友。不管你是刚接触DMA的新手还是用过但没系统梳理过的老手这篇内容都能帮你把DMA这条线彻底理清楚。2. DMA核心原理拆解为什么它能不占CPU2.1 从“CPU搬砖”到“专职搬运工”的思维转变要理解DMA先要理解没有DMA时数据是怎么走的。以串口接收为例传统中断方式是这样的串口收到一个字节硬件置位RXNE标志触发中断CPU进入中断服务函数从USART_DR寄存器把数据读出来存到内存缓冲区清除标志退出中断。每来一个字节这个流程走一遍。115200波特率下一个字节大约87微秒如果缓冲区有128字节CPU就要被打断128次。这期间CPU什么正事都干不了全在搬数据。DMA的思路完全不同。CPU先配置好DMA控制器源地址是USART_DR目标地址是内存缓冲区首地址传输长度128方向是外设到内存。配置完启动DMA之后每来一个字节DMA控制器自己把数据从USART_DR搬到内存CPU完全不参与。等128字节全部搬完DMA产生一个传输完成中断CPU才被通知一次。中断次数从128次降到1次这就是DMA最直观的收益。这里有个关键点很多人忽略DMA搬运数据的过程本质上是在总线上进行的。它占用的是总线带宽而不是CPU执行单元。所以DMA和CPU可以并行工作只要总线不冲突。这也是为什么DMA特别适合高速数据流场景——ADC连续采样、SPI高速通信、串口大数据量收发这些场景下数据产生速度远快于CPU处理速度DMA就是唯一的解。2.2 DMA控制器的内部结构通道、优先级与仲裁不同芯片的DMA控制器结构不一样但核心概念是相通的。以STM32F1系列的DMA1为例它有7个通道每个通道可以独立配置。STM32F4的DMA更复杂有DMA1和DMA2每个有8个数据流每个数据流有8个通道可选。这里要区分“通道”和“数据流”的概念数据流是硬件搬运的物理通路通道是外设请求的逻辑映射。一个数据流同一时间只能服务一个外设请求。多个DMA请求同时到来时需要一个仲裁机制决定谁先用总线。STM32的DMA优先级分四档最高、高、中、低。硬件优先级由数据流编号决定编号越小优先级越高。软件优先级可以在配置时指定。仲裁器会根据优先级决定哪个请求先获得总线访问权。这里有个实操经验如果两个DMA通道传输速率都很高一定要把更紧急的那个设成更高优先级否则可能出现高优先级数据被低优先级传输阻塞的情况。还有一个概念叫“突发传输”Burst。普通传输是一个数据一个数据地搬突发传输是一次搬4个、8个或16个数据。突发传输能提高总线利用率因为地址计算和仲裁开销被分摊了。STM32的TIM DMA Burst应用就是典型场景定时器触发DMA一次搬运多个数据到GPIO或DAC实现精确的波形输出。配置突发传输时要注意源地址和目的地址的位宽要匹配否则数据会错位。2.3 传输模式单次、循环与双缓冲DMA的传输模式直接决定了它适合什么场景。最基础的是单次传输模式配置好长度启动搬完就停产生一次传输完成中断。适合一次性大数据块搬运比如从Flash搬一段数据到RAM。循环传输模式是串口DMA接收的标配。配置好缓冲区DMA搬完一圈自动回到起点继续搬不需要CPU重新配置。串口DMA接收用循环模式配合空闲中断IDLE来判断一帧数据结束这是目前最主流的串口接收方案。为什么用空闲中断而不是传输完成中断因为传输完成中断只在缓冲区满的时候触发如果一帧数据没填满缓冲区就永远等不到中断。空闲中断在总线空闲一个字节时间后触发能准确判断一帧结束。双缓冲模式Double Buffer是更高级的玩法。它有两个缓冲区DMA在搬缓冲区A的时候CPU可以处理缓冲区B的数据搬完A自动切到B同时通知CPU处理A。这样实现了真正的“零等待”数据流。STM32F4和F7系列支持双缓冲做音频流、高速ADC采集时特别有用。配置双缓冲需要设置两个内存地址寄存器M0AR和M1AR并通过NDTR寄存器判断当前在哪个缓冲区。3. 串口DMA收发实战从配置到代码落地3.1 串口DMA接收的完整配置流程串口DMA接收是嵌入式开发中最常见的DMA应用。我以STM32 HAL库为例把完整流程拆开讲。首先是CubeMX配置打开USART1模式选Asynchronous波特率115200然后在DMA Settings里点Add选USART1_RX模式选Circular优先级选High。中断方面NVIC里要打开USART1全局中断DMA通道中断可以不开用空闲中断就够了。生成代码后核心初始化代码如下#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn); } void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart1); }启动接收用HAL_UART_Receive_DMA但这里有个坑HAL库默认的DMA接收是Normal模式搬完就停。我们要的是循环模式所以要么在CubeMX里设成Circular要么手动改DMA初始化结构体里的Mode字段。启动代码如下HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);最后一句是关键使能空闲中断。没有它你无法知道一帧数据什么时候结束。3.2 空闲中断配合DMA判断一帧数据结束的正确姿势空闲中断的处理逻辑是当串口总线空闲超过一个字节传输时间硬件置位IDLE标志。在中断服务函数里我们要做三件事清除IDLE标志、计算本次接收到的数据长度、通知应用层处理。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(huart1.hdmarx); uint16_t received RX_BUFFER_SIZE - remaining; if (received 0) { process_frame(rx_buffer, received); } // 重新校准DMA计数器防止累积偏移 __HAL_DMA_DISABLE(huart1.hdmarx); huart1.hdmarx-Instance-CNDTR RX_BUFFER_SIZE; __HAL_DMA_ENABLE(huart1.hdmarx); } }这里有个细节很多人踩坑__HAL_DMA_GET_COUNTER返回的是剩余未传输的数量用缓冲区总大小减去它才是已接收长度。但如果在处理过程中DMA还在继续搬数据计数器会变化所以要先关DMA再读。另外处理完一帧后要重置CNDTR寄存器否则下一帧的长度计算会出错。注意空闲中断的清除顺序很重要。必须先读SR寄存器再读DR寄存器或者直接调用HAL库的清除宏。顺序错了标志位清不掉会反复进中断。3.3 串口DMA发送什么时候该用什么时候不该用串口DMA发送比接收简单但也不是所有场景都适合。DMA发送适合大数据块、低实时性要求的场景比如发送日志、上传传感器批量数据。如果只是发几个字节的命令用DMA反而增加开销因为配置DMA的时间比直接写DR寄存器还长。DMA发送的配置和接收类似方向改成Memory to Peripheral模式用Normal发完就停。启动发送用HAL_UART_Transmit_DMA。这里要注意DMA发送期间不能修改发送缓冲区的内容否则发出去的数据会错乱。如果应用层需要连续发送多帧要么等上一帧发完再启动下一帧要么用双缓冲。还有一个常见问题DMA发送完成中断和串口TC中断的区别。DMA传输完成中断表示数据已经从内存搬到了串口的发送寄存器但最后一个字节可能还在移位寄存器里没发出去。如果要确保数据完全发完再关闭串口或进入低功耗需要等TCTransmission Complete标志而不是DMA完成标志。这个坑我在做低功耗项目时踩过数据没发完就进睡眠对方收到的是半截帧。4. SPI DMA与ADC DMA高速数据采集的实战方案4.1 SPI DMA全双工通信的配置要点SPI DMA是高速外设通信的标配。SPI时钟可以跑到几十MHz没有DMA的话CPU根本来不及搬数据。SPI DMA的配置比串口复杂一点因为SPI是全双工收发同时进行需要两个DMA通道一个负责TX内存到SPI_DR一个负责RXSPI_DR到内存。以STM32F4的SPI1为例TX用DMA2 Stream3 Channel3RX用DMA2 Stream0 Channel3。配置时要注意几个点第一SPI的TX和RX DMA要同时启动否则会出现只发不收或只收不发的情况。第二DMA的传输长度要一致TX和RX的NDTR要设成相同的值。第三SPI的时钟极性和相位要和从设备匹配这个虽然和DMA无关但配置错了DMA搬回来的全是错数据。// SPI1 TX DMA配置 hdma_spi1_tx.Instance DMA2_Stream3; hdma_spi1_tx.Init.Channel DMA_CHANNEL_3; hdma_spi1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc DMA_MINC_ENABLE; hdma_spi1_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_spi1_tx.Init.Mode DMA_NORMAL; hdma_spi1_tx.Init.Priority DMA_PRIORITY_HIGH; // 启动全双工DMA传输 HAL_SPI_TransmitReceive_DMA(hspi1, tx_buf, rx_buf, length);实测下来SPI1跑在21MHzDMA搬运1KB数据大约需要0.5msCPU占用几乎为零。如果不用DMA同样的数据量CPU要被打断上千次根本没法跑其他任务。4.2 ADC连续采样DMA循环搬运数据采集的标准架构ADCDMA是数据采集系统的经典组合。ADC连续转换模式下每转换完一个通道就产生一个DMA请求DMA自动把结果搬到内存数组。配置成循环模式后ADC一直在采DMA一直在搬CPU只需要定期去内存数组里取数据就行。这里的关键参数是采样时间和DMA传输长度。采样时间要根据信号源阻抗来定阻抗高就要加长采样时间否则采到的值不准。DMA传输长度一般设成通道数的整数倍比如用3个通道缓冲区就设成3的倍数这样每个通道的数据在数组里的位置是固定的。安富莱的AD7606驱动就是ADCDMA的典型应用。AD7606是8通道16位并行ADC用FSMC接口读取配合DMA搬运可以实现8通道同步采样采样率200kSPS。它的DMA配置要点是FSMC的读操作触发DMADMA把FSMC数据寄存器里的值搬到内存。因为AD7606是并行接口数据吞吐量大没有DMA根本跑不到标称采样率。4.3 DMA测速怎么验证你的DMA配置真的在干活配好DMA不代表它真的在高效工作。我习惯做一个简单的测速验证用定时器产生一个已知频率的触发信号DMA搬运固定长度的数据用示波器或逻辑分析仪测传输完成中断的频率反推实际传输速率。更简单的办法是用GPIO翻转。在DMA传输完成中断里翻转一个IO口用示波器看波形周期。比如配置DMA搬1000字节中断里翻转IO示波器测到周期是1ms那传输速率就是1MB/s。这个方法直观、准确不需要额外工具。还有一种软件测速法用DWT计数器Cortex-M3/M4自带记录DMA启动和完成的时间戳算出耗时。DWT的CYCCNT寄存器每个时钟周期加一精度很高。实测STM32F407在168MHz主频下DMA搬运1KB数据到SPI耗时约48000个周期约0.286ms和理论值基本吻合。提示测速时要注意DMA优先级和总线仲裁的影响。如果同时有其他DMA通道在工作测出来的速率会偏低。最好在系统空闲时测或者把被测通道设成最高优先级。5. DMA驱动开发中的常见问题与排查技巧5.1 首帧丢失HAL库串口DMA接收的经典坑用HAL库做串口DMA接收很多人遇到第一个问题就是首帧丢失。现象是上电后第一帧数据收不到从第二帧开始正常。这个问题的根源在于HAL库的初始化顺序。HAL_UART_Receive_DMA启动DMA接收之前如果串口已经收到了数据这些数据会丢失因为DMA还没准备好。解决办法有两个一是在启动DMA接收之前先清空串口接收寄存器二是调整初始化顺序先启动DMA接收再使能串口。我一般用第一种在HAL_UART_Receive_DMA之前加一句__HAL_UART_CLEAR_OREFLAG(huart1)清除溢出标志再把DR寄存器读一次清空硬件FIFO。还有一种情况是首帧数据不完整。这是因为DMA启动的瞬间串口可能正在接收一个字节这个字节的前半部分丢了。解决办法是在启动DMA之前先关闭串口接收配置完DMA再打开。虽然HAL库没有直接提供这个接口但可以通过操作CR1寄存器的RE位来实现。5.2 DMA传输卡死计数器不归零与总线冲突DMA传输卡死是另一个高频问题。表现是DMA启动后一直不产生完成中断或者传输到一半停住。常见原因有三个第一DMA通道被其他外设占用仲裁器一直不给总线。第二源地址或目标地址配置错误DMA访问了非法地址触发总线错误。第三传输长度设成了0DMA启动后立即完成但不产生中断。排查方法先看DMA的NDTR寄存器如果它停在某个值不动说明传输被阻塞。再看DMA的ISR寄存器有没有TEIF传输错误、HTIF半传输、TCIF传输完成标志。如果TEIF置位说明访问了非法地址检查源和目标的地址范围。如果什么标志都没有检查DMA通道是否使能以及外设的DMA请求是否使能。STM32F103的DMA通道和外设的映射关系是固定的不能随意改。比如USART1_RX只能用DMA1 Channel5USART1_TX只能用DMA1 Channel4。如果配错了通道DMA根本不会响应外设请求。这个在CubeMX里会自动处理但手动写寄存器时容易搞错。5.3 数据错位与覆盖缓冲区管理的注意事项DMA循环模式下如果CPU处理数据的速度跟不上DMA搬运的速度就会出现数据覆盖。比如DMA缓冲区256字节串口每10ms来一帧128字节CPU处理一帧需要15ms那第二帧数据就会覆盖第一帧还没处理完的部分。解决办法是加大缓冲区或者用双缓冲。缓冲区大小要根据最坏情况下的数据速率和处理时间来算。公式是缓冲区大小 ≥ 数据速率 × 最大处理延迟。比如数据速率是128字节/10ms最大处理延迟是20ms那缓冲区至少要256字节实际建议留一倍余量设512字节。还有一个隐蔽的问题是内存对齐。DMA搬运16位或32位数据时源地址和目的地址必须对齐到相应的位宽。如果缓冲区定义成uint8_t buf[100]但DMA配置成16位传输访问到奇数地址时可能触发硬件错误。解决办法是用__attribute__((aligned(4)))修饰缓冲区确保4字节对齐。问题现象可能原因排查方法解决方案首帧丢失DMA启动前数据已到达检查初始化顺序启动DMA前清空串口FIFO传输卡死通道冲突或地址非法查NDTR和ISR寄存器检查通道映射和地址范围数据覆盖缓冲区太小计算数据速率和处理延迟加大缓冲区或用双缓冲数据错位内存未对齐检查缓冲区地址用aligned属性对齐中断不触发中断未使能或标志未清查NVIC和标志位使能中断并正确清标志5.4 嵌入式面试中的DMA八股文怎么答到点子上DMA是嵌入式面试的高频考点。常见问题有“DMA的工作原理是什么”“DMA传输有哪几种模式”“DMA和中断的区别是什么”很多人背了标准答案但面试官一追问就露馅。我的经验是答DMA问题要抓住三个核心解放CPU、总线搬运、完成通知。工作原理就围绕这三点展开DMA控制器接管总线在外设和内存之间直接搬运数据搬运完成后通过中断通知CPU。传输模式要能说出单次、循环、双缓冲的区别和适用场景。和中断的区别要强调中断是CPU参与的数据搬运DMA是硬件参与的数据搬运中断适合低频小数据量DMA适合高频大数据量。如果面试官问“DMA传输过程中CPU能不能访问内存”答案是能但会有总线竞争。DMA占用总线时CPU访问内存会被插入等待周期所以DMA传输会轻微降低CPU执行效率但远小于中断方式的开销。这个点能答出来面试官会觉得你真的用过DMA而不是只背了书。6. DMA方案选型与性能优化的经验总结6.1 什么时候用DMA什么时候不用DMA不是万能的。我总结了一个简单的判断标准数据速率超过CPU处理能力的50%或者数据传输期间CPU有更重要的任务要执行就用DMA。反之如果只是偶尔发几个字节用中断或轮询更简单。具体来说串口波特率超过57600且数据量大于64字节建议用DMA。SPI时钟超过1MHz建议用DMA。ADC采样率超过10kSPS建议用DMA。低于这些阈值中断方式完全够用而且代码更简单调试更方便。还有一个容易被忽略的点DMA会占用总线带宽。如果系统里已经有多个DMA通道在高速工作再加一个DMA通道可能导致总线饱和所有DMA通道的性能都下降。这时候要考虑降低优先级低的通道的传输速率或者改用中断分时处理。6.2 优先级与总线仲裁的实战配置建议多DMA通道共存时优先级配置直接影响系统实时性。我的配置原则是实时性要求最高的通道设最高优先级大数据量但非实时的通道设低优先级。比如音频播放的I2S DMA设最高日志存储的SD卡DMA设最低。STM32的DMA优先级分硬件和软件两层。硬件优先级由数据流编号决定改不了。软件优先级可以在配置时指定。如果两个通道硬件优先级相同软件优先级高的先获得总线。如果软件优先级也相同编号小的先获得总线。实测下来STM32F4的DMA2比DMA1优先级高因为DMA2连接的是高速外设如以太网、USB。所以对性能敏感的DMA通道尽量分配到DMA2上。这个细节在芯片参考手册的DMA章节有说明但很多人不看配完了发现性能不达标才回头查。6.3 从寄存器到HAL库不同抽象层的取舍DMA配置可以直接写寄存器也可以用HAL库或LL库。三种方式各有优劣。直接写寄存器最灵活、效率最高但可移植性差换个芯片就要重写。HAL库可移植性好但代码臃肿中断处理链路长高频场景下开销明显。LL库介于两者之间轻量且可移植但功能不如HAL全。我的建议是产品开发用HAL库快速验证用LL库极致性能用寄存器。HAL库的DMA配置虽然绕但CubeMX能自动生成省去查手册的时间。LL库适合对代码体积和效率有要求的场景比如用STM32F0做低成本产品。寄存器操作适合做DMA测速和性能调优能精确控制每个环节。不管用哪种方式都要理解底层寄存器的含义。HAL库的HAL_DMA_Start最终也是写CNDTR、CPAR、CMAR这几个寄存器。理解了寄存器调试时才能直接看寄存器状态定位问题而不是在HAL库的层层封装里迷路。6.4 DMA调试的实用技巧与工具调试DMA问题光靠打印日志不够要善用硬件工具。逻辑分析仪是必备的能同时抓SPI时钟、数据线和DMA完成中断的IO翻转一眼看出时序问题。示波器适合看模拟信号和DMA触发的定时器波形。J-Link或ST-Link的实时变量查看功能可以在不打断DMA的情况下观察缓冲区和计数器变化。软件层面我习惯在DMA完成中断里翻转一个空闲IO用逻辑分析仪测中断周期验证传输速率是否符合预期。还会在DMA错误中断里点亮一个LED一旦触发就能立即发现。这些辅助手段在项目初期就能发现配置问题避免后期调试时抓瞎。还有一个技巧用DMA的半传输中断HT做流水线处理。DMA搬完一半数据时触发HT中断CPU处理前半部分同时DMA继续搬后半部分。这样处理延迟减半适合对实时性要求高的场景。配置时使能HT中断在中断里判断是前半还是后半完成分别处理。7. 从DMA看嵌入式驱动开发的底层思维DMA这条线串起来其实反映的是嵌入式驱动开发的一个核心思维把CPU从重复劳动中解放出来让它去做真正需要智能决策的事。中断是这样DMA是这样RTOS的任务调度也是这样。理解了这一点再看其他外设驱动思路就通了。我在带新人的时候经常让他们先用手动方式实现一个功能比如用轮询收串口数据再用中断实现一遍最后用DMA实现一遍。三遍下来他们对“效率”和“实时性”的理解会深刻很多。DMA不是孤立的知识点它是嵌入式系统设计哲学的一个缩影硬件能做的事不要交给软件底层能做的事不要交给上层。实际项目中DMA配置只是第一步更重要的是围绕DMA设计数据流架构。缓冲区多大、中断怎么处理、数据怎么同步、异常怎么恢复这些才是驱动开发的真功夫。我见过太多人DMA配置没问题但数据流设计得一塌糊涂跑起来不是丢数据就是卡死。所以学DMA不要只学配置要学怎么围绕它构建可靠的数据通道。最后分享一个我常用的DMA自检方法上电时用DMA搬运一段已知数据到另一个缓冲区比较结果是否一致。这个自检能覆盖地址配置、位宽配置、传输模式等大部分配置错误几行代码就能加上但能省去很多现场调试的麻烦。嵌入式开发就是这样多花十分钟做自检可能省下十小时的排查。