嵌入式DMA驱动开发实战:从串口收发到测速与故障排查
DMA这东西,刚接触嵌入式驱动开发的人多半会觉得它"神秘"——明明代码里只是配置了几个寄存器,数据就自己从外设搬到了内存,CPU全程没怎么插手。但真到项目里用起来,各种诡异问题就冒出来了:数据对不上、传输卡死、测速结果忽高忽低、串口收包丢字节。这一期我就把DMA驱动开发里那些文档不会明说、但实际调试中一定会遇到的东西,系统性地捋一遍。
这篇内容适合已经能跑通基础外设驱动、准备把DMA用进实际项目的嵌入式软件工程师,也适合正在准备嵌入式岗位面试、想把DMA这块八股文答出深度的人。我会从DMA到底解决了什么问题讲起,一路讲到串口DMA收发、SPI DMA、测速方法、常见故障排查,尽量把每个"为什么"都讲透。
1. DMA到底在替CPU扛什么活
1.1 从一次串口收包看CPU的无效劳动
先设想一个最朴素的场景:串口波特率115200,每字节大约87微秒到达一次。如果用轮询方式接收,CPU就得不停查状态寄存器,确认有没有新数据。假设一帧数据100字节,CPU在这8.7毫秒里几乎被绑死在串口上,别的任务基本干不了。换成中断方式好一些,但每来一个字节就进一次中断,100字节就是100次中断进出,上下文保存恢复的开销累加起来依然可观。
DMA的思路完全不同:告诉DMA控制器"源地址是串口数据寄存器,目标地址是内存缓冲区,要搬100个字节",然后CPU就可以去干别的事了。每搬完一个字节,DMA控制器自己把地址指针往后挪,把计数器减一,全程不需要CPU参与。等100字节搬完,DMA产生一个传输完成中断,CPU再来处理这一整块数据。
这就是DMA的核心价值:把数据搬运这种重复性、机械性的工作从CPU手里接过去。CPU擅长的是逻辑判断和运算,让它去做"从这个地址读一个字节写到那个地址"这种活,是极大的浪费。
1.2 DMA控制器的内部结构决定了你怎么配它
要配好DMA,得先知道它内部大概长什么样。以常见的STM32 DMA控制器为例,一个通道(Channel)或流(Stream)内部有这么几个关键部件:
- 源地址寄存器和目标地址寄存器:分别指向数据从哪来、到哪去。
- 传输计数器:还剩多少个数据单元没搬。
- 地址增量配置:源地址和目标地址是否在每次传输后自动加一。
- 数据宽度配置:每次搬8位、16位还是32位。
- 优先级仲裁:多个通道同时请求时谁先谁后。
理解这几个部件,后面所有配置代码就都能看懂了。比如串口接收为什么源地址不增量、目标地址要增量?因为串口数据寄存器永远是同一个地址(新数据覆盖旧数据),而内存缓冲区需要一个个往后写。这个逻辑想通了,配置就不会配错。
1.3 什么时候该用DMA,什么时候别硬上
不是所有场景都适合DMA。我总结的判断标准是:数据量大、传输频繁、且CPU在传输期间有别的活要干,这三个条件满足两个以上,就值得上DMA。
反过来说,如果只是偶尔发几个字节的配置命令,用DMA反而增加复杂度——要配置通道、要处理完成中断、要管理缓冲区,代码量和调试成本都上去了,收益却几乎为零。我见过有项目连发一个字节的使能命令都用DMA,结果为了等这个传输完成还加了个信号量,纯属给自己找麻烦。
还有一个容易被忽略的点:DMA传输本身要占用总线带宽。如果系统里DMA通道很多、都在高频搬运数据,总线仲裁会成为瓶颈,这时候CPU访问内存反而变慢。所以DMA不是越多越好,要算总账。
2. 串口DMA收发:配置逻辑与那些反直觉的细节
2.1 接收方向:为什么源地址不能增量
串口DMA接收的配置里,最容易让人犯迷糊的就是地址增量。我们来看正确的配置逻辑:
// 以STM32 HAL库风格示意串口DMA接收配置 hdma_usart_rx.Instance = DMA1_Stream5; hdma_usart_rx.Init.Channel = DMA_CHANNEL_4; hdma_usart_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设到内存 hdma_usart_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不增量 hdma_usart_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址增量 hdma_usart_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart_rx.Init.Mode = DMA_NORMAL; // 或DMA_CIRCULAR hdma_usart_rx.Init.Priority = DMA_PRIORITY_HIGH;PeriphInc必须关掉,因为串口接收数据寄存器(比如USART1->DR)永远是一个固定地址,每次新数据都写到这里。如果开了增量,DMA第二次就去读DR+1的地址了,那读到的就是别的寄存器,数据全乱。MemInc必须打开,因为你要把连续收到的字节依次存进缓冲区。
这个逻辑其实很直白,但我在带新人的时候发现,很多人是"照着例程抄"而没想过为什么,一旦换个外设(比如SPI)就配错了。SPI的接收寄存器同样是固定地址,逻辑一样;但如果是内存到内存的搬运,两个地址通常都要增量。
2.2 接收缓冲区的两种管理模式:普通模式和循环模式
DMA接收缓冲区怎么管,是串口DMA开发里分歧最大的地方。两种主流做法:
普通模式(Normal):DMA搬完指定数量的字节就停,产生完成中断。适合"我知道这帧数据有多长"的场景,比如Modbus RTU这种有明确长度字段或固定长度的协议。缺点是如果实际数据比预期短,DMA会一直等,直到超时;如果比预期长,多出来的数据就丢了。
循环模式(Circular):DMA搬完一圈自动回到缓冲区开头继续搬,永不停止。适合数据流持续不断、长度不固定的场景。但循环模式带来一个新问题:你怎么知道当前搬到哪了?答案是读DMA的剩余计数器(NDTR),用缓冲区总长度减去剩余计数,就是当前写指针位置。
我个人的经验是:协议长度固定的用普通模式,数据流式的用循环模式配合空闲中断。空闲中断(IDLE)是串口DMA接收的黄金搭档——当总线上一段时间没有新数据时触发,此时读一下NDTR就知道这帧收了多少字节。这个组合能优雅地处理不定长数据帧。
2.3 空闲中断加DMA:不定长接收的标准解法
把空闲中断和DMA结合起来,是处理不定长串口数据的经典方案。核心思路是:DMA在后台默默往缓冲区搬数据,总线一空闲就触发中断,在中断里算出这帧的长度并处理。
// 串口空闲中断回调示意 void USART1_IRQHandler(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); uint16_t received = RX_BUFFER_SIZE - remaining; // 处理received个字节的数据 process_frame(rx_buffer, received); // 重新启动DMA接收 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); } }这里有个坑要特别注意:读NDTR之前最好先停DMA。因为DMA可能正在搬运,你读到的剩余计数和实际处理的数据之间会有竞争。虽然多数情况下差一两个字节不影响,但在高速率、高实时性要求的场景下,这个竞争会导致偶发的数据错位。停DMA再读,虽然损失一点点接收窗口,但换来了确定性。
另一个坑是空闲中断的清除方式。不同芯片清除IDLE标志的流程不一样,有的是"读SR再读DR",有的是直接写标志位。如果清得不干净,中断会反复触发,CPU被拖死。这个一定要对着参考手册确认,别想当然。
2.4 发送方向:什么时候该等,什么时候不该等
串口DMA发送比接收简单,但有个关键判断:发送完成中断和发送寄存器空中断不是一回事。
- 发送寄存器空(TXE):数据从发送寄存器挪到了移位寄存器,可以写下一个字节了。但此时最后一个字节可能还在线上没发完。
- 发送完成(TC):移位寄存器也空了,整帧数据真正发完了。
用DMA发送时,如果传输完成后立刻切换RS485收发方向或者关闭外设,必须等TC标志,不能只看DMA传输完成。因为DMA传输完成只代表数据都写进了发送寄存器,不代表都发出去了。这个坑在RS485半双工通信里特别常见——方向切换早了,最后一个字节就被截断了。
我一般的做法是:DMA发送完成中断里不急着做后续动作,而是等USART的TC标志置位后再处理。或者更稳妥的,用TC中断来触发后续逻辑。
3. SPI DMA与高速数据采集的配合
3.1 SPI全双工DMA:收发要成对配置
SPI是全双工总线,发一个字节的同时必然收一个字节。用DMA做SPI传输时,发送和接收两个方向的DMA通道要同时配置好,否则会出现"只发不收"或"只收不发"导致的数据错位。
以读取SPI Flash或ADC为例,典型流程是:先发命令字节,再发地址,然后读数据。用DMA的话,发送DMA负责把命令和地址推出去,接收DMA负责把返回的数据收进来。两个通道的传输长度要匹配,因为SPI时钟是发送和接收共用的——你发多少字节,就必然收多少字节。
这里有个细节:接收DMA必须在发送开始前就准备好。如果发送DMA已经启动了,接收DMA还没配好,前几个字节的接收数据就丢了。我习惯的写法是先启动接收DMA,再启动发送DMA,确保接收端不会漏掉任何时钟周期。
3.2 高速ADC采集:DMA加定时器触发的组合拳
做数据采集的时候,最常见的需求是"以固定频率采样,采一批数据后统一处理"。这个场景的黄金组合是:定时器触发ADC转换,ADC转换完成触发DMA搬运,DMA搬完一批产生中断。
定时器负责精确的采样间隔,比如1kHz采样就是每1毫秒触发一次。ADC在定时器触发下启动转换,转换结果放进数据寄存器。DMA把这个结果搬到内存数组里。整个过程CPU完全不参与,只在DMA搬完一批(比如1024个点)后才被唤醒做数据处理。
这个架构的关键参数是采样率和缓冲区大小的匹配。如果采样率是1kHz,缓冲区1024点,那么每1.024秒CPU被唤醒一次。这个间隔要跟你的数据处理耗时匹配——如果处理一次要2秒,那数据就会堆积,必须加大缓冲区或者降低采样率。
安富莱的AD7606模块就是这类应用的典型,8通道同步采样,靠DMA把数据搬进内存。配置的时候要注意AD7606的转换信号和STM32的定时器触发要严格对齐,否则采样点会漂移。
3.3 DMA传输宽度不匹配会怎样
一个很容易踩的坑:源和目标的数据宽度配置不一致。比如ADC是12位结果,存在16位寄存器里,你配DMA传输宽度为8位,那每个采样值就会被拆成两个字节搬运,数据全乱。
正确的做法是让DMA传输宽度和外设数据寄存器宽度一致。ADC的DR寄存器是16位(实际有效12位),DMA传输宽度就配16位,内存数组也用uint16_t。这样一次搬一个完整的采样值,不会出现字节拆分。
如果确实需要宽度转换(比如32位内存到8位外设),DMA控制器一般支持,但要注意字节序问题。小端模式下,32位数据的低字节先被搬走,这个顺序要想清楚。
4. DMA测速:怎么测才准,测出来怎么看
4.1 测速的本质是测什么
"DMA测速"这个词其实有点模糊,得先明确测的是什么。常见的有三种:
- 传输速率:单位时间内搬运了多少字节,反映DMA的吞吐能力。
- 传输延迟:从触发到第一个字节到达的时间,反映响应速度。
- CPU占用率:DMA传输期间CPU还能干多少活,反映DMA的"减负"效果。
大部分人说测速,指的是第一种。但只看吞吐量是不够的,因为DMA的吞吐量受总线频率、仲裁优先级、传输宽度影响很大,单纯一个数字说明不了问题。
4.2 一个可靠的测速方法
测速最忌讳的就是"用软件计时去测软件触发的传输",因为软件开销会污染结果。可靠的做法是用硬件定时器来计时。
思路是:配置一个高精度定时器(比如1MHz计数频率),在DMA启动前读一次计数值,DMA传输完成中断里再读一次,两次之差就是传输耗时。用传输字节数除以耗时,就是速率。
volatile uint32_t dma_start_tick = 0; volatile uint32_t dma_end_tick = 0; volatile uint32_t dma_bytes = 0; void start_dma_transfer(void) { dma_start_tick = TIM2->CNT; // 读定时器计数 HAL_DMA_Start_IT(&hdma, src, dst, dma_bytes); } void DMA_TransferCompleteCallback(void) { dma_end_tick = TIM2->CNT; uint32_t elapsed = dma_end_tick - dma_start_tick; // 假设定时器1MHz,elapsed单位是微秒 float speed_mbps = (dma_bytes * 8.0f) / elapsed; // Mbps }要注意定时器计数会溢出,如果传输时间超过定时器周期,得处理溢出。简单办法是把定时器周期设得足够长,或者用溢出中断累加。
4.3 测速结果为什么忽高忽低
很多人测速会发现结果不稳定,同一段代码跑几次,速率能差出一倍。原因通常有这几个:
总线竞争:如果测速期间CPU还在访问内存或外设,总线仲裁会让DMA的传输断断续续。测速时最好让CPU进入低功耗或空转,减少干扰。
缓存影响:如果目标内存区域在Cache里,DMA写入后Cache和内存不一致,后续读取会触发Cache维护操作,影响观感。测速时要注意Cache一致性配置。
中断干扰:测速期间如果有其他高优先级中断频繁触发,会打断DMA的连续性。测速时尽量关掉无关中断。
传输宽度:8位传输和32位传输的吞吐量差很多,因为每次总线事务的开销是固定的。同样搬1024字节,32位宽度只要256次事务,8位要1024次。测速时要明确宽度配置。
我一般测速会跑多次取平均,同时记录最大值和最小值,看波动范围。如果波动超过20%,说明有干扰因素没排除,测出来的数不可信。
5. DMA调试中那些让人抓狂的故障
5.1 传输卡死:DMA启动了但永远不完成
这是最经典的DMA故障。现象是:代码执行到启动DMA后,等完成标志一直等不到,程序卡死。
排查思路按这个顺序走:
第一步,确认外设的DMA请求使能了没。很多人配好了DMA通道,却忘了在外设侧打开DMA请求位。比如串口要置位USART_CR3的DMAR和DMAT位,SPI要置位SPI_CR2的RXDMAEN和TXDMAEN位。DMA通道配得再对,外设不发请求,DMA就干等着。
第二步,确认DMA通道和外设的映射关系对不对。STM32的DMA请求和通道是固定映射的,串口1的接收只能用某个特定通道。如果映射错了,DMA收不到请求。这个要查数据手册的DMA请求映射表,别凭记忆。
第三步,确认传输计数器不是0。如果配置时长度参数传了0,DMA会认为无事可做,可能直接完成也可能卡住,行为取决于芯片。这个低级错误我见过不止一次。
第四步,确认时钟使能了。DMA控制器自己的时钟、外设时钟、相关GPIO时钟,缺一个都不行。
5.2 数据错位:收到的数据整体偏移
数据错位通常表现为:缓冲区里第一个字节是上次的残留,或者数据整体往后挪了一位。根因往往是DMA启动时机和实际数据到达时机没对齐。
比如串口接收,如果在配置DMA之前串口就已经开始收数据了,那第一个字节可能在DMA就绪前就到了,被丢弃或覆盖。解决办法是先配好DMA,再使能串口接收,或者干脆在启动DMA前清空接收寄存器。
另一个常见原因是缓冲区没清零。DMA只写它搬过来的字节,缓冲区里没被写到的位置保留着上次的旧数据。如果处理时按固定长度读,就会读到旧数据。习惯做法是每次启动DMA前把缓冲区清零,或者用长度信息精确控制处理范围。
5.3 测速失败代码的典型特征
网上搜"dma测速失败代码",能看到很多类似的求助。这些失败案例有个共同特征:用软件循环去等DMA完成,然后发现耗时远超预期。
问题出在软件循环本身有开销,而且循环期间CPU占用总线,反而拖慢了DMA。正确的测速必须用硬件计时,且测速期间CPU尽量不干扰。
还有一种失败是测速代码里包含了初始化时间。比如把DMA配置、外设使能都算进传输时间里,那测出来的当然慢。测速要只测纯传输段,配置时间单独算。
5.4 循环模式下缓冲区溢出的隐蔽性
循环模式虽然方便,但有个隐蔽的坑:如果CPU处理速度跟不上DMA搬运速度,缓冲区会被覆盖。而且这种覆盖是静默的,不会报错,你只会发现数据偶尔对不上。
判断方法是:在处理数据时记录处理耗时,和DMA搬完一圈的时间对比。如果处理耗时接近或超过一圈时间,就有溢出风险。解决办法要么加快处理,要么加大缓冲区,要么降低DMA触发频率。
我一般会在循环模式里加一个"覆盖检测":记录上次处理到的位置,如果这次发现DMA写指针已经越过上次位置一圈了,就说明溢出了,打个标志出来。这个检测在调试阶段非常有用。
6. 把DMA用稳的几条实战心得
6.1 缓冲区对齐和大小选择
DMA对缓冲区的对齐有要求,尤其是传输宽度大于8位时。比如16位传输,缓冲区地址最好是2字节对齐;32位传输要4字节对齐。不对齐可能导致传输效率下降甚至硬件异常。
缓冲区大小也有讲究。太小了频繁中断,CPU被中断拖累;太大了占用内存,且首次数据延迟增加。我的经验值是:中断频率控制在100Hz到1kHz之间比较舒服。比如采样率10kHz,缓冲区就设10到100个点。
6.2 中断优先级的安排
DMA传输完成中断的优先级要合理设置。设太高会打断关键实时任务,设太低又可能导致数据处理不及时。一般原则是:DMA完成中断的优先级略低于最关键的实时中断,但高于普通任务。
如果是循环模式配合空闲中断,空闲中断的优先级要足够高,否则可能被其他中断延迟,导致帧边界判断出错。
6.3 用DMA前先想清楚数据流
最后一条也是最重要的:动手配DMA之前,先在纸上画清楚数据从哪来到哪去。源地址是什么、目标地址是什么、各自要不要增量、传输宽度多少、传输长度多少、什么时候触发、完成后怎么通知。这几个问题答清楚了,配置代码就是水到渠成的事。
我见过太多人一上来就抄例程,结果换个外设就懵了。DMA的配置逻辑是通用的,理解了数据流,任何外设的DMA配置都能推出来。
关于DMA的调试,我个人体会最深的一点是:大部分DMA问题不在DMA本身,而在外设的DMA请求配置和时钟使能上。DMA控制器其实很"老实",你让它搬它就搬,它不搬一定是没收到请求或者没使能。所以排查时先怀疑外设侧,再怀疑DMA侧,能省很多时间。另外,示波器或者逻辑分析仪在调DMA时特别好用,直接看总线上的波形,比盯着代码猜快得多。