ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103 ADC电压读取完整工程:采样周期、滤波与注入通道

STM32F103 ADC电压读取完整工程:采样周期、滤波与注入通道 简介这个工程覆盖了基于STM32F103的模拟电压采集完整链路包括GPIO模拟输入配置、ADC校准、12位采样精度设置、单次/连续/扫描模式切换、采样时间匹配以及通过查询、中断或DMA读取转换结果并换算为实际电压值特别适合刚接触STM32或需要在传感器项目中快速实现ADC采集的开发者。资源压缩包内共237个文件以C源码、头文件和汇编启动文件为主同时包含Keil工程配置、编译中间文件o、d、crf、lst、链接映射map、axf、烧录文件hex以及htm/html格式的说明文档整体大小约3.98MB。已有1888人学习下载。通过研读工程源码可以直观理解12位ADC的采样时序和校准流程掌握多通道扫描、中断与DMA传输的配置方法还能借助描述中的误差分析与低功耗设计思路避免常见噪声和精度问题适合作为项目模板直接修改复用。1. 为什么一个读电压的程序反而最容易翻车ADC读取电压在STM32开发里属于「看起来简单调起来头疼」的典型。很多人第一次用STM32F103读电位器电压代码十几行就写完编译下载后发现串口打印的数值要么乱跳要么固定满量程甚至换一块板子读数就不一样。这个现象背后的原因不是你的ADC没配好而是对采样时间、参考电压、通道扫描顺序这些细节缺少完整的认知框架。这个资源提供的正是STM32F103 ADC读取电压的完整工程里面包含了ADC、GPIO、RCC、USART、EXTI、TIM、SysTick等模块的编译输入文件以及I2C接口的AT24CXX和单总线的DS18B20驱动代码适合刚入门STM32但想一次性把模拟采集链路走通的人也适合在写驱动时想对照标准外设库配置细节的开发者。STM32F103的ADC是一个12位逐次逼近型转换器最快转换时间1us左右挂在APB2总线上。它的难点不在「启动一次转换并读结果」而在采样周期的选择、规则组与注入组的配合、以及DMA搬移数据时的边界条件。下面从原理、代码、滤波和排错四个层面逐步拆开。2. ADC在STM32F103上的硬件细节与采样周期计算很多人的程序就是ADC_RegularChannelConfig()之后反复调用ADC_GetConversionValue()但读回来的数值跳动幅度达到几十甚至上百。问题经常出在采样周期过短。采样周期是ADC对输入信号进行电荷采样的时间周期越短对信号源阻抗的要求越苛刻。STM32F103的ADC允许设置从1.5周期到239.5周期共8档采样时间默认配置往往是1.5周期这只对低阻抗信号源有意义。先从ADC时钟说起。STM32F103的ADC最大工作时钟是14MHz而APB2总线时钟一般是72MHz所以ADC预分频器只能选择6分频或8分频分别得到12MHz和9MHz。用6分频时一个ADC时钟周期约83.3ns1.5周期采样也就是125ns。如果你的信号源输出阻抗比较高比如一个10K欧姆电位器直接接在ADC引脚上采样电容还没充满电就开始转换了结果自然偏低且不稳定。常见做法是把采样时间设到55.5周期甚至239.5周期。这个损失换来的是稳定得多的读数。具体到转换时间的计算一次完整的ADC转换时间等于采样时间加上12.5个ADC时钟周期这是逐次逼近比较本身需要的固定周期。以72MHz系统时钟、ADC时钟12MHz为例采样周期选1.5周期总转换时间 (1.5 12.5) / 12MHz 1.17us采样周期选55.5周期总转换时间 (55.5 12.5) / 12MHz 5.67us采样周期选239.5周期总转换时间 (239.5 12.5) / 12MHz 21us如果你的信号是电池电压这种变化缓慢的量21us的转换时间完全可以接受。但如果信号是高频的比如音频或振动传感器的输出那就要权衡采样周期和信号频率了。根据奈奎斯特条件每通道采样率至少是信号最高频率的两倍实际工程里建议做到5到10倍这个资源里的ADC配置是在做电压采集属于慢变信号所以采样周期可以放心拉长。STM32F103的ADC还有一个容易被忽略的特性规则通道和注入通道。规则通道最多16个按顺序扫描转换结果存入唯一的数据寄存器ADC_DR注入通道是抢占式的最多4个结果存入ADC_JDR1到ADC_JDR4。如果你的应用需要周期性采样主信号、又要随时响应一个突发信号注入通道是比中断更好的方案。除此之外STM32F103的ADC还支持双重模式。两个ADC模块可以配置为同步采样使用ADC_CDR规则数据寄存器读取结果这在三相电机电流采样里特别有用因为同一时刻的电流采样值才能支撑Clark变换。热词里提到的「三重模式ADC转换」在F103上其实只有双重模式三重模式是F4系列才有的特性这里的要点是不要把F103的双重模式命令照着F4的参考手册去理解。关于采样时间的表格如下这个表在配置时可以直接查ADC 时钟1.5周期采样55.5周期采样239.5周期采样12MHz1.17us5.67us21.0us9MHz1.56us7.56us28.0us3. 基于标准外设库的ADC初始化与电压读取实现这个工程使用的是标准外设库而不是HAL库从文件列表里的stm32f10x_usart.__i、stm32f10x_adc.__i这些Keil中间文件就能看出来。标准外设库的特点是寄存器操作更透明每个函数都能对应到参考手册上的一个功能描述。对于ADC这种时序敏感的外设我建议先在标准库下把原理弄清楚再决定是否切HAL。先看ADC初始化代码。假设我们使用PA1作为模拟输入引脚对应ADC的通道1采用软件触发、单次转换模式采样周期设成55.5周期void ADC_Init_Mode(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; // 1. 使能GPIOA和ADC1的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); // 2. 配置PA1为模拟输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. ADC时钟分频72MHz / 6 12MHz RCC_ADCCLKConfig(RCC_PCLK2_Div6); // 4. ADC参数初始化 ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); // 5. 配置通道1采样周期55.5周期 ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_55Cycles5); // 6. 使能ADC并软件触发 ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); }这段代码里值得展开说明的是ADC_NbrOfChannel 1这个参数。它的含义不是「只使用通道1」而是「扫描模式下参与转换的通道数量」。即使你关闭了扫描模式这个值也决定规则序列的转换个数。如果配置为2但只给序列1设置了通道第二次转换就会读到上次的数据寄存器残留这是刚开始写ADC的人常犯的错。校准的步骤不要省。ADC_ResetCalibration()和ADC_StartCalibration()虽然是阻塞调用但转换速度很快不会影响实时性。热词里提到的「CLA读取ADC结果寄存器时可能读到的是尚未应用ADCOFFTRIM的原始值」在F103上是一个类似的校准问题——F103没有独立的ADCOFFTRIM寄存器但启动时不做校准的话固定的偏移误差可能达到正负几个LSB。读取转换结果并转换为电压值的代码如下uint16_t ADC_Read_Channel(uint8_t channel) { ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_55Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); return ADC_GetConversionValue(ADC1); } float ADC_To_Voltage(uint16_t adc_value, float vref) { return (float)adc_value * vref / 4096.0f; }ADC_GetFlagStatus()里的EOC标志在读取ADC_DR寄存器后会自动清除所以如果你在一个循环里反复调用采样函数不需要手动清标志。ADC_GetConversionValue()返回的是右对齐的12位数据范围是0到4095。换算电压时用4096还是4095作为分母是有讲究的严格来说12位ADC的满量程对应的是2^12 4096个码但最后一个码对应的模拟量是VREF * 4095/4096所以用4096作为除数在数学上更准确。如果你需要连续转换模式初始化时把ADC_ContinuousConvMode改为ENABLE之后调用一次ADC_SoftwareStartConvCmd(ADC1, ENABLE)就不要再手动触发了ADC会自己不断转换并更新ADC_DR。这种情况下读数据有两种方式轮询EOC标志或者用DMA。DMA方式更重要因为连续转换模式下如果CPU在某个时刻没来得及读走结果下一次转换会把旧数据覆盖掉。4. 采样数据稳定化软件滤波与标定补偿ADC本身是12位的但raw值直接拿来用通常不是明智的做法。电源纹波、参考电压噪声、信号源内阻引起的热噪声都会叠加在结果上。业界常用的处理方式是多级滤波策略不同场景用不同组合。参考热词里「C语言ADC值滤波函数」和「ADC数据漂移」的搜索热度这个坑很多人踩过。最基础的是均值滤波。连续采样N次排序后去掉最大值和最小值再对剩下的值求平均。这个组合在嵌入式里叫「中值平均滤波」既能抑制脉冲干扰又能平滑随机噪声。N一般取5到15太大响应变慢太小滤波效果不明显。#define FILTER_N 9 uint16_t ADC_Filter_MedianAvg(uint8_t channel) { uint16_t buf[FILTER_N]; uint16_t temp; int i, j; uint32_t sum 0; for(i 0; i FILTER_N; i) { buf[i] ADC_Read_Channel(channel); } // 冒泡排序只取中间值区域 for(i 0; i FILTER_N - 1; i) { for(j 0; j FILTER_N - 1 - i; j) { if(buf[j] buf[j 1]) { temp buf[j]; buf[j] buf[j 1]; buf[j 1] temp; } } } // 去掉最小两个和最大两个 for(i 2; i FILTER_N - 2; i) { sum buf[i]; } return (uint16_t)(sum / (FILTER_N - 4)); }这个滤波函数每次调用都会执行完整的采样和排序耗时和采样周期强相关。以55.5周期采样、ADC时钟12MHz为例单次转换约5.7us9次采样加排序大约60到80us对电压监控场景足够。但如果你在中断服务函数里调用它要考虑中断执行时间是否会挤占主循环的时间片。如果对实时性要求更高可以用滑动平均moving average替代。滑动平均的核心思想是维护一个环形缓冲区每次新采样进来把最旧的样本从累加器里减掉再加上新的时间复杂度降到O(1)。这里的代价是内存占用9个样本的环形缓冲区需要18字节在F103上完全可以接受。标定补偿是另一个容易被忽略的问题。STM32F103的ADC内部有一个参考电压通道ADC_Channel_Vrefint出厂时在30摄氏度、3.3V供电条件下校准后的典型值是1.20V左右但具体数值因芯片批次不同会有差异。你可以通过读这个内部参考电压来反推实际的VREF电压实现无需外部基准的电压标定。float ADC_Calibrated_Voltage(uint16_t adc_value) { uint16_t vrefint_raw; float vdda; // 读取内部参考电压通道的ADC值 vrefint_raw ADC_Read_Channel(ADC_Channel_Vrefint); // VREFINT典型值1.20V反推实际供电电压 vdda 1.20f * 4096.0f / (float)vrefint_raw; // 用实际供电电压重新计算目标通道的电压 return (float)adc_value * vdda / 4096.0f; }这个方法的精度取决于VREFINT的出厂校准值典型误差在正负1%以内。如果你的应用要求更高的绝对精度比如0.1%还是老老实实外接一个高精度基准源比如TL431或者REF3030然后把基准输出接到一个空闲的ADC通道实时校正。标定的另一面是零漂问题。ADC在输入接地时读数不为0高位数码上偶尔会出现1到2个LSB的跳动这通常是PCB上的地噪声造成的。解决办法是在ADC引脚附近加一个RC低通滤波器常见配置是100欧电阻串联加100nF电容对地。这个RC的截止频率约15.9kHz对于直流电压测量绰绰有余。热词里多次出现的「∑-δ ADC前端RC滤波设计」虽然针对的是更高精度的ADC但RC滤波的思路在SAR型ADC上同样适用区别只是截止频率可以设得更高。5. 常见坑点排查从DMA数据紊乱到注入通道读取时机ADC功能跑起来之后问题往往出在一些边界情况。热词里的「GD32E230 ADC DMA数据紊乱」虽然针对的是GD32芯片但F103上同样存在类似问题根源都是DMA配置和ADC事件标志的使用不当。先说DMA。使用DMA搬运ADC数据时常见的错误是初始化DMA时数据长度和ADC的转换次数不匹配。比如你配置了3个规则通道但DMA的BufferSize设为1DMA传输完一个数据就触发半传输和完全传输中断但此时ADC还在转换剩下的两个通道结果就是数组里有一部分有效数据、一部分陈旧数据。另一个错误是DMA的外设地址应该固定为ADC1-DR但有人写成了(uint32_t)ADC1-DR传值而不是传地址。标准外设库的DMA_InitStructure.DMA_PeripheralBaseAddr需要的是地址值这里的坑在于如果你加了一层类型转换编译器不一定报错但运行结果就是乱码。再谈注入通道的读取时机。参考热词里的问题「什么时机读取注入通道采集的数据比较合适」答案取决于触发方式。如果注入组由外部事件触发比如定时器的TRGO信号那么应该在外部事件发生后、且JEOC标志置位时读取ADC_JDRx。如果你在JEOC置位之前读数据寄存器里的值就是上一次转换的残留。void EXTI_IRQHandler(void) { if(EXTI_GetITStatus(EXTI_Line10) ! RESET) { // 清除中断标志 EXTI_ClearITPendingBit(EXTI_Line10); // 注入通道转换由外部引脚触发此时等待JEOC标志 if(ADC_GetFlagStatus(ADC1, ADC_FLAG_JEOC) ! RESET) { uint16_t inject_value ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_1); // 处理注入通道的转换结果 } } }注入通道的ADC_ExternalTrigInjecConv一般配置为ADC_ExternalTrigInjecConv_Ext_IT15或定时器触发。前者的触发源是TIM1的TRGO后者的触发源是外部引脚中断的TIM15但F103没有TIM15实际可用的外部触发是EXTI线15和TIM1/TIM2的TRGO这个细节要看具体型号。时钟抖动和电源噪声的问题热词里提到了「规避时钟抖动与电源噪声的3个PCB布局要点」。这在ADC应用里对应的是第一模拟地要单点连接不要在ADC引脚下方穿过数字信号线第二VDDA引脚上的滤波电容要靠近MCU典型配置是1uF和100nF并联且电容地端直接回到模拟地第三就是前面说的RC低通滤波器。这里有一个反直觉的结论接地噪声反而会影响采样时序。当系统时钟抖动加大时ADC的采样窗口也在抖动在高频输入信号上采样抖动的表现就是额外的噪声。所以当你发现ADC结果有周期性波动时优先怀疑开关电源的纹波是否串入了VDDA而不是ADC本身配置有误。6. 用注入通道做后台监控节省一个外部中断这个技巧适用于需要同时采样一个主信号和若干个「偶尔看一下」的辅助信号的场景能够在不增加DMA通道的情况下完成多路监控。思路是这样的主信号走规则组的DMA循环采样辅助信号走注入组定时器周期性触发注入转换。辅助信号不需要连续监控时直接把定时器关掉需要时再打开CPU只有在JEOC标志置位时才去读一次数据。具体配置里注入组转换结果的读取不会干扰规则组的DMA传输因为两组数据存放在不同的寄存器区域。规则组和注入组的转换在硬件上是互斥的注入转换会打断正在进行的规则转换但被打断的规则序列会在注入序列完成后继续执行这是硬件自动恢复的不需要软件介入。以下代码演示了如何用TIM2的更新事件周期触发注入通道转换void TIM2_Init_InjectionTrigger(void) { TIM_TimeBaseInitTypeDef TIM_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_InitStructure.TIM_Period 999; // 1kHz触发 TIM_InitStructure.TIM_Prescaler 71; // 72MHz / 72 1MHz TIM_InitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_InitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_InitStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); TIM_Cmd(TIM2, ENABLE); // 配置注入通道由TIM2触发 ADC_ExternalTrigInjectedConvConfig(ADC1, ADC_ExternalTrigInjecConv_T2_TRGO); ADC_ExternalTrigInjectedConvCmd(ADC1, ENABLE); ADC_InjectedChannelConfig(ADC1, ADC_Channel_2, 1, ADC_SampleTime_55Cycles5); }参数说明TIM_Period 999加上TIM_Prescaler 71得到的触发频率是72MHz / 72 / 1000 1kHz也就是每毫秒触发一次注入转换。ADC_InjectedChannelConfig的第二个参数是注入通道的序号不是物理通道号。注入通道可以任意映射到物理通道比如这里把物理通道2映射到注入序列的第1个位置。什么场景下值得用这个方案如果你在做电机控制器规则通道周期采样三相电流注入通道用来在过流保护触发时立刻捕获母线电压那注入通道就是救命的配置。相比之下如果所有通道都放在规则组里一旦发生需要快速采样的异常事件你必须等整个规则序列轮询结束才能读到目标通道这在时序上晚了一个轮询周期在高动态控制里可能就是几十毫秒已经足够让系统保护失效了。最后提醒一下这里注入通道的时序参数不是随便写的。ADC的注入组转换与规则转换在等待EOC标志时不要同时轮询两个标志位否则可能出现标志位清除竞争。优先用DMA处理规则组、用中断读取注入组可以让代码的时序行为变得可预测。本文还有配套的精品资源点击获取
返回列表