DMA 基础:工作原理、传输模式与控制器结构
大家好,我是做嵌入式系统十几年的老工程师了。这章咱们聊聊 DMA,也就是直接存储器访问。说实话,很多新手工程师在刚接触 DMA 时,总觉得它是个黑盒子——配置好寄存器就能搬数据,但到底怎么搬的、搬的时候 CPU 在干嘛、会不会和 CPU 抢总线?这些问题不清不楚。
嗯,我在一个高速数据采集项目里就踩过坑。当时觉得 DMA 模式已经配对了,结果采回来的数据总是错位,后来发现是传输模式的配置和缓存一致性问题。所以这一章,我们先打好地基。
DMA 工作原理
DMA 的核心作用,说白了就是「让硬件自己搬数据,CPU 歇着」。没有 DMA 的时候,CPU 得一条条指令把数据从外设读进寄存器,再写到内存,这叫 PIO(程序控制 I/O)。每次传输 CPU 都要参与,效率极低。
DMA 控制器(DMAC)是一个独立的总线主设备。它自己就能发起读写操作,不需要 CPU 逐个指令干预。工作流程大概是这样的:
- CPU 配置 DMA 控制器——告诉它源地址、目的地址、传输长度、传输模式等。
- DMA 控制器拿到总线控制权(通过总线仲裁),开始从源地址读数据。
- 数据暂存到内部 FIFO(有些控制器没有 FIFO,是直通模式)。
- 写入目的地址,同时更新地址指针和计数器。
- 传输完成后触发中断,通知 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 控制器内部结构:
图里这几个部件是必须认识的:
- 控制逻辑:大脑。负责解析配置、产生地址和读写时序、处理突发传输。
- 源/目的地址寄存器:每次传输后自动递增或递减(也可以固定)。我习惯把它们想象成“两个指针”。
- 传输计数器:记录还剩多少字节/字要搬。减到 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,不同的总线设计会导致天差地别的速度。