资讯动态

STM32F103 ADC多通道采集与DMA配置详解:从基础到双缓冲实战

发布时间:2026/8/27 20:44:40 来源:尧图企业网站定制
1. 项目缘起为什么STM32F103的ADC多通道采集必须搭配DMA如果你正在用STM32F103做数据采集尤其是需要同时采集多个传感器信号比如温度、压力、电压那么你大概率会遇到一个核心矛盾采集速度要求与CPU资源占用之间的冲突。STM32F103的ADC本身性能不错12位精度最快1MHz的采样率但问题在于当你开启多通道扫描模式后ADC每转换完一个通道的数据就会产生一个中断或者需要CPU去读取数据寄存器。如果通道多、采样快CPU就会频繁被ADC中断打断几乎干不了其他正事整个系统的实时性会大打折扣。这时候DMA直接存储器访问就成了救星。它的核心思想是“让硬件自己搬数据别烦CPU”。ADC转换完成的数据由DMA控制器自动搬运到你指定的内存数组中整个过程无需CPU干预。等一组数据比如8个通道各采100次全部搬运完毕DMA再产生一个中断通知CPU“数据齐了你来处理吧”。这样CPU就从频繁的“跑腿”工作中解放出来可以专注进行复杂的数据处理、算法运行或系统调度。所以“STM32F103的ADC多通道采集配合DMA使用”不是一个可选项而是中高速、多通道数据采集应用的标准且高效的解决方案。本篇文章我将结合我实际在电机控制、环境监测等项目中的经验为你彻底拆解两种最主流、最实用的配合方法并深入背后的配置逻辑和避坑要点。2. 基础认知STM32F103的ADC与DMA硬件机制剖析在动手写代码之前我们必须先吃透硬件是怎么工作的。一知半解地拷贝代码是调试过程痛苦万分的根源。2.1 ADC多通道扫描与DMA请求STM32F103的ADC1和ADC2部分型号有ADC3支持多通道扫描。在扫描模式下ADC会按照你预先在ADC_RegularChannelConfig函数中设置的顺序依次对多个通道进行转换。关键点在于转换完成事件。每个通道转换结束时ADC都会产生一个“转换结束”EOC事件。如果开启了DMA那么这个EOC事件会自动触发一次DMA请求。DMA控制器响应这个请求执行一次数据搬运把ADC_DR数据寄存器中的12位结果搬运到你定义的内存地址里。这里有一个非常重要的细节ADC的DMA模式是单次请求Single还是循环请求Circular在标准库中通过DMA_InitStructure.DMA_Mode来设置。对于连续采集的应用我们几乎总是使用DMA_Mode_Circular循环模式。在这个模式下DMA的传输计数器DMA_CNDTR在减到0后会自动重载初始值然后从头开始搬运形成一个“数据缓冲区环”。这意味着只要你初始化好ADC就会永不停歇地采集DMA就会永不停歇地搬运新数据不断覆盖旧数据你只需要在合适的时间去内存里读取最新的一批数据即可。2.2 内存数据结构的设计思考DMA搬运的目标是内存所以我们必须事先在内存中开辟好一个数组来存放数据。这个数组怎么设计直接关系到后续数据处理的便利性。假设我们要采集3个通道CH1, CH2, CH3每个通道连续采集100个点。通常有两种数组设计思路交错式数组一维数组uint16_t adc_buf[300];DMA搬运顺序CH1[0], CH2[0], CH3[0], CH1[1], CH2[1], CH3[1], ...优点DMA配置简单只需要设置一个目标地址和总长度300。缺点数据处理时需要计算索引才能找到特定通道的所有数据不够直观。例如要取第n次采样的CH2数据索引是n * 3 1。二维数组/多个一维数组uint16_t adc_buf_ch1[100], adc_buf_ch2[100], adc_buf_ch3[100];这需要用到DMA的“存储器地址非增量”模式或者更高级的“双缓冲”和“M2M”模式即下文要讲的第二种方法配置相对复杂。优点数据处理极其直观adc_buf_ch1[i]就是通道1的第i个数据。缺点DMA配置复杂需要更精细的控制。对于绝大多数初学者和常规应用我强烈推荐使用第一种“交错式数组”。它的配置逻辑直白完全匹配ADC多通道扫描后DMA依次搬运的硬件行为。数据处理时的那点索引计算在代码清晰度和可维护性面前代价是完全可以接受的。我们本文重点讲解的第一种方法也是基于这种结构。3. 方法一经典单次DMA搬运ADC_DMA模式这是最常用、资料最多、也最容易被理解的方法。其核心是ADC工作在扫描连续转换模式并启用DMADMA工作在循环模式从ADC数据寄存器外设地址搬运到用户自定义的内存数组。3.1 配置步骤与代码实现我们以ADC1采集通道0、1、2、3循环搬运至数组adcValue[4]为例展示使用标准外设库SPL的配置流程。#include stm32f10x.h #define ADC_CHANNEL_NUM 4 uint16_t adcValue[ADC_CHANNEL_NUM]; // DMA搬运的目标数组 void ADC1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; ADC_InitTypeDef ADC_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; /* 1. 开启时钟 */ RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); /* 2. 配置ADC通道对应的GPIO为模拟输入 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; // 模拟输入 GPIO_Init(GPIOA, GPIO_InitStructure); /* 3. 配置DMA1通道1 (ADC1固定使用DMA1通道1) */ DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(ADC1-DR); // 外设地址ADC1数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adcValue; // 内存地址我们的数组 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 传输方向外设-内存 DMA_InitStructure.DMA_BufferSize ADC_CHANNEL_NUM; // 传输数量4个数据 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址固定 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; // 外设数据宽度16位 DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; // 内存数据宽度16位 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_Cmd(DMA1_Channel1, ENABLE); // 使能DMA通道 /* 4. 配置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 ADC_CHANNEL_NUM; // 规则通道序列长度 ADC_Init(ADC1, ADC_InitStructure); /* 5. 配置ADC规则通道的转换顺序和采样时间 */ // 参数ADCx, 通道, 序列中的排名(1~16), 采样时间周期 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5); /* 6. 使能ADC的DMA请求 */ ADC_DMACmd(ADC1, ENABLE); /* 7. 使能ADC */ ADC_Cmd(ADC1, ENABLE); /* 8. 复位校准寄存器并执行校准 */ ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); /* 9. 软件触发启动转换对于连续模式只需启动一次 */ ADC_SoftwareStartConvCmd(ADC1, ENABLE); }初始化完成后ADC就会开始连续转换DMA会自动将4个通道的结果依次搬运到adcValue[0],adcValue[1],adcValue[2],adcValue[3]中并且循环覆盖。你在主循环中直接读取这个数组就能拿到最新的ADC值。3.2 关键细节与避坑指南DMA通道是固定的对于STM32F103ADC1使用DMA1的通道1。这个不能选错硬件决定的。ADC2和ADC3也使用DMA1但通道不同具体需查阅数据手册。缓冲区大小与传输计数器DMA_BufferSize设置为通道数本例为4。这意味着DMA每搬运4个数据一轮完整的扫描就完成一次“循环”。这个值必须和你目标数组的有效长度以及ADC的通道数匹配。如果你定义adcValue[100]但BufferSize设为4那么DMA只会使用数组的前4个元素后面的96个元素永远是初始值不会被覆盖。这通常不是我们想要的。如果你想要每个通道存储多个历史数据点就需要将数组定义为adcValue[ADC_CHANNEL_NUM * SAMPLE_DEPTH]并将BufferSize设置为ADC_CHANNEL_NUM * SAMPLE_DEPTH。采样时间与转换速度ADC_SampleTime_55Cycles5是相对较慢的采样有利于提高精度。如果你追求速度可以设置为ADC_SampleTime_1Cycles5但前提是你的信号源阻抗足够低能快速给ADC内部的采样电容充电否则精度会严重下降。一个经验法则是信号源阻抗Rs与采样时间Ts需满足Rs * C(ADC) * ln(2^n) Ts其中n是精度位数12C(ADC)是采样电容在数据手册中STM32F103大约为8pF。高速采样时通常需要在ADC输入端加一个运放做缓冲。数据对齐ADC_DataAlign_Right右对齐是最常用的。此时ADC_DR寄存器的低12位是有效数据。如果你读取的是16位的uint16_t数组那么值范围是0-4095。如果设置成左对齐数据会放在高12位处理起来反而麻烦。校准的必要性ADC校准第8步强烈建议不要省略。它用于消除ADC模块内部的偏移误差对提高测量准确性尤其是小信号测量时的准确性至关重要。校准过程需要几十个微秒应在系统初始化时完成。4. 方法二双缓冲与定时器触发的高级玩法第一种方法虽然简单但它有一个潜在问题数据一致性问题。想象一下DMA正在搬运数据到数组adcValue中当它刚搬运完第2个通道的数据时CPU突然来读取整个数组。此时你读到的数据是通道1新数据、通道2新数据、通道3旧数据、通道4旧数据。这组数据在时间上是“错位”的对于需要严格同步分析多通道数据的应用比如计算三相电的功率这是致命的。为了解决这个问题并实现更精确的定时采样我们引入第二种方法双缓冲Ping-Pong Buffer配合定时器触发。4.1 核心思想与工作流程这种方法的核心是利用DMA的“传输完成中断”和两个缓冲区。缓冲区A和缓冲区B在内存中定义两个同样大小的数组adcBufA[4]和adcBufB[4]。DMA初始指向缓冲区ADMA从ADC搬运数据到adcBufA。缓冲区A满后触发中断当DMA搬运完预设数量4个的数据后产生“传输完成中断”或“半传输完成中断”实现双缓冲。中断服务程序中切换缓冲区在DMA中断里我们不做复杂的数据处理只做两件事将DMA的目标内存地址切换到adcBufB。将adcBufA的地址赋值给一个“就绪数据指针”如pReadyData并设置一个标志位dataReadyFlag 1。主循环处理就绪数据主循环不断检查dataReadyFlag。一旦发现它为1就知道pReadyData指向的缓冲区现在是adcBufA里有一组完整的、时间同步的新数据可以安全地进行滤波、计算、显示等操作。处理完后清除标志位。循环往复当adcBufB被填满时再次触发中断将目标地址切回adcBufA并将adcBufB标记为就绪。同时我们不再使用ADC的连续转换模式而是使用定时器TIM来触发ADC转换。例如设置TIM2每1ms产生一次更新事件这个事件作为ADC的转换触发源。这样采样间隔就由硬件定时器精确控制不受软件延迟影响非常适合需要固定采样率的应用如数字信号处理、音频采集。4.2 配置实现与代码片段这里展示关键的不同之处基于标准库。uint16_t adcBufA[ADC_CHANNEL_NUM]; uint16_t adcBufB[ADC_CHANNEL_NUM]; volatile uint16_t *pReadyData NULL; volatile uint8_t dataReadyFlag 0; void DMA1_Channel1_IRQHandler(void) { if(DMA_GetITStatus(DMA1_IT_TC1)) { // 检查传输完成中断 DMA_ClearITPendingBit(DMA1_IT_TC1); static uint8_t bufSelector 0; // 0 for BufA, 1 for BufB if(bufSelector 0) { // 当前DMA目标地址是BufA它刚被填满 pReadyData adcBufA; // BufA数据就绪 DMA_SetCurrDataCounter(DMA1_Channel1, ADC_CHANNEL_NUM); // 重置计数器重要 DMA_SetMemoryBaseAddr(DMA1_Channel1, (uint32_t)adcBufB); // 切换目标到BufB bufSelector 1; } else { // 当前DMA目标地址是BufB它刚被填满 pReadyData adcBufB; DMA_SetCurrDataCounter(DMA1_Channel1, ADC_CHANNEL_NUM); DMA_SetMemoryBaseAddr(DMA1_Channel1, (uint32_t)adcBufA); bufSelector 0; } dataReadyFlag 1; // 通知主循环 } } void ADC1_DMA_TimerTrig_Init(void) { // ... 前面GPIO、ADC基本配置与第一种方法类似 ... /* ADC配置关键不同 */ ADC_InitStructure.ADC_ContinuousConvMode DISABLE; // 禁用连续转换 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_TRGO; // 定时器2触发 // ... 配置ADC通道顺序 ... /* 配置TIM2作为触发源 */ TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period 1000 - 1; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; // 预分频器72MHz/72 1MHz计数频率 // 1MHz / 1000 1kHz 触发频率即每秒触发1000次ADC转换 TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); // 更新事件作为触发输出(TRGO) TIM_Cmd(TIM2, ENABLE); /* DMA配置关键不同使能传输完成中断 */ DMA_InitStructure.DMA_Mode DMA_Mode_Normal; // 使用普通模式而非循环模式 // 因为我们要在中断里手动切换缓冲区 DMA_InitStructure.DMA_BufferSize ADC_CHANNEL_NUM; DMA_Init(DMA1_Channel1, DMA_InitStructure); DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); // 使能传输完成中断 NVIC_EnableIRQ(DMA1_Channel1_IRQn); // 使能DMA通道1中断 // ... 使能ADC DMA、校准ADC ... /* 启动DMA然后启动定时器ADC等待定时器触发 */ DMA_Cmd(DMA1_Channel1, ENABLE); // ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 软件触发不再需要 }在主循环中while(1) { if(dataReadyFlag) { dataReadyFlag 0; // 安全地使用 pReadyData 指向的数据进行处理 processADCData((uint16_t*)pReadyData); } // ... 其他任务 ... }4.3 方法二的优缺点与适用场景优点数据一致性主循环处理的数据一定是同一时刻采集的完整通道组解决了“数据错位”问题。精确的采样定时由硬件定时器触发采样间隔极其精确且稳定不受软件任务调度影响。数据处理与采集解耦数据处理可以在主循环中从容进行不会阻塞采集流程。DMA中断服务程序非常短只做指针切换实时性高。缺点与注意事项配置更复杂需要管理中断、双缓冲指针、定时器代码复杂度显著增加。对处理速度有要求主循环处理一组数据的时间必须小于DMA填充一个缓冲区的时间即采样间隔*通道数。否则缓冲区会被覆盖导致数据丢失。例如1kHz采样4个通道DMA每1ms触发一次但每次转换4个通道需要一定时间假设总共需要0.1ms那么缓冲区填充时间约为0.4ms。你的数据处理必须在0.4ms内完成否则就需要降低采样率或增大缓冲区存储多次扫描的结果。中断频率DMA中断频率等于采样频率 * 缓冲区数量。如果采样率很高中断会非常频繁增加系统负荷。此时可以考虑使用“半传输完成中断”HT和“传输完成中断”TC来构建双缓冲将中断频率降低一半。适用场景对多通道数据同步性要求高、需要精确采样定时、或主系统任务繁重不希望被ADC数据搬运打断的中高速数据采集系统如电力质量分析、振动信号分析、多路同步音频采集等。5. 实战调试从现象倒推问题的排查链路无论用哪种方法第一次配置时很可能不成功。以下是我总结的从现象出发的排查链路你可以像侦探一样一步步缩小范围。现象ADC值全为0或固定值如4095检查时钟首先确认RCC_APB2PeriphClockCmd是否开启了ADC1和GPIOA的时钟RCC_AHBPeriphClockCmd是否开启了DMA1时钟这是最常被忽略的第一步。检查GPIO模式ADC通道对应的GPIO必须设置为模拟输入GPIO_Mode_AIN。如果误设为浮空输入很可能读到固定值。检查DMA传输计数器在调试器中查看DMA1_Channel1-CNDTR寄存器的值。如果初始化后它没有变成你设定的BufferSize或者使能后一直不减少说明DMA没有启动或没有被触发。检查DMA_Cmd和ADC_SoftwareStartConvCmd方法一或定时器方法二是否已使能。检查ADC状态寄存器查看ADC1-SR寄存器。EOC位是否在翻转如果EOC一直为0说明ADC根本没有开始转换。检查ADC_Cmd、触发源配置和校准流程。现象ADC值乱跳噪声大硬件滤波ADC输入端是否并联了一个0.1uF的滤波电容到地这对于滤除高频噪声至关重要尤其是长导线连接传感器时。电源质量给STM32的模拟部分VDDA供电是否干净建议使用LC滤波如10uH电感10uF电容从数字电源分离出模拟电源并在VDDA和VSSA之间接一个0.1uF的瓷片电容。采样时间不足如果信号源阻抗较大如10kΩADC_SampleTime_1Cycles5可能太短采样电容充电不足导致读数偏低且不稳定。尝试增加采样时间到ADC_SampleTime_239Cycles5看看是否稳定。参考电压确保VREF引脚连接了稳定、干净的参考电压通常直接接VDDA。如果VDDA波动ADC读数也会波动。现象方法二中数据就绪标志从未被置位检查DMA中断是否使能DMA_ITConfig和NVIC_EnableIRQ是否都调用了检查中断服务函数名STM32的中断服务函数名称是固定的必须完全匹配。DMA1通道1的中断函数名是DMA1_Channel1_IRQHandler拼写错误会导致中断无法进入。检查中断标志位清除在中断服务函数里是否用DMA_ClearITPendingBit清除了对应的中断标志如果不清除中断只会触发一次。调试器打断点的影响在中断服务函数里打太多断点或者单步执行时间过长可能会导致DMA传输紊乱。尝试在中断里只做最简单的指针切换把数据处理放到主循环。6. 性能优化与进阶思考当你基本功能跑通后可以考虑以下优化让系统更稳健、高效。使用HAL库与CubeMX虽然本文基于标准库讲解以明晰原理但对于新项目我强烈推荐使用ST的HAL库和CubeMX图形化工具。CubeMX可以直观地配置ADC、DMA、定时器的所有参数并生成初始化代码能避免大量底层配置错误。HAL库的HAL_ADC_Start_DMA函数封装得很好配合回调函数如HAL_ADC_ConvCpltCallback能更优雅地实现双缓冲逻辑。过采样提升分辨率如果你需要高于12位的分辨率比如测量慢变化的温度需要14位精度可以使用软件过采样。方法一的结构非常适合设置一个远大于通道数的DMA缓冲区例如每个通道连续采16次DMA搬运完成后在内存中对每个通道的16个数据做累加平均。这相当于用速度换精度能将有效分辨率提高到14位12位 log2(√16)。注入通道与看门狗STM32的ADC除了规则通道组还有注入通道组。注入通道可以打断规则通道的转换用于处理更高优先级的信号如过流保护。ADC硬件看门狗可以监控某个通道的电压是否超出设定的阈值并产生中断非常适合用于故障检测。这些高级功能可以在基础应用稳定后再深入研究。DMA与其它外设的冲突DMA1只有7个通道它们被ADC、SPI、I2C、USART等外设共享。在设计系统时需要规划好DMA通道的使用避免冲突。例如如果你同时使用ADC1的DMA和USART1的TX DMA它们都使用DMA1通道4这就冲突了必须分时复用或使用另一个外设。回到最初的问题STM32F103的ADC多通道与DMA配合两种方法并无绝对优劣只有适用场景之别。方法一经典单次搬运以其简单可靠胜任了80%以上的常规采集任务。方法二双缓冲定时触发则是在应对高同步性、高定时精度、复杂系统负载时的利器。我的建议是先从方法一入手彻底理解ADC和DMA联动的每一个环节。当你的项目遇到数据错位、定时不准、CPU被拖累这些问题时再平滑地升级到方法二。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价