第4章:DMA基础概念:DMA工作原理、DMA控制器、传输模式
各位好,欢迎来到第四章。
前面我们聊了中断,讲了怎么处理CPU被打断的那些事儿。但有个问题一直悬着:如果数据搬运全让CPU干,那CPU还干不干正事了?
举个例子,你写了个蓝牙驱动,从HCI接口收数据。每来一个包,CPU就得把数据从外设寄存器搬到内存里。一次两次还行,每秒几百上千次呢?CPU直接跑满,啥也干不了。
这时候,DMA就该登场了。
4.1 DMA是什么?说白了就是“数据搬运工”
DMA,全称Direct Memory Access,直接存储器访问。
它的核心思想很简单:让一个专门的硬件模块来负责数据搬运,CPU只管发号施令。
我刚开始接触嵌入式时,总觉得DMA是个很玄乎的东西。后来在调试一个音频播放项目时,发现CPU占用率奇高,查了半天才发现——数据从I2S接口到内存,全是CPU在逐字节拷贝。嗯,那会儿我才真正理解DMA的价值。
核心要点:DMA允许外设直接读写系统内存,无需CPU参与。CPU只需配置好DMA控制器,然后就可以去干别的事了。
你想想看,这就像你让快递员去取件,而不是自己亲自跑一趟。CPU就是那个发指令的人,DMA就是那个跑腿的。
4.2 DMA工作原理:三步走
DMA的工作流程,我习惯把它拆成三步:
- 配置阶段:CPU告诉DMA控制器——你要从哪里搬数据,搬到哪里去,搬多少。
- 传输阶段:DMA控制器接管总线,开始搬运。CPU可以继续执行其他任务。
- 完成阶段:传输完毕,DMA控制器发个中断通知CPU:“活干完了”。
这里有个细节要注意:DMA传输期间,会占用系统总线。如果CPU也要访问内存,就得等DMA用完总线。不过现代芯片都有多级总线架构,这个问题已经大大缓解了。
我的经验:在配置DMA时,一定要确认源地址和目标地址的对齐方式。很多DMA控制器要求地址按4字节对齐,否则会触发错误中断。我曾经在这个坑里爬了整整一天。
4.3 DMA控制器:幕后英雄
DMA控制器,就是那个负责具体干活儿的硬件模块。
它内部通常包含以下几个关键部分:
| 组件 | 作用 | 备注 |
|---|---|---|
| 控制寄存器 | 配置传输参数 | 源地址、目标地址、传输长度等 |
| 状态寄存器 | 指示当前状态 | 是否忙、是否完成、是否有错误 |
| 地址寄存器 | 保存源和目标地址 | 支持自动递增 |
| 计数寄存器 | 记录剩余传输量 | 每传一个单元自动减1 |
| 仲裁逻辑 | 处理总线请求优先级 | 多个DMA通道同时请求时用 |
我记得在调试一个多通道DMA的蓝牙项目时,两个DMA通道同时请求总线,结果优先级设置错了,导致音频数据断断续续。后来查了芯片手册才发现,DMA通道0的优先级默认最高,而我偏偏把低优先级的音频流放在了通道0上。
避坑指南:我曾经因为忘记初始化DMA控制器的时钟,导致DMA一直不工作。查了三天才发现是时钟门控没打开。所以,配置DMA前,先确认时钟是否使能。
4.4 传输模式:三种常见场景
DMA的传输模式,说白了就是数据从哪来到哪去。常见的有三种:
4.4.1 内存到内存
这种模式最简单。就是把数据从内存的一块区域搬到另一块区域。
典型应用:内存拷贝、缓冲区管理、数据整理。
// 伪代码示例:配置DMA进行内存到内存拷贝
dma_config_t cfg;
cfg.src_addr = (uint32_t)src_buf; // 源地址
cfg.dst_addr = (uint32_t)dst_buf; // 目标地址
cfg.transfer_size = 1024; // 传输1024字节
cfg.src_increment = 1; // 源地址递增
cfg.dst_increment = 1; // 目标地址递增
cfg.mode = DMA_MODE_MEM_TO_MEM; // 内存到内存模式
dma_init(&cfg);
dma_start();
你可能会问:用memcpy不就行了?干嘛用DMA?
嗯,问得好。memcpy是CPU在干活,会占用CPU时间。而DMA拷贝时,CPU可以去做别的事。比如在音频处理中,一边用DMA搬运数据,一边让CPU做音频算法,效率就上来了。
4.4.2 外设到内存
这是蓝牙驱动中最常用的模式。
外设(比如UART、SPI、I2S)收到数据后,DMA直接把数据搬到内存缓冲区。CPU只需要在DMA完成中断里处理数据即可。
// 伪代码示例:配置UART接收DMA
dma_config_t cfg;
cfg.src_addr = (uint32_t)&UART->DR; // UART数据寄存器地址
cfg.dst_addr = (uint32_t)rx_buffer; // 接收缓冲区
cfg.transfer_size = 256; // 接收256字节
cfg.src_increment = 0; // 外设地址不递增
cfg.dst_increment = 1; // 内存地址递增
cfg.mode = DMA_MODE_PERIPH_TO_MEM; // 外设到内存模式
dma_init(&cfg);
dma_start();
关键点:外设地址通常不递增,因为数据寄存器就一个。而内存地址要递增,否则数据会覆盖。
我在做蓝牙HCI驱动时,就是用这种方式接收ACL数据包的。UART每收到一个字节,DMA就自动搬到缓冲区。等收到完整的一帧,DMA中断触发,CPU再解析。这样CPU几乎不参与数据搬运,效率极高。
4.4.3 内存到外设
这个模式正好反过来。CPU把要发送的数据准备好,DMA负责把数据从内存搬到外设的发送寄存器。
典型应用:音频播放、蓝牙数据发送、LCD显示刷新。
// 伪代码示例:配置SPI发送DMA
dma_config_t cfg;
cfg.src_addr = (uint32_t)tx_buffer; // 发送缓冲区
cfg.dst_addr = (uint32_t)&SPI->DR; // SPI数据寄存器
cfg.transfer_size = 128; // 发送128字节
cfg.src_increment = 1; // 内存地址递增
cfg.dst_increment = 0; // 外设地址不递增
cfg.mode = DMA_MODE_MEM_TO_PERIPH; // 内存到外设模式
dma_init(&cfg);
dma_start();
我的建议:使用内存到外设模式时,最好用双缓冲(ping-pong buffer)。一个缓冲区在发送,另一个在准备数据。这样能避免数据断流。我在音频项目中吃过这个亏,单缓冲导致声音卡顿。
4.5 传输粒度:字节、半字、字
DMA传输时,每次搬运的数据大小叫传输粒度。
- 字节(8位):适合UART、I2C等串行接口
- 半字(16位):适合音频数据、SPI等
- 字(32位):适合高速外设、内存拷贝
选择传输粒度时,要考虑外设的数据宽度。比如UART通常是8位,那就用字节传输。如果外设是32位的,用字节传输反而效率低,因为DMA要多次访问总线。
避坑指南:我曾经在配置DMA时,源地址是8位对齐,目标地址是32位对齐,结果传输到一半就卡死了。原因是DMA控制器不支持非对齐传输。所以,源和目标地址的字节对齐方式必须匹配传输粒度。
4.6 环形缓冲区与DMA
在蓝牙驱动中,环形缓冲区(Ring Buffer)和DMA是绝配。
DMA可以配置成循环模式:传输完指定长度后,自动回到起始地址继续传输。这样就不需要每次传输完都重新配置DMA。
// 伪代码示例:配置DMA循环模式
dma_config_t cfg;
cfg.src_addr = (uint32_t)&UART->DR;
cfg.dst_addr = (uint32_t)ring_buffer;
cfg.transfer_size = RING_BUFFER_SIZE; // 缓冲区大小
cfg.circular_mode = 1; // 使能循环模式
cfg.mode = DMA_MODE_PERIPH_TO_MEM;
dma_init(&cfg);
dma_start();
这样配置后,UART收到的数据会源源不断地写入环形缓冲区。CPU只需要定期检查缓冲区,处理新数据即可。
嗯,说到这里,我想起一个项目。当时用环形缓冲区+DMA接收蓝牙数据,结果发现数据偶尔会丢失。查了半天,原来是DMA写指针和CPU读指针冲突了。后来加了原子操作和内存屏障才解决。
4.7 小结
这一章我们聊了DMA的基础概念。说白了,DMA就是让硬件帮你搬数据,CPU腾出手来干更重要的事。
核心要点再强调一遍:
- DMA工作原理:配置→传输→完成中断
- DMA控制器:寄存器配置、状态查询、优先级管理
- 三种传输模式:内存到内存、外设到内存、内存到外设
- 传输粒度:字节、半字、字,要和外设匹配
- 环形缓冲区:配合DMA循环模式,实现高效数据流
下一章,我们会深入实战,讲讲如何在蓝牙驱动中具体配置和使用DMA。到时候我会拿出一个真实的项目代码,一步步带你走通整个流程。
咱们下章见。