3、DMA 传输基本要素:源地址、目的地址、传输计数、传输宽度

做 DMA 配置,说白了就是跟四个数字打交道:

  • 源地址 —— 数据从哪来
  • 目的地址 —— 数据送到哪去
  • 传输计数 —— 总共搬多少笔
  • 传输宽度 —— 每笔搬多宽的数据

这四个要素拧在一起,就构成了 DMA 传输的基本骨架。我记得刚接手第一个 DMA 驱动时,觉得寄存器就那么几个,填就是了。结果数据搬出来全是乱的。嗯,后来才发现,地址递增方向和宽度没配好。

个人习惯:我每次写 DMA 配置代码前,都会先在纸上把源地址、目标地址、计数、宽度这四个数写一遍,再跟寄存器需求比对。看起来笨,但真的能提前挡掉一半的 bug。
DMA 传输四大要素关系图 DMA 传输 源地址 起始位置 + 递增方向 目的地址 目标位置 + 递增方向 传输计数 总共搬 N 次 传输宽度 字节/半字/字 四个要素必须协调一致,否则数据搬出来就是坏的

3.1 源地址与目的地址

我做嵌入式这几年的经验是,地址配置最容易出问题的地方,不是地址本身写错,而是递增模式搞反了。

大多数 DMA 控制器支持三种模式:

  • 固定地址:每次传输后地址不变,适合访问 FIFO 或外设数据寄存器
  • 递增地址:每次传输后地址增加(增加量 = 传输宽度),适合搬运内存块
  • 环形地址:地址到达边界后自动回绕,适合循环缓冲

我曾经调试过一个音频播放项目,DMA 从内存搬数据到 I2S 发送 FIFO。内存地址设成了递增,没问题;目的地址也设成了递增……结果 FIFO 的地址递增后就写到别处去了,播放声音全是噼啪噪声。所以外设目标地址通常要设成固定模式。

避坑指南:配置源/目的地址时,先确认外设寄存器地址是否需要固定。当源或目的是 FIFO/数据寄存器时,务必把对应地址递增关掉。

3.2 传输计数

传输计数,就是 DMA 要搬多少次。注意,不是搬多少字节,而是搬多少笔。每次搬多少字节由传输宽度决定。

举个例子:你想搬 100 个 32 位整数(400 字节)。如果传输宽度设成 32 位(4 字节),那么计数就是 100。如果宽度设成 8 位(1 字节),计数就得是 400。

大多数 DMA 控制器的计数寄存器宽度是有限的,比如 16 位(最大 65535)或 24 位(最大 16777215)。

传输宽度 计数=100 时总字节数 适用场景
8 位 (1 字节) 100 字节 字节流、UART 传输
16 位 (2 字节) 200 字节 16 位音频、SPI 半字交换
32 位 (4 字节) 400 字节 内存复制、缓冲区搬运
64 位 (8 字节) 800 字节 高性能数据通道(Cortex‑M7 等)
核心要点:传输计数 × 传输宽度 = 总数据传输量。配置时先确认这个等式是否匹配你的实际数据量。

3.3 传输宽度

传输宽度决定了 DMA 每次搬运多少字节。常见宽度有:

  • 字节 (8 位) —— 适合串口、SPI 等逐字节外设
  • 半字 (16 位) —— 适合 16 位外设或音频采样
  • 字 (32 位) —— 最适合内存到内存的搬运,效率最高
  • 双字 (64 位) —— 部分高端 MCU 才支持

宽度选不对,轻则性能下降,重则数据错位。原因是对齐问题——如果源地址是 0x20001001(非字对齐),但你设成 32 位宽度,某些 DMA 会产生 bus error 或者硬件阻塞。

我有个习惯:只要是内存间的 DMA,优先用 32 位宽度,除非芯片明确不支持。但外设侧,一定要看外设数据寄存器的实际位宽。比如 USART 数据寄存器通常是 8 位,你设个 32 位读它,可能会读到无关的相邻字节。

3.4 四个要素的协同配置

下面给一个实际配置的例子(伪代码风格),假设我们要从内存 buf 搬 256 个字到 SPI 数据寄存器:

DMA_InitTypeDef dma_cfg;

dma_cfg.src_addr       = (uint32_t)buf;          // 内存源
dma_cfg.src_inc        = DMA_INC_ENABLE;         // 源地址递增(每次+4)
dma_cfg.dst_addr       = (uint32_t)&spi->DR;     // SPI 数据寄存器
dma_cfg.dst_inc        = DMA_INC_DISABLE;        // 目的地址固定
dma_cfg.transfer_count = 256;                    // 搬 256 次
dma_cfg.transfer_width = DMA_WIDTH_32BIT;        // 每次搬 4 字节

DMA_Config(&dma_cfg);

注意,这个例子中传输计数 256,宽度 32 位,总共 1024 字节 —— 完全对应 buf 的大小。如果计数写错成 1024,宽度不变,那 DMA 就会把内存后面无关的数据也搬出去。

查错小技巧:如果 DMA 传输完发现数据少半截或多半截,先检查(计数 × 宽度)是不是等于你要传输的总字节数。大部分 bug 都出在这里。

你可能会问:为什么 DMA 不直接按字节计数,而是搞出一个宽度来?

原因很简单:效率。硬件一次搬 32 位比搬 4 次 8 位快得多。而且在总线设计上,宽度固定可以减少地址线上的冗余计算。所以 DMA 把「一次搬多少」和「总共搬多少次」分开,让你灵活匹配不同外设和内存对宽度的要求。

3.5 我个人总结的配置顺序

  1. 确定传输宽度 —— 看源和目的两端中较小的位宽(保证不会读/写多出数据)。
  2. 计算传输计数 —— 总字节数 ÷ 宽度,注意整除。
  3. 写源地址 —— 起始地址,选择递增或固定。
  4. 写目的地址 —— 起始地址,选择递增或固定。
  5. 检查对齐 —— 地址是否按宽度对齐?不对齐要降宽度或者用字节模式。

这个顺序我用过不下几十次,没出过大错。你也可以把它贴在电脑旁,省得每次都要重新想。