DMA 基础:工作原理、传输模式与控制器结构

大家好,我是做嵌入式系统十几年的老工程师了。这章咱们聊聊 DMA,也就是直接存储器访问。说实话,很多新手工程师在刚接触 DMA 时,总觉得它是个黑盒子——配置好寄存器就能搬数据,但到底怎么搬的、搬的时候 CPU 在干嘛、会不会和 CPU 抢总线?这些问题不清不楚。

嗯,我在一个高速数据采集项目里就踩过坑。当时觉得 DMA 模式已经配对了,结果采回来的数据总是错位,后来发现是传输模式的配置和缓存一致性问题。所以这一章,我们先打好地基。

DMA 工作原理

DMA 的核心作用,说白了就是「让硬件自己搬数据,CPU 歇着」。没有 DMA 的时候,CPU 得一条条指令把数据从外设读进寄存器,再写到内存,这叫 PIO(程序控制 I/O)。每次传输 CPU 都要参与,效率极低。

DMA 控制器(DMAC)是一个独立的总线主设备。它自己就能发起读写操作,不需要 CPU 逐个指令干预。工作流程大概是这样的:

  1. CPU 配置 DMA 控制器——告诉它源地址、目的地址、传输长度、传输模式等。
  2. DMA 控制器拿到总线控制权(通过总线仲裁),开始从源地址读数据。
  3. 数据暂存到内部 FIFO(有些控制器没有 FIFO,是直通模式)。
  4. 写入目的地址,同时更新地址指针和计数器。
  5. 传输完成后触发中断,通知 CPU “活干完了”。

这里有一个关键问题:DMA 和 CPU 共享系统总线。我见过不少工程师以为 DMA 是“独立通道”,实际上它还是得通过总线去读写存储器和外设。所以配置不当的话,CPU 可能会被“饿死”,或者 DMA 被 CPU 频繁抢占导致传输碎片化。

重要提醒:DMA 传输期间,CPU 依然可以执行指令——只要不访问被 DMA 占用的总线资源。但要注意,如果 CPU 和 DMA 同时访问同一个内存区域,就会引发缓存一致性问题(后续章节重点讲)。

三种传输模式

DMA 不是只能“从 A 到 B”这么简单。实际项目中,根据数据流向可以分为三大类。我个人习惯用一张表来对比:

传输模式 源端 目的端 典型应用 注意点
内存到外设 内存(RAM) 外设寄存器(如 SPI、UART 的数据寄存器) 通过 SPI 发送大量数据到 DAC 外设速度通常慢,可用突发传输
外设到内存 外设寄存器(如 ADC 数据寄存器) 内存(RAM) 高速 ADC 连续采样,DMA 自动搬到内存缓冲区 注意外设数据宽度和对齐
内存到内存 内存(RAM) 内存(RAM) 快速拷贝大数组、内存搬运 源和目的不能重叠(除非硬件支持)

你可能会问:内存到内存的 DMA 是不是最省事?其实不然。这模式会严重消耗总线带宽,CPU 在这期间基本没法访问内存。我在一个图像处理项目里试过用 DMA 搬整帧画面,结果 CPU 连中断响应都变慢了。后来发现是 DMA 占用了所有总线周期,改成用 CPU 的 memcpy 加缓存优化反而更快。

经验谈:内存到内存的 DMA 只适合大批量连续数据且 CPU 确实空闲的场景。大多数嵌入式应用中,外设到内存的 DMA 是使用频率最高的。

DMA 控制器基本结构

每个 MCU 的 DMA 控制器细节不同,但基本骨架大同小异。我们来看一个典型的 DMA 控制器内部结构:

DMA 控制器内部结构及工作原理 DMA 控制器内部结构 DMA 控制逻辑 源地址寄存器 (SAR) 目的地址寄存器 (DAR) 传输计数器 (BCR) 控制寄存器 (CR) 数据 FIFO / 暂存器 总线接口单元 (BIU) 读/写总线控制 中断请求 (IRQ) 读/写控制 接系统总线(AHB/APB) 中断输出到 CPU(NVIC)

图里这几个部件是必须认识的:

  • 控制逻辑:大脑。负责解析配置、产生地址和读写时序、处理突发传输。
  • 源/目的地址寄存器:每次传输后自动递增或递减(也可以固定)。我习惯把它们想象成“两个指针”。
  • 传输计数器:记录还剩多少字节/字要搬。减到 0 就发中断。
  • 数据 FIFO:用于缓冲,避免总线频繁切换。有些廉价 MCU 没有 FIFO,数据直接在总线上穿透,效率会差一些。
  • 总线接口单元:负责与系统总线握手、仲裁、响应。

避坑指南:我曾在某款 Cortex-M4 芯片上调试 DMA,发现数据总是不完整。查了很久才发现,该 DMA 控制器的 FIFO 深度只有 4 字节,而我的外设支持突发传输 8 字,结果 FIFO 溢出导致数据丢失。所以配置 DMA 前,一定要看芯片手册里的 FIFO 深度和最大突发大小。

一个简单的配置代码示例

下面是用裸机寄存器配置一个“内存到外设”DMA 传输的伪代码。不同平台具体位域不一样,但思路通用:

// 假设 DMA 通道 0,内存源地址 buffer,外设目标 UART->DR
DMA_BASE = 0x40020000;  // DMA 控制器基址

// 1. 设置源地址寄存器(每次递增)
*(uint32_t*)(DMA_BASE + 0x00) = (uint32_t)buffer;  

// 2. 设置目的地址寄存器(固定到外设寄存器)
*(uint32_t*)(DMA_BASE + 0x04) = (uint32_t)&UART->DR;  

// 3. 设置传输计数器
*(uint32_t*)(DMA_BASE + 0x08) = 1024;  // 1024 个字节

// 4. 设置控制寄存器:使能通道、选择传输方向、数据宽度、中断等
uint32_t ctrl = 0;
ctrl |= (1 << 0);     // 使能通道
ctrl |= (2 << 2);     // 传输方向:内存到外设
ctrl |= (0 << 4);     // 源数据宽度:字节
ctrl |= (0 << 6);     // 目的数据宽度:字节
ctrl |= (1 << 8);     // 传输完成中断使能
*(uint32_t*)(DMA_BASE + 0x0C) = ctrl;

// 5. 启动传输(有些平台需要额外写触发寄存器)
*(uint32_t*)(DMA_BASE + 0x10) = 1;  // 启动位

代码很简单,但每一步都容不得马虎。我见过有人把源和目的地址宽度搞反,结果搬了一半数据发现目的地址越界——嗯,总线错误直接进 HardFault。

好了,这一章先给你搭个认知框架。下回咱们聊 DMA 传输中的仲裁策略和性能分析,到时候你就会明白,为什么同样是 DMA,不同的总线设计会导致天差地别的速度。