第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的工作流程,我习惯把它拆成三步:

  1. 配置阶段:CPU告诉DMA控制器——你要从哪里搬数据,搬到哪里去,搬多少。
  2. 传输阶段:DMA控制器接管总线,开始搬运。CPU可以继续执行其他任务。
  3. 完成阶段:传输完毕,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。到时候我会拿出一个真实的项目代码,一步步带你走通整个流程。

咱们下章见。