1. DMA不是"高级选项",而是嵌入式驱动工程师的分水岭
刚入行那会儿,我对DMA的理解停留在"搬运数据不用CPU管"这句话上。直到有一次做一个串口屏项目,波特率拉到921600,用中断方式接收,每帧数据大概200字节,结果CPU占用率直接飙到60%以上,主循环里跑的其他逻辑开始出现肉眼可见的卡顿。当时我的第一反应是"主频不够",差点去换更高主频的芯片。后来一位前辈看了一眼代码说:你这串口为什么不用DMA?换成DMA之后,同样的波特率,CPU占用率掉到了个位数,主循环丝滑得像是换了一颗芯片。
这件事让我彻底明白了一个道理:在嵌入式驱动开发里,DMA不是"锦上添花的高级功能",而是区分"能跑"和"跑得好"的关键分水岭。你去看任何一家做嵌入式产品的公司,招聘要求里几乎都会写"熟悉DMA、中断、内存管理等底层机制",面试八股文里DMA的出现频率也极高。原因很简单——只要你的系统涉及串口、SPI、I2S、ADC、SDIO这些外设,数据量一上来,不用DMA就是在浪费CPU的生命。
这一期我们就专门把DMA这件事讲透。从它到底解决了什么问题,到STM32、ESP32这些常见平台上DMA的配置逻辑,再到串口DMA接收、SPI DMA、DMA测速这些实战场景,最后聊聊那些只有踩过坑才知道的细节。不管你是刚接触嵌入式的新手,还是已经写过一些驱动但总觉得DMA"用得不踏实"的开发者,这篇内容应该都能给你一些能直接落地的东西。
2. 先把DMA的账算清楚:它到底省了什么
2.1 从一次串口接收看CPU到底在忙什么
很多人对DMA的理解是模糊的,觉得"用了DMA就快了"。但快在哪里?我们得把账算清楚。
假设你用STM32F103,主频72MHz,串口波特率115200,接收100字节的一帧数据。如果不用DMA,用中断方式接收,每收到一个字节触发一次中断。一次中断的完整开销包括:保存现场、跳转到中断向量、执行中断服务函数、恢复现场,保守估计在几十个时钟周期到上百个时钟周期之间。100字节就是100次中断,光中断开销就吃掉了大量CPU时间。如果波特率提到921600,字节到达的间隔急剧缩短,中断频率高到CPU几乎一直在处理中断,主循环根本抢不到时间片。
而DMA的工作方式是:你告诉DMA控制器"从串口数据寄存器搬数据到这块内存,搬100个字节,搬完了通知我"。整个搬运过程DMA控制器自己完成,CPU只需要在最后处理一次"传输完成"中断。100次中断变成1次,CPU被解放出来去做真正需要它的事情。
这里有个关键点很多人忽略:DMA省的不只是中断次数,更是"上下文切换"的代价。每次中断都意味着CPU要打断当前正在执行的逻辑,保存寄存器状态,处理完再恢复。这种切换本身不产生任何业务价值,纯粹是开销。DMA把这个开销从"每字节一次"降到了"每帧一次"。
2.2 DMA控制器的内部结构:理解它才能用好它
要用好DMA,得知道它内部大概是怎么组织的。以STM32的DMA控制器为例,它有几个核心概念:
- 通道(Channel)/流(Stream):每个DMA控制器有多个通道,每个通道对应特定的外设请求。比如STM32F103的DMA1有7个通道,串口1的TX和RX分别固定映射到某个通道上。这个映射关系是硬件定死的,不能随便改,选错了通道DMA根本不工作。
- 源地址和目的地址:DMA搬运数据的起点和终点,可以是外设寄存器地址,也可以是内存地址。
- 传输方向:外设到内存、内存到外设、内存到内存,三种模式。
- 传输数量:一次搬多少个数据单元。
- 数据宽度:源和目的各自的数据宽度,可以是字节、半字、字。这里有个坑,源和目的宽度不一致时,DMA的行为需要特别注意。
- 地址增量模式:搬运一个数据后,地址是否自动递增。比如从内存搬到外设,内存地址要递增,外设地址通常固定不变。
理解这些概念之后,你再看任何一款芯片的DMA配置,本质上都是在填这几个参数。STM32用寄存器或者HAL库来配,ESP32用它自己的GDMA描述符链表来配,Linux内核里用dmaengine框架来配,形式不同,底层逻辑是相通的。
2.3 什么时候该用DMA,什么时候不该用
不是所有场景都适合上DMA。我见过有人给一个每秒才发几个字节的传感器数据也配了DMA,结果代码复杂度上去了,收益几乎为零。
判断标准其实很简单:看数据量和频率。如果某个外设的数据传输是"高频、批量、CPU不关心中间过程"的,那就该用DMA。典型场景包括:
| 场景 | 数据特点 | 是否适合DMA |
|---|---|---|
| 串口高速接收 | 连续、批量、不定长 | 非常适合 |
| SPI驱动LCD刷屏 | 大批量、连续 | 非常适合 |
| ADC连续采样 | 周期性、批量 | 非常适合 |
| I2S音频流 | 实时、连续 | 必须用 |
| 低频按键扫描 | 偶发、单字节 | 不适合 |
| 配置寄存器写入 | 一次性、少量 | 不适合 |
一句话总结:DMA是为"数据流"设计的,不是为"控制流"设计的。你让DMA去搬几个字节的配置数据,纯属杀鸡用牛刀,还增加了调试难度。
3. 串口DMA接收:从"能收"到"收得稳"的完整链路
3.1 为什么串口DMA接收是面试和实战的双重高频题
串口DMA接收几乎是嵌入式面试的必考题,也是实际项目里用得最多的DMA场景。原因在于它同时考察了几个能力:对DMA机制的理解、对串口外设的掌握、对"不定长数据"这个经典问题的处理思路。
串口接收的难点在于:你事先不知道对方会发多少字节。可能是固定长度的协议帧,也可能是变长的AT指令,还可能是连续不断的数据流。用DMA接收,你得解决"什么时候算一帧收完"这个问题。
常见的解决方案有三种:
- 固定长度DMA + 传输完成中断:适合协议帧长度固定的场景。配置DMA接收N个字节,收满触发中断。简单可靠,但灵活性差。
- DMA + 空闲中断(IDLE):这是最经典的方案。DMA在后台持续接收,串口总线空闲时触发IDLE中断,在中断里读取DMA当前剩余计数,算出实际收到的字节数。适合不定长数据。
- DMA + 定时器超时:用定时器做超时判断,超过一定时间没有新数据就认为一帧结束。适合数据流有间隔的场景。
实际项目里,方案2(DMA + IDLE)是使用最广的,因为它不需要额外的定时器资源,而且IDLE中断的触发时机正好对应"一帧数据结束"。
3.2 手把手配置串口DMA接收(以STM32 HAL库为例)
下面这段配置逻辑,我以STM32F103 + HAL库为例走一遍。其他平台思路一样,只是API不同。
第一步,初始化DMA通道。串口1的RX在STM32F103上固定映射到DMA1的Channel5:
// DMA句柄定义 DMA_HandleTypeDef hdma_usart1_rx; void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_usart1_rx.Instance = DMA1_Channel5; hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不递增 hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode = DMA_NORMAL; // 普通模式,非循环 hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart1_rx); // 关联DMA到串口 __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); // 配置中断优先级 HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn); }第二步,启动DMA接收并开启IDLE中断:
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; void UART_StartDMAReceive(void) { // 启动DMA接收 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); // 开启串口空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }第三步,在中断服务函数里处理IDLE事件:
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(&hdma_usart1_rx); uint16_t received = RX_BUFFER_SIZE - remaining; // 处理收到的数据 ProcessReceivedData(rx_buffer, received); // 重新启动DMA接收 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(&huart1); }这段代码看起来不复杂,但里面有几个关键点,每一个都对应一个常见的坑。
3.3 串口DMA接收里最容易翻车的三个细节
第一个坑:IDLE中断标志的清除顺序。必须先读SR寄存器再读DR寄存器才能清除IDLE标志,HAL库的__HAL_UART_CLEAR_IDLEFLAG宏帮你做了这件事。如果你手动操作寄存器,顺序搞反了,中断会反复触发,程序直接卡死。
第二个坑:DMA剩余计数的读取时机。__HAL_DMA_GET_COUNTER必须在停止DMA之后、重新启动之前读取。如果你在DMA还在运行的时候读,读到的值可能正在变化,算出来的字节数不准。我见过有人在这里读出了负数(因为无符号数相减溢出),排查了半天。
第三个坑:缓冲区溢出。如果对方发来的数据超过了你设置的RX_BUFFER_SIZE,DMA会从头覆盖,你之前的数据就丢了。解决方案有两种:一是把缓冲区设得足够大,二是用DMA的循环模式配合双缓冲。实际项目里,我一般会把缓冲区设成最大帧长的2倍以上,留足余量。
提示:如果你的串口数据是持续不断的流(比如GPS模块),IDLE中断可能永远不触发,因为总线一直不空闲。这种情况要用定时器超时方案,或者用DMA的循环模式配合半传输中断。
4. SPI DMA与ADC DMA:不同外设的DMA配置差异
4.1 SPI DMA刷屏:为什么它能决定LCD的流畅度
SPI驱动LCD是DMA的经典应用场景。一块320x240的TFT屏,刷一屏需要传输320x240x2=153600字节。如果用SPI逐字节发送,即使SPI时钟拉到18MHz,CPU也要全程参与,刷屏期间什么都干不了。用DMA之后,CPU只需要配置好DMA,然后就可以去处理其他逻辑,SPI硬件自动把数据搬完。
SPI DMA的配置和串口有几个关键差异:
- SPI是全双工:发送和接收同时进行。如果你只发不收,可以只配置TX方向的DMA,RX方向不用管。但有些SPI外设要求RX也要配置,否则时钟不产生,这个坑在STM32的某些型号上存在。
- SPI的DMA请求映射:SPI1的TX和RX分别映射到DMA1的不同通道,具体查参考手册的DMA请求映射表。
- 数据宽度:SPI可以按8位或16位传输,DMA的数据宽度要和SPI的数据宽度匹配。如果你SPI配的是16位,DMA配的是8位,数据会错位。
刷屏场景下,我通常会把DMA配置成"内存到外设"方向,内存地址递增,外设地址固定,传输数量设为要刷新的像素字节数。刷完之后在DMA传输完成中断里做下一步操作。
4.2 ADC DMA:连续采样的正确打开方式
ADC连续采样是另一个DMA的刚需场景。比如你要做一个音频采集或者振动监测,ADC需要以固定频率连续采样,采样结果要存到缓冲区里。如果用中断方式,每次转换完成触发一次中断,采样率一高CPU就扛不住了。
ADC DMA的配置要点:
- ADC要配置成连续转换模式:这样ADC会不停地采样,每次转换完成自动触发DMA请求。
- DMA配置成循环模式:缓冲区满了之后自动从头开始,形成一个环形缓冲区。这样你不需要反复重启DMA。
- 配合半传输中断和传输完成中断:半传输中断在缓冲区前半满时触发,传输完成中断在缓冲区全满时触发。你可以在中断里处理数据,实现"一边采集一边处理"。
这里有个实际经验:ADC的DMA缓冲区大小要和你数据处理的速度匹配。如果缓冲区太小,中断太频繁,CPU又忙不过来;如果缓冲区太大,数据延迟就大。我一般会根据采样率和处理逻辑的耗时来算,保证两次中断之间有足够的时间处理完上一批数据。
4.3 不同平台DMA配置的思维差异
STM32的DMA配置是"寄存器填参数"式的,你直接告诉DMA控制器源地址、目的地址、传输数量这些信息。而ESP32的GDMA用的是"描述符链表"的方式,你预先构建一个描述符链表,每个描述符描述一次传输,DMA控制器按链表顺序执行。Linux内核的dmaengine框架又是另一种抽象,它把DMA控制器抽象成channel,用dmaengine_prep_slave_sg之类的API来提交传输请求。
虽然形式不同,但核心逻辑是一样的:描述传输的源、目的、长度、方向,然后启动传输,最后处理完成事件。你只要理解了STM32的DMA,再看其他平台的DMA,只是换了一套API而已。
5. DMA测速:怎么知道你的DMA到底跑多快
5.1 为什么要做DMA测速
DMA测速这件事,很多人觉得没必要——"DMA是硬件搬运,速度肯定是够的"。但实际项目中,DMA的实际吞吐量受很多因素影响:总线仲裁、内存访问冲突、DMA优先级、外设时钟频率等等。我遇到过SPI DMA刷屏速度远低于预期的情况,最后发现是DMA优先级设低了,被其他DMA通道抢占了总线。
做DMA测速的目的,一是验证DMA配置是否正确,二是评估系统的实际吞吐能力,三是为优化提供数据支撑。
5.2 一个简单可靠的DMA测速方法
测速的基本思路很简单:记录DMA传输开始和结束的时间戳,用传输字节数除以时间。
以STM32为例,可以用DWT(Data Watchpoint and Trace)单元来做高精度计时:
// 启用DWT计数器 void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } // 获取当前周期计数 uint32_t DWT_GetCycles(void) { return DWT->CYCCNT; } // 测速示例:SPI DMA传输 void SPI_DMA_SpeedTest(void) { uint32_t start, end, cycles; uint32_t data_size = 4096; DWT_Init(); start = DWT_GetCycles(); HAL_SPI_Transmit_DMA(&hspi1, tx_buffer, data_size); // 等待传输完成 while (spi_dma_done == 0); end = DWT_GetCycles(); cycles = end - start; float time_us = (float)cycles / (SystemCoreClock / 1000000.0f); float speed_MBps = (float)data_size / time_us; printf("传输 %lu 字节,耗时 %.2f us,速度 %.2f MB/s\r\n", data_size, time_us, speed_MBps); }这个方法的精度取决于DWT计数器的时钟,通常是CPU主频,精度足够。注意DWT计数器是32位的,在72MHz下大约60秒溢出一次,测速时间短的话不用担心溢出问题。
5.3 测速结果怎么看:几个参考值
不同外设的DMA速度差异很大,下面是我在实际项目中测到的一些参考值(STM32F103,72MHz):
| 外设 | 配置 | 实测速度 | 理论速度 |
|---|---|---|---|
| SPI1 TX | 18MHz时钟 | 约2.2 MB/s | 2.25 MB/s |
| USART1 TX | 921600波特率 | 约90 KB/s | 92 KB/s |
| ADC1 | 连续采样 | 约1 MSPS | 1 MSPS |
| 内存到内存 | 32位宽度 | 约30 MB/s | - |
可以看到,SPI和串口的DMA速度基本能达到理论值,说明DMA本身没有成为瓶颈。如果实测值远低于理论值,就要检查DMA优先级、总线仲裁、内存对齐这些因素。
注意:内存到内存的DMA速度受总线带宽限制,而且会和其他DMA通道竞争。如果你同时跑多个DMA通道,每个通道的实际速度都会下降。
6. 那些只有踩过才知道的DMA坑
6.1 DMA传输完成中断不触发:先查这三个地方
DMA配置好了,数据也搬完了,但传输完成中断死活不触发。这种情况我遇到过好几次,排查下来无非三个原因:
第一,中断使能没开。DMA的传输完成中断需要在DMA控制器和NVIC两个层面都使能。只开了NVIC没开DMA控制器的中断使能位,中断不会触发。HAL库的HAL_DMA_Start_IT会帮你开DMA层面的中断,但如果你用的是HAL_DMA_Start(不带IT),就不会开中断。
第二,中断标志没清。DMA的中断标志需要在中断服务函数里手动清除。如果你没清,第一次中断触发后标志一直是置位的,后续中断就被屏蔽了。HAL库的HAL_DMA_IRQHandler会帮你清标志,但如果你自己写中断服务函数,就要手动清。
第三,DMA通道选错了。每个外设的DMA请求固定映射到特定通道,选错了通道DMA根本不工作,自然也不会有中断。这个错误在初次配置时很常见,查参考手册的DMA请求映射表就能确认。
6.2 数据错位:源和目的数据宽度不一致的后果
DMA的源和目的数据宽度可以独立配置,这给了灵活性,但也埋了坑。如果源是8位、目的是16位,DMA搬运时会怎么处理?不同芯片的行为可能不同。有的芯片会把8位数据扩展到16位,有的芯片会按字节搬运但地址递增方式不同,导致数据错位。
我的经验是:除非你明确知道自己在做什么,否则源和目的的数据宽度保持一致。如果确实需要转换,在软件层面做,不要依赖DMA的自动转换。
6.3 DMA和Cache的一致性:高性能芯片上的隐形杀手
这个问题在STM32F103这种没有Cache的芯片上不存在,但如果你用的是STM32F7、H7或者ESP32这类带Cache的芯片,就必须面对。
问题本质是这样的:CPU访问内存时,数据可能被缓存在Cache里。DMA直接访问物理内存,不经过Cache。如果CPU写了一段数据到内存(还在Cache里没写回),然后启动DMA把这个内存搬到外设,DMA读到的可能是旧数据。反过来,DMA把外设数据搬到内存,CPU读的时候可能读到Cache里的旧数据。
解决方案是Cache维护操作:在DMA传输前,对源内存做Clean操作(把Cache写回内存);在DMA传输后,对目的内存做Invalidate操作(让Cache失效,强制从内存重新读)。STM32H7上可以用SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr这两个函数。
这个坑的隐蔽性在于:它时灵时不灵。Cache命中的时候数据是对的,Cache不命中的时候数据就错了。调试的时候很容易被误导。
6.4 DMA中断优先级配置的讲究
DMA中断优先级的配置有个原则:和它配合的外设中断优先级要匹配。比如串口DMA接收,串口的IDLE中断和DMA的传输完成中断,如果优先级设置不当,可能出现IDLE中断处理到一半被DMA中断打断,导致状态不一致。
我一般的做法是:把同一个数据流相关的DMA中断和外设中断设成相同的优先级,或者让DMA中断优先级略低于外设中断。这样保证外设中断先处理,DMA中断后处理,逻辑上更顺。
7. 从会用到用好:DMA驱动开发的进阶思路
7.1 双缓冲:让DMA传输永不间断
单缓冲的DMA有个天然的空档期:DMA传输完成之后,你需要处理数据,然后重新启动DMA,这中间外设的数据没人管,可能丢失。双缓冲解决了这个问题:准备两块缓冲区,DMA在写缓冲区A的时候,CPU处理缓冲区B;DMA写满A自动切换到B,CPU处理A。如此循环,DMA永远在传输,CPU永远在处理,两者并行。
STM32的DMA支持双缓冲模式(Double Buffer Mode),配置好两个内存地址,DMA会自动交替。用HAL库的话,可以用HAL_DMAEx_MultiBufferStart来启动。这个模式在音频处理、高速数据采集场景下非常有用。
7.2 DMA和RTOS的配合:别在中断里做太多事
在RTOS环境下用DMA,一个常见的模式是:DMA传输完成中断里释放一个信号量或者发送一个消息到队列,具体的处理逻辑放在任务里做。中断里只做最少的操作,避免阻塞其他中断。
这里有个细节:RTOS的API在中断里的使用有限制。比如FreeRTOS的xSemaphoreGive在中断里要用xSemaphoreGiveFromISR,而且要注意pxHigherPriorityTaskWoken参数的处理。用错了会导致任务调度异常。
7.3 什么时候该考虑用DMA,什么时候该考虑换方案
最后说一个判断逻辑:如果你发现DMA配置越来越复杂,中断处理逻辑越来越绕,可能说明DMA不是这个场景的最优解。比如一个低速的传感器数据采集,用定时器中断轮询可能比DMA更简单可靠。DMA的价值在于"大批量、高频率、CPU不关心中间过程"的数据传输,脱离这个前提去用DMA,就是给自己找麻烦。
我在实际项目中的体会是:DMA用得好不好,不看你配置了多少个通道,而看你是否在正确的场景用了正确的方案。一个只有两三个DMA通道但都用得恰到好处的驱动,比一个配了七八个DMA通道但到处是隐患的驱动,质量高得多。
嵌入式驱动开发这件事,说到底是对硬件行为的理解和掌控。DMA只是其中一个工具,理解它的原理、知道它的边界、踩过它的坑,你才能真正把它变成自己手里的利器。下一期我们聊聊中断管理,那个话题和DMA配合起来,能讲的东西更多。