1. 为什么DMA是嵌入式驱动开发的必修课
搞嵌入式驱动开发,绕不开的一个话题就是DMA。你可以不知道某些冷门外设的寄存器怎么配,但如果连DMA都玩不转,那基本等于还没入门。我做了十多年底层驱动,从早期的8位机到现在主流的Cortex-M系列,DMA几乎是每个量产项目里都会用到的核心机制。它解决的是一个非常朴素的问题:CPU太忙了,不该把时间浪费在搬数据这种体力活上。
打个比方,CPU是公司的核心工程师,串口、SPI、ADC这些外设是各个部门的同事。如果没有DMA,每次外设收到一批数据,都要工程师亲自跑过去把数据搬到内存里,搬完再回来干自己的活。数据量小的时候还能忍,一旦遇到高速ADC连续采样、串口大数据量收发、SPI屏幕刷图这些场景,工程师就彻底被搬数据的活淹没了,什么正经事都干不了。DMA就是专门雇来的一个搬运工,你告诉它从哪搬到哪、搬多少、搬完了怎么通知你,剩下的事它全包了,CPU该干嘛干嘛。
这一期我就围绕DMA这个主题,把嵌入式驱动开发中跟DMA相关的核心知识点、实操配置、踩坑经验系统地梳理一遍。内容会覆盖DMA的基本工作原理、STM32平台上常见的几种DMA应用场景(串口DMA收发、ADC+DMA连续采样、SPI+DMA高速传输、TIM+DMA的Burst应用)、DMA测速的方法和工具、以及实际调试中遇到的各种疑难问题排查。不管你是刚接触DMA的新手,还是已经用过但总觉得理解不够透彻的老手,相信都能从中找到对自己有用的东西。
2. DMA核心原理与工作模式拆解
2.1 DMA到底是怎么搬数据的
DMA的全称是Direct Memory Access,直接内存访问。它的本质是一块独立的硬件控制器,能够在不需要CPU干预的情况下,在外设和存储器之间、或者存储器和存储器之间直接传输数据。注意这里的关键词是“不需要CPU干预”,这意味着CPU只需要在传输开始前做好配置,传输过程中完全可以去执行其他代码,传输结束后DMA会通过中断或标志位通知CPU。
DMA控制器的内部结构并不复杂,核心就是几个东西:源地址寄存器、目标地址寄存器、传输长度寄存器、控制寄存器。你把这些寄存器配好,使能DMA通道,它就开始一个字节一个字节地搬。每搬一个字节,源地址和目标地址会根据配置自动递增或保持不变,传输长度计数器递减,减到零就产生传输完成事件。
这里有一个很多人一开始会困惑的点:DMA的传输宽度。STM32的DMA支持字节(8位)、半字(16位)、字(32位)三种传输宽度,而且源端和目标端的宽度可以不一样。比如你可以配置源端是字节、目标端是半字,DMA会自动做数据宽度的转换。但这里有个坑,如果源端和目标端宽度不一致,传输长度寄存器的含义会变得微妙,它表示的是源端的数据项个数,而不是总字节数。我见过不少人在配置ADC+DMA时,ADC数据寄存器是16位的,但DMA目标内存定义成了uint8_t数组,结果数据全乱了,就是因为没搞清楚这个宽度匹配的问题。
2.2 普通模式和循环模式的选择逻辑
DMA有两种基本工作模式:普通模式(Normal)和循环模式(Circular)。普通模式下,DMA搬完指定数量的数据就停了,需要重新使能才能开始下一次传输。循环模式下,DMA搬完一轮之后自动回到起始地址重新开始,周而复始,直到你主动关闭它。
这两种模式的选择完全取决于你的应用场景。串口发送一批固定长度的数据,用普通模式就够了,发完中断里处理一下就行。但如果是ADC连续采样,那就必须用循环模式,因为ADC在不停地产生新数据,DMA必须不停地搬,中间不能停。我早期做过一个音频采集的项目,ADC以48kHz的速率采样,DMA配置成循环模式,半传输中断和传输完成中断交替触发,CPU在半传输中断里处理前半缓冲区的数据,在传输完成中断里处理后半缓冲区的数据,这样就能实现无缝的连续采集。这个双缓冲的机制在音频、高速数据采集里非常常用,后面讲ADC+DMA的时候我会详细展开。
还有一个细节值得注意:循环模式下,DMA的传输长度计数器在每轮结束后会自动重载,但如果你在传输过程中修改了传输长度寄存器,新的值会在当前轮结束后生效。这个特性在某些动态调整传输长度的场景下很有用,但也很容易出错,建议除非有明确需求,否则不要在运行中动态修改。
2.3 中断机制与优先级管理
DMA的中断类型主要有三种:传输完成中断(TC)、半传输中断(HT)、传输错误中断(TE)。传输完成中断最好理解,就是搬完了触发。半传输中断是在搬了一半的时候触发,这个中断在双缓冲场景下非常关键。传输错误中断则是在DMA访问出错时触发,比如访问了非法地址。
中断优先级的配置有一个容易被忽视的点:DMA中断的优先级应该根据数据传输的实时性要求来定。比如音频采集的DMA中断,优先级要设得比较高,否则如果被其他低优先级中断阻塞太久,可能导致缓冲区溢出。但也不能无脑设最高,因为如果DMA中断太频繁且优先级过高,会严重影响其他中断的响应。我一般的做法是:高速连续传输的DMA中断设为次高优先级,仅次于系统滴答定时器和看门狗这类系统级中断。
另外,STM32不同系列的DMA控制器架构差异很大。F1系列是DMA1和DMA2两个控制器,每个控制器有多个通道,通道和外设的对应关系是固定的,不能随意映射。F4系列引入了DMA流(Stream)和通道(Channel)的概念,每个流可以选择不同的通道,灵活性更高。到了H7系列,又变成了DMA1、DMA2加BDMA的架构。所以在做跨平台移植的时候,DMA部分的代码基本是要重写的,不要指望能直接复用。
3. 串口DMA收发实战配置
3.1 串口DMA发送的配置要点
串口DMA发送是最常见的DMA应用之一。配置流程大致是这样的:先初始化串口,使能串口的DMA发送请求,然后配置DMA通道的源地址为发送缓冲区、目标地址为串口数据寄存器、传输方向为存储器到外设、传输宽度为字节、模式为普通模式,最后在需要发送数据时调用HAL_UART_Transmit_DMA或者直接操作寄存器启动传输。
这里有几个实操中容易踩的坑。第一个是发送缓冲区的生命周期问题。DMA发送是异步的,你调用发送函数之后,函数立刻返回,但数据还在后台搬。如果你用的是局部数组作为发送缓冲区,函数返回后栈空间被回收,DMA还在搬那块内存,数据就乱了。我早期就犯过这个错误,调试了半天以为是串口配置问题,最后发现是缓冲区被覆盖了。正确的做法是用全局数组或者静态数组,或者用动态内存分配并在传输完成回调里释放。
第二个坑是连续发送时的冲突问题。如果你在前一次DMA发送还没完成时又启动了一次发送,后一次会直接返回错误。所以要么在发送前检查DMA状态,要么用发送完成回调来驱动下一次发送,形成一个发送队列。我在实际项目中一般会封装一个环形发送缓冲区,应用层只管往缓冲区里写数据,DMA发送完成中断里自动从缓冲区取下一批数据发送,这样应用层完全不用关心DMA的状态。
第三个坑是串口的TC(Transmission Complete)标志和DMA的TC标志的区别。DMA的传输完成中断只表示DMA把数据全部写进了串口的发送数据寄存器,并不代表数据已经从串口线上发出去了。如果你在DMA传输完成中断里立刻关闭串口或者进入低功耗模式,最后几个字节可能还没发完就被截断了。正确的做法是等串口的TC标志置位,或者用HAL库的UART_DMATransmitCplt回调里再等TC。
3.2 串口DMA接收的配置要点
串口DMA接收比发送要复杂一些,因为接收的数据长度往往是不确定的。常见的方案有三种:定长接收、空闲中断+DMA、以及循环DMA+空闲中断。
定长接收最简单,就是配置DMA接收固定长度的数据,收满了触发中断。适合协议帧长度固定的场景,比如某些工业传感器的数据帧就是固定20个字节。但大多数场景下帧长度是可变的,这时候就需要用到串口空闲中断。
串口空闲中断的原理是:当串口总线在一个字节的传输时间内没有新数据到来,硬件就会置位空闲标志。配合DMA使用,你可以在空闲中断里读取DMA当前剩余的传输长度,用总长度减去剩余长度就得到了实际接收到的数据长度。这个方案非常实用,我做的绝大多数串口接收都是这么处理的。
具体配置上,DMA接收通道要配置成循环模式,这样DMA会一直等着接收数据,不会因为收满一次就停止。然后在串口空闲中断里计算接收长度、处理数据、重置DMA计数器。这里有个细节:重置DMA计数器的时候要先关闭DMA通道,修改完传输长度后再重新使能,否则可能出现在修改过程中DMA又搬了一个字节导致数据错位的情况。
第三种方案是循环DMA配合半传输和传输完成中断,本质上和ADC的双缓冲类似。这种方式适合数据流持续不断的场景,比如GPS模块的NMEA数据输出。但实现起来比空闲中断方案复杂,需要维护读指针和写指针,处理缓冲区回绕的情况。除非数据量特别大或者速率特别高,否则我一般还是推荐空闲中断方案,简单可靠。
3.3 串口DMA配置的完整代码示例
下面给出一段基于STM32 HAL库的串口DMA接收配置代码,采用空闲中断+循环DMA的方案。这段代码在F103和F407上都验证过,可以直接参考。
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_len = 0; volatile uint8_t rx_flag = 0; void uart_dma_init(void) { // 串口初始化省略,假设huart1已经初始化完成 // 使能串口空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 启动DMA接收,循环模式 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); } // 串口中断处理函数中调用 void uart_idle_handler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 停止DMA以读取剩余计数 HAL_UART_DMAStop(&huart1); // 计算接收到的数据长度 uint16_t remaining = __HAL_DMA_GET_COUNTER(huart1.hdmarx); rx_len = RX_BUFFER_SIZE - remaining; if (rx_len > 0) { rx_flag = 1; } // 重新启动DMA接收 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); } }这段代码的核心逻辑就是:DMA一直在后台循环接收,每收到一批数据触发空闲中断,在中断里算出实际长度并置标志位,主循环检测到标志位后处理数据。注意HAL_UART_DMAStop会同时关闭串口的DMA请求和DMA通道,重新启动时HAL_UART_Receive_DMA会重新配置并使能,这个流程是安全的。
注意:在空闲中断里不要做太耗时的数据处理,只做长度计算和标志置位,把数据处理放到主循环里做。否则如果数据处理时间超过了下一帧数据的到达间隔,会导致数据丢失。
4. ADC与SPI的DMA高级应用
4.1 ADC多通道DMA连续采样
ADC+DMA是嵌入式数据采集的经典组合。STM32的ADC在规则通道模式下,每次转换完成会把结果写入同一个数据寄存器,如果不及时读走就会被下一次转换覆盖。用DMA来搬运ADC数据,就可以实现无人值守的连续采样。
多通道采样的配置稍微复杂一些。你需要把ADC配置成扫描模式,设置好每个通道的转换顺序和采样时间,然后使能ADC的DMA请求,配置DMA为循环模式,目标地址为一个数组,传输宽度为半字(因为ADC结果是12位或16位的)。这样ADC就会按照你设定的顺序依次转换各个通道,每转换完一个通道DMA就搬走一个结果,搬完一轮数组后自动从头开始。
这里有一个非常关键的细节:ADC的DMA请求是在每次转换完成后产生的,所以DMA的传输长度应该等于通道数。比如你用了3个通道,DMA传输长度就设为3,目标数组大小也是3。这样DMA搬完3个数据正好对应一轮完整的通道扫描,数组里第0个元素是第一个通道的值,第1个是第二个通道的值,以此类推。
采样时间的配置也很有讲究。STM32的ADC采样时间决定了采样保持电容的充电时间,采样时间太短会导致高阻抗信号源采样不准。一般来说,对于低阻抗信号源(比如运放输出),采样时间可以设短一些,比如15个ADC时钟周期;对于高阻抗信号源(比如直接接传感器),采样时间要设长一些,比如239.5个周期。我一般会先用较长的采样时间验证功能,确认没问题后再根据实际信号源阻抗逐步缩短,在精度和速度之间找平衡。
还有一个坑是ADC的参考电压和校准。STM32的ADC在使用前需要执行校准,否则精度会受影响。HAL库提供了HAL_ADCEx_Calibration_Start函数,在ADC初始化之后、启动DMA之前调用一次即可。另外,如果VDDA和VREF+不是标准的3.3V,计算实际电压值时要用实测的参考电压,不要想当然用3.3V去算。
4.2 SPI DMA高速传输配置
SPI的速率比串口高得多,STM32的SPI时钟可以跑到几十MHz,用DMA来搬运数据几乎是必须的。SPI+DMA最常见的应用就是驱动TFT屏幕和读取高速ADC(比如AD7606这类并行或SPI接口的采集芯片)。
SPI DMA的配置和串口类似,但有一个特殊之处:SPI是全双工的,发送和接收同时进行。即使你只想发送数据,SPI也会同时接收数据(虽然你可能不关心)。所以配置DMA时,通常需要同时配置发送DMA和接收DMA两个通道。如果你只关心发送,接收DMA可以配置成搬运到一个临时缓冲区,或者干脆不使能接收DMA但要注意清除接收寄存器里的数据。
以驱动SPI屏幕为例,刷屏时需要发送大量像素数据。如果用CPU轮询的方式一个字节一个字节地发,刷一屏480x320的16位色数据需要发送307200字节,即使SPI跑到40MHz,CPU也要被占满好几十毫秒。用DMA之后,CPU只需要启动传输,然后就可以去处理其他任务,传输完成中断里再启动下一块数据的传输。
这里有个实操技巧:对于大块数据传输,可以把数据分成多个小块,用DMA的传输完成中断来驱动下一块的传输。这样做的好处是可以实现类似流水线的效果,而且每块的传输时间较短,不会长时间占用DMA通道影响其他外设。块的大小一般设为256字节或512字节,太小了中断太频繁,太大了影响其他DMA请求的响应。
4.3 TIM DMA Burst应用解析
TIM DMA Burst是一个相对高级的应用,热词里提到了“stm32的tim dma burst应用”,我就在这里展开讲一下。TIM的DMA Burst功能允许在定时器的一个更新事件或比较事件触发时,通过DMA向定时器的多个寄存器连续写入数据。这个功能在生成复杂的PWM波形序列时非常有用。
举个例子,假设你要生成一段频率和占空比都动态变化的PWM信号,传统做法是在每个PWM周期中断里修改ARR和CCR寄存器,但中断太频繁会消耗大量CPU资源。用TIM DMA Burst,你可以预先算好一组ARR和CCR的值放在数组里,配置DMA在每次更新事件时自动把下一组值写入定时器寄存器,完全不需要CPU干预。
配置TIM DMA Burst的关键是设置好DMA的源地址、目标地址和传输长度。源地址是你的参数数组,目标地址是定时器的第一个寄存器(比如ARR),传输长度要覆盖所有需要写入的寄存器。STM32的TIM DMA Burst支持在每次触发时传输多个数据项,具体传输几个由DMA的传输长度和定时器的DMA Burst长度共同决定。这个功能在电机控制、LED调光、信号发生器等领域都有实际应用。
不过说实话,TIM DMA Burst的配置比较复杂,寄存器之间的耦合关系多,调试起来不太直观。我建议除非确实需要生成高频变化的波形序列,否则用普通的定时器中断修改寄存器的方式更简单可靠。如果非要用,一定要仔细对照参考手册里的DMA Burst时序图,确认每个寄存器的写入顺序和时机。
5. DMA测速方法与性能评估
5.1 DMA测速的基本原理
DMA测速的本质是测量单位时间内DMA搬运的数据量。最直接的方法是用一个高精度定时器记录DMA传输开始和结束的时间戳,然后用传输的数据量除以时间差。但实际操作中,DMA传输的启动和结束都有一定的软件开销,如果传输的数据量太小,测出来的速率会明显偏低。
我一般用两种方法来测。第一种是固定数据量测时间:准备一块足够大的内存区域(比如64KB),配置DMA从内存搬到内存(或者内存到外设),在启动DMA前读取定时器计数,在传输完成中断里再读一次,算出时间差。第二种是固定时间测数据量:让DMA在循环模式下持续搬运,用定时器每隔固定时间(比如1秒)统计一次传输完成中断的次数,乘以每次传输的数据量就是速率。
内存到内存的DMA传输速率主要受总线带宽和DMA控制器本身的能力限制。以STM32F407为例,DMA1和DMA2挂在AHB总线上,理论带宽可以到168MHz×4字节=672MB/s,但实际测下来内存到内存的DMA速率大概在200-300MB/s左右,因为DMA控制器本身有读写开销,而且总线上还有其他主设备在竞争。
5.2 影响DMA速率的因素分析
影响DMA实际速率的因素很多,我整理了一个表格方便对照排查。
| 影响因素 | 影响方式 | 优化建议 |
|---|---|---|
| 传输宽度 | 8位传输比32位传输效率低很多 | 尽量用32位传输,源和目标宽度一致 |
| 地址递增 | 源和目标都递增时效率最高 | 避免一端递增一端固定以外的复杂配置 |
| 总线竞争 | CPU和其他DMA通道同时访问总线会降低速率 | 错开高带宽外设的DMA使用时间 |
| 存储器类型 | Flash比SRAM慢,外部存储器更慢 | 高频DMA传输用内部SRAM |
| 中断频率 | 中断太频繁会打断DMA传输 | 增大传输块大小,减少中断次数 |
| DMA通道优先级 | 低优先级通道会被高优先级抢占 | 关键传输用高优先级通道 |
这里重点说一下传输宽度的影响。我实测过STM32F407上内存到内存的DMA传输,8位宽度时速率大概只有32位宽度的四分之一。原因很简单,DMA每次传输都要占用总线周期,8位传输每次只搬1个字节,32位传输每次搬4个字节,总线周期的利用率差了4倍。所以在条件允许的情况下,尽量用32位传输。如果源数据是8位的,可以考虑在内存里先拼成32位再传输,或者接受速率损失。
5.3 DMA测速失败常见原因
热词里提到了“dma测速失败代码”和“dma测速工具”,说明不少人在测速过程中遇到了问题。我总结了几种常见的测速失败原因。
第一种是DMA根本没启动。这种情况通常是配置遗漏了某个使能位,比如忘了使能DMA时钟、忘了设置DMA请求映射、或者忘了使能外设的DMA请求。排查方法很简单,在启动DMA后读一下DMA的使能位和传输长度寄存器,确认配置生效了。
第二种是传输完成中断没触发。可能是中断优先级配置有问题,或者中断标志没有正确清除。还有一种可能是DMA传输长度设成了0,DMA会立刻完成但不产生中断。我遇到过有人把传输长度设成了0,然后一直在等中断,等了半天没反应。
第三种是测出来的速率异常高或异常低。异常高通常是定时器配置有问题,比如定时器时钟频率算错了。异常低则可能是DMA被频繁打断,或者传输的数据量太小导致软件开销占比过大。建议测速时传输数据量不要小于4KB,否则测出来的速率没有参考价值。
第四种是数据校验不通过。DMA搬完之后对比源数据和目标数据发现不一致,这通常是地址配置错误或者传输宽度不匹配导致的。比如源地址是uint8_t数组,目标地址是uint16_t数组,DMA按字节搬,目标数组的高字节全是0,数据自然对不上。
6. 常见问题排查与避坑经验
6.1 DMA配置中最容易犯的错误
做了这么多年DMA驱动,我总结了几类最高频的错误。第一类是时钟没使能。DMA控制器有自己的时钟,在STM32里通常通过RCC_AHB1ENR寄存器使能。很多人初始化DMA的时候只配置了参数,忘了开时钟,结果DMA完全不工作。这个错误很隐蔽,因为配置寄存器的读写看起来都正常,但DMA就是不搬数据。
第二类是通道或流选择错误。STM32F1的DMA通道和外设的对应关系是固定的,比如USART1_TX只能用DMA1_Channel4,用错了通道DMA请求根本不会触发。F4系列虽然流和通道可以灵活映射,但每个外设能用的流也是有限制的,不是随便选一个就行。我建议配置前先查参考手册的DMA请求映射表,确认外设对应的通道或流。
第三类是地址不匹配。DMA的源地址和目标地址必须和传输方向一致。比如配置的是外设到存储器,那源地址应该是外设数据寄存器,目标地址是内存缓冲区。如果搞反了,DMA会把内存数据往寄存器里写,结果完全不可预期。这个错误在调试时表现为数据完全不对,但DMA中断正常触发,容易误导排查方向。
第四类是传输长度单位搞错。前面提过,传输长度的单位是数据项个数,不是字节数。如果源端是16位、目标端是8位,传输长度设为100,实际传输的字节数是200(源端100个16位数据,目标端每个数据拆成2个字节)。这个细节在混合宽度传输时特别容易出错。
6.2 DMA与Cache一致性问题
这个问题主要出现在带Cache的MCU上,比如STM32F7、H7系列。当DMA把数据搬到内存时,数据是先写到物理内存的,但CPU可能从Cache里读到旧数据,因为Cache不知道DMA修改了内存。反过来,CPU写数据到内存时可能还在Cache里没写回物理内存,DMA去搬的时候搬到的就是旧数据。
解决Cache一致性问题有几种方法。第一种是把DMA缓冲区放在非Cache区域,比如STM32H7的DTCM RAM就是非Cache的,DMA可以直接访问。第二种是在DMA传输前后手动维护Cache,传输前用SCB_CleanDCache清理CPU写入的数据,传输后用SCB_InvalidateDCache无效化Cache让CPU重新从内存读取。第三种是把缓冲区配置成Write-Through模式,CPU写数据时同时写Cache和内存,但这样会影响CPU性能。
我一般推荐第一种方案,把DMA缓冲区放在非Cache区域,省心省力。如果非Cache区域不够用,再用第二种方案,但要注意Cache维护操作的时机和范围,清理或无效化的地址范围要覆盖整个DMA缓冲区,而且地址要按Cache行对齐。
6.3 DMA传输中的数据错位问题
数据错位是DMA调试中最让人头疼的问题之一。表现是接收到的数据整体偏移了几个字节,或者某些字节重复出现。这类问题通常有几个原因。
第一个原因是DMA和CPU同时访问缓冲区。比如串口DMA接收用循环模式,DMA在后台不停地往缓冲区写数据,CPU在主循环里读缓冲区。如果CPU读的速度跟不上DMA写的速度,或者读写指针没有正确同步,就会出现数据错位。解决方法是使用双缓冲或者环形缓冲,并确保读写指针的更新是原子的。
第二个原因是DMA传输过程中修改了配置。比如在DMA还在传输时修改了传输长度或者地址寄存器,DMA的行为会变得不可预测。正确的做法是先关闭DMA通道,修改配置,再重新使能。
第三个原因是中断处理不当。比如在DMA传输完成中断里没有及时清除标志位,导致中断重复触发,或者在半传输中断和传输完成中断里处理数据的顺序搞反了。我建议在中断处理函数里第一件事就是清除中断标志,然后再做数据处理。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| DMA完全不工作 | 时钟未使能、通道选错、使能位未置位 | 检查RCC寄存器、对照映射表、读DMA使能位 |
| 传输完成中断不触发 | 中断未使能、优先级配置错误、标志未清除 | 检查NVIC配置、读中断标志寄存器 |
| 数据全部为0或全为0xFF | 地址配置错误、外设未使能DMA请求 | 检查源/目标地址、外设DMA使能位 |
| 数据错位几个字节 | 读写指针不同步、传输中修改配置 | 用双缓冲、避免运行中改配置 |
| 速率远低于预期 | 传输宽度太小、总线竞争、中断太频繁 | 改用32位传输、错开高带宽外设、增大块大小 |
| 偶发数据错误 | Cache一致性问题、电源噪声 | 维护Cache、检查电源滤波 |
| DMA传输一半就停 | 传输错误、总线错误、优先级被抢占 | 检查传输错误标志、提高通道优先级 |
提示:排查DMA问题时,善用调试器的寄存器查看功能,直接观察DMA的源地址、目标地址、传输长度计数器和状态寄存器的值,比在代码里打印日志高效得多。
7. 跨平台DMA驱动的移植经验
7.1 从F1到F4的DMA差异
STM32F1和F4的DMA架构差异很大,移植时基本要重写DMA配置部分。F1的DMA是通道制,每个通道固定对应特定的外设请求,配置寄存器也比较简单。F4改成了流和通道的两级结构,每个流可以独立配置,通过通道选择寄存器来映射外设请求。F4的DMA还支持双缓冲模式和FIFO,功能更强但配置也更复杂。
移植的时候,首先要确认外设在F4上对应哪个DMA流和通道。比如USART1_TX在F1上是DMA1_Channel4,在F4上可以用DMA2_Stream7的Channel4。然后要修改DMA初始化结构体,F4的DMA_InitTypeDef多了FIFO模式、突发传输等字段。最后要检查中断向量表,F4的DMA中断向量和F1完全不同。
7.2 HAL库与标准库的DMA使用差异
标准库的DMA配置更接近寄存器操作,你需要手动填充DMA_InitTypeDef结构体,然后调用DMA_Init。HAL库则把DMA配置封装在了外设的初始化流程里,比如HAL_UART_Receive_DMA会自动配置DMA通道。用HAL库的好处是代码更简洁,但灵活性稍差,某些特殊配置还是需要直接操作寄存器。
从标准库迁移到HAL库时,最大的变化是DMA的启动方式。标准库需要先配置DMA再使能外设的DMA请求,HAL库则通过HAL_XXX_DMA函数一步完成。另外HAL库的中断处理回调机制也和标准库不同,需要重写相应的回调函数而不是直接写中断服务函数。
7.3 DMA驱动的可移植性设计
如果你做的项目需要在多个平台上复用DMA驱动,建议把DMA相关的操作抽象成一层接口。比如定义dma_send()、dma_receive()、dma_config()等函数,底层根据不同的平台实现。这样上层应用代码不用关心具体用的是哪个型号的MCU,移植时只需要实现底层的接口函数。
我在实际项目中会定义一个dma_ops结构体,里面包含函数指针,初始化时根据平台注册不同的实现。这种面向接口的设计在跨平台项目中非常实用,虽然前期多写一些代码,但后期移植和维护的成本大大降低。
8. 几个实战中总结的小技巧
最后分享几个我在实际项目中总结的DMA使用技巧,都是文档里不会写的。
第一个技巧:DMA传输完成中断里不要做耗时操作。我见过有人在DMA中断里做数据解析、协议处理甚至打印日志,结果导致中断响应时间过长,影响了其他中断。正确的做法是在中断里只做标志置位和缓冲区切换,把数据处理放到主循环或者低优先级任务里。
第二个技巧:用DMA传输前先清理缓冲区。特别是用循环模式的时候,如果缓冲区里有上次残留的数据,可能会被误认为是新收到的数据。我一般会在启动DMA接收前用memset把缓冲区清零,虽然多花一点时间,但能避免很多莫名其妙的错误。
第三个技巧:DMA的传输长度不要设得太大。有些朋友为了减少中断次数,把传输长度设成几KB甚至几十KB,结果中断是少了,但数据处理的实时性也差了。因为数据要等DMA搬完一整块才能处理,中间这段时间数据是“不可见”的。我一般把传输长度控制在256到1024字节之间,兼顾中断频率和实时性。
第四个技巧:调试DMA时先用小数据量验证。不要一上来就传几KB的数据,先用几个字节验证配置是否正确、中断是否触发、数据是否正确。确认基本功能没问题后,再逐步增大数据量测试极限性能。
第五个技巧:注意DMA通道的优先级分配。STM32的DMA通道优先级只在多个通道同时请求时起作用,如果只有一个通道在用,优先级设成什么都一样。但在多通道场景下,优先级分配不合理会导致高优先级通道饿死低优先级通道。我一般把实时性要求最高的通道设为最高优先级,比如音频采集,把非实时性的通道设为低优先级,比如屏幕刷新。
这些经验都是我在实际项目中踩坑踩出来的,希望能帮到正在跟DMA较劲的朋友。DMA这个东西,刚接触的时候觉得复杂,用熟了之后会发现它是真的香,能把CPU从繁重的数据搬运中解放出来,让整个系统的性能提升一个档次。