拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32F103 ADC三通道连续转换实战:扫描模式+DMA高效读取

简介一套面向STM32F10x系列开发者的ADC三通道连续转换完整工程代码适合需要处理多路模拟信号的嵌入式入门或项目移植场景解决连续采样、通道切换、DMA搬运与中断配合的关键问题。工程涵盖ADC初始化、通道采样序列配置、DMA传输设置、软件触发启动、转换完成中断处理以及数据读取等环节代码结构清晰注释详细可直接在Keil MDK中导入运行。压缩包约309KB以源代码工程文件为主已有422人学习下载。学习这份代码可快速掌握STM32F1x多通道ADC连续转换的实现方法理解DMA如何减少CPU干预同时采样时间、分辨率、时钟分频等参数均有据可查便于二次开发时按需调整。在实际项目中开发者只需替换引脚、配置对应通道即可将这套采集逻辑复用到环境监测、电机控制、工业采集等场景作为底层数据采集模块直接调用。1. 项目概述与核心思路搞嵌入式的人迟早会碰上一件事项目里好几个传感器都输出模拟电压主控拿起来就用但不能一路一路地切太慢了。最典型的场景就是电池电压监测、电位器角度采集、还有模拟量传感器阵列——三个通道的数据需要周期性刷新而且最好是同一个转换流程里连续地取数据节奏一致。这个工程就是围绕这个痛点来的用STM32F103F1x系列的ADC模块完成三通道的连续扫描转换代码直接可跑配置细节也不藏着掖着全部拆开讲。先说清楚为什么不能简单粗暴地开三次单通道转换呢因为ADC每组规则转换只能有一个结果寄存器如果你开了三个单通道每次启动、等待、读取都要走一遍完整的配置流程软件开销大而且每个通道采样之间的间隔不稳定三次转换的时间点不连贯这对要求采样时序均匀的场景很不友好。而使用“三通道连续转换”的思路就完全不同了一次启动ADC按规则序列依次扫描三个通道完成一轮转换后数据寄存器自动刷新DMA也可以介入搬运CPU几乎不用参与中间过程效率高得多。这个工程的适用范围很明确只要不是追求超高精度测量比如称重仪表那种需要高分辨率、强隔离的场景而是在单片机控制系统中做常规模拟量的多路采集比如电机电流反馈、电位器位置、温度电压分压后的信号那这套方案就非常合适。代码基于标准外设库兼容绝大多数STM32F103系列芯片工程结构上留好了扩展位后续改成HAL库或换芯片引脚也只是小工作量。我采用的是“扫描模式 连续转换 规则序列多通道”的经典组合。不过在实际操作中很多新手会栽在通道序列和转换顺序上又或者被结果寄存器覆盖的问题搞晕。这篇文章就是把这些坑一个个填平附带完整的可编译代码让你不用翻无数个帖子一条路走到通。2. 硬件配置与工程准备先解决硬件层面的问题。STM32F103的ADC1可以使用的通道比较多其中规则通道最多16个但用于三通道连续转换我们需要选择三个支持模拟输入的引脚。正常情况下都在PA0-PA7、PB0-PB1等引脚上映射ADC通道。我在工程里选的是PA0、PA1、PA2对应ADC1的通道0、通道1、通道2这是最常用的三路组合ST官方评估板上也经常这么接。如果你想换引脚查一下对应的数据手册中ADC通道映射表改初始化代码里的GPIO和通道号即可。时钟方面有个硬性约束ADC的输入时钟ADCCLK最大只能到14MHz而STM32F103的APB2总线默认是72MHz所以有多个分频因子可选。如果APB2是72MHz那ADC预分频就选6分频得到12MHz选4分频则得到18MHz超了不行选8分频得到9MHz精度够但转换速度下降。我的建议是优先取12MHz也就是PCLK2除以6既能保证精度又留出稍高的转换速率。如果你用的是HAL库那这块的区别只是把HAL_ADC_Init里的ClockPrescaler改一下但本质上手动配寄存器更直观。我这个工程用的标准外设库的ADC_InitTypeDef结构体来配置逻辑清晰而且大批老STM32工程都是这么写的参考代码多遇到问题也好搜。工程组织的建议是这样的建一个ADC子模块包含adc.c和adc.h两个文件里面封装了ADC初始化和读取函数主程序里直接调用初始化函数然后轮询标志位或直接读寄存器拿结果。文件分层简单但职责要明确别把校准和数据滤波的逻辑全堆到主循环里否则后面调试的时候你会哭的。还有一个细节必须提三个通道如果接的是高阻抗信号源比如纯电阻分压、高阻输出传感器那么ADC采样保持电容充电时间会变长如果采样时间太短转换结果会偏低。这种情况下应该把采样时间调长一些比如55.5周期或更久的周期。别看这只是个参数它经常是实际工程里ADC测量值无故偏低或跳动剧烈的元凶下面我会再细说。3. 核心配置与代码实现这段是全文最直接能用的内容。我把核心代码分成几个部分GPIO配置、ADC整体初始化、通道序列配置、启动与读取函数、以及一个完整的主程序示例。注意我使用的是标准外设库工程里已经包含了所需的stm32f10x_adc.c和stm32f10x_gpio.c等源文件的编译选项。首先是GPIO的初始化。三个引脚都设为模拟输入模式注意引脚速度无所谓模拟输入模式不需要设置速度void ADC_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); }这段代码没什么需要多说的只要你确实想在PA0/PA1/PA2上采集电压它就是唯一标准的写法。特别提醒如果引脚本来复用为其他功能比如JTAG/SWD占用等要注意冲突。PA0-PA2一般不占用调试口所以没问题。然后是ADC核心初始化。配置ADC1为连续转换模式扫描模式打开规则序列长度设置为3并且把三个通道依次编入序列设置采样时间。等配置完成后使能ADC并校准一次。void ADC_Init_ThreeChannel(void) { ADC_InitTypeDef ADC_InitStructure; ADC_RegularConfTypeDef ADC_Regular_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); 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 3; // 序列长度3 ADC_Init(ADC1, ADC_InitStructure); ADC_Regular_InitStructure.ADC_Channel ADC_Channel_0; ADC_Regular_InitStructure.ADC_SampleTime ADC_SampleTime_239Cycles5; ADC_Regular_InitStructure.ADC_Order 1; ADC_RegularConfig(ADC1, ADC_Regular_InitStructure); ADC_Regular_InitStructure.ADC_Channel ADC_Channel_1; ADC_Regular_InitStructure.ADC_Order 2; ADC_RegularConfig(ADC1, ADC_Regular_InitStructure); ADC_Regular_InitStructure.ADC_Channel ADC_Channel_2; ADC_Regular_InitStructure.ADC_Order 3; ADC_RegularConfig(ADC1, ADC_Regular_InitStructure); ADC_Cmd(ADC1, ENABLE); // 校准 ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }关于标准库里有没有ADC_RegularConfig这样的函数我需要说清楚。不同的标准外设库版本命名略有不同有的版本直接用ADC_RegularChannelConfig()函数一次配一个通道和其在序列中的位置。为了兼容性下面的写法更通用说实话我也更推荐这个ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_239Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_239Cycles5);这个函数本质上就是设置序列中第几个位置对应哪个通道、采样时间是多少。注意第三个参数是“在规则序列中的次序”不是通道号很多人这里写错导致明明配置了三个通道结果数据全都是同一个通道的值。这个坑我见过太多次了。初始化完成后ADC就自动一轮接一轮地转换了因为我们启用了连续模式并且已经用软件发起第一次转换。之后所谓的“读取”其实就是不断从ADC1-DR寄存器里取值但这里有个关键问题规则序列只有一个数据寄存器三路数据是依次写入的软件如果不判断当前是哪一轮的哪个通道很容易取错。下面第三节会专门讲这个部分的读取策略。4. 数据读取与判定逻辑在连续扫描模式下读数据教科书上通常有两种思路直接用查询状态标志位或者开DMA。我们先说软件轮询的read函数。当你启用连续转换后ADC会按照序列顺序重复转换。轮询方式就是检查ADC状态寄存器SR里的EOC标志位End of Conversion。每个通道转换完成时EOC被置位读取DR数据后该位自动清除。所以只要循环判断EOC位就可以一个通道一个通道地取数据。问题是你怎么知道当前读到的是哪个通道如果你的时间没有和外部的转换时间完全同步那么软件可能在同一个通道的数据还没刷新之前多读了一次或者在序列的中途插入读取数据就会串。解决这个问题的简单办法是让软件读完整的“一轮”数据。也就是先开启转换后按顺序连续读完三个通道把它们当成一组数据来使用。因为转换是周期的只要第一轮开始后赶紧按序读出三次数就大概率能跟上。但这样有竞态风险如果主循环里还有别的耗时操作就会错位。更好的办法是配合DMA。用DMA把转换结果连续搬运到内存数组每次转换完成都会自动传输。DMA传输完成后会有传输完成中断在中断里你可以知道一组数据全部更新完成然后再加工。DMA模式下不存在读错通道的问题因为硬件接管了搬运顺序每个通道的结果按照序列顺序存进数组的固定位置。如果你坚持用轮询我给你一个比较简单也可靠的实现每轮只取一次标志位然后连续读三次正好构成一轮结果。前提是这次读操作的时间要小于下一次转换完成的时间。这个条件对低速应用够用但不适合高速。下面是轮询读三通道的代码uint16_t ADC_Values[3]; void ADC_Read_ThreeChannels(uint16_t *values) { while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); values[0] ADC_GetConversionValue(ADC1); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); values[1] ADC_GetConversionValue(ADC1); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); values[2] ADC_GetConversionValue(ADC1); }你觉得这个代码有问题吗仔细看EOC在读取数据后会被硬件自动清除。这样第一个通道转换完毕EOC置位读回来通道0的值通道1的转换可能已经开始或者已经完成接着等EOC读通道1的值。如果一切顺利这样确实能读完一轮。但实际上如果在等第一个EOC的当口通道0的转换已经结束并且通道1、通道2也很快完成了你只能读到最后一个通道的值前面的通道数据被覆盖。这会导致values[0]和values[1]实际上是通道2的数据只是碰巧被读出来并排查成通道0和通道1。这个问题的原因是EOC标志不会告诉你当前“轮到”哪个通道了。所以轮询读法本质上是不可靠的除非你限制转换速度或者主动在触发后按严格时序读。在我这个工程里我用的正是配合DMA的做法。初始化时使能ADC的DMA通道把三个通道的数据直接用硬件搬运到内存数组。代码是这样的uint16_t adc_buf[3]; void ADC_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize 3; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; 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_Cmd(DMA1_Channel1, ENABLE); ADC_DMACmd(ADC1, ENABLE); }配置完成后只要ADC1的DR寄存器有新的转换结果DMA就会自动把它搬到adc_buf数组对应的位置循环模式下三轮数据无限刷新。因为DMA的缓冲大小设置为3每次转换完三个通道指针回绕所以数组下标0、1、2分别永远对应序列里的第1、2、3个通道即通道0、1、2完美对应。这个方案最大的好处就是——主循环里只需要直接读adc_buf数组不需要任何标志位判断也不存在通道串位的问题。代价是DMA占用一个通道但DMA1_Channel1在STM32F103上多数留给了ADC1使用几乎没有冲突。5. 常见问题与排查技巧实录这个工程写完之后我在实际调试中遇到不少问题有一些是共性的我给你逐个拆解。第一个问题就是前面提到的通道顺序错乱。表现为主循环里读到的adc_buf[1]和adc_buf[2]数值比较稳定但adc_buf[0]始终是最后一个通道的值。排查方法很简单把三个输入引脚分别接不同的电压比如0V、1V、2V然后把采集值打印出来看是不是一一对应。如果发现顺序乱了优先检查ADC_RegularChannelConfig调用时的第三个参数“序列次序”一定是从1开始依次排。另外DMA缓冲区只有一组Cyclic模式下如果主循环读数组的时候正好赶上DMA搬运到一半可能读到半个新值、半个旧值的坏数据这时候需要在主循环前先关中断或使用双缓冲但在大多数电压监控场景中这个问题影响不大。第二个常见问题是采样值跳得厉害大幅波动。原因不外乎两个一是引脚悬空或者信号源内阻太大二是采样时间太短。如果是信号源内阻大比如一个10k电阻分压器ADC内部采样电容只有几pF需要在采样时间内充满如果采样时间不够结果就会偏低且不稳定。常规解决方法是把ADC_SampleTime_1Cycles5改为ADC_SampleTime_239Cycles5我之前给出初始化代码时就直接用了239.5周期这是底线如果你发现数值还是跳就继续检查外部有没有加滤波电容一般建议在模拟输入端加一个100nF的高频滤波电容前提是信号源能够驱动这个电容。第三个问题是转换结果整体偏低而且接近电源电压时饱和不了。这个其实和第二个问题同根但经常被误判为电源/参考电压有问题。我调试时遇到过用万用表量输入引脚确实是3.3V但ADC读出来只有3280左右换算下来约3.27V少了0.03V。根因就是采样时间不够导致建立不完全。你要是想追求最高精度看ADC的采样建立时间尽量留足余量而不是一味追求采样速率。第四个问题是三路信号串扰。当三路通道扫描时前一个通道的采样保持电容上可能残余少量电荷紧跟着的通道立刻开始采样后者会受到轻微影响。在快速连续扫描模式下如果你用1.5周期采样时间这个影响会更明显。工程上可以通过将采样时间设置长一点来缓解或者把关键通道安排到序列末尾给它多一点“缓冲”。如果项目里某个通道的数据必须很干净可以单独为它设置独立的19.5周期以上的采样时间。第五个容易踩的坑是ADC启动后第一次转换结果经常不准或者第一次DMA读到的数据是旧的。STM32F10x系列ADC上电后需要经历约两个ADC时钟周期的稳定时间通常建议在使能之后加一点延时再启动转换或者在初始化完成后先读一次数据并丢弃。我上面写的校准流程也有同样效果校准的时候会消耗一定时间所以实际校准完成后ADC内部状态已经稳定。针对以上问题我把排查建议做成一个自查表方便你调试时对照现象可能原因解决办法通道数据对不上规则序列配置次序错误检查ADC_RegularChannelConfig的序列号数据跳变GPIO悬空、无滤波电容接100nF电容到地数据整体偏低采样时间不足改大采样时间参数某一路明显受其他路影响扫描顺序引起的残余电荷调整通道顺序或增加采样时间第一次读数不对ADC刚上电未稳定初始化后执行校准或丢弃前几个数据DMA数据时对时不对主循环读取时机与DMA竞争使用传输完成中断更新数据副本还有一个很多初学者会忽略的大事ADC通道引脚的复用冲突。比如有些板子PA0连接着板载按键或LED如果你接了外接信号源板载器件会把电压拉死。排查数据异常的时候先查原理图确认这几个引脚上没有其他的分压电阻、上拉电阻在电路板层面干扰。6. 工程扩展与优化建议这个三通道连续转换工程稳定跑通之后可以顺着几个方向去扩展。最优先推荐的升级就是引入定时器触发替代纯软件触发。在某些项目里采样不仅要连续还希望采样频率严格可控比如每100us采样一次三通道电压那用TIM3产生更新事件接到ADC的外部触发引脚这样转换时刻由硬件定时器决定比软件轮询稳定得多。配置时只需要把ADC_ExternalTrigConv改为ADC_ExternalTrigConv_TIM3_CC1或TRGO并把定时器配置为对应频率的PWM/更新输出其他逻辑保持不变。这个改动对电机控制等项目里的同步采样尤其重要。另一个方向是升级到多ADC同步采样。三路信号本身如果是同一时刻的物理量比如三相电流使用单ADC扫描必然存在时间差虽然很小但做矢量控制时误差就会被放大。STM32F103有ADC1和ADC2可以设置双ADC同步规则模式让两个ADC同时转换各自的通道从硬件层面保证采样时刻一致。这个属于进阶玩法代码量和复杂度都高不少但值得去啃。如果只是想做简单滤波又不舍得花太多CPU资源可以尝试“过采样”的思路。对每个通道多次采样比如采8次求平均等效位数能提升一点点但不能直接等同于分辨率翻倍还要注意采样速率下降的问题。在STM32F103上通过DMA连续循环采集然后软件累加求平均是成本最低的滤波手段。最重要的还是建议你用串口或者调试器把ADC换算后的电压值打出来提前观察信号质量。我习惯的做法是在开发阶段每100ms打印一次三通道的原始值和换算值换算公式是Voltage (ADC_Value / 4095.0) * VREF;注意使用浮点运算会占用较多Flash如果你的主频够高、代码量不大直接用浮点打印没问题。如果资源紧张可以改用整数运算比如先乘以1000再除以4095得到毫伏级别的整数结果再格式化输出这样既省Flash又比浮点快很多。再说一个很容易被忽略的细节如果参考电压VREF不是精确的3.3V而是带有一定纹波或者偏差那么所有通道的换算结果都会跟着偏移。想要高精度就得用外部高精度基准或者做软件校准。其实STM32F1内部没有可编程参考电压所以如果你需要精度唯一稳定的办法是测量一个已知电压点然后用它来校正转换关系。比如输入精密稳压源1.000V读到的ADC值是1320那么实际转换系数就可以调整为1.0/1320而不是3.3/4095。这个校正方法对大多数工控项目够用。7. 写在后面的实操心得这个工程代码里前后花了不到一个下午就把基本功能跑通但要想在实际项目中不出幺蛾子还需要仔细去抠采样时间和通道顺序的细节。我个人最大的体会是不要迷信网上那种“简化版”三通道连续转换代码很多代码把连续模式、扫描模式和DMA混在一起却没有把读取机制讲透导致一调试就翻车。我在文章中特别强调DMA方案是因为它从机制上解决了通道错位问题这是真正的工程级做法。再分享一个很实用的调试小技巧在adc_buf数组里故意把其中一个通道的输入接一个固定电压比如接GND或VCC看它的值是不是稳定在0或者4095附近其他两个通道接可调电位器。这样一旦发现异常立刻能定位是硬件问题还是软件配置问题省掉大量排查时间。最后一个建议是在你把这个代码移植到自己的板子之前先确认三件事——ADC输时钟没有超过14MHz要用的GPIO配置成了模拟输入DMA1_Channel1没有被其他外设占用。这三个条件满足这个工程基本就能跑。如果你用的是HAL库核心配置思路完全一样只是把对应的结构体和函数名称换一下这组逻辑依然是通用的。希望这套代码和这些排错心得能帮你少走几步弯路。做嵌入式就是这样把一个个不起眼的模块吃透整个系统的可靠性就都建立在这些小地方之上。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门