STM32 DMA存储器到存储器传输实战:从原理到代码配置详解

最近在嵌入式开发中,经常需要处理大量数据在内存与外设(如ADC、DAC、UART)之间的搬运。如果全靠CPU来“手动”搬运,不仅效率低下,还会严重占用CPU资源,导致系统响应变慢。这时,DMA(直接存储器访问)技术就成了解放CPU、提升系统性能的关键。本文将围绕STM32的DMA控制器,以“19 DMA 19DMA-04”这一典型应用或学习模块为切入点,手把手带你从零理解DMA原理,并完成一个完整的实战项目:使用DMA实现存储器到存储器的数据高效传输。无论你是刚接触STM32的新手,还是想深化DMA应用的开发者,这篇从概念到代码、从配置到排错的系统教程都能让你直接复用。

1. DMA核心概念:为什么它是嵌入式开发的“效率神器”?

在深入代码之前,我们必须搞清楚DMA到底是什么,以及它为何如此重要。

DMA(Direct Memory Access),中文译为直接存储器访问。它是一种允许特定硬件子系统(外设)直接读写系统内存,而无需中央处理器(CPU)介入的技术。你可以把CPU想象成公司的总经理,把数据搬运想象成搬运货物。没有DMA时,总经理(CPU)需要亲自去仓库(内存)取货,再搬到卡车(外设)上,整个过程他什么别的活都干不了。有了DMA后,总经理只需要雇佣一个专业的搬运队(DMA控制器),告诉搬运队“从A仓库搬100箱货到B卡车”,然后就可以去处理其他重要会议(执行核心算法、响应中断等)了。搬运队独立完成工作,完成后通知一下总经理即可。

DMA解决的核心问题

  1. 解放CPU:将CPU从繁琐的、耗时的数据搬运工作中解脱出来,专注于计算、逻辑控制和紧急事件响应。
  2. 提高数据吞吐率:DMA控制器通常针对数据块传输进行优化,传输速度远高于CPU通过加载/存储指令的单个操作。
  3. 实现高实时性:在音频流、图像采集、高速通信等场景中,数据必须按时到达,DMA能确保数据流的连续性,避免因CPU处理其他任务而导致的数据丢失。

在STM32中的常见应用场景

  • 外设到存储器:ADC采集数据直接存入数组、UART接收大量数据到缓冲区。
  • 存储器到外设:从数组发送数据到DAC生成波形、通过UART发送大块数据。
  • 存储器到存储器:内存内部的数据块复制、快速填充或搬移数据。这正是我们本文实战的重点(19DMA-04通常指向此类实验)。

相关重要概念

  • 通道(Channel):DMA控制器有多个通道,每个通道服务于一个或多个特定的外设(如ADC1、USART1_TX)。对于存储器到存储器的传输,可以选用未被外设占用的通道。
  • 仲裁器(Arbiter):当多个通道同时请求DMA传输时,仲裁器根据优先级(软件可配置)决定哪个通道先被服务。
  • 传输计数器(NDTR):指定要传输的数据项数量。每完成一次传输,计数器减1。
  • 外设/存储器地址(PAR/MAR):分别指向数据传输的源地址和目的地址。在存储器和存储器模式下,这两个地址都指向内存空间。
  • 数据宽度(PSIZE/MSIZE):可以配置为字节(8位)、半字(16位)或字(32位),需与地址对齐要求匹配以提高效率。

理解了这些,我们就知道,配置DMA本质上是“雇佣并指挥搬运队”的过程:告诉它从哪里搬(源地址)、搬到哪里去(目的地址)、搬多少(数据量)、怎么搬(数据宽度、是否循环等)。

2. 环境准备与硬件平台说明

在开始编程前,我们需要明确开发环境。不同的STM32系列(如F1、F4、H7)其DMA控制器(如DMA1, DMA2)和库函数(标准库、HAL库、LL库)略有差异,但核心思想和配置流程高度相似。

本文实战环境

  • MCU型号:STM32F103C8T6(Blue Pill开发板,性价比高,资料丰富)。其DMA控制器为DMA1,拥有7个通道。
  • 开发环境:Keil MDK-ARM (uVision5) 或 STM32CubeIDE。
  • 编程库:STM32标准外设库(Standard Peripheral Library)。虽然ST主推HAL/LL库,但标准库代码更贴近寄存器,易于理解原理。理解了标准库,迁移到HAL库将非常轻松。
  • 核心功能:使用DMA1的某个通道(例如通道1),实现将一段内存区域(数组srcBuffer)的数据,搬运到另一段内存区域(数组dstBuffer)。

项目工程结构预览: 在开始前,你的工程应该包含以下关键文件:

Your_Project/ ├── CMSIS/ (Cortex微控制器软件接口标准文件,由Keil或CubeIDE自动添加) ├── FWlib/ (STM32标准外设库文件,如`stm32f10x_dma.c/.h`, `stm32f10x_rcc.c/.h`等) ├── User/ │ ├── main.c (主程序,包含DMA配置和测试逻辑) │ ├── stm32f10x_conf.h (外设库配置文件) │ └── ...其他用户文件 └── ...(工程配置文件)

确保在stm32f10x_conf.h中已经取消了#define USE_FULL_ASSERT的注释(用于调试),并确保包含了DMA和外设时钟的头文件。

3. DMA传输模式与关键配置结构体详解

STM32标准库通过初始化结构体来配置DMA。我们以DMA_InitTypeDef为核心,拆解每一个关键成员。

// 文件路径:User/main.c (部分代码) #include "stm32f10x.h" #include "stm32f10x_dma.h" #include "stm32f10x_rcc.h" #include <stdio.h> // 用于printf调试,需重定向串口 #define BUFFER_SIZE 32 uint32_t srcBuffer[BUFFER_SIZE]; uint32_t dstBuffer[BUFFER_SIZE];

DMA_InitTypeDef 结构体成员精讲

DMA_InitTypeDef DMA_InitStructure;
  1. DMA_PeripheralBaseAddr (外设基地址)

    • 作用:数据传输的源地址或目的地址之一(取决于数据传输方向)。在存储器到存储器模式下,它作为源地址
    • 配置:设置为源数组的首地址,例如(uint32_t)srcBuffer
    • 注意:地址必须是32位对齐的(因为我们将数据宽度设为字)。
  2. DMA_MemoryBaseAddr (存储器基地址)

    • 作用:数据传输的另一个地址。在存储器到存储器模式下,它作为目的地址
    • 配置:设置为目的数组的首地址,例如(uint32_t)dstBuffer
  3. DMA_DIR (传输方向)

    • 作用:定义数据流的方向。
    • 取值
      • DMA_DIR_PeripheralDST:从存储器到外设。(此时Peripheral是目的)
      • DMA_DIR_PeripheralSRC:从外设到存储器。(此时Peripheral是源)
      • 对于存储器到存储器模式:ST标准库中,该模式是通过使能DMA_CCR寄存器中的MEM2MEM位单独控制的,DMA_DIR通常设置为DMA_DIR_PeripheralSRC(将Peripheral端视为源,Memory端视为目的),但关键在于开启MEM2MEM。更清晰的逻辑是:源是DMA_PeripheralBaseAddr,目的是DMA_MemoryBaseAddr
  4. DMA_BufferSize (缓冲区大小)

    • 作用:指定要传输的数据项数量。即传输计数器(NDTR)的初始值。
    • 配置:例如BUFFER_SIZE。传输完成后,该值会被硬件自动递减为0。
  5. DMA_PeripheralInc (外设地址递增)

    • 作用:每次传输后,外设地址是否自动递增。在存储器到存储器模式下,“外设”端就是我们的源内存地址。
    • 取值DMA_PeripheralInc_EnableDMA_PeripheralInc_Disable
    • 配置:如果要从一个连续的数组搬运数据,必须设置为Enable,否则每次都会从同一个地址读取数据。
  6. DMA_MemoryInc (存储器地址递增)

    • 作用:每次传输后,存储器地址是否自动递增。在存储器到存储器模式下,“存储器”端就是我们的目的内存地址。
    • 配置:同样,为了将数据顺序存入目的数组,必须设置为Enable
  7. DMA_PeripheralDataSize (外设数据宽度)

  8. DMA_MemoryDataSize (存储器数据宽度)

    • 作用:定义单次传输的数据单元大小(字节、半字、字)。两者必须保持一致
    • 取值DMA_PeripheralDataSize_Byte/ HalfWord/ Word
    • 配置:根据数组的数据类型决定。我们的srcBufferdstBufferuint32_t数组,所以设置为DMA_PeripheralDataSize_WordDMA_MemoryDataSize_Word(即32位/4字节)。这能最大化总线利用效率。
  9. DMA_Mode (模式)

    • 作用:选择DMA通道是工作在单次模式还是循环模式。
    • 取值
      • DMA_Mode_Normal:单次模式。传输完指定数量的数据后,DMA通道自动停止,需要软件重新使能才能再次传输。
      • DMA_Mode_Circular:循环模式。传输完成后,硬件自动重装传输计数器(NDTR)和地址寄存器,立即开始新一轮传输。适用于需要持续数据流的场景(如ADC连续采样)。本文示例使用Normal模式。
  10. DMA_Priority (优先级)

    • 作用:当多个DMA通道同时请求时,仲裁器根据此优先级决定服务顺序。
    • 取值DMA_Priority_VeryHigh/ High/ Medium/ Low
    • 配置:在存储器到存储器传输中,通常设为DMA_Priority_High,因为这种传输通常希望尽快完成,释放总线。
  11. DMA_M2M (存储器到存储器模式)

    • 作用这是启用存储器到存储器传输的关键位!
    • 取值DMA_M2M_EnableDMA_M2M_Disable
    • 配置:必须设置为DMA_M2M_Enable。当此位使能时,DMA传输将由软件触发(通过DMA_Cmd使能通道),而不是由外设硬件请求触发。

4. 完整实战:DMA存储器到存储器数据传输

下面我们一步步构建一个完整的、可验证的DMA传输示例。

4.1 初始化数据缓冲区

首先,我们准备源数据和清零目的缓冲区,以便验证。

// 文件路径:User/main.c (续) int main(void) { // 1. 初始化缓冲区 for(int i = 0; i < BUFFER_SIZE; i++) { srcBuffer[i] = i * 2; // 源数据:0, 2, 4, 6, ... dstBuffer[i] = 0; // 目的数据清零 } // ... 后续代码 }

4.2 配置系统时钟与DMA时钟

DMA作为AHB总线上的外设,其时钟由RCC(复位和时钟控制)模块管理。必须使能对应的时钟。

// 2. 开启DMA1时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
  • 为什么?在STM32中,任何外设在使用前都必须开启其时钟,这是为了降低功耗。DMA1的时钟在AHB总线上。

4.3 详细配置DMA初始化结构体

根据第3部分的讲解,我们填充结构体。

// 3. 配置DMA初始化结构体 DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel1); // 将DMA1通道1寄存器重置为默认值 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)srcBuffer; // 源地址 DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)dstBuffer; // 目的地址 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; // 传输方向:外设为源 DMA_InitStructure.DMA_BufferSize = BUFFER_SIZE; // 传输数据项数量 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Enable; // 源地址递增 DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 目的地址递增 DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Word; // 数据宽度:字 DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Word; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; // 单次模式 DMA_InitStructure.DMA_Priority = DMA_Priority_High; // 通道优先级高 DMA_InitStructure.DMA_M2M = DMA_M2M_Enable; // 使能存储器到存储器模式! DMA_Init(DMA1_Channel1, &DMA_InitStructure); // 将配置写入DMA1通道1的寄存器

4.4 启动DMA传输并等待完成

配置完成后,使能通道即可启动传输。由于是软件触发(M2M模式),无需等待外部事件。

// 4. 使能DMA通道,启动传输 DMA_Cmd(DMA1_Channel1, ENABLE); // 5. 等待传输完成(轮询方式) while(DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET) { // 传输未完成,在此等待。在实际应用中,可以在此处执行其他低优先级任务。 // DMA1_FLAG_TC1 是 DMA1 通道1传输完成标志位。 } // 传输完成,清除标志位 DMA_ClearFlag(DMA1_FLAG_TC1);
  • 轮询 vs 中断:这里使用了最简单的轮询方式等待传输完成标志。在真实项目中,如果传输数据量大或CPU需要及时响应其他事件,应该使用DMA传输完成中断。在中断服务函数中处理完成后的逻辑(如设置标志、通知任务等),这样CPU在DMA工作时是完全自由的。

4.5 验证传输结果

最后,我们检查目的数组的数据是否与源数组一致。

// 6. 验证传输结果 uint8_t transferSuccess = 1; for(int i = 0; i < BUFFER_SIZE; i++) { if(dstBuffer[i] != srcBuffer[i]) { transferSuccess = 0; break; } } if(transferSuccess) { // 可以通过串口打印成功信息,或者点亮LED // printf("DMA Memory-to-Memory Transfer Succeeded!\n"); GPIO_SetBits(GPIOC, GPIO_Pin_13); // 假设LED连接在PC13,低电平点亮 } else { // printf("DMA Transfer Failed!\n"); // 错误处理... } while(1) { // 主循环 } }

为了看到效果,你需要初始化一个GPIO(如PC13,连接了Blue Pill板载LED)并在验证成功后控制它。同时,如果需要printf,需提前重定向串口(例如USART1)到调试器。

完整流程总结

  1. 准备数据:初始化源和目的缓冲区。
  2. 开时钟:使能DMA控制器时钟。
  3. 配参数:填充DMA_InitTypeDef结构体,核心是设置源/目的地址、数据量、地址递增、数据宽度,并务必使能DMA_M2M
  4. 启传输:调用DMA_Cmd使能通道。
  5. 等完成:轮询或中断等待传输完成标志。
  6. 验结果:比对数据,确认传输正确性。

5. 常见问题与深度排查指南

在实际操作中,你可能会遇到DMA不工作、数据错误等问题。下面是一个系统的排查清单。

问题现象可能原因排查步骤与解决方案
DMA根本不启动,标志位永不置位1. DMA时钟未开启。
2.DMA_M2M模式未使能。
3. 源/目的地址非法或未对齐。
4. 缓冲区大小(BufferSize)为0。
1. 检查RCC_AHBPeriphClockCmd(DMA1, ENABLE)是否已调用。
2. 确认DMA_InitStructure.DMA_M2M = DMA_M2M_Enable
3. 检查地址是否为有效的内存地址(如全局数组)。对于字传输,地址必须是4字节对齐(即地址低2位为0)。
4. 确保BUFFER_SIZE是一个大于0的整数。
数据只复制了一部分或目的地址数据错误1. 地址递增配置错误。
2. 数据宽度配置错误。
3. 传输过程中被更高优先级中断打断,且未处理好。
1. 确认PeripheralIncMemoryInc根据需求正确设置(连续传输必须Enable)。
2. 确认PeripheralDataSizeMemoryDataSize一致,且与数组数据类型匹配(uint8_t用Byte,uint16_t用HalfWord,uint32_t用Word)。
3. 检查中断优先级。DMA传输本身不能被中断,但CPU访问同一总线资源可能受影响。确保关键代码段或DMA配置过程的原子性。
传输完成后,源数据被破坏存储器到存储器模式下,DMA读操作不会破坏源数据。如果发生,极可能是:
1. 源和目的地址区域发生重叠,且传输模式/方向导致覆盖。
2. 程序其他部分修改了源数据。
1. 确保源和目的缓冲区是独立的、不重叠的内存区域。
2. 使用调试器观察源数组地址,在传输前后设置断点,查看是否被其他代码修改。
使用中断时,中断服务函数不执行1. DMA通道的中断未使能。
2. NVIC(嵌套向量中断控制器)未配置。
3. 中断服务函数名写错或未实现。
4. 中断标志未正确清除。
1. 在DMA初始化后,调用DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE)使能传输完成中断。
2. 配置NVIC,设置优先级并使能DMA1通道1中断:NVIC_Init(...)
3. 在stm32f10x_it.c中正确定义中断函数void DMA1_Channel1_IRQHandler(void)
4. 在中断函数内,检查标志位后必须调用DMA_ClearITPendingBit(DMA1_IT_TC1)清除中断标志。
调试时发现DMA传输速度不如预期1. 系统时钟(SYSCLK)或AHB总线时钟(HCLK)配置过低。
2. 使用了字节或半字传输,而总线是32位的,未能充分利用带宽。
3. 内存访问冲突(如同时访问Flash)。
1. 检查系统时钟配置,确保运行在最高允许频率(对于F103,通常为72MHz)。
2. 在地址对齐允许的情况下,尽量使用字(Word)传输。
3. 对于需要极致速度的场合,考虑将缓冲区放在SRAM中(默认就是),并避免在DMA传输期间进行大量的Flash读取操作。

6. 工程最佳实践与进阶思考

掌握了基础操作后,将这些知识安全、高效地应用到实际项目中,还需要遵循一些最佳实践。

6.1 内存管理与地址对齐

  • 使用全局变量或静态变量:确保DMA缓冲区的生命周期足够长,避免使用栈上的局部变量(函数退出后失效)。
  • 地址对齐:这是性能和安全的关键。对于字传输,源和目的地址都应是4字节对齐。编译器通常会对齐全局数组。你可以使用GCC/ARMCC的特定属性(如__attribute__((aligned(4))))来显式指定对齐。不对齐的访问在某些架构上会导致硬件错误(HardFault)。
  • 使用volatile关键字:对于被DMA和CPU共同访问的缓冲区(例如DMA写入,CPU读取),应使用volatile关键字声明,防止编译器进行错误的优化(如将读操作缓存到寄存器)。
volatile uint32_t dma_buffer[1024] __attribute__((aligned(4)));

6.2 中断与事件驱动设计

  • 避免轮询:在生产代码中,强烈建议使用DMA完成中断(TC)、半传输中断(HT)或传输错误中断(TE),而不是轮询标志位。这能极大提高CPU效率。
  • 中断服务函数(ISR)要短小精悍:在ISR中只做最必要的操作,如设置事件标志、释放信号量、递增计数器等。复杂的处理应放到主循环或任务中。
  • 合理设置中断优先级:DMA中断的优先级应根据业务紧急程度设置。注意,如果DMA服务于一个高实时性外设(如ADC),其完成中断的优先级应高于系统中其他大部分中断。

6.3 双缓冲区与循环DMA

  • 双缓冲区(Ping-Pong Buffer):在处理连续数据流(如音频)时,可以设置两个缓冲区。当DMA向缓冲区A填充数据时,CPU处理缓冲区B的数据;DMA完成后自动切换到缓冲区B,CPU则处理缓冲区A。这能实现无缝的数据处理,避免丢失。
  • 循环DMA模式(Circular Mode):对于永不停止的数据流,配置DMA_Mode_Circular。DMA在传输完指定数量数据后,会自动重载计数器和地址,重新开始传输。结合双缓冲区和半传输中断(HT)、传输完成中断(TC),可以高效地管理数据。

6.4 安全与健壮性

  • 传输前校验参数:在启动DMA前,检查缓冲区大小是否为正,地址是否有效(非NULL)。
  • 处理传输错误:使能DMA传输错误中断(DMA_IT_TE),并在中断中记录错误、复位DMA通道,进行错误恢复。
  • 防止缓冲区溢出:确保BufferSize不大于实际分配的缓冲区大小。
  • 临界区保护:如果DMA的配置(如改变目标地址)可能被中断或其他任务打断,应在操作前后使用关中断或互斥锁进行保护。

6.5 从标准库迁移到HAL库

如果你使用STM32CubeMX和HAL库,概念完全一致,但API不同。核心步骤:

  1. 在CubeMX图形界面中启用DMA通道,并配置参数(模式、优先级、数据宽度等)。
  2. 生成的代码会初始化DMA句柄(hdma_xxx)。
  3. 使用HAL_DMA_Start()HAL_DMA_Start_IT()(中断方式)启动传输。
  4. 传输完成回调函数是HAL_DMA_XferCpltCallback()

HAL库封装程度更高,但原理相通。理解标准库的寄存器级操作,能让你在使用HAL库时更从容地排查底层问题。

通过以上从理论到实践,从配置到排错,再到最佳实践的系统学习,你应该已经能够独立在STM32项目中驾驭DMA,特别是存储器到存储器传输这一强大功能。它不仅是优化性能的工具,更是构建高效、实时嵌入式系统的基石。