1. 为什么DMA是嵌入式驱动开发的必修课
搞嵌入式驱动开发,绕不开DMA这个坎。你可以把CPU想象成一个效率极高但时薪很贵的资深工程师,而DMA就是那个专门帮你搬砖的实习生——数据搬运这种体力活交给他,CPU腾出手来干真正需要脑子的活。我在早期做串口驱动的时候,没有用DMA,波特率一开到115200以上,CPU有将近40%的时间耗在串口中断里,稍微加点其他任务系统就开始丢包。后来切到DMA方案,同样的硬件,CPU占用直接降到个位数,整个系统的实时性上了一个台阶。
DMA全称Direct Memory Access,直接内存访问。它的核心价值就一句话:在外设和内存之间开辟一条不经过CPU的数据通道。不管是串口收发、SPI批量传输、ADC连续采样,还是LCD刷屏、SD卡读写,只要涉及大批量数据搬运,DMA几乎都是标配。我见过太多项目,前期功能验证阶段用中断方式跑得好好的,一到量产环境数据量上来就各种丢包、卡顿,回头再改DMA,代码架构要大动,非常痛苦。所以我的建议是:驱动设计初期就把DMA纳入方案考量,哪怕第一版不用,接口也要预留。
这篇文章面向的是有一定MCU或嵌入式Linux驱动基础的开发者,不管你是刚接触STM32的初学者,还是已经在做Linux内核驱动但想系统梳理DMA的老手,都能从中找到可以直接参考的内容。我会从方案选型、核心原理、实操配置、问题排查几个维度展开,把DMA驱动开发这件事讲透。
2. DMA驱动的整体设计思路与方案选型
2.1 什么时候该用DMA,什么时候不该用
不是所有场景都适合上DMA。我总结了一个简单的判断标准:
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 单次传输小于16字节,频率低 | 中断/轮询 | DMA配置开销大于收益 |
| 单次传输大于32字节,或连续传输 | DMA | CPU占用显著降低 |
| 高频率小包(如每1ms一次) | DMA+空闲中断 | 减少中断次数 |
| 需要精确时序控制 | DMA | 硬件自动搬运,时序稳定 |
| 低功耗场景 | DMA | CPU可休眠,DMA独立工作 |
我踩过的一个坑:在一个低功耗项目里,为了省事给一个每次只发4字节的传感器数据也配了DMA,结果每次传输的配置时间比直接写寄存器还长,功耗反而增加了。DMA的优势在于批量,不在于单次。
2.2 MCU裸机DMA vs Linux DMAengine
这是两个完全不同的技术栈,选型逻辑也不一样。
MCU裸机DMA(以STM32为例):直接操作DMA控制器寄存器或使用HAL/LL库,配置通道、源地址、目的地址、传输长度,启动传输,等中断。特点是直接、可控、代码量小。适合资源受限、实时性要求高的场景。
Linux DMAengine框架:内核提供的一套通用DMA抽象层,驱动通过dmaengineAPI申请通道、准备描述符、提交传输。特点是抽象程度高、跨平台好,但学习曲线陡,调试手段相对少。适合嵌入式Linux项目,尤其是需要跨SoC移植的场景。
我的经验是:如果你做的是RTOS或裸机项目,直接啃MCU参考手册的DMA章节,把寄存器搞明白,比什么都强。如果你做的是Linux驱动,老老实实学dmaengine框架,别想着绕过它直接操作寄存器,后期维护会要命。
2.3 通道分配与优先级设计
DMA控制器通常有多个通道,每个通道对应特定的外设请求线。以STM32F103C8T6为例,DMA1有7个通道,每个通道的请求映射是固定的,不能随意分配。比如USART1_TX固定映射到DMA1_Channel4,USART1_RX固定映射到DMA1_Channel5。
这里有个容易忽略的点:通道优先级。当多个通道同时请求时,硬件按优先级仲裁。我的建议是:
- 实时性要求高的(如ADC采样、电机控制)设最高优先级
- 大批量但可容忍延迟的(如SD卡读写、LCD刷新)设中低优先级
- 同优先级的通道按通道号轮询
注意:优先级只影响仲裁顺序,不影响单次传输的原子性。高优先级通道不会打断正在进行的低优先级传输,只是在下一次仲裁时优先。
3. DMA核心原理与关键细节拆解
3.1 数据传输的三要素:源、目的、长度
任何一次DMA传输,本质上就是三个参数的组合:从哪里搬、搬到哪里、搬多少。听起来简单,但每个参数都有坑。
源地址和目的地址:可以是内存地址,也可以是外设数据寄存器地址。关键点在于地址是否递增。比如串口发送,源地址是内存缓冲区,每次传输后地址要递增(指向下一个字节);目的地址是串口数据寄存器,固定不变。这个递增/固定的配置如果搞反了,要么数据全发的是第一个字节,要么直接跑飞。
传输长度:以数据单元为单位,不是字节。如果数据宽度配置为16位,传输长度10,实际传输20字节。这个在ADC采样场景特别容易搞错,ADC是12位精度,通常用16位宽度存储,算缓冲区大小时要按16位算。
3.2 传输模式:单次 vs 循环 vs 双缓冲
单次模式(Normal):传输完成后停止,需要重新配置才能再次传输。适合一次性大批量传输,比如SPI刷一整屏数据。
循环模式(Circular):传输完成后自动重载,从头开始。适合连续周期性传输,比如ADC连续采样、串口接收。我做的环境监控项目里,ADC用循环模式DMA,CPU完全不用管,定时去缓冲区取最新值就行。
双缓冲模式(Double Buffer):有两个缓冲区交替使用,一个在传输时另一个可以被CPU处理。适合高速连续数据流,比如音频采集。STM32的DMA支持双缓冲,但配置稍复杂,需要设置两个内存地址和两个长度。
3.3 中断类型与触发时机
DMA的中断类型主要有三种:
- 传输完成中断(TC):整个传输完成后触发。最常用。
- 半传输中断(HT):传输到一半时触发。双缓冲和流式处理时有用。
- 传输错误中断(TE):传输过程中出错触发。必须处理,否则出错后DMA会静默停止。
实操心得:TE中断一定要开,并且要在中断里打印错误状态寄存器。我遇到过DMA传输偶尔丢数据的问题,查了两天才发现是传输错误中断没开,DMA出错后自动停止了,但主循环还在等数据,整个系统就卡死了。
3.4 数据宽度与对齐问题
DMA支持8位、16位、32位数据宽度。源和目的的数据宽度可以不同,但必须保证对齐。比如源是8位数组,目的是16位外设寄存器,DMA会自动做打包,但要求源地址按8位对齐,目的地址按16位对齐。
对齐问题在结构体传输时特别容易出问题。比如你定义了一个结构体,里面有char、int、short混合,直接拿结构体地址做DMA源,很可能因为对齐问题导致数据错乱。我的做法是:DMA传输的缓冲区一律用__attribute__((aligned(4)))强制4字节对齐,数据宽度统一用32位或8位,避免混合宽度。
4. 实操过程与核心环节实现
4.1 STM32串口DMA收发完整配置
以STM32F103C8T6的USART1为例,目标是实现不定长数据的DMA接收和发送。
第一步:时钟和GPIO配置
// 使能时钟 RCC->APB2ENR |= RCC_APB2ENR_USART1EN | RCC_APB2ENR_IOPAEN; RCC->AHBENR |= RCC_AHBENR_DMA1EN; // PA9 TX 复用推挽输出 GPIOA->CRH &= ~(0xF << 4); GPIOA->CRH |= (0xB << 4); // PA10 RX 浮空输入 GPIOA->CRH &= ~(0xF << 8); GPIOA->CRH |= (0x4 << 8);第二步:USART配置
USART1->BRR = 72000000 / 115200; // 72MHz主频 USART1->CR1 |= USART_CR1_UE | USART_CR1_TE | USART_CR1_RE; USART1->CR1 |= USART_CR1_IDLEIE; // 开启空闲中断 USART1->CR3 |= USART_CR3_DMAR | USART_CR3_DMAT; // 使能DMA收发第三步:DMA接收配置
// 先关闭通道再配置 DMA1_Channel5->CCR &= ~DMA_CCR_EN; DMA1_Channel5->CPAR = (uint32_t)&USART1->DR; DMA1_Channel5->CMAR = (uint32_t)rx_buffer; DMA1_Channel5->CNDTR = RX_BUFFER_SIZE; DMA1_Channel5->CCR = DMA_CCR_MINC | DMA_CCR_TCIE | DMA_CCR_TEIE; DMA1_Channel5->CCR |= DMA_CCR_EN;第四步:空闲中断处理
void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_IDLE) { USART1->SR; USART1->DR; // 清标志 uint16_t len = RX_BUFFER_SIZE - DMA1_Channel5->CNDTR; // 处理接收到的len字节数据 process_rx_data(rx_buffer, len); // 重新启动DMA接收 DMA1_Channel5->CCR &= ~DMA_CCR_EN; DMA1_Channel5->CNDTR = RX_BUFFER_SIZE; DMA1_Channel5->CCR |= DMA_CCR_EN; } }这套方案的核心思路是:DMA负责搬数据,空闲中断负责判断一帧结束。相比每收到一个字节就中断一次,CPU占用降低了一个数量级。
4.2 SPI DMA刷屏实战
SPI刷LCD屏是DMA的经典应用。以ST7789驱动的240x320屏幕为例,一帧数据153600字节,如果用SPI中断发送,CPU基本干不了别的。
// SPI1 TX DMA配置 DMA1_Channel3->CPAR = (uint32_t)&SPI1->DR; DMA1_Channel3->CMAR = (uint32_t)lcd_buffer; DMA1_Channel3->CNDTR = LCD_BUFFER_SIZE; DMA1_Channel3->CCR = DMA_CCR_MINC | DMA_CCR_DIR | DMA_CCR_TCIE; DMA1_Channel3->CCR |= DMA_CCR_EN; // 启动SPI DMA发送 SPI1->CR2 |= SPI_CR2_TXDMAEN;这里有个关键点:SPI的DMA请求是TXE(发送寄存器空)触发的,DMA每搬一个字节到DR,硬件自动发送。传输完成后TC中断触发,此时最后一个字节可能还在移位寄存器里,需要等BSY标志清零才能拉CS。
踩坑记录:我一开始在TC中断里直接拉高CS,结果最后一个字节总是丢失。后来改成在TC中断里等
SPI1->SR & SPI_SR_BSY清零再拉CS,问题解决。这个细节在参考手册里写得很隐蔽,但实际项目中非常关键。
4.3 ADC连续采样与DMA循环模式
环境监控项目里,我需要同时采集8路ADC通道,每100ms取一次平均值。
// ADC配置为扫描模式,DMA循环 ADC1->CR1 |= ADC_CR1_SCAN; ADC1->CR2 |= ADC_CR2_DMA | ADC_CR2_CONT; ADC1->SQR1 = (8 - 1) << 20; // 8个转换 // 设置通道顺序 ADC1->SQR3 = ADC_CHANNEL_0 | (ADC_CHANNEL_1 << 5) | ...; // DMA循环模式 DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR; DMA1_Channel1->CMAR = (uint32_t)adc_buffer; DMA1_Channel1->CNDTR = 8; DMA1_Channel1->CCR = DMA_CCR_MINC | DMA_CCR_CIRC | DMA_CCR_PSIZE_16 | DMA_CCR_MSIZE_16; DMA1_Channel1->CCR |= DMA_CCR_EN; ADC1->CR2 |= ADC_CR2_ADON | ADC_CR2_SWSTART;循环模式下,DMA自动把8个通道的数据循环写入adc_buffer,CPU随时可以读取最新值。这里数据宽度配置为16位,因为ADC是12位精度,用16位存储刚好。
4.4 Linux DMAengine驱动框架实操
Linux下的DMA驱动走的是另一套逻辑。以SPI DMA为例,核心流程是:
// 1. 申请DMA通道 chan = dma_request_chan(dev, "tx"); // 2. 配置传输参数 slave_config.direction = DMA_MEM_TO_DEV; slave_config.dst_addr = spi_reg_base + SPI_TX_REG; slave_config.dst_addr_width = DMA_SLAVE_BUSWIDTH_1_BYTE; slave_config.dst_maxburst = 16; dmaengine_slave_config(chan, &slave_config); // 3. 准备描述符 desc = dmaengine_prep_slave_sg(chan, sgl, sg_len, DMA_MEM_TO_DEV, DMA_PREP_INTERRUPT); // 4. 设置回调 desc->callback = spi_dma_tx_complete; desc->callback_param = spi; // 5. 提交传输 dmaengine_submit(desc); dma_async_issue_pending(chan);Linux DMAengine的关键在于**描述符(descriptor)**的概念。一次传输可以拆成多个描述符,硬件按链表顺序执行。dmaengine_prep_slave_sg用于外设到内存的散列表传输,dmaengine_prep_dma_cyclic用于循环传输。
经验之谈:Linux DMA调试最有效的手段是打开
CONFIG_DMA_API_DEBUG,内核会检查地址对齐、长度合法性等问题。另外/sys/kernel/debug/dmaengine/下面有每个通道的状态信息,排查通道占用问题非常有用。
5. 常见问题与排查技巧实录
5.1 DMA传输丢数据
这是最常见的问题,原因通常有几种:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 偶尔丢最后一个字节 | TC中断时数据还在移位寄存器 | 等BSY标志清零再处理 |
| 连续传输时丢中间数据 | 传输错误中断未处理 | 开启TE中断,打印错误状态 |
| 高速传输时丢包 | DMA优先级太低被抢占 | 提高通道优先级 |
| 接收不定长数据丢帧 | 空闲中断未及时处理 | 降低中断延迟,或增大缓冲区 |
5.2 DMA传输速度上不去
DMA测速是很多人关心的问题。实测STM32F103在72MHz主频下,SPI DMA传输速度可以跑到SPI时钟的满速(18MHz),但有几个限制因素:
- 总线仲裁:DMA和CPU共享总线,CPU频繁访问内存会拖慢DMA
- 外设时钟:SPI时钟不能超过外设允许的最大值
- 内存带宽:如果源数据在Flash里,Flash访问速度可能成为瓶颈
我的测速方法是:用定时器计时,DMA传输固定长度数据,算实际速率。实测SPI1 DMA在18MHz时钟下,传输1MB数据耗时约0.47秒,速率约2.1MB/s,基本达到理论值。
5.3 DMA与Cache一致性问题
这个问题在带Cache的MCU(如STM32H7、i.MX RT系列)上特别突出。DMA直接访问内存,不经过Cache,如果CPU刚写过数据还在Cache里没回写到内存,DMA搬走的就是旧数据。
解决方案有三种:
- 使用非Cache区域:把DMA缓冲区放在MPU配置为非Cache的内存区
- 手动Cache维护:发送前
SCB_CleanDCache_by_Addr,接收后SCB_InvalidateDCache_by_Addr - 使用DMA一致性内存:Linux下用
dma_alloc_coherent申请
血泪教训:我在一个STM32H7项目里,DMA发送的数据总是随机出错,查了三天才发现是Cache问题。发送前加了Clean操作后,问题彻底解决。带Cache的MCU做DMA,Cache维护不是可选项,是必选项。
5.4 常见问题速查表
| 问题 | 排查方向 | 快速解决 |
|---|---|---|
| DMA完全不工作 | 时钟、通道使能、请求映射 | 检查RCC和DMA_CCR_EN |
| 传输长度不对 | CNDTR配置、数据宽度 | 确认单位是数据单元不是字节 |
| 数据错位 | 地址递增配置、对齐 | 检查MINC和PINC,强制对齐 |
| 中断不触发 | 中断使能、NVIC配置 | 检查TCIE和NVIC_EnableIRQ |
| 循环模式不循环 | CIRC位未设置 | 确认DMA_CCR_CIRC |
| Linux下通道申请失败 | 通道被占用或未注册 | 查dmaengine debugfs |
6. 进阶技巧与性能优化
6.1 双缓冲实现零等待数据流
双缓冲的核心思想是:DMA在写缓冲区A的时候,CPU处理缓冲区B;DMA写满A切换到B时,CPU处理A。这样CPU和DMA完全并行,没有等待。
STM32的DMA支持双缓冲模式,配置两个内存地址和两个长度,硬件自动切换。Linux下可以用dmaengine_prep_dma_cyclic配合两个period来实现类似效果。
6.2 DMA与RTOS的协同
在RTOS环境下,DMA传输完成中断里不要做耗时操作,应该释放信号量或发送消息队列,让任务去处理数据。我常用的模式是:
void DMA_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; // 清中断标志 vTaskNotifyGiveFromISR(dma_task_handle, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }任务里等通知,收到后处理数据。这样中断延迟极短,数据处理在任务上下文,可以安全调用RTOS API。
6.3 DMA测速工具与方法
自己写一个简单的DMA测速工具其实不难。核心思路:用定时器记录DMA传输前后的时间戳,传输固定长度数据,计算速率。
uint32_t t1 = TIM2->CNT; // 启动DMA传输 while (!dma_done); uint32_t t2 = TIM2->CNT; float speed = DATA_SIZE / ((t2 - t1) / (float)TIMER_CLK);注意定时器要配置为最高分辨率,避免计时误差。另外测速时要关闭其他中断,排除干扰。
6.4 低功耗场景下的DMA使用
低功耗场景下,DMA的价值更加突出。CPU可以进入Sleep或Stop模式,DMA独立工作,传输完成后通过中断唤醒CPU。这样平均功耗可以降低一个数量级。
配置要点:DMA中断要能唤醒CPU,NVIC和EXTI要正确配置唤醒源,外设时钟在低功耗模式下要保持开启。
7. 从驱动开发到系统架构的思考
做了这么多年嵌入式驱动,我越来越觉得DMA不仅仅是一个外设,它是一种系统设计思维。当你习惯用DMA的视角看问题,你会自然而然地思考:哪些事情必须CPU做,哪些可以交给硬件,数据流怎么设计才能让各个模块并行工作。
嵌入式架构师和普通驱动工程师的区别,很大程度上就体现在这里。普通工程师关注“这个功能怎么实现”,架构师关注“这个数据流怎么设计最合理”。DMA就是数据流设计的核心工具之一。
如果你正在准备嵌入式面试,DMA相关的问题几乎必考。常见的问题包括:DMA的工作原理、传输模式的区别、Cache一致性问题、DMA与中断的配合使用。建议不要死记硬背,而是自己动手写一遍配置代码,把每个寄存器的含义搞清楚,面试时自然对答如流。
最后分享一个我个人的习惯:每次做新项目,我都会先画一张数据流图,标出所有需要DMA的路径,然后逐个配置验证。这个习惯帮我避免了很多后期返工。DMA配置不复杂,但细节多,前期多花半小时理清楚,后期能省好几天调试时间。