2. DMA配置实战:CubeMX配置、MDK工程建立、外设到内存传输、内存到外设传输

DMA这东西,说白了就是帮CPU干苦力的。CPU本来要亲自把数据从外设搬到内存,或者从内存搬到外设,现在DMA代劳了,CPU就能腾出手来处理更重要的逻辑。我刚开始接触DMA时,总觉得配置起来很麻烦,后来发现只要理清三个要素——源地址、目的地址、传输长度——剩下的就是工具链的事了。

这一章我们直接动手。从CubeMX生成代码开始,到MDK里建立工程,再到两种经典传输模式:外设到内存、内存到外设。你跟着走一遍,以后DMA配置就是肌肉记忆。

2.1 用CubeMX配置DMA

我个人习惯,所有外设初始化都先在CubeMX里搞定。图形化界面点一点,比手写寄存器快得多,也不容易漏配置。

2.1.1 选择DMA通道

打开CubeMX,选好你的芯片型号(我以STM32F407为例)。在Pinout & Configuration标签页里,找到DMA设置。你会看到DMA1和DMA2两个控制器,每个控制器下面有多个Stream(数据流)。

选哪个Stream?记住一个原则:外设的DMA请求是固定的。比如USART1的发送请求只能连到DMA2的Stream7,接收请求只能连到Stream5。你可以在参考手册的DMA请求映射表里查到。

外设 请求方向 DMA控制器 Stream 通道
USART1_TX 内存→外设 DMA2 Stream7 通道4
USART1_RX 外设→内存 DMA2 Stream5 通道4
SPI1_TX 内存→外设 DMA2 Stream3 通道3
ADC1 外设→内存 DMA2 Stream0 通道0

嗯,这里要注意:同一个Stream不能同时服务两个外设。我在项目里就踩过这个坑,ADC和定时器抢同一个Stream,结果数据全乱了。

2.1.2 配置参数

选好Stream后,点Add按钮,配置以下参数:

  • Direction:Peripheral to Memory 或 Memory to Peripheral
  • Priority:一般选Medium,实时性要求高的选High
  • Mode:Normal(单次传输)或 Circular(循环传输)
  • Data Width:Byte、Half Word、Word,必须和外设寄存器宽度一致
  • Increment Address:外设地址一般不递增,内存地址递增
我的经验:ADC采集多通道时,一定要把Memory的Increment Address勾上,否则每次采集都会覆盖同一个地址。我刚开始做数据采集时忘了勾,折腾了一下午才发现。

2.1.3 生成代码

配置完点Project → Generate Code。CubeMX会自动生成MX_DMA_Init()函数,以及外设的DMA初始化代码。你不需要手动写寄存器,但建议打开生成的代码看一眼,理解HAL库是怎么配置的。

2.2 MDK工程建立与代码整合

CubeMX生成的代码是HAL库风格的。如果你用MDK(Keil),直接打开生成的.uvprojx文件就行。我个人习惯把用户代码写在/* USER CODE BEGIN */和/* USER CODE END */之间,这样重新生成代码时不会被覆盖。

2.2.1 工程结构

打开工程后,你会看到这些关键文件:

  • main.c:主函数,外设初始化调用
  • stm32f4xx_hal_msp.c:外设的GPIO、DMA、NVIC配置
  • stm32f4xx_it.c:中断服务函数
  • dma.c:DMA初始化函数

别急着写代码。先编译一次,确保工程能通过。我见过太多人上来就写逻辑,结果编译报错一堆,最后发现是CubeMX生成的代码版本和HAL库不匹配。

2.2.2 添加用户代码

在main.c的USER CODE区域,添加DMA传输的启动函数。比如:

/* USER CODE BEGIN 2 */
HAL_UART_Transmit_DMA(&huart1, tx_buffer, BUFFER_SIZE);
HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE);
/* USER CODE END 2 */

启动后,DMA会自动工作。你只需要在传输完成中断里处理数据。

2.3 外设到内存传输实战

这个场景最常见:ADC采集数据、UART接收数据、SPI读取传感器数据。数据从外设寄存器自动搬到内存数组里,CPU不用管。

2.3.1 配置要点

  • 外设地址:固定不变,比如ADC的DR寄存器地址
  • 内存地址:递增,指向一个数组
  • 传输长度:等于你要采集的数据个数
  • 数据宽度:和外设寄存器位宽一致,ADC一般是16位

核心逻辑:外设每产生一个数据,DMA就自动搬运一次。搬运完指定次数后,触发传输完成中断。

2.3.2 代码示例

以ADC多通道采集为例:

#define ADC_BUFFER_SIZE 100
uint16_t adc_buffer[ADC_BUFFER_SIZE];

// 启动DMA传输
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);

// 传输完成回调
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
    // 数据已经在adc_buffer里了,直接处理
    ProcessADCData(adc_buffer, ADC_BUFFER_SIZE);
    
    // 重新启动下一次采集
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);
}

你看,主循环里根本不用管数据搬运的事。CPU可以专心做数据处理、显示、通信。

2.4 内存到外设传输实战

反过来,把内存里的数据发送到外设。比如UART发送数据、SPI发送波形数据、DAC输出波形。

2.4.1 配置要点

  • 内存地址:递增,指向发送数据数组
  • 外设地址:固定不变,比如UART的DR寄存器
  • 传输长度:要发送的数据字节数
  • 循环模式:如果你要持续输出波形,用Circular模式
注意:内存到外设传输时,一定要确保数据在传输过程中不会被修改。我遇到过在中断里改了发送缓冲区,结果发出去的数据是乱的。建议用双缓冲机制,一个在发,一个在准备。

2.4.2 代码示例

以UART发送为例:

#define TX_BUFFER_SIZE 256
uint8_t tx_buffer[TX_BUFFER_SIZE];

// 填充数据
sprintf((char*)tx_buffer, "Hello DMA!\r\n");

// 启动DMA发送
HAL_UART_Transmit_DMA(&huart1, tx_buffer, strlen((char*)tx_buffer));

// 发送完成回调
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    // 发送完成,可以准备下一包数据
    PrepareNextPacket();
}

用DMA发送UART数据,CPU占用率几乎为零。我做过一个项目,需要同时用4个UART口以115200波特率发送数据,用DMA后CPU占用率不到5%。

2.5 两种传输模式对比

对比项 外设→内存 内存→外设
典型应用 ADC采集、UART接收 UART发送、DAC输出
地址递增 外设固定,内存递增 内存递增,外设固定
触发源 外设数据就绪 外设发送请求
循环模式 连续采集数据 持续输出波形
中断触发 传输完成或半传输 传输完成或半传输

2.6 知识结构图

下面这张图帮你理清DMA配置的整体流程:

DMA配置实战知识结构 CubeMX图形化配置 选择DMA通道/Stream 配置传输参数 生成初始化代码 MDK工程建立与代码整合 外设 → 内存 ADC采集 / UART接收 内存 → 外设 UART发送 / DAC输出 关键配置:源地址 | 目的地址 | 传输长度 | 数据宽度 | 地址递增 模式选择:Normal / Circular | 优先级:Low / Medium / High

2.7 避坑指南

最后分享几个我实际项目中踩过的坑:

  • DMA时钟没使能:CubeMX一般会自动处理,但如果你手动改过时钟树,记得检查RCC里DMA的时钟是否打开。
  • 缓冲区对齐:DMA传输要求缓冲区地址按数据宽度对齐。比如Word传输,缓冲区地址必须是4的倍数。我遇到过数组定义在非对齐地址上,结果DMA传输数据错位。
  • 中断优先级:DMA传输完成中断的优先级不要设得太低,否则在高负载下可能丢数据。我一般设成比主循环高、比实时中断低。
  • 半传输中断:Circular模式下,用半传输中断可以实现双缓冲。数据填满前半段时,CPU处理前半段,DMA继续填后半段,效率翻倍。
一个小技巧:调试DMA时,可以在传输完成中断里翻转一个GPIO,用示波器看波形。如果波形间隔均匀,说明DMA工作正常。如果波形忽长忽短,检查中断响应时间或DMA优先级。

好了,这一章的内容就是这些。你跟着配置一遍,跑通两个例程,DMA的基本功就算打牢了。以后遇到大数据量传输的场景,你第一个想到的就会是DMA。