干嵌入式开发这些年,我对DMA的态度经历了从“不敢碰”到“什么都想上”再到“按需取用”三个阶段。DMA这三个字母听起来确实有点唬人,什么Direct Memory Access、直接内存访问,感觉像是隐藏在单片机里的某种魔法。但你把寄存器翻完、把实测数据跑完就会发现,它本质上就是一个硬件级的“搬运工”,搬完数据给你发个中断,仅此而已。
这篇文章我打算换个讲法,不给你背概念,直接从“为什么需要DMA”说起,结合我在STM32上做串口、SPI、ADC、I2C、PWM这些外设的实测经验,把CubeMX配置、请求映射、循环模式、双缓冲、空闲中断这些高频关键词一次讲透。尤其适合正在用STM32做项目、却被“不定长串口接收”“连续DMA发送失败”“数据错乱”逼到头秃的同学。读完之后你至少能搞清楚一件事:什么时候该用DMA,什么时候用了反而添乱。
1. DMA到底解决了什么:从“老板亲自搬砖”到“专职搬运工”
1.1 轮询、中断、DMA,本质上都在争同一样东西
在聊DMA之前,得先把CPU和外设之间的三种数据传输方式摆在一起看。你可以把CPU想象成一个老板,外设和内存是分布在公司两头的仓库。老板要定期把A仓库的货搬到B仓库,有两种传统做法:
第一种叫轮询。老板搬张凳子坐在A仓库门口,隔一会儿看一眼,发现新货来了就亲手搬一趟,没货就继续等。这种做法实现最简单,但老板本人被彻底锁死在仓库门口,CPU占用率几乎是100%。你去看那个while循环里不停判断标志位的代码,就是老板在仓库门口蹲着的样子。
第二种叫中断。仓库门口装了个门铃,来新货了门铃响一下,老板从办公室跑过来签收、搬运,搬完了再回办公室继续办公。比轮询好,但有个致命问题——如果货流量特别大,老板来回跑动的时间成本会迅速累积。每进一次中断都有压栈、出栈、跳转、寄存器保存恢复这几道工序,一次两次无所谓,一秒进来几万次,CPU的宝贵时间全耗在“跑路”上了。
DMA做的事情就是给老板请了个专职搬运工。这个搬运工认路、认货、认数量,老板提前告诉它“从A仓搬500件到B仓,数据宽度是32位”,它就开始自己干活。搬到一半可以发个“半程报告”,全部搬完再发个“完工报告”,也就是半传输中断和传输完成中断。整个过程中老板该干嘛干嘛,完全不需要插手。
1.2 DMA的工作流程:不是魔法,是套标准流程
DMA的工作流程如果用四步来总结,看完你就不觉得它神秘了:
- CPU配置DMA控制器的源地址、目的地址、传输方向、传输数据宽度、传输长度,然后使能DMA。
- DMA控制器监视外设的DMA请求信号。外设的数据寄存器准备好接收或发送一个数据单元时,会发出一个请求,DMA直接控制总线完成一次搬运。
- DMA内部计数器递减,到达半程时触发半传输中断(可选),到达零时触发传输完成中断。
- CPU在中断回调里做后续处理:比如处理接收到的数据、重新启动下一次DMA传输。
这里有个关键点:DMA并不存在于“真空”里,它对总线的访问要和CPU的访问仲裁。DMA优先级高,CPU访问总线就要等待,所以DMA优先级设太高,某种程度上也会“卡”CPU,只不过卡的时间极短,大多数项目体感不出来。STM32CubeMX里配置DMA的时候会让你选优先级:Low、Medium、High、Very High,这就是在告诉DMA控制器“你和CPU抢总线的时候谁说了算”。
1.3 不是所有场景都该上DMA:什么时候用了也白用
我见过不少朋友上了DMA之后反而觉得“更卡了”,这并不是DMA不好用,而是场景没选对。DMA适合的是数据量较大、传输频率稳定、不需要CPU逐字节干预的场景,比如ADC连续采样、串口接收一大包数据、SPI读取传感器连续输出。但下面这几种情况,你用了DMA大概率是负优化:
- 每次传输的数据量特别小。比如一个字节、两个字节,DMA的初始化配置开销可能比中断处理本身还大。
- 传输频率特别高但数据量极小。典型就是APB上的某些高速外设,比如CAN。CAN一帧报文最多8个字节,标准CAN还经常以非常高的中断频率进来看数据。对大多数应用来说,CAN用中断接收是合理的,只有在总线负载极高、中断频率已经明显拖累主循环的情况下,才需要认真考虑DMA甚至FIFO方案。我做过一个跑CANopen的从站,总线波特率降到125K,报文量不大,中断完全够用;后来换到500K、消息ID全过一遍,中断负载上来了,才把接收改成DMA+空闲中断的组合。
- 实时性要求远高于数据量要求。DMA把数据搬到内存之后,CPU并不知道“什么时候搬完”的精确时刻,只能通过中断来感知。如果逻辑上希望每收到一个数据立即处理,DMA反而把“即时性”变成了“批处理性”。
判断方法很简单:算一下中断请求频率。假设每秒有1万个中断,每个中断处理耗时20微秒,那CPU光处理中断就用掉了20%的时间,这时候就值得考虑DMA。如果每秒只有100个中断,总开销才0.2%,就别折腾DMA了,保持代码简单才是王道。
2. 动手配DMA之前,先看懂STM32的DMA资源与请求映射
2.1 F1、F4、H7系列:DMA通道的“分配逻辑”完全不同
这是大家第一次用CubeMX配DMA最容易懵的地方。STM32家族很大,不同系列的DMA架构差异非常大,如果你拿F103的固定通道思路去配F407,大概率会发现“DMA请求怎么选不了这个外设”。
我做了个表格,把常见系列的DMA资源差异列出来:
| 系列 | DMA控制器 | 通道/数据流资源 | 请求映射方式 | 配置特点 |
|---|---|---|---|---|
| STM32F1 | DMA1(7通道)+DMA2(5通道) | 固定通道 | 固定映射 | 去看参考手册的DMA请求映射表,USART1_TX固定就是DMA1通道4 |
| STM32F4 | DMA1/DMA2各8个Stream | 每个Stream有8个通道可选 | 外设请求基本固定并且多对一 | 要同时选对Stream和Channel,CubeMX会自动做,手动配容易错 |
| STM32H7/G4系列 | 带DMAMUX,多路复用请求 | 任意外设请求映射到任意通道 | 动态灵活任意映射 | 用到LPTIM、SPI、SAI等复杂外设时优势非常明显 |
F103的同学圈个重点:DMA1通道4对应USART1_TX,DMA1通道5对应USART1_RX,DMA1通道1对应ADC1。这些固定映射关系在参考手册的“DMA request table”里有张表,你配置的时候必须按表来,不存在“通道随便挑”的说法。F4是一个控制器8个Stream、每个Stream 8个通道,外设的走向在硬件上是定死的,比如USART1_RX一般就落在DMA2的特定Stream/Channel上,CubeMX会替你选好,但你得理解为什么生成代码里是那组数字。H7/G4有了DMAMUX之后自由度大增,几乎所有外设请求都能路由到任意DMA通道,这也是新系列写驱动舒服的地方。
这一阶段的实操经验是:别背映射表,手里一定要有对应型号的参考手册,或者直接打开CubeMX看它为你选的DMA配置。手动配寄存器时把映射表抄错,是最冤的一种“DMA不工作”。
2.2 Normal模式、Circular模式、半传输中断,到底各自怎么用
DMA传输模式这个概念不复杂,但很多新手把“半传输”和“循环”混淆。
- Normal模式(单次模式):设置一个传输长度,DMA搬完就停。之后需要重新设置长度并启动,不然不会再有DMA请求响应。适合一次性的大量数据读取,比如SPI读芯片上的一块数据。
- Circular模式(循环模式):DMA搬满指定长度后,地址自动回到起始位置,继续下一次搬运,外设请求持续响应。适合ADC连续采集、UART不定长接收、定时器触发采样这类“永远需要接收数据”的场景。
- 半传输中断:当DMA内部的计数器减到一半时触发中断。注意,半传输中断在Normal和Circular模式下都会产生。它的典型用途是配合“乒乓缓冲”,半程时CPU去处理前半段数据,传输完成后DMA又去填后半段,两段数据不冲突,CPU也不丢数据。
F4系列上还有一个“Burst传输”概念,即每次DMA传输可以搬运4、8、16个数据单元。但开启Burst必须保证内存地址与外设地址的对齐方式符合要求,否则会触发总线错误。我在H7上开过Burst,确实能提升搬运效率,但没特殊需求别乱试,配置错了排查起来很熬人。
2.3 双缓冲(Double Buffer)这个高级特性,什么项目才值得用
双缓冲也是热词搜索里经常出现的。它和“循环模式”的差别在于:循环模式只有一个缓冲区,DMA不停地在里面转圈,如果数据量太大,后进来的数据会覆盖前面还没来得及处理的数据。双缓冲则是DMA交替填充两个缓冲区:DMA正在往缓冲区A写的这段时间,CPU在缓冲区B里处理数据;DMA填满A就切到B,CPU再回头处理A。两边互不干扰,适合高速连续数据流场景,比如音频采集、高速ADC采样要同时做小规模滤波运算。
但我必须泼一盆冷水:双缓冲的代码复杂度显著上升,你要处理缓冲区切换、半传输中断/完成中断的时序、两段缓冲区边界处的数据连续性。STM32F4系列是有硬件双缓冲模式的,但CubeMX里并不是所有外设都能一键开启,有些情况你得手动改了中断回调。如果你只是单纯接收串口数据,一般用循环模式加空闲中断就够了,真没必要上双缓冲。
2.4 顺手说下DMA测速这事
有朋友问DMA测速软件怎么选,我实测下来的方法是:不要依赖什么花里胡哨的工具,直接看DMA完成中断的频率或者说搬运的字节总数就行。在代码里用一个定时器统计,1秒内触发了多少次DMA完成中断、每次搬运多少字节,一乘就是实际吞吐量。比如SPI DMA传输,从发送请求到传输完成,1毫秒搬了4096字节,那就是4MB/s左右。注意DMA测速通常受限于外设本身的带宽(APB1/APB2时钟频率)和存储器的访问速度,真正的瓶颈往往不是DMA控制器,而是源外设的采样率或时钟分频。
3. CubeMX实战:UART空闲中断+DMA接收不定长数据,一条链路全打通
3.1 为什么“空闲中断+DMA”是串口接收的黄金组合
如果只用DMA接收串口数据,你必须预先指定长度。可实际项目里的帧长度经常不定——比如AT指令、私有协议、北斗报文,一帧收多少个字节完全看对方脸色。这时候你把DMA接收长度设成“缓冲区长度”,配合USART的空闲中断(IDLE),就能在对方发送完一帧、总线空闲的瞬间触发中断,告诉CPU:“这帧发完了,数据已经在DMA缓冲区里了,你赶紧去取。”这比固定长度DMA灵活太多,也比纯中断接收节省大量CPU时间。
3.2 CubeMX里的具体配置步骤
我用STM32F103和G4都配过这个方案,步骤基本一致(以F103为例,其实G4也差不多):
- 打开STM32CubeMX,选择你的MCU型号,配置USART1为异步模式,波特率按实际需求,比如115200。
- 切换到DMA Settings标签,点击Add添加USART1_RX,方向选Peripheral To Memory;如果有发送需求,再添加USART1_TX,方向选Memory To Peripheral。模式先选Normal(后面有说明),数据宽度按实际数据格式选Byte。
- 在NVIC Settings中把USART1全局中断使能。注意:使用空闲中断时一定要开串口全局中断,而不仅仅是DMA中断。
- 生成工程代码。
这里说一个细节:CubeMX生成的DMA通道如果默认是Normal模式,配合HAL_UARTEx_ReceiveToIdle_DMA使用是没问题的,每次收到空闲中断后由你在回调里重新启动下一次DMA接收。也有的工程师习惯把DMA设成Circular模式,然后在空闲中断里计算“当前指针”,但这种做法我建议新手先别碰,因为缓冲区覆盖的边界很隐晦,出了问题不好定位。
3.3 核心代码实现:从启动到回调
生成代码后,在主程序中启动接收:
uint8_t uart_rx_buf[256]; // 根据最大帧长定义 int main(void) { // ... 初始化代码 ... HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_buf, sizeof(uart_rx_buf)); // 启动后DMA就在后台接收,总线空闲时产生Idle中断 while (1) { // 主循环不用管串口接收的事 } }然后重写串口事件回调函数:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart == &huart1) { // Size是这一帧实际收到的字节数 // 对uart_rx_buf[0..Size-1]做协议解析或其他处理 // 处理完之后重新开启下一次接收 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_buf, sizeof(uart_rx_buf)); } }这个回调是HAL库在“接收到空闲事件并且DMA已经搬运了数据”之后调用的。注意函数名里的Ex和RxEventCallback,老版本的HAL库可能叫HAL_UART_ErrorCallback或者通过HAL_UART_IRQHandler间接处理,不同CubeMX版本API有差异,但整体思路一致。
3.4 实测里最典型的坑:接收到的数据总是少尾字节
我第一次用这个方案时,串口调试助手发了一条AT+CMD\r\n,回调里Size显示的是9,但解析函数却发现最后面少了\n。排查了老半天,最后定位到原因:空闲中断触发的时机,和DMA把最后一个字节从串口数据寄存器搬到内存的时间,有一个微小的“同异步差”。调试助手发出最后一个字节后,总线上已经空闲了,USART外设检测到空闲马上置位IDLE标志并触发中断,CPU立刻进入回调。可此时DMA可能还没来得及把最后一个字节的数据从USART_DR搬到内存的接收缓冲区,你读Size和缓冲区,自然就少了一截。
解决思路有几种,我在项目里用到的是最稳的“等待搬运完整再处理”方案:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart == &huart1) { // 先停DMA,防止继续往缓冲区里写 HAL_UART_DMAStop(&huart1); // 等待RXNE标志清除,确保最后字节已搬到内存 while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET); // 此时再取数据是完整的 handle_frame(uart_rx_buf, Size); HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_buf, sizeof(uart_rx_buf)); } }还有同事的做法是在回调里先延时几十微秒再去读数据,也能凑效,但我觉得不优雅。核心原因是“数据在途”,你要给DMA一个完成总线周期的时间。跑高速波特率(比如921600)的时候这个问题尤其明显,这个等待RXNE清除的小技巧省了很多事。
4. 高频外设组合玩法:SPI、ADC、PWM、I2C和DMA怎么配合
4.1 SPI通过DMA读取外部芯片:真的需要两个DMA吗
“SPI需要两个DMA吗”这个问题,很多人纠结过。我的答案是:如果你既要发又要收,就开两个;如果你只是单方向发,一个都不开也行,轮询就够。
SPI是同步全双工总线,主机往外部Flash或传感器里发一个字节的同时,必定会从MISO线上收回一个字节。当你要读取芯片数据时,本质上干了两件事:主机持续发(通常是发0),同时对收到的数据做接收。如果只开RX DMA,HAL库内部实际上也会开起TX DMA,往MOSI线上发送数据以维持SCK时钟,只不过发的内容是全0。所以你在CubeMX里把SPI1的TX DMA和RX DMA都勾上,是合情合理的标准做法。
实际代码里实现SPI DMA读取外部FLASH数据,流程是:先通过SPI发送读命令和地址(这段用小量数据传输,轮询没问题),然后启动HAL_SPI_Receive_DMA读取大片数据。
uint8_t spi_rx_buf[4096]; // 先发送读命令/地址,可以用轮询 uint8_t cmd[4] = {0x03, 0x00, 0x00, 0x00}; HAL_SPI_Transmit(&hspi1, cmd, 4, HAL_MAX_DELAY); // 读取4096字节,内部自动使用TX DMA发送0,RX DMA接收数据 HAL_SPI_Receive_DMA(&hspi1, spi_rx_buf, 4096);这里有个“坑中坑”:SPI的DMA接收完成回调HAL_SPI_RxCpltCallback触发时,不代表SPI总线的SCK已经完全停止。某些芯片要求CS引脚必须在最后一个SCK时钟之后才能拉高,否则最后一两个字节会错位或丢数据。有人直接进回调就拉高CS,读出来的数据最后一个字节永远不对,查了大半天。我在实测中会在接收完回调里加上一个极短延时或等待SPI状态寄存器为空,再来操作CS引脚,问题立刻消失。数据量大、波特率又高(超过几Mbps)的时候,这种针对时序细节的处理尤其重要。
4.2 ADC多通道DMA采集:从CubeMX到代码一次配通
ADC多通道采集是DMA用得最多的场景。你不配DMA的版本是:启动ADC转换,等待转换结束,读数据寄存器,再启动下一个通道……多通道下来繁琐且CPU占用极高。配了DMA之后,ADC每转换完一个通道,DMA自动搬运到缓冲区,DMA缓冲区里就按通道顺序存着所有通道的最新值。
CubeMX配置要点:
- ADC开启扫描模式(Scan Mode),开启连续转换模式(Continuous Conversion Mode)。
- 规则组通道数量设为你要采集的通道数,比如3个。
- DMA Mode选Circular,这样才能持续更新数据。
- DMA缓冲区长度要和你规则组的通道数匹配,假设有ADC_CH0、ADC_CH1、ADC_CH2三个通道,那么定义
uint32_t adc_values[3];就够了。注意如果你的HAL库是12位的,DMA数据宽度建议选Word,因为STM32F1系列的ADC数据寄存器虽然是32位但只在低16位有效,用HalfWord也常见,具体要看CubeMX警告。
生成代码后,DMA会自动把采样结果不停写入adc_values数组。你要读ADC值,直接访问这个数组就可以了。很多初学者非要等DMA传输完成中断再来拷贝数据,其实ADC连续采样场景下没必要。你想读的时候就主循环里直接取数组,每次取到的都是DMA最近一次搬运进来的值。这样CPU开销最低,代码也最简洁。
顺带提醒一个和“连续请求”相关的概念:ADC连续转换模式和DMA循环模式并不完全相同。ADC的连续转换是指ADC自己不停采样,DMA的循环模式是指DMA搬完一批数据后回卷继续搬。两者通常配合在一起用,但如果你用定时器触发ADC采样,希望每隔一段时间采一次,那ADC的连续转换应该关闭,让定时器事件作为触发源,DMA仍然循环搬运。把这两个“连续”搞混,你会看到数据全是0或者数据波形完全不是你预期的频率。
4.3 PWM+DMA生成复杂波形:一个几乎不占CPU的骚操作
“PWM加DMA”能玩出什么花样?最典型的就是用普通定时器驱动灯光。比如控制一个WS2812灯带,准备一组内存数组,每个元素是对应时间点上定时器比较寄存器(CCR)应输出的值,然后用DMA把这一组值逐个写进定时器的CCR寄存器,PWM引脚上就能输出一串不同占空比的脉冲。CPU只负责往内存里放数据,不用管输出的时序细节。
代码形式像是这样:
uint32_t pwm_pattern[] = {100, 200, 300, 400, 500, 600, 700, 800}; // 将pwm_pattern中的数据依次写入TIM2_CH1的CCR HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, pwm_pattern, 8);前提是你在CubeMX里把定时器配置成PWM模式,ARR设置为最大值(比如1000),DMA请求放在定时器的更新事件上,这样每次定时器计数溢出,DMA就把下一个CCR值搬进去。使用循环模式时,波形会反复输出,灯带效果就可以一直播下去。
这里我从实践里总结出一个重要经验:别让DMA把数据搬到CCR之后,又去主循环里直接改内存缓冲区里的数据。因为DMA是异步连续搬运的,你改数据的那一刻,DMA可能正好在读同一个内存位置,轻则波形出现一个毛刺,重则整个脉冲序列全部错位。正确做法是:准备一个“后台缓冲区”,先改后台缓冲,在确认DMA当前传输周期结束之后,通过切换指针或等待回调把新缓冲区换成生效缓冲区。这和前面讲的双缓冲思路一模一样,本质都是“生产者在后台写,消费者在后台读,两边不碰同一个内存”。
4.4 I2C上DMA:我为什么总是建议谨慎
I2C的DMA是我用得最少的一项。原因很简单:I2C本身速率低(常规400K/s,高速模式才1M),而且每次通信前有起始条件、设备地址、寄存器地址、重复起始条件。DMA只负责搬运数据,它管不了这些命令阶段。STM32的HAL库里,HAL_I2C_Mem_Write_DMA这种函数虽然能自动处理地址阶段,但一旦中间出错,比如从机器NACK、总线仲裁丢失,DMA状态和外设状态就很容易“脱节”,之后总线可能锁死,需要你手动拉时钟或复位I2C才能恢复。
所以我的建议很直接:I2C上DMA,除非你是在做大量连续数据传输,否则中断或轮询就够了。非得用DMA时,一定要做好超时处理,并且在I2C错误回调里增加恢复逻辑。例如在HAL_I2C_ErrorCallback里立刻调用HAL_I2C_DeInit再HAL_I2C_Init,把外设和DMA状态重新初始化一遍。
5. 接招吧:DMA实测中逃不掉的五个坑和解决思路
5.1 HAL库串口DMA发送不能连续发的问题
“STM32串口HAL库使用DMA发送数据不能连续发送”这个现象,我假设你在网上搜过不少次。典型代码是:
HAL_UART_Transmit_DMA(&huart1, buf1, len1); HAL_UART_Transmit_DMA(&huart1, buf2, len2);第一行执行之后,第二行大概率是失败的,但不是数据发错,而是HAL_UART_Transmit_DMA返回了HAL_BUSY。原因在于HAL库维护了一个发送状态gState,上一次DMA发送还没完成时,状态是HAL_UART_STATE_BUSY_TX,你再次调用发送接口,API直接告诉你:忙,不接单。
解决办法我在项目里常用两种:
第一种,等上一次发送完成再发。重写发送完成回调函数:
volatile uint8_t uart_tx_complete = 1; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { uart_tx_complete = 1; } }每次要发数据之前:
while (uart_tx_complete == 0); // 等上一次发完 uart_tx_complete = 0; HAL_UART_Transmit_DMA(&huart1, buf, len);第二种,如果你确实要在一帧还没发完时就急着重启发送(比如立刻更新数据),应该先调用HAL_UART_AbortTransmit(&huart1),强制中止,再重新启动DMA。但中止发送可能会截掉上一帧的尾部字节,要根据协议允许程度决定是否使用。
另外还有人遇到的现象是“DMA发送几次之后,从某一帧开始突然乱了”,这个我碰到过的根因是缓冲区没有保持有效,或者发送长度填错了。比如你一次性把len填成一个很大的数,DMA会一直去内存里搬,它不管这些内存地址是不是真的还没被释放。
5.2 数据乱码和错位的真正元凶:DMA Cache一致性问题
在F4以上的内核里,带D-Cache的芯片(比如部分F4、F7、H7)上,乱码问题十有八九是缓存一致性。CPU读内存时先读到的是Cache里的旧副本,而DMA搬进来的新数据在物理内存里,两边不一致。
处理套路是固定的:
// 接收方向:DMA写好后,CPU读之前,让CPU先把cache中的数据失效 SCB_InvalidateDCache_by_Addr((uint32_t *)uart_rx_buf, sizeof(uart_rx_buf)); // 这样CPU下一次读就会从实际内存里读DMA刚写进去的数据 // 发送方向:CPU写好数据后,DMA读之前,让cache先把数据刷到内存 SCB_CleanDCache_by_Addr((uint32_t *)uart_tx_buf, sizeof(uart_tx_buf));也可以使用CubeMX的__HAL_DCACHE_Invalidate,效果一样。别忘了把缓冲区定义成对齐地址,比如:
__attribute__((aligned(32))) uint8_t uart_rx_buf[256];我之前在H743上跑以太网和高速串口,没注意cache问题,数据一多就乱,排查了整整一天。最后发现是DMA和CPU在缓存一致性上“打架”,加上内存对齐之后问题彻底消失。建议如果用的是带D-Cache的MCU,一开始就把对齐和Cache操作写进DMA驱动的公共代码里,别等出问题再补。
5.3 DMA接收中途手动改外设状态,导致“假死机”怎么办
这个坑很容易踩到:DMA正在接收串口数据,这时候你在某段代码里调用了HAL_UART_StateTypeDef相关的接口去改变串口状态,或者直接调用HAL_UART_Receive_IT想切换成中断接收。结果就是DMA和外设的内部状态对不上,程序像死机一样,或者数据彻底不来了。
我对这个问题的经验是:切换DMA模式之前,先彻底停止DMA和外设,再重新初始化。别天真的以为“我只是临时改个标志位,不影响DMA”。DMA控制器、外设、HAL库三者之间的状态一致性,往往比你想象得脆弱。
// 安全地停下来 HAL_UART_DMAStop(&huart1); // 如果还不放心,直接重新初始化串口 HAL_UART_DeInit(&huart1); HAL_UART_Init(&huart1); // 再按新方式启动5.4 中断回调里千万别做“重活”
这是个老生常谈,但每回都有人栽。DMA传输完成中断意味着什么?意味着CPU正在中断上下文里。你在HAL_UART_TxCpltCallback或者HAL_ADC_ConvCpltCallback里做协议解析、浮点运算、Flash写入,甚至是调用HAL_Delay,极有可能导致下一个中断无法及时响应或DMA数据传输错乱。
我处理回调的规矩非常固定:回调里只做三件事——置标志位、把关键数据拷贝到应用缓冲区、重新启动必要的DMA传输。真正的解析和处理全部放主循环或业务任务里。比如上面串口空闲中断的例子,回调里我只把rx_len记录下来,然后置uart_idle_flag,主循环检测到uart_idle_flag再去处理那一帧数据。这眼看着多绕了一层,但稳定性和可调试性好太多。
5.5 DMA中断优先级的“玄学”:为什么高速时会丢数据
DMA中断也具备优先级,这个优先级和外设中断优先级是两个维度的东西。DMA中断优先级影响的是:当DMA完成中断产生时,它能在多短的时间里打断CPU当前的任务进入中断处理。如果DMA中断优先级太低,而某段主循环代码又不主动让出CPU,后面的DMA中断就会排队,等待时间过长。在高速连续传输场景里,下一个DMA周期已经开始,而你还没处理上一个周期的数据,就会出现丢数据或者覆盖数据的现象。
我的配置经验是:DMA完成中断的优先级设置成比普通业务外设中断高,但不能高于SysTick。比如串口+DMA接收时,USART全局中断(负责空闲中断检测)、DMA传输完成中断,这两者在NVIC里的抢占优先级我通常会设成中等偏上。如果串口波特率特别高,再把DMA中断优先级往上提一提。这种微调没有绝对公式,只能说你用逻辑分析仪或一个GPIO翻转来实测丢帧率,是最可靠的校准手段。
6. 收尾前,说几句我的DMA使用心得
我最后想说的,不是总结,而是一个实际工程里特别朴素的判断逻辑。你问一百次“该不该用DMA”,不如先问自己“这个外设每秒钟会产生多少个数据单元、CPU每秒钟要被打断多少次”。数据量大、打断频繁、数据搬运又是机械重复动作的,交给DMA准没错;反之,数据量小、逻辑分支复杂、每次都要求即时响应的,别折腾DMA,老老实实用中断和轮询。
从我个人的使用习惯来说,DMA已经被我当成STM32项目的基础设施,而不是某个高大上的高级功能。尤其是串口空闲中断+DMA、ADC多通道+DMA、SPI大块数据收发,这三个组合我现在几乎每个项目都会用。把它们在CubeMX里配好、代码框架搭好、缓存和中断回调的边界画清楚,后面的开发会很省心。
最后分享一个非常实用的小技巧:DMA相关的问题,无论是数据错乱、传输中断还是优先级冲突,先查参考手册里DMA请求映射表,再查外设和DMA的时钟关系,最后查Cache和缓冲对齐。按这个顺序排查,百分之九十的坑都能在半小时内定位出来。如果你也正在被哪一个具体的DMA问题卡住,欢迎把现象和代码贴出来,咱们一起拆。