4. DMA控制器原理:传输流程、地址配置与突发传输
DMA,全称是Direct Memory Access,直接存储器访问。说白了,就是让外设和内存之间能直接“对话”,不用CPU一遍遍地当搬运工。我早年做第一个带LCD的嵌入式项目时,就是靠CPU逐像素刷屏,结果CPU占用率飙到90%以上,系统几乎干不了别的。后来换成DMA,CPU瞬间就解放了。
这一节,我们就把DMA的底裤扒干净。从传输流程、地址配置,到传输宽度和突发传输,一个一个说清楚。
4.1 DMA传输流程:一次完整的搬运
一次典型的DMA传输,可以拆成5个步骤。我习惯把它叫作“请求→响应→搬运→计数→结束”。
- 外设发起DMA请求:比如SPI的接收缓冲区非空,或者ADC转换完成,外设会拉高DMA请求信号。
- DMA控制器仲裁:如果系统里有多个DMA通道,控制器会按优先级决定先响应谁。
- DMA获得总线控制权:CPU暂时交出总线,DMA成为总线主人。
- DMA执行传输:从源地址读数据,写到目的地址。每次传输后,源/目的地址和计数器都会更新。
- 传输完成,释放总线:当计数器减到0,DMA产生完成中断,把总线还给CPU。
核心要点:整个过程中,CPU只需要在开始配置DMA,在结束处理中断。中间的数据搬运,CPU完全不用管。
嗯,这里要注意:DMA传输期间,CPU虽然不能访问总线,但可以继续执行内部指令——只要不涉及内存访问。所以系统整体吞吐量是提升的。
4.2 源地址与目的地址配置
DMA传输的核心,就是告诉控制器“从哪读,写到哪”。这两个地址的配置方式,决定了你能实现什么样的传输模式。
| 地址类型 | 常见配置 | 典型场景 |
|---|---|---|
| 源地址 | 外设数据寄存器地址 | SPI_DR、ADC_DR、UART_DR |
| 目的地址 | 内存缓冲区地址 | 数组、帧缓冲、环形缓冲区 |
| 源地址 | 内存缓冲区地址 | 从内存发送数据到SPI |
| 目的地址 | 外设数据寄存器地址 | DAC输出、PWM占空比更新 |
地址递增模式也很关键。大多数DMA控制器支持三种模式:
- 固定地址:每次传输后地址不变。适合外设寄存器——你永远只想读写同一个寄存器。
- 递增地址:每次传输后地址+1(或+传输宽度)。适合内存缓冲区,数据要连续存放。
- 递减地址:用得少,但某些协议栈的栈操作会用到。
我的习惯:配置外设到内存的传输时,源地址设成固定,目的地址设成递增。反过来,内存到外设,源地址递增,目的地址固定。这个组合能覆盖90%以上的场景。
4.3 传输宽度:一次搬多少
传输宽度,就是DMA每次搬运的数据位数。常见的有8位、16位、32位。有些高端控制器还支持64位甚至128位。
为什么要有这个参数?因为外设和内存的数据宽度可能不一样。比如:
- SPI硬件是8位寄存器,但你的帧缓冲是32位像素。
- ADC是12位结果,但内存是16位数组。
这时候就需要DMA做宽度匹配。我遇到过最坑的情况:某款MCU的DMA不支持半字(16位)传输,只能用字节或字。结果ADC结果存到内存里,高低字节全乱了。后来我查了三天手册才发现这个限制。
| 传输宽度 | 适用外设 | 注意事项 |
|---|---|---|
| 8位(字节) | UART、SPI(8位模式)、I2C | 地址递增步长=1 |
| 16位(半字) | SPI(16位模式)、定时器、ADC | 地址递增步长=2,注意对齐 |
| 32位(字) | LCD控制器、DAC、帧缓冲 | 地址必须4字节对齐 |
避坑指南:我曾经在一个项目里,把DMA传输宽度设成32位,但源地址是外设的8位寄存器。结果DMA一次读了4个字节,把相邻寄存器的值也读进来了。数据全错。记住:源和目的的实际宽度必须匹配,或者至少你清楚DMA的打包/拆包行为。
4.4 突发传输:批量搬运的效率革命
突发传输(Burst Transfer),是DMA提升效率的关键手段。普通传输一次只搬一个数据单元,然后释放总线。突发传输则是一次请求,连续搬多个数据单元,期间一直占用总线。
你想想看,每次申请总线都有开销——仲裁、握手、切换。如果一次申请能搬4个、8个甚至16个数据,总开销就摊薄了。
突发传输的典型配置参数:
- 突发长度:一次突发传输中连续搬运的数据单元个数。常见值:4、8、16。
- 突发类型:固定长度突发(FLB)和增量突发(INCR)。前者每次突发长度固定,后者可以变化。
我举个实际例子。用DMA从内存搬运一帧图像数据到LCD控制器:
- 如果单次传输,每搬一个像素就要申请一次总线。一帧800×480的图像,要申请384,000次总线。
- 如果突发长度=16,只需要申请24,000次。总线利用率大幅提升。
性能对比:突发传输能让DMA的有效带宽提升30%~50%,尤其是在系统总线负载较重的情况下。
但突发传输不是万能的。有些外设的FIFO深度很浅,比如只有4个字节。如果你突发长度设成16,外设FIFO会溢出。所以突发长度必须≤外设FIFO深度。
4.5 DMA传输的完整流程图
下面这张图,把整个DMA传输的流程和关键配置点串起来了。我建议你把它存下来,以后配置DMA时对照着看。
4.6 实际配置示例:SPI接收的DMA设置
光说不练假把式。我们看一个实际代码片段,配置DMA从SPI接收数据到内存缓冲区。
// 以STM32 HAL库为例,配置DMA从SPI接收数据
// 假设:SPI2, DMA1通道4, 接收缓冲区rx_buf[256]
DMA_HandleTypeDef hdma_spi2_rx;
void DMA_Config(void)
{
// 1. 使能DMA时钟
__HAL_RCC_DMA1_CLK_ENABLE();
// 2. 配置DMA句柄
hdma_spi2_rx.Instance = DMA1_Channel4;
hdma_spi2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设到内存
hdma_spi2_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址固定
hdma_spi2_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_spi2_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; // 8位
hdma_spi2_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; // 8位
hdma_spi2_rx.Init.Mode = DMA_NORMAL; // 普通模式
hdma_spi2_rx.Init.Priority = DMA_PRIORITY_HIGH; // 高优先级
// 3. 初始化DMA
HAL_DMA_Init(&hdma_spi2_rx);
// 4. 将DMA句柄关联到SPI
__HAL_LINKDMA(&hspi2, hdmarx, hdma_spi2_rx);
// 5. 启动DMA传输
// 源地址:SPI2数据寄存器 &hspi2.Instance->DR
// 目的地址:rx_buf
// 传输长度:256字节
HAL_SPI_Receive_DMA(&hspi2, rx_buf, 256);
}
我的建议:配置DMA时,先把Direction、PeriphInc、MemInc这三个参数想清楚。它们决定了数据流的方向和地址变化方式。这三个对了,剩下的就是宽度和长度的微调。
4.7 突发传输的配置要点
突发传输的配置,不同MCU差异很大。但有几个通用原则:
- 突发长度 ≤ 外设FIFO深度:否则数据会丢失。我见过有人把突发长度设成16,但UART的FIFO只有8,结果数据错位。
- 内存地址对齐:突发传输通常要求源和目的地址对齐到突发长度的整数倍。比如突发长度=4(32位),地址必须4字节对齐。
- 总线锁定的影响:突发传输期间,DMA一直占用总线。如果系统里有对延迟敏感的外设(比如定时器),要考虑突发长度是否过大。
我曾经踩过的坑:在一个多主设备的总线系统里,我把DMA突发长度设成了16。结果DMA传输期间,CPU想读取一个定时器的计数值,等了十几个时钟周期才拿到总线。定时器中断响应延迟直接超标。后来我把突发长度降到4,问题解决。