从零开始搞懂DMA:让CPU“解放双手”的数据搬运术
你有没有遇到过这种情况——系统里接了个高速ADC,采样率一上来,CPU瞬间被中断“淹死”,连个简单的LED都闪不动了?或者想做个音频播放器,结果PCM数据还没处理完,下一帧就已经来了?
这其实是很多嵌入式开发者都会踩的坑。而解决这类问题的关键,并不是换颗更快的芯片,而是学会用好一个“隐形助手”:DMA(Direct Memory Access)。
今天我们就抛开晦涩术语和复杂框图,手把手带你走一遍DMA读写操作的真实硬件流程。哪怕你是零基础,也能看懂它是怎么让CPU“躺平”、自己默默搬完几万字节数据的。
为什么我们需要DMA?
先来想一个问题:如果没有DMA,CPU是怎么把外设的数据存到内存里的?
比如ADC完成一次转换后,数据存在它的寄存器里。传统做法是:
- CPU不断轮询或等待中断;
- 中断触发后,CPU跳转到服务程序;
- CPU从ADC寄存器读出一个值;
- CPU把这个值写进内存数组;
- 计数+1,判断是否结束……
听起来不难对吧?但当你需要每秒采集10万次数据时,就意味着CPU要响应10万次中断——每次中断至少几十条指令开销,光是进出中断上下文就能耗掉大半性能。
更糟的是,这些时间本该用来做控制算法、通信协议、用户交互等真正重要的事。
于是,工程师们就想:能不能让这个“搬砖”的活儿交给别人干?只要告诉它“从哪搬到哪,搬多少”,剩下的让它自己去跑?
DMA 就是为此而生的专职“搬运工”。
DMA到底是个啥?一句话讲清楚
DMA 是一块独立的硬件电路,它可以在不打扰CPU的情况下,自动把数据从一个地方搬到另一个地方。
这里的“地方”通常是:
- 外设的数据寄存器(如 ADC_DR、SPI_DR)
- 内存中的某个缓冲区(如 SRAM 里的数组)
整个过程就像快递员取件送货:外设说“我有货了!”→ DMA 接单 → 自动取走数据 → 存进指定地址 → 完事后打个报告。
CPU只需要在开头下个单、结尾签个收,中间完全可以去睡觉。
硬件层面发生了什么?三步拆解真实流程
我们以最常见的场景为例:用DMA实现ADC连续采样。
想象你现在坐在调试器前,按下复位键,系统启动。接下来会发生什么?
第一步:搭桥铺路 —— 初始化配置
在代码里你写了这么一段:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, 1024);这一行看似简单,背后却完成了几个关键动作:
打开时钟电源
- 给ADC模块供电:__HAL_RCC_ADC_CLK_ENABLE()
- 给DMA控制器供电:__HAL_RCC_DMA2_CLK_ENABLE()设定搬运规则
- 搬运方向:外设 → 内存(DMA_PERIPH_TO_MEMORY)
- 数据宽度:半字(16位,匹配ADC输出)
- 地址变化:外设地址不变(始终读ADC_DR),内存地址递增(填满buffer)
- 工作模式:循环模式(采集完1024点自动重头再来)建立物理连接
- 把ADC的“数据就绪”信号线接到DMA控制器的请求输入端(硬件连线)
- 配置DMA通道优先级(避免和其他外设抢总线)
这时候,整个通路已经搭好,只等第一个数据到来。
第二步:自动搬运 —— 真实传输发生时
假设你用了定时器触发ADC,每10μs启动一次转换。
当第1次转换完成:
- ADC内部状态机置位EOC(Conversion Complete)
- 自动拉高DMA请求信号(DMA_REQ)
- DMA控制器检测到请求,向总线仲裁器申请访问权
- 获得AHB总线控制权后,返回确认信号(DMA_ACK)
- 发起一次总线读操作:从ADC_DR读取数据
- 同时发起写操作:写入
adc_buffer[0] - 更新内存地址指针 +2(半字=2字节)
- 传输计数器减1
第2次、第3次……直到第1024次:
一切重复进行,无需任何CPU干预。
整个过程中,CPU可能正在执行PID计算、处理UART命令、甚至进入了低功耗睡眠模式。
第三步:任务完成 —— 回报与处理
当第1024个数据搬完后,DMA控制器做了两件事:
触发中断
- 设置传输完成标志位(TCIF)
- 如果使能了中断,则产生DMA_TC_IRQHandler自我复位
- 清除状态标志
- 进入空闲状态等待下次请求
- 若为循环模式,则自动重载初始地址和计数器,准备下一周期
此时你在中断服务函数中可以做:
void DMA2_Stream0_IRQHandler(void) { HAL_DMA_IRQHandler(&hdma_adc1); // HAL库处理 // 此处可添加数据分析、滤波、上传等逻辑 }注意:这个中断每1024次才来一次,而不是每1次!相比中断驱动方式,CPU负载直接降了两个数量级。
实战技巧:那些手册不会明说的坑
别以为配置完参数就万事大吉。实际项目中,DMA常常“悄无声息”地出问题。以下是几个高频踩坑点和应对策略。
坑点1:明明收到了数据,但内容总是错的?
原因:缓存一致性问题(Cache Coherency)
现代MCU如STM32H7、i.MX RT系列带有数据缓存(D-Cache)。当DMA往SRAM写数据时,如果该区域被缓存了,CPU读取时会命中缓存中的旧数据!
✅解决方案:
- 方法一:关闭相关内存区域的缓存(MPU配置)
- 方法二(推荐):在处理前执行缓存无效化
SCB_InvalidateDCache_by_Addr((uint32_t*)adc_buffer, sizeof(adc_buffer));⚠️ 记住口诀:“DMA写,CPU读 → 先清缓存;DMA读,CPU写 → 先刷缓存”。
坑点2:程序跑着跑着突然HardFault?
原因:地址未对齐或越界访问
例如你设置传输宽度为32位,但目标缓冲区起始地址是奇数地址(非4字节对齐),就会引发BusFault。
✅检查清单:
- 半字传输 → 起始地址必须2字节对齐
- 全字传输 → 必须4字节对齐
- 缓冲区长度不能超过DMA最大支持(通常65535次)
可以用编译器关键字强制对齐:
uint16_t adc_buffer[1024] __attribute__((aligned(4)));坑点3:多个外设同时用DMA,谁先谁后?
原因:总线竞争导致优先级混乱
比如同时开了ADC采集和UART发送,两者都走DMA,可能会出现串口卡顿或采样丢点。
✅解决方案:
合理分配通道优先级:
hdma_adc.Init.Priority = DMA_PRIORITY_HIGH; // 关键实时任务 hdma_uart.Init.Priority = DMA_PRIORITY_MEDIUM; // 次要通信任务必要时使用不同DMA控制器(如DMA1 vs DMA2)分流。
不止于ADC:DMA还能怎么玩?
DMA的能力远不止采集这么简单。掌握之后你会发现,很多原本棘手的问题变得轻松起来。
✅ 音频播放:I2S + DMA 实现无感播放
配置I2S为DMA源,内存音频缓冲区为目标,开启循环模式,就能实现背景音乐播放,CPU几乎零参与。
HAL_I2S_Transmit_DMA(&hi2s, (uint8_t*)audio_buf, SAMPLES_PER_BUF);✅ 高速通信:UART + DMA 收发海量数据
接收不定长数据时启用DMA+空闲中断组合技:
- DMA持续将收到的数据搬进ring buffer
- 空闲线检测(IDLE Line)触发中断,表示一包数据结束
- CPU只需一次性处理整包,效率极高
✅ 图像采集:DCMI/OV摄像头+DMA直灌内存
配合FSMC或FMC接口,直接将摄像头原始图像帧搬进外部SRAM,为后续JPEG压缩或AI推理提供原材料。
✅ 双缓冲模式:无缝衔接采集与处理
启用双缓冲后,DMA会在两个缓冲区间交替传输:
Buffer A ← 正在传输 ← ADC ↑ Buffer B ← 已完成 ← CPU正在处理当DMA切换到B时,CPU就可以安心处理A中的数据,真正做到“边采边算”。
如何验证你的DMA真的在工作?
有时候代码看着没问题,但DMA就是不动。别慌,试试这几个排查方法:
🔍 方法1:查标志位
if (__HAL_DMA_GET_FLAG(&hdma_adc1, DMA_FLAG_TCIF)) { // 传输完成中断已触发 }🔍 方法2:用逻辑分析仪抓信号
测量以下关键信号:
-DMA_REQ:是否有脉冲?频率是否正确?
-DMA_ACK:是否被响应?
- 总线上的地址/数据变化是否符合预期?
🔍 方法3:观察电流变化
DMA运行时虽然CPU休眠,但总线活动会导致整体功耗轻微上升。对比开启前后电流曲线,也能间接判断是否在传输。
写在最后:DMA教会我们的系统思维
学习DMA,不只是学会配几个寄存器那么简单。它背后体现的是一种分层协作、各司其职的系统设计理念。
CPU擅长决策和调度,就不该让它去做重复性的体力劳动。就像公司老板不该亲自打印文件一样,我们应该把合适的工作交给合适的模块。
当你掌握了DMA,你就迈出了构建高性能嵌入式系统的第一步。下一步,你可以探索:
- MDMA(增强型DMA)在Linux设备树中的应用
- DMA与FFT加速器联动实现频谱分析
- 使用链式DMA(Linked List Mode)处理超大数据流
技术的世界层层递进,而DMA,正是那块最坚实的垫脚石。
如果你也在用DMA解决实际问题,欢迎留言分享你的应用场景或调试心得。我们一起把这块“看不见的搬运工”,变成手中真正的利器。