1. 项目概述:从“轮询”到“解放CPU”的思维跃迁

作为一名从8位AVR单片机一路摸爬滚打过来的嵌入式开发者,初次接触STM32时,最让我感到震撼的并非其宣称的32位性能,而是一种编程范式的转变。过去,我们习惯了“CPU中心论”:ADC转换完成了吗?我去读一下。串口数据来了吗?我去收一下。这种轮询(Polling)或中断(Interrupt)的方式,让CPU像个忙碌的管家,事无巨细都要亲自过问。直到我遇到了DMA(Direct Memory Access,直接存储器访问),才真正体会到什么是“让专业的人做专业的事”。DMA就像一个高效、沉默的搬运工,能在内存(Memory)与外设(Peripheral)寄存器之间,或者内存与内存之间,自主地搬运数据,整个过程完全不需要CPU的干预。这不仅仅是提升效率,更是将CPU从繁琐的I/O数据搬运中彻底解放出来,使其能专注于逻辑计算、任务调度等核心事务。对于实时性要求高的应用,如高速数据采集(ADC)、音频流处理(I2S)、图像传输(DCMI)或网络通信(ETH),DMA几乎是不可或缺的利器。本文,我将结合一个具体的ADC多通道采集并通过串口发送的实例,拆解STM32 DMA模块的核心原理、配置细节以及那些手册上不会写的实战心得,希望能帮你绕过我踩过的坑,快速掌握这把利器。

2. DMA核心机制与架构深度解析

2.1 DMA的本质:数据高速公路的独立交警系统

你可以把STM32的内部总线想象成一个城市的交通网络。CPU是市长,负责决策和复杂运算;各种外设(ADC、USART、SPI等)是分布在城市各处的工厂、仓库;内存(SRAM)则是中央物流园区。在没有DMA的时代,市长(CPU)需要亲自开车去工厂取货(读ADC数据),再开车送到物流园区(写入内存),或者从物流园区取货送到港口(USART发送)。市长的大部分时间都浪费在了路上。

DMA的出现,相当于建立了一套独立的、高效的“物流交警系统”。这套系统拥有自己的运输车队(DMA控制器),熟知所有交通规则(总线协议),并且能直接接收工厂(外设)和物流园区(内存)的送货/取货指令。一旦市长(CPU)签发了运输单(初始化DMA并启动),具体的运输工作就全权交给了DMA交警系统。市长可以回去处理更重要的市政规划(运行用户应用程序)。

在STM32中,DMA控制器是一个独立于Cortex-M内核的硬件模块,它拥有自己的时钟(通常由AHB总线提供),能够直接操作总线矩阵,访问内存和外设寄存器。其核心能力是进行“数据搬运”,搬运的源和目的地可以是:

  1. 外设寄存器 ↔ 内存
  2. 内存 ↔ 内存
  3. 外设寄存器 ↔ 外设寄存器(特定型号支持)

这种搬运是“透明”的,对CPU来说,它只看到源地址的数据最终出现在了目的地址,而不知道中间复杂的搬运过程。

2.2 STM32 DMA的通道、流与仲裁机制

不同系列的STM32,其DMA架构略有差异。以经典的STM32F1系列和更先进的STM32F4/F7/H7系列为例:

STM32F1系列 :结构相对简单,主要概念是“通道(Channel)”。DMA1有7个通道(CH1-CH7),DMA2有5个通道(CH1-CH5)。每个通道与特定的外设请求源固定映射。例如,DMA1的通道1可能映射到ADC1,通道4映射到USART1的发送(TX),通道5映射到USART1的接收(RX)。当多个通道同时请求时,通过硬件优先级(VeryHigh/High/Medium/Low)进行仲裁。

STM32F4/F7/H7系列 :引入了更灵活的“流(Stream)”和“通道(Channel)”概念。以STM32F4为例,有2个DMA控制器(DMA1/DMA2),每个控制器有8个流(Stream0-Stream7),每个流可以映射到多达8个通道(Channel0-7)之一。这里的“通道”指的是请求源(如USART1_RX, TIM2_CH3等)。这种设计带来了极大的灵活性:你可以将Stream5分配给USART1_RX,也可以将它分配给ADC1,只需在配置时选择对应的通道号即可。优先级仲裁也更为精细。

注意 :本文的实例基于STM32F1系列,但其配置思想和核心概念完全适用于其他系列,主要区别在于初始化结构体和映射关系,使用时务必查阅对应型号的参考手册。

2.3 关键配置参数背后的设计逻辑

初始化DMA时,我们需要填充一个 DMA_InitTypeDef 结构体。每一个成员都不是随意设置的,背后都有其硬件逻辑:

  • DMA_PeripheralBaseAddr DMA_MemoryBaseAddr :这是搬运的起点和终点。地址必须是物理地址,并且要根据数据对齐方式(半字、字)确保地址是对齐的。例如,如果你设置数据宽度为半字(16位),那么这两个地址最好都是2字节对齐的,否则可能引发硬件错误或性能下降。
  • DMA_DIR :方向控制。 PeripheralSRC 表示外设是源,内存是目的地(如ADC采集); PeripheralDST 表示外设是目的地,内存是源(如USART发送)。这决定了数据流的方向。
  • DMA_BufferSize :缓冲区大小。这个参数的单位是“数据项”的数量,具体数据项的大小由 DMA_PeripheralDataSize DMA_MemoryDataSize 决定。它定义了DMA单次传输(或循环模式下单轮)要搬运多少个数据项。这是防止内存越界的关键。
  • DMA_PeripheralInc DMA_MemoryInc :地址递增模式。当需要从外设的单一寄存器(如ADC的数据寄存器DR)连续读取多个数据时,外设地址应设为 Disable 。而当DMA需要将数据搬运到内存中的一个数组时,内存地址必须设为 Enable ,这样每搬运一个数据后,目标地址会自动跳到下一个元素的位置。
  • DMA_PeripheralDataSize DMA_MemoryDataSize :数据宽度。必须与外设和内存中数据的实际宽度匹配。ADC通常为12位,结果存放在16位寄存器中,所以常用 HalfWord 。如果源和目的宽度不一致(如字节到半字),DMA控制器会自动进行打包或解包,但这需要额外总线周期,且配置更复杂,初学者建议保持一致。
  • DMA_Mode :传输模式。 Normal (普通模式):传输完 BufferSize 个数据后,DMA通道自动停止,需要软件重新使能才能再次传输。 Circular (循环模式):传输完缓冲区数据后,地址指针自动回到起始位置,周而复始,非常适合连续、不间断的数据流采集或发送。
  • DMA_Priority :优先级。当多个DMA通道/流同时发起请求时,仲裁器根据此优先级决定谁先使用总线。在实时系统中,需要根据任务紧要程度合理分配。
  • DMA_M2M :内存到内存模式。此模式使能后,DMA可以在两个内存区域间搬运数据,且传输由软件触发(设置 DMA_Cmd 后立即开始),而非外设请求。这在初始化大片内存数据或进行数据块拷贝时非常高效。

3. 实战:ADC多通道采集与USART发送的DMA配置全流程

下面,我将以STM32F103C8T6为例,详细展示如何配置DMA来实现ADC1的两个通道(PA0, PA1)连续采集,并通过USART1以9600波特率将数据实时发送到PC端串口助手。

3.1 系统架构与工程搭建

首先明确数据流: ADC1 (外设源) -> DMA -> 内存数组ADC_ConvertedValue[2] -> USART1 (外设目标)。这里涉及两个DMA传输:

  1. ADC到内存:由ADC转换完成信号触发,DMA负责搬运。
  2. 内存到USART:可以由定时器触发,或者在我们的例子里,为了简化,在主循环中查询标志位后手动启动DMA传输(实际更高效的做法是用另一个DMA通道或定时器触发)。

我们使用标准外设库(Standard Peripheral Library)进行开发。工程目录应清晰包含:用户代码( main.c , stm32f10x_conf.h , stm32f10x_it.c 等)、库文件( CMSIS StdPeriph_Driver )、以及链接脚本和启动文件。

3.2 外设与DMA的详细配置代码与注释

第一步:时钟使能(RCC) 这是最容易忽略却至关重要的一步!DMA、ADC、USART、GPIO以及它们所在的总线(AHB, APB2)时钟都必须使能。

// 在main函数初始化部分
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1 | RCC_APB2Periph_USART1, ENABLE);
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // DMA1时钟在AHB总线上
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); // 如果需要重映射,需开启AFIO

第二步:GPIO配置 配置PA0、PA1为模拟输入模式(ADC),PA9(TX)、PA10(RX)为复用推挽输出和浮空输入模式(USART)。

GPIO_InitTypeDef GPIO_InitStructure;
// ADC引脚
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入
GPIO_Init(GPIOA, &GPIO_InitStructure);

// USART引脚
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入
GPIO_Init(GPIOA, &GPIO_InitStructure);

第三步:ADC配置(多通道扫描+连续转换+DMA) 目标是让ADC1连续扫描通道0和1,每个转换完成都通过DMA请求将数据搬走。

ADC_InitTypeDef ADC_InitStructure;
DMA_InitTypeDef DMA_InitStructure;

// 1. 先复位并初始化ADC
ADC_DeInit(ADC1);
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; // 独立模式
ADC_InitStructure.ADC_ScanConvMode = ENABLE; // 扫描模式(多通道必须开启)
ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; // 连续转换模式
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; // 软件触发
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; // 数据右对齐
ADC_InitStructure.ADC_NbrOfChannel = 2; // 要转换的通道数量
ADC_Init(ADC1, &ADC_InitStructure);

// 2. 配置ADC规则组通道及其采样时间
// 规则组序列1:通道0,采样时间55.5个周期
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
// 规则组序列2:通道1,采样时间55.5个周期
ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5);

// 3. 使能ADC的DMA请求
ADC_DMACmd(ADC1, ENABLE);
// 4. 使能ADC
ADC_Cmd(ADC1, ENABLE);

// 5. ADC校准(提高精度)
ADC_ResetCalibration(ADC1);
while(ADC_GetResetCalibrationStatus(ADC1));
ADC_StartCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1));

第四步:DMA配置(用于ADC) 这是连接ADC和内存的桥梁。

// 定义存储ADC值的数组
__IO uint16_t ADC_ConvertedValue[2] = {0};

// 复位DMA1通道1(ADC1对应DMA1通道1,需查数据手册)
DMA_DeInit(DMA1_Channel1);

DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&(ADC1->DR); // 外设地址:ADC1数据寄存器
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC_ConvertedValue; // 内存地址:数组首地址
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; // 方向:外设为源(ADC->内存)
DMA_InitStructure.DMA_BufferSize = 2; // 缓冲区大小:2个数据(对应2个通道)
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不递增(始终读DR寄存器)
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增(存入数组)
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; // 外设数据宽度:半字(16bit)
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; // 内存数据宽度:半字
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式(连续采集)
DMA_InitStructure.DMA_Priority = DMA_Priority_High; // 优先级:高
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; // 非内存到内存模式
DMA_Init(DMA1_Channel1, &DMA_InitStructure);

// 使能DMA通道
DMA_Cmd(DMA1_Channel1, ENABLE);

第五步:USART配置 配置串口为9600-8-N-1模式。

USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE);

第六步:主循环与数据处理 启动ADC转换后,DMA会自动工作。我们在主循环中读取内存数组的值,并通过USART发送。为了演示,我们采用简单的轮询发送。

// 启动ADC软件触发转换
ADC_SoftwareStartConvCmd(ADC1, ENABLE);

char sendBuffer[64];
while (1) {
    // 简单延时,控制发送频率
    Delay_ms(500);

    // ADC_ConvertedValue[0]和[1]已被DMA自动更新
    uint16_t adc0_val = ADC_ConvertedValue[0];
    uint16_t adc1_val = ADC_ConvertedValue[1];

    // 格式化字符串,通过USART发送
    sprintf(sendBuffer, "ADC0: %04d, ADC1: %04d\r\n", adc0_val, adc1_val);
    for (int i = 0; sendBuffer[i] != '\0'; i++) {
        USART_SendData(USART1, (uint8_t)sendBuffer[i]);
        while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); // 等待发送完成
    }
}

实操心得 :在这个例子中,ADC到内存的DMA传输是自动、循环进行的,完全解放了CPU。而内存到串口的发送,我们用了CPU轮询,这其实是一个可以优化的点。更高效的做法是配置另一个DMA通道(如DMA1_Channel4)专门负责将 sendBuffer 搬运到 USART1->DR 寄存器,并用定时器触发或基于前一个DMA传输完成中断来启动这次发送,从而实现从采集到发送的全程DMA化,CPU介入极少。

4. 进阶技巧与避坑指南

4.1 双缓冲(Double Buffer)机制的应用

在循环DMA传输中,CPU读取的内存数组正在被DMA写入,如果处理速度慢于DMA写入速度,就可能读到“半新半旧”的数据。为了解决这个问题,可以使用双缓冲机制。即定义两个大小相同的缓冲区(BufferA和BufferB),DMA配置为循环模式,但 DMA_MemoryBaseAddr 在传输一半(半传输完成中断HT)和全部完成(传输完成中断TC)时,由中断服务程序自动切换。

__IO uint16_t ADC_Buffer[2][2]; // 双缓冲,每个缓冲区2个数据
// 初始化时指向Buffer0
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ADC_Buffer[0];
// 使能DMA传输完成中断和半传输完成中断
DMA_ITConfig(DMA1_Channel1, DMA_IT_TC | DMA_IT_HT, ENABLE);
// 在NVIC中配置DMA通道中断

// 在DMA中断服务程序中
void DMA1_Channel1_IRQHandler(void) {
    if (DMA_GetITStatus(DMA1_IT_TC1)) {
        // 传输完成,意味着Buffer0(或Buffer1)已满,可以处理
        currentReadyBuffer = 1; // 假设标记Buffer1就绪
        DMA_ClearITPendingBit(DMA1_IT_TC1);
    }
    if (DMA_GetITStatus(DMA1_IT_HT1)) {
        // 半传输完成,意味着另一半缓冲区(如Buffer0的后半部分或Buffer1的前半部分)已满
        // 可以在此处处理半缓冲区数据,或用于更精细的流控
        DMA_ClearITPendingBit(DMA1_IT_HT1);
    }
}

主循环中只需检查 currentReadyBuffer 标志,然后处理对应的缓冲区即可。这确保了CPU总是在处理一个“完整”且“稳定”的数据块。

4.2 DMA传输完成中断与CPU同步

虽然DMA不占用CPU时间,但CPU需要知道DMA何时完成了一次传输(尤其是在 Normal 模式下),以便处理数据。这就需要使用DMA传输完成中断(TC)。

  1. 使能中断 :在DMA初始化后,调用 DMA_ITConfig(DMA_Channelx, DMA_IT_TC, ENABLE);
  2. 配置NVIC :设置DMA通道中断的优先级并使能。
  3. 编写ISR :在中断服务程序中,清除中断标志,并设置一个软件标志(如 dma_transfer_done = 1 )。
  4. 主程序查询 :在主循环中查询该软件标志,进行后续处理。

注意事项 :在中断服务程序中,应尽快完成标志位的设置和清除,避免长时间占用。复杂的数据处理应放到主循环或任务中。

4.3 内存对齐与数据宽度陷阱

这是一个隐蔽的坑。假设你的 DMA_MemoryDataSize 设置为 DMA_MemoryDataSize_Word (32位),但你的内存数组定义为 uint16_t (16位),并且首地址不是4字节对齐的。DMA控制器会试图每次读写32位,这可能导致数据错乱或硬件错误(HardFault)。

避坑法则

  • 确保 DMA_PeripheralDataSize DMA_MemoryDataSize 与实际数据物理宽度匹配。
  • 使用编译器指令(如 __align(4) )或确保数组定义在全局区(通常已对齐)来保证内存地址对齐。
  • 对于结构体作为DMA缓冲区,要特别注意结构体的对齐方式。

4.4 外设FIFO与DMA的配合

一些高速外设(如SDIO、以太网MAC)自带FIFO(先入先出缓冲区)。DMA通常是从外设的FIFO读取数据寄存器,而非直接读取数据总线。配置时需要注意:

  • 突发传输(Burst) :为了提升总线效率,DMA可以配置为突发传输模式(在F4等系列中),一次请求传输多个数据项。这需要与外设FIFO的深度相匹配。
  • FIFO阈值 :设置DMA请求是在FIFO半满、全满时触发,这需要根据数据流速率和实时性要求精细调整。

5. 调试心得与常见问题排查

5.1 DMA不工作的终极检查清单

当你配置好一切,却发现数据没有如预期般搬运时,请按以下顺序排查:

  1. 时钟!时钟!时钟! :这是头号杀手。再次确认 RCC_AHBPeriphClockCmd (DMA时钟)和所有相关外设的时钟是否已使能。我曾在早期项目中花了数小时,最终发现仅仅是漏掉了 RCC_AHBPeriphClockCmd(DMA1, ENABLE) 这一行。
  2. 外设DMA请求使能 :DMA就绪了,但外设没有发出请求信号。检查是否调用了 ADC_DMACmd USART_DMACmd 等函数。
  3. 触发源是否启动 :对于ADC,是否调用了 ADC_SoftwareStartConvCmd 或使能了外部触发?对于USART发送,是否在使能DMA前已经向DR寄存器写了数据(对于内存到外设)?
  4. 地址配置错误 PeripheralBaseAddr 是否指向了正确的外设数据寄存器(如 &(ADC1->DR) &(USART1->DR) )? MemoryBaseAddr 是否是你定义的数组地址?在调试器中查看这些地址的值。
  5. 缓冲区大小(BufferSize) :是否设置为0?或者远大于实际数组长度导致越界?
  6. 传输模式(Mode) :如果用的是 Normal 模式,传输一次后DMA就停止了,需要重新使能。你是否在等待传输完成中断并重新启动了?
  7. 中断冲突与优先级 :如果使用了中断,DMA中断的NVIC配置是否正确?是否被更高优先级的中断长时间阻塞?
  8. 硬件连接 :对于ADC,模拟输入引脚是否正确连接信号?对于USART,TX/RX线是否连接正确?用示波器或逻辑分析仪查看物理信号是最直接的方法。

5.2 使用调试器(如ST-Link, J-Link)观察DMA

现代IDE(如IAR EWARM, Keil MDK)的调试视图非常强大:

  • 查看DMA寄存器 :在 Peripherals -> DMA 视图中,可以实时查看对应通道的 CNDTR (剩余数据计数)、 CPAR (外设地址)、 CMAR (内存地址)等寄存器。如果传输正在进行, CNDTR 的值应该递减。
  • 查看内存内容 :在 Memory 窗口中,输入你的内存数组地址(如 &ADC_ConvertedValue ),观察数据是否在动态更新。
  • 查看外设数据寄存器 :同样在 Memory 窗口或 Peripherals 视图中,查看ADC的DR寄存器或USART的DR寄存器,看数据是否在变化。

5.3 性能优化考量

  • 总线矩阵竞争 :DMA和CPU都通过总线矩阵访问内存和外围设备。当它们同时访问同一资源(如SRAM)时,会发生仲裁,可能引入等待状态。对于性能关键的应用,可以考虑:
    • 将DMA源/目的地放在CCM RAM(如果芯片支持,通常只有CPU能高速访问)或DTCM RAM中,减少竞争。
    • 合理安排CPU和DMA的数据访问时段。
  • 数据一致性 :如果CPU和DMA会访问同一块内存区域,需要注意缓存一致性问题(在带有Cache的Cortex-M7等内核中)。可能需要使用 SCB_CleanDCache_by_Addr 等函数来手动维护缓存。
  • 使用DMA2D(图形加速器) :对于带有LCD控制器的型号(如STM32F429/F7),DMA2D是专为图形操作优化的DMA,能高效执行像素格式转换、填充、混合等操作,比通用DMA和CPU快得多。

从轮询到中断,再到DMA,是嵌入式开发者对系统资源理解不断深化的过程。DMA不是万能的,它的配置相对复杂,对于极少量、非周期性的数据传输,使用CPU直接操作可能更简单。但对于任何涉及持续、批量数据移动的场景,DMA都是提升系统整体性能和实时性的不二之选。掌握DMA,意味着你开始以系统架构师的视角来思考问题,而不仅仅是编写顺序执行代码的程序员。最初接触时,那些结构体成员可能会让你感到繁琐,但一旦理解其硬件本质,它们就会成为你手中精准控制数据流的利器。多动手实验,从简单的例子开始,逐步增加复杂度(如结合双缓冲、中断),并善用调试工具观察数据流,你很快就能感受到这种“解放CPU”的编程方式带来的巨大优势。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐