STM32 DMA实战:从轮询到解放CPU的ADC多通道采集与串口发送
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总线提供),能够直接操作总线矩阵,访问内存和外设寄存器。其核心能力是进行“数据搬运”,搬运的源和目的地可以是:
- 外设寄存器 ↔ 内存
- 内存 ↔ 内存
- 外设寄存器 ↔ 外设寄存器(特定型号支持)
这种搬运是“透明”的,对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传输:
- ADC到内存:由ADC转换完成信号触发,DMA负责搬运。
- 内存到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)。
- 使能中断 :在DMA初始化后,调用
DMA_ITConfig(DMA_Channelx, DMA_IT_TC, ENABLE);。 - 配置NVIC :设置DMA通道中断的优先级并使能。
- 编写ISR :在中断服务程序中,清除中断标志,并设置一个软件标志(如
dma_transfer_done = 1)。 - 主程序查询 :在主循环中查询该软件标志,进行后续处理。
注意事项 :在中断服务程序中,应尽快完成标志位的设置和清除,避免长时间占用。复杂的数据处理应放到主循环或任务中。
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不工作的终极检查清单
当你配置好一切,却发现数据没有如预期般搬运时,请按以下顺序排查:
- 时钟!时钟!时钟! :这是头号杀手。再次确认
RCC_AHBPeriphClockCmd(DMA时钟)和所有相关外设的时钟是否已使能。我曾在早期项目中花了数小时,最终发现仅仅是漏掉了RCC_AHBPeriphClockCmd(DMA1, ENABLE)这一行。 - 外设DMA请求使能 :DMA就绪了,但外设没有发出请求信号。检查是否调用了
ADC_DMACmd、USART_DMACmd等函数。 - 触发源是否启动 :对于ADC,是否调用了
ADC_SoftwareStartConvCmd或使能了外部触发?对于USART发送,是否在使能DMA前已经向DR寄存器写了数据(对于内存到外设)? - 地址配置错误 :
PeripheralBaseAddr是否指向了正确的外设数据寄存器(如&(ADC1->DR),&(USART1->DR))?MemoryBaseAddr是否是你定义的数组地址?在调试器中查看这些地址的值。 - 缓冲区大小(BufferSize) :是否设置为0?或者远大于实际数组长度导致越界?
- 传输模式(Mode) :如果用的是
Normal模式,传输一次后DMA就停止了,需要重新使能。你是否在等待传输完成中断并重新启动了? - 中断冲突与优先级 :如果使用了中断,DMA中断的NVIC配置是否正确?是否被更高优先级的中断长时间阻塞?
- 硬件连接 :对于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”的编程方式带来的巨大优势。
更多推荐


所有评论(0)