第3章 DMA初始化配置:HAL库流程与传输方向详解
DMA初始化,说白了就是告诉DMA控制器三件事:谁传、传给谁、怎么传。HAL库把这套流程封装得很规整,但规整不等于简单。我见过不少工程师,代码能跑就以为配对了,结果一上低功耗模式就翻车。今天咱们把这层窗户纸捅破。
3.1 HAL库DMA初始化标准流程
HAL库的DMA初始化,核心就一个函数:HAL_DMA_Init()。但调用它之前,你得先填好一个结构体——DMA_HandleTypeDef。我个人习惯把初始化拆成五步,每一步都有坑。
DMA初始化五步法
- 使能DMA时钟:__HAL_RCC_DMAx_CLK_ENABLE()
- 配置DMA_HandleTypeDef结构体:方向、地址、数据宽度、优先级等
- 调用HAL_DMA_Init():将配置写入寄存器
- 配置中断(可选):HAL_NVIC_SetPriority() + HAL_NVIC_EnableIRQ()
- 关联外设句柄:比如__HAL_LINKDMA()宏
嗯,这里要注意:很多人忘了第5步。DMA本身是个独立模块,你得告诉SPI或UART“你的数据由DMA1通道3来搬”。这个关联动作,HAL库用宏来实现。
// 标准初始化示例 - SPI1使用DMA2 Stream3
DMA_HandleTypeDef hdma_spi1_tx;
void MX_DMA_Init(void)
{
__HAL_RCC_DMA2_CLK_ENABLE(); // 第一步:开时钟
hdma_spi1_tx.Instance = DMA2_Stream3; // 指定数据流
hdma_spi1_tx.Init.Channel = DMA_CHANNEL_3; // 指定通道
hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; // 内存到外设
hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不递增
hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_spi1_tx.Init.Mode = DMA_NORMAL; // 普通模式
hdma_spi1_tx.Init.Priority = DMA_PRIORITY_LOW;
HAL_DMA_Init(&hdma_spi1_tx); // 写入寄存器
// 关联SPI1句柄
__HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx);
}
我的经验:初始化顺序不能乱。先开时钟再配结构体,这是铁律。我曾经在项目里把这两步写反了,结果DMA寄存器写不进去,查了整整半天。
3.2 传输方向配置:三种模式深度解析
DMA传输方向,HAL库用Direction成员控制。一共三种,咱们一个一个说。
| 方向宏定义 | 含义 | 典型场景 | 地址递增规则 |
|---|---|---|---|
DMA_PERIPH_TO_MEMORY |
外设→内存 | ADC采集、UART接收 | 外设不变,内存递增 |
DMA_MEMORY_TO_PERIPH |
内存→外设 | SPI发送、DAC输出 | 内存递增,外设不变 |
DMA_MEMORY_TO_MEMORY |
内存→内存 | 数据搬移、校验计算 | 源递增,目标递增 |
3.2.1 外设到内存(Periph to Memory)
这是最常用的方向。外设数据寄存器地址固定,内存缓冲区地址递增。比如ADC连续采样,每次转换完成,DMA自动把结果搬到数组里。
配置要点:PeriphInc = DMA_PINC_DISABLE,MemInc = DMA_MINC_ENABLE。外设地址不动,内存地址每次传输后加1(或加2/加4,取决于数据宽度)。
避坑指南:我曾经在UART接收时忘了关外设地址递增,结果DMA每次读的地址都往后跳了一个字节,收到的数据全是乱的。UART的DR寄存器只有一个,千万别让它递增。
3.2.2 内存到外设(Memory to Periph)
和上面正好相反。内存地址递增,外设地址固定。典型场景是SPI发送波形数据,或者DAC输出音频。
配置要点:PeriphInc = DMA_PINC_DISABLE,MemInc = DMA_MINC_ENABLE。嗯,你没看错,和外设到内存的配置一模一样。区别在于Direction不同,DMA控制器会自动判断源和目标。
3.2.3 内存到内存(Memory to Memory)
这个模式比较特殊。它不需要外设触发,配置完成后DMA自己就开始搬。常用于数据拷贝、校验计算。
配置要点:PeriphInc = DMA_PINC_ENABLE,MemInc = DMA_MINC_ENABLE。两个地址都要递增。注意,这个模式下Periph其实也是内存地址,只是DMA把它当“外设”看待。
// 内存到内存配置示例
hdma_mem2mem.Init.Direction = DMA_MEMORY_TO_MEMORY;
hdma_mem2mem.Init.PeriphInc = DMA_PINC_ENABLE; // 源地址递增
hdma_mem2mem.Init.MemInc = DMA_MINC_ENABLE; // 目标地址递增
hdma_mem2mem.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD;
hdma_mem2mem.Init.MemDataAlignment = DMA_MDATAALIGN_WORD;
hdma_mem2mem.Init.Mode = DMA_NORMAL;
我的建议:内存到内存模式慎用。它占用总线带宽,会影响CPU执行指令。我一般只在系统空闲时做数据搬移,或者用DMA2(挂在AHB总线上)来减少冲突。
3.3 数据宽度与对齐:细节决定成败
数据宽度,就是DMA每次传输多少字节。HAL库提供三个选项:BYTE(1字节)、HALFWORD(2字节)、WORD(4字节)。
| 宏定义 | 宽度 | 适用场景 |
|---|---|---|
DMA_PDATAALIGN_BYTE |
8位 | UART、I2C、单字节传感器 |
DMA_PDATAALIGN_HALFWORD |
16位 | 定时器CCR、16位ADC |
DMA_PDATAALIGN_WORD |
32位 | SPI 32位模式、DAC |
这里有个关键点:外设数据宽度和内存数据宽度可以不同。比如外设是8位寄存器,内存是32位数组。DMA会自动做数据打包或拆分。
但要注意对齐问题。STM32的DMA要求:源地址和目标地址必须按各自数据宽度对齐。什么意思?如果内存数据宽度是WORD(4字节),那内存地址必须是4的倍数。否则DMA会进入错误状态。
我曾经踩过的坑:定义一个uint8_t数组,然后强制转换成uint32_t指针传给DMA。结果地址不是4字节对齐,DMA传输到一半卡死了。后来我改用__attribute__((aligned(4)))声明数组,问题解决。
// 正确做法:声明对齐的内存缓冲区
__attribute__((aligned(4))) uint8_t buffer[1024];
// 或者用联合体强制对齐
typedef union {
uint8_t bytes[1024];
uint32_t words[256];
} AlignedBuffer;
3.4 知识体系总览
下面这张图,把DMA初始化的核心逻辑串起来了。你对照着看,思路会更清晰。
3.5 实战配置清单
最后,我整理了一份配置自查清单。每次写DMA初始化代码,我都会对着过一遍。
DMA初始化配置自查清单
- ☐ DMA时钟是否已使能?
- ☐ 传输方向是否正确?(外设→内存 / 内存→外设 / 内存→内存)
- ☐ 外设地址递增是否关闭?(固定地址的外设必须关)
- ☐ 内存地址递增是否开启?(连续缓冲区必须开)
- ☐ 外设数据宽度是否匹配外设寄存器宽度?
- ☐ 内存数据宽度是否匹配缓冲区元素宽度?
- ☐ 内存缓冲区地址是否按数据宽度对齐?
- ☐ 传输模式选择是否正确?(NORMAL / CIRCULAR / PFCTRL)
- ☐ 优先级是否合理?(避免低优先级任务饿死)
- ☐ 是否通过__HAL_LINKDMA()关联了外设句柄?