3. DMA控制器架构与寄存器

学DMA,我觉得第一件事不是看寄存器手册,而是先搞懂控制器内部是怎么串起来的。你想想看,一个外设要搬运数据,得通过DMA控制器来协调总线、通道、地址和计数。不懂架构,写配置代码就像蒙着眼睛走路。

3.1 核心架构:谁在干活?

一个典型的DMA控制器,内部大致分成这几块:

  • 系统总线接口:负责和CPU、存储器、外设握手。AHB或AXI居多。
  • 通道控制逻辑:每个通道独立管理自己的传输参数。我见过的芯片少则4通道,多则32通道。
  • 地址发生器:产生源地址和目标地址,支持递增、递减或固定模式。
  • 传输计数器:记录还剩多少字节。减到零就触发中断。
  • 仲裁器:多个通道同时请求时,谁先上?轮询还是固定优先级?
  • 状态与控制寄存器组:软件配置和状态查询的窗口。

架构核心:所有数据流都由硬件状态机驱动,无需CPU干预。配置好后,只要发一个“使能”信号,它自己跑完整个传输。

我画了一张结构图,把这些模块串起来看会更清楚:

DMA控制器内部架构 系统总线接口(AHB/AXI) 通道控制逻辑(Channel x 4) 请求检测 | 传输模式 | 中断控制 | 软件触发 地址发生器 传输计数器 数据缓存 / FIFO 仲裁器 + 状态寄存器 所有模块通过内部总线连接,寄存器组映射到系统地址空间

3.2 关键寄存器一览

不管哪家厂商的DMA,寄存器套路都差不多。下面是我整理的核心寄存器组,基本上覆盖了90%的配置需求:

寄存器名称位宽功能说明
SRC_ADDR32源起始地址,支持自动递增/递减/固定
DST_ADDR32目标起始地址,模式同上
COUNT16/32传输字节数或传输项数,每完成一次减1
CONTROL32使能、方向、数据宽度、优先级、中断使能等
STATUS32完成标志、错误标志、通道忙标志
CLR_INT32写1清除中断,防止重复响应

个人习惯:我会在工程里把寄存器定义做成结构体,按通道号索引,写代码时清晰很多。比如 DMA->CH[ch].SRC_ADDR = ...。

3.3 CONTROL寄存器细节——最常踩坑的地方

CONTROL寄存器是配置的灵魂。我一般在项目开始前先把它的位域画出来:

// 以某MCU的DMA控制寄存为例
typedef union {
    uint32_t val;
    struct {
        uint32_t EN          : 1;  // [0] 通道使能
        uint32_t DIR         : 1;  // [1] 方向:0=内存到外设,1=外设到内存
        uint32_t SRC_MODE    : 2;  // [3:2] 00=固定 01=递增 10=递减
        uint32_t DST_MODE    : 2;  // [5:4] 同源模式
        uint32_t SRC_WIDTH   : 2;  // [7:6] 00=8bit 01=16bit 10=32bit
        uint32_t DST_WIDTH   : 2;  // [9:8] 同源宽度
        uint32_t PRIORITY    : 2;  // [11:10] 00=低 01=中 10=高
        uint32_t INT_EN      : 1;  // [12] 传输完成中断使能
        uint32_t ERR_INT_EN  : 1;  // [13] 错误中断使能
        uint32_t CYCLE_TYPE  : 2;  // [15:14] 循环模式等
        uint32_t reserved    : 16; // [31:16]
    } bit;
} DMA_CONTROL_t;

这里有几个地方特别容易翻车:

  • 源和目标的宽度不匹配:比如源是8位,目标是32位,硬件会自动组合或拆分,但有些DMA不支持,必须一致。我遇到过一款芯片的BUG,不同宽度传输会导致数据错位。
  • 使能位(EN)必须最后写:所有地址和计数配置完成后,才置位EN。如果先使能再设地址,DMA可能立刻启动并用了未初始化的地址。
  • 清除中断要用写1而不是写0:很多新手直接写0,中断挂死。手册上写的是“Write 1 to clear”。

我曾经在一个项目里因为先写了EN再写SRC_ADDR,导致DMA从0x00000000开始搬运数据,直接把堆栈区踩坏了。定位花了半天,从此我把配置顺序写成了模板。

3.4 多通道与优先级仲裁

多通道同时请求时,仲裁器决定谁先获得总线。常见的策略有两种:

  1. 固定优先级:通道号越小优先级越高。简单粗暴,但低通道容易被高通道饿死。
  2. 轮询(Round-Robin):每个通道机会均等,适合外设频率比较均匀的场景。

有些DMA支持混合模式:高优先级通道用固定,低优先级通道用轮询。我个人建议,把实时性要求高的外设(比如USB、音频)放在高优先级通道,其他批量数据放在低优先级。

场景推荐策略原因
音频I2S流固定高优先级需要实时不间断,否则破音
SPI Flash读取轮询可以等待,不需要立即响应
多通道ADC扫描固定+轮询混合关键通道高优,其余轮询

3.5 配置流程与寄存器操作步骤

嗯,这里要严格按照顺序来。我总结了一个通用流程,适用于大多数DMA:

// 伪代码:DMA通道配置步骤
1. 关中断(全局),避免配置中被中断打断
2. 写 SRC_ADDR 寄存器
3. 写 DST_ADDR 寄存器
4. 写 COUNT 寄存器(传输字节数)
5. 写 CONTROL 寄存器(除了EN位外全部设好)
6. 使能外设DMA请求(如串口、SPI的DMA使能)
7. 置位 CONTROL.EN 位,启动传输
8. 开中断(全局),等待传输完成或错误中断

注意:有些DMA支持连锁传输(linked list),即多个描述符链在一起。这个后面章节会详细讲,但寄存器操作本质一样,只是地址来自描述符表而非CPU直接写。

3.6 避坑清单——寄存器操作的常见问题

我做过的项目里,下面几个坑反复出现,拿出来共享:

  • 未清零STATUS寄存器再开始新传输:上次的完成标志还挂着,中断一来就误判。步骤里应该加一句:写CLR_INT清除所有状态。
  • COUNT寄存器设成0:有些DMA会认为0代表65536次传输(溢出),有些直接不启动。最好预留判断,避免设0。
  • 源地址或目标地址不是对齐的:比如地址是奇数,但宽度设成16位。总线会触发对齐错误。我从某次调试发现,STM32的DMA会悄悄忽略低1位,但另一家芯片直接hardfault。
  • 忘记禁止缓存一致性:如果DMA和CPU共用内存,读写顺序可能被Cache搞乱。需要加内存屏障或使用非缓存区域。

我习惯:每配置完一个通道,读回CONTROL寄存器验证写入是否成功。有些DMA写使能后会自动清掉某些位,读回来才发现问题。

3.7 小结

DMA控制器的核心就是通道+地址+计数+控制,寄存器操作虽然琐碎,但摸清套路后大部分芯片都差不多。我的建议是:先把本章的流程图和寄存器表装进脑子里,后面配置任何DMA都能快速上手。下次遇到新芯片,先找这几个关键寄存器,八成能找到。