DMA 硬件架构:控制器内部结构、通道与请求线、关键寄存器

做 DMA 驱动,第一件事不是翻手册看寄存器列表,而是先把硬件框图吃透。我见过不少同事上来就对着寄存器表一个个配,结果 DMA 要么不工作,要么传错地址。说白了,你得先知道 DMA 控制器长什么样,它的手脚(通道、请求线)连到哪里,脑子(寄存器组)怎么用。

这一章,我们就拆开 DMA 控制器的黑盒子,看看里面到底有什么。

2.1 DMA 控制器内部结构

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

  • 控制逻辑单元:核心大脑,负责解析配置、仲裁优先级、产生控制信号。
  • 通道寄存器组:每个通道都有一组独立的寄存器,包括源地址、目的地址、传输计数、控制配置等。
  • 地址生成器:根据配置的地址模式和传输宽度,自动计算下一次读写的地址。
  • 数据缓冲/FIFO:临时存放从源端读出的数据,等待写入目的端。大小通常 4~32 字节。
  • 仲裁器:当多个通道同时请求时,决定谁先获得总线控制权。
  • 中断控制单元:传输完成、传输错误等事件触发中断给 CPU。

我早期调试一个音频 DMA 驱动时,发现数据总是有杂音。查了半天,原来是 FIFO 深度设置太小,加上总线仲裁延迟,导致数据断流。嗯,从那以后我每次看 datasheet,都会先确认 FIFO 大小和仲裁策略。

核心要点:DMA 控制器本质上是一个“智能搬运工”,它不需要 CPU 参与数据搬运,但需要 CPU 告诉它“从哪里搬、搬到哪里、搬多少、怎么搬”。

DMA 控制器内部结构框图 DMA 控制器 控制逻辑单元(大脑) 仲裁器(优先级裁决) 通道 0 寄存器组 源地址寄存器 目的地址寄存器 传输计数寄存器 控制配置寄存器 ... 通道 1 寄存器组 源地址寄存器 目的地址寄存器 传输计数寄存器 控制配置寄存器 ... 通道 N 寄存器组 源地址寄存器 目的地址寄存器 传输计数寄存器 控制配置寄存器 ... 数据缓冲 / FIFO 中断控制单元 每个通道拥有独立的寄存器组,通道之间通过仲裁器竞争总线使用权

2.2 通道与请求线

通道是 DMA 控制器的基本工作单元。每个通道可以独立配置,服务于一个外设或一段内存传输。你想想看,如果只有一个通道,那所有外设都得排队等,效率太低了。所以芯片设计者会放多个通道,比如 4 个、8 个甚至 16 个。

每个通道都对应一条或多条请求线。外设(比如 UART、SPI、ADC)通过请求线告诉 DMA:“我有数据准备好了,快来搬!”

经验之谈:我建议你在看芯片手册时,先画一张“外设 → 请求线 → 通道”的映射表。很多坑都出在这里——比如你配了 UART 的 DMA 通道,但实际 UART 的请求线连的是另一个通道号。

请求线的类型通常有两种:

  • 硬件请求:由外设硬件自动触发,比如 UART 接收 FIFO 非空时拉高请求信号。
  • 软件请求:由 CPU 写寄存器手动触发,常用于内存到内存的拷贝。

我记得有一次调试一个 SPI 从设备的 DMA 接收,数据总是少几个字节。查了三天,最后发现是 SPI 的请求线极性配反了——外设请求是高电平有效,我配成了低电平。嗯,这种细节手册上往往只写一句话,但能让你折腾好几天。

2.3 源地址与目的地址寄存器

这两个寄存器是 DMA 传输的核心。源地址寄存器存放数据来源的地址,目的地址寄存器存放数据要写往的地址。

地址模式通常有几种:

模式 说明 典型场景
固定地址 每次传输后地址不变 外设数据寄存器(如 UART_DR)
递增地址 每次传输后地址 + 传输宽度 内存缓冲区
递减地址 每次传输后地址 - 传输宽度 某些特殊 FIFO 或栈操作
循环/回环 地址到达边界后回到起始值 音频环形缓冲区

注意:源地址和目的地址的宽度必须与传输宽度匹配。比如你配了 32 位传输,但源地址没有 4 字节对齐,轻则传输错误,重则触发总线错误异常。我曾经在一个项目中因为结构体成员对齐问题,导致 DMA 从非对齐地址读数据,结果系统直接挂死。

2.4 传输计数寄存器

这个寄存器告诉 DMA:“这次总共要搬多少数据。” 每次完成一次传输(一个数据单元),计数寄存器就减 1。当减到 0 时,DMA 认为本次传输完成,可以触发中断或自动重载。

这里有个容易混淆的点:传输计数是数据单元个数,不是字节数。比如你配了传输宽度为 32 位(4 字节),计数设为 100,那么总共传输 400 字节。

我个人的习惯是,在驱动初始化时,把传输计数和传输宽度的关系用宏定义写清楚:

/* 传输宽度定义 */
#define DMA_WIDTH_BYTE      0
#define DMA_WIDTH_HALFWORD  1
#define DMA_WIDTH_WORD      2

/* 计算传输计数 */
#define DMA_BUF_SIZE        1024    /* 总字节数 */
#define DMA_WIDTH           DMA_WIDTH_WORD
#define DMA_TRANSFER_COUNT  (DMA_BUF_SIZE / (1 << DMA_WIDTH))

这样写,别人看代码时一目了然,也避免了自己算错。

传输计数寄存器还有一个常见特性——自动重载。当计数减到 0 时,如果使能了自动重载模式,硬件会自动把初始值重新加载进去,适合做周期性的数据传输,比如音频播放的 ping-pong 缓冲。

避坑指南:我曾经在做一个高速数据采集项目时,传输计数寄存器设成了 65535,结果发现 DMA 传输到一半就停了。查手册才发现,这个控制器的传输计数寄存器只有 16 位,最大只能设 65535。如果你要传输超过 65535 个数据单元,要么用链表/描述符模式,要么分多次启动 DMA。

2.5 小结