1. 项目概述:从零开始搞定STM32F103C8的ADC单通道采集
如果你手头正好有一块经典的“蓝桥杯”或“正点原子”开发板,核心是STM32F103C8T6,想用它来读取一个电位器的电压,或者监测一下电池电量,那么你大概率会用到它的ADC功能。ADC,模数转换器,是连接模拟世界和数字世界的桥梁,对于单片机来说,这是最基础也最核心的外设之一。STM32F103C8的ADC功能强大,但初次接触时,面对时钟配置、通道选择、采样时间这些参数,很容易一头雾水,代码跑起来要么没数据,要么数据跳得跟心电图一样。
这个内容,就是为你准备的。我将以一个最典型的场景——使用STM32F103C8的ADC1,单次转换模式,读取PA1引脚(ADC通道1)上的模拟电压——作为主线,手把手带你从原理到代码,从配置到调试,完整走一遍流程。我们不仅会写出能用的代码,更会搞清楚每一个配置项背后的“为什么”,比如为什么采样时间会影响精度,为什么需要校准,以及当你的ADC值像得了“多动症”一样不稳定时,该怎么给它“治病”。无论你是刚接触STM32的新手,还是想巩固ADC基础的老手,这篇内容都能让你获得可以直接“抄作业”的代码和真正解决问题的思路。
2. ADC基础与STM32F103C8的硬件资源解析
在动手写代码之前,我们必须先和手里的“兵器”——STM32F103C8的ADC模块——打个招呼,了解它的脾气和能力边界。这能避免很多后续的“想当然”错误。
2.1 ADC是什么?我们为什么需要它?
单片机是数字世界的居民,它只认识0和1。但现实世界是连续的、模拟的,比如温度、压力、声音、光线强度,它们的变化是平滑的曲线。ADC(Analog-to-Digital Converter)就像一个翻译官,它的工作就是测量一个引脚上的模拟电压(比如0V到3.3V之间的任意值),然后把它转换成一个单片机可以理解的数字值。
对于STM32F103C8的12位ADC来说,这个数字值的范围是0到4095(因为2^12 = 4096)。假设我们单片机的供电电压(VDDA)是标准的3.3V,那么ADC的转换关系理论上就是线性的:0V对应数字值0,3.3V对应数字值4095。你测到1.65V,理论上ADC值就应该是2048左右。这个“翻译”过程,就是我们整个项目的核心目标。
2.2 STM32F103C8的ADC家底盘点
STM32F103C8T6这颗芯片内部集成了2个12位精度的ADC模块(ADC1和ADC2)。它们非常灵活,支持多种工作模式,但对于我们单通道采集这个入门任务,需要关注以下几个关键特性:
- 分辨率与量程:12位分辨率,意味着它能将参考电压范围分成4096份。量程由参考电压决定,通常VREF+接VDDA(3.3V),VREF-接VSSA(0V),所以输入电压必须在0V到3.3V之间,超过这个范围不仅测不准,还可能损坏ADC输入引脚。
- 通道与引脚:ADC1有16个外部模拟输入通道(ADC2有10个)。它们复用在GPIO口的特定引脚上。例如,ADC1的通道1对应PA1,通道2对应PA2,以此类推。这是第一个容易踩的坑:你必须确保你使用的GPIO引脚,确实复用了你想要的ADC通道。比如,你想用ADC1通道1,就必须把PA1配置为模拟输入模式,而不是简单的浮空输入。
- 转换模式:
- 单次转换:触发一次,只转换一个通道一次,然后停止。适合低速、非连续的应用,比如读取一个电位器。
- 连续转换:开启后,ADC会不停地自动转换指定的通道。适合需要实时监控的场景。
- 扫描模式:配合多通道使用,ADC按顺序自动转换一组预先设置好的通道。 对于单通道,我们通常从单次转换模式开始,逻辑最清晰。
- 触发源:ADC需要一个“开始转换”的信号,这个信号就是触发。可以是软件触发(你写一条命令让它开始),也可以是硬件触发(比如一个定时器到时了,自动让它开始)。入门阶段,用软件触发最简单。
- 采样时间:这是影响精度和速度的关键参数。ADC不是瞬间“抓住”电压值的,它需要一小段时间(称为采样时间)让内部采样保持电容上的电压稳定到与外部输入电压一致。时间太短,电容充电不足,转换结果就会不准;时间太长,转换速度就慢。STM32的ADC采样时间以“时钟周期”为单位,可配置。对于高阻抗的信号源(比如用一个很大的电阻分压),需要更长的采样时间。
了解这些,你就知道配置ADC时,那些下拉菜单里的选项大概是什么意思了。接下来,我们进入实战环节。
3. 开发环境搭建与CubeMX图形化配置
我强烈推荐使用ST官方的STM32CubeMX工具进行初始化配置。它通过图形界面生成底层硬件初始化代码(HAL库),能极大减少因寄存器配置错误导致的低级问题,让我们更专注于应用逻辑。这里我们以使用ADC1单次转换读取PA1电压为例。
3.1 工程创建与核心时钟配置
首先,打开CubeMX,新建工程,选择MCU型号为STM32F103C8Tx。在Pinout & Configuration视图下,开始配置:
- 系统核心(SYS):在
Debug下拉菜单中,选择Serial Wire。这开启了SWD下载和调试接口,对于C8T6是必须的,否则第一次下载程序后可能就无法再次连接了。 - 时钟(RCC):在
High Speed Clock (HSE)选择Crystal/Ceramic Resonator。我们的开发板外部通常有一个8MHz的晶振,这里就是告诉芯片使用这个外部晶振作为时钟源。 - 时钟配置(Clock Configuration):这是关键一步。我们需要为ADC提供一个时钟。STM32F103的ADC时钟最大不能超过14MHz。通常的配置路径是:HSE(8MHz) → PLL倍频到72MHz作为系统时钟(SYSCLK) → 然后通过分频给APB2总线(APB2时钟也是72MHz) → ADC预分频器。在时钟树图上,找到
APB2,确保它是72MHz。然后找到ADC Prescaler,选择PCLK2 divided by 6。这样,ADC时钟 = 72MHz / 6 = 12MHz,符合小于14MHz的要求,保证了ADC的正常工作。
3.2 ADC1单通道参数详细配置
转到Analog->ADC1进行配置。
- 模式(Mode):
- 因为我们只用一个通道,所以
Independent mode(独立模式)即可。
- 因为我们只用一个通道,所以
- 参数设置(Parameter Settings):
Data Alignment: 选择Right alignment(右对齐)。这样,12位的转换结果存储在16位变量的低12位,我们直接读取即可,比较直观。左对齐需要额外处理。Scan Conversion Mode: 选择Disabled。我们不是多通道扫描。Continuous Conversion Mode: 选择Disabled。我们使用单次转换,需要时手动启动。Discontinuous Conversion Mode: 选择Disabled。这是用于扫描模式下分组触发的,我们不用。DMA Continuous Requests: 选择Disabled。单次单通道,用查询或中断方式读取数据更简单,暂时不用DMA。End Of Conversion Selection: 保持默认。
- 通道配置:
- 在左侧的芯片引脚图上,找到PA1,点击它,选择
ADC1_IN1。这步操作会自动将PA1的GPIO模式设置为模拟输入(Analog)。 - 回到
ADC1配置页面,下方会出现IN1的配置行。点击Rank,选择Regular channel 1(规则通道1)。 - 关键参数
Sampling Time(采样时间):这里我们先设置为Cycles 239.5。这是最长的采样时间,约等于239.5个ADC时钟周期。在ADC时钟为12MHz时,采样时间约为20微秒。为什么一开始设这么长?为了稳定。在调试初期,我们优先保证转换结果的正确性,避免因采样时间不足导致数据乱跳。后续优化时,可以根据信号源特性调小这个值以提高转换速率。
- 在左侧的芯片引脚图上,找到PA1,点击它,选择
- 触发配置:
- 在
External Trigger Conversion Source中,选择Regular Conversion launched by software。这意味着我们将通过调用HAL_ADC_Start()函数来软件触发一次转换。
- 在
3.3 生成工程代码
配置完成后,转到Project Manager页面,设置好工程名称、存储路径、IDE(比如MDK-ARM V5)。在Code Generator选项卡,勾选Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral,这样ADC的代码会独立成文件,结构更清晰。
点击GENERATE CODE,CubeMX会生成完整的初始化代码。用你的IDE(如Keil)打开工程,在main.c的/* USER CODE BEGIN 2 */和/* USER CODE END 2 */之间,就是我们添加自己应用代码的地方。
4. 单次转换模式下的ADC数据采集代码实现
生成了代码骨架,现在我们来注入灵魂——编写读取ADC值的应用程序。我们采用“启动转换-等待转换完成-读取结果”的简单轮询模式。
4.1 ADC初始化的补充检查
在开始写主循环代码前,先确认一下CubeMX生成的初始化是否正确。打开main.c,找到MX_ADC1_Init()函数,或者直接查看adc.c文件。你应该能看到类似以下的配置,重点是采样时间和触发源:
// 在 adc.c 的 MX_ADC1_Init 函数中应有类似代码 hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件触发 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; if (HAL_ADC_Init(&hadc1) != HAL_OK) { Error_Handler(); } // 配置通道1的采样时间 ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; // 239.5周期采样 if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) { Error_Handler(); }4.2 主循环中的采集逻辑
在main.c的while (1)主循环里,我们编写采集代码。思路是:启动转换 -> 等待转换完成 -> 读取值 -> 延时 -> 循环。
/* USER CODE BEGIN WHILE */ while (1) { uint32_t adc_value = 0; float voltage = 0.0f; // 1. 启动ADC单次转换 HAL_ADC_Start(&hadc1); // 2. 等待转换完成,超时时间设为10ms if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) { // 3. 读取转换结果(12位右对齐,值范围0-4095) adc_value = HAL_ADC_GetValue(&hadc1); // 4. 将ADC值转换为电压值 (假设VDDA=3.3V) voltage = (float)adc_value * 3.3f / 4095.0f; // 5. 通过串口打印结果(需要事先配置好USART并重定向printf) printf("ADC Value: %lu, Voltage: %.3f V\r\n", adc_value, voltage); } else { printf("ADC Conversion Timeout!\r\n"); } // 6. 停止ADC(单次转换模式下,一次转换完成后自动停止,也可显式调用) HAL_ADC_Stop(&hadc1); // 7. 延时一段时间,避免打印刷屏太快 HAL_Delay(500); // 延时500ms /* USER CODE END WHILE */代码逻辑拆解与注意事项:
HAL_ADC_Start(): 这个函数会启动ADC,并等待触发。因为我们配置的是软件触发,所以调用它之后,转换就立即开始了。HAL_ADC_PollForConversion(): 这是一个轮询等待函数。它会阻塞在这里,直到ADC转换完成标志被置位,或者超过你设置的超时时间(这里是10毫秒)。对于单次转换一个通道,在12MHz ADC时钟和239.5周期采样时间下,一次转换时间大约为(239.5 + 12.5) / 12MHz ≈ 21us,远小于10ms,所以通常不会超时。如果超时,可能是ADC根本没启动成功,或者时钟配置有问题。HAL_ADC_GetValue(): 读取数据寄存器中的值。这里有一个细节:这个函数会清除“转换完成”标志位(EOC)。所以如果你用中断方式,在中断服务函数里读取一次后,标志位就清了,不会导致中断不断触发。- 电压计算:公式
电压 = ADC值 * 参考电压 / 4095是理想情况。这里的3.3f是你实际测量的VDDA电压。如果板子供电不稳,这个值会变,影响测量绝对精度。对于需要高绝对精度的场合,需要测量真实的VDDA,或者使用芯片内部的参考电压(VREFINT)进行校准计算。 - 串口打印:为了方便观察,我用了
printf。你需要确保工程中已经实现了串口重定向(例如重写_write函数)。如果还没做,可以先用调试器查看变量值,或者点个LED灯来简单指示。
4.3 编译、下载与初步测试
将代码编译无误后,下载到开发板。用一根杜邦线将PA1引脚连接到开发板的3.3V电源引脚,观察串口输出。你应该看到ADC值稳定在4095附近,电压值在3.3V附近。再将PA1连接到GND,值应接近0。如果接一个电位器的中间抽头,转动电位器,数值应平滑变化。
如果此时数值跳动很大,或者根本不变,别急,我们接下来就要解决这些典型问题。
5. ADC值不稳定(跳动)的根源分析与实战滤波
“我的ADC读数怎么一直在跳?”这是新手遇到最多的问题。即使是接一个稳定的电压源,读出来的值也可能在±几个LSB(最低有效位)范围内波动。这其中有正常噪声,也有可以改善的问题。
5.1 识别跳动类型:噪声还是错误?
首先,要量化“跳动”。接一个固定电压(比如用板载的3.3V),连续读取100个ADC值,观察变化范围。
- 小范围跳动(±2~5个数字):这很可能是正常的白噪声,来源于电源纹波、ADC本身的量化噪声、PCB板上的数字信号串扰等。需要通过软件滤波来处理。
- 大范围跳动(几十甚至上百个数字)或固定偏差:这通常是硬件或配置问题,需要优先排查。
5.2 硬件层面的排查与优化(治本)
软件滤波是“治标”,硬件优化才是“治本”。在加滤波算法前,先检查这些:
- 电源质量:ADC的参考电压(VDDA)必须干净、稳定。如果VDDA随着数字电路工作而波动,ADC结果必然跳动。
- 检查:确保开发板上VDDA和VSSA(模拟地)已经通过磁珠或0欧电阻与VDD和VSS(数字电源和地)连接,并且靠近MCU处有足够的去耦电容(通常是一个10uF钽电容并联一个100nF陶瓷电容)。
- 实操:用示波器探头(打在AC耦合档)测量VDDA引脚对地的波形,观察纹波大小。好的电源纹波应该在几十mV以内。
- 信号源阻抗与采样时间:这是最容易被忽略的一点。ADC输入端内部可以等效为一个开关加一个小电容。采样阶段,开关闭合,外部信号源需要在这个时间内给这个电容充电到稳定电压。如果信号源阻抗太高(比如用一个1MΩ的电阻分压),充电就会很慢。
- 问题:采样时间不足,电容充电未完成,转换值就不准,且每次充电程度随机,导致跳动。
- 解决方案:
- 增加采样时间:在CubeMX里把
Sampling Time调大,比如从239.5调到71.5或55.5试试。对于高阻抗源,可能需要最长的采样时间。 - 降低信号源阻抗:在分压电路后,加一个电压跟随器(运算放大器)进行缓冲,它可以提供极低的输出阻抗,完美驱动ADC。或者,减小分压电阻的阻值(比如从1MΩ降到10kΩ),但要注意功耗。
- 增加采样时间:在CubeMX里把
- PCB布局与走线:
- 模拟走线远离数字走线:特别是时钟线、PWM线、数据总线等高速数字信号,它们会产生强烈的电磁干扰。
- 模拟地(AGND)的单点接地:确保模拟部分的地最终只有一个点连接到数字地,避免数字噪声电流在模拟地路径上产生压降。
- 在ADC输入引脚就近对地加一个小电容(例如100pF~10nF的陶瓷电容)。这个电容可以作为电荷池,在采样瞬间提供瞬时电流,同时也能滤除一部分高频噪声。但要注意:电容会和信号源阻抗形成一个低通滤波器,可能影响信号带宽。对于直流或慢变信号,利大于弊。
5.3 软件滤波算法实战(治标)
硬件优化后,剩余的微小跳动就需要软件滤波来平滑了。这里介绍几种简单有效的方法:
算术平均滤波:连续采样N次,求和后取平均值。这是最常用、最简单的。
#define SAMPLE_TIMES 10 // 采样次数 uint32_t ADC_Filter_Average(void) { uint32_t sum = 0; for(uint8_t i=0; i<SAMPLE_TIMES; i++) { HAL_ADC_Start(&hadc1); if(HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) { sum += HAL_ADC_GetValue(&hadc1); } HAL_Delay(1); // 可选,让每次采样有点间隔 } HAL_ADC_Stop(&hadc1); return (sum / SAMPLE_TIMES); }优缺点:能有效抑制周期性干扰和随机噪声。但平滑度和速度是矛盾的,N越大越平滑,速度越慢。对于缓变信号效果好,对快速变化信号会产生滞后。
滑动平均滤波:维护一个长度为N的队列,每次新采样值入队,最老的出队,然后计算队列中所有值的平均值。相当于一个不断移动的窗口。
#define FILTER_LEN 8 uint32_t adc_buf[FILTER_LEN] = {0}; uint8_t buf_index = 0; uint32_t ADC_Filter_MovingAverage(uint32_t new_val) { uint32_t sum = 0; adc_buf[buf_index] = new_val; // 新值替换旧值 buf_index = (buf_index + 1) % FILTER_LEN; // 索引循环 for(uint8_t i=0; i<FILTER_LEN; i++) { sum += adc_buf[i]; } return (sum / FILTER_LEN); } // 在主循环中:读取原始值new_val,然后 filtered_val = ADC_Filter_MovingAverage(new_val);优缺点:对数据的平滑效果好,实时性比算术平均好(每次只更新一个值)。但同样有滞后,且需要占用固定的内存空间。
一阶滞后滤波(低通滤波):这是一种递归滤波,本次输出结果 = α * 本次采样值 + (1-α) * 上次输出结果。其中α是滤波系数(0<α<1)。
float alpha = 0.2f; // 系数越小,越平滑,滞后越大 float filtered_voltage = 0.0f; // 初始值 uint32_t ADC_Filter_LowPass(uint32_t new_val) { float new_voltage = (float)new_val * 3.3f / 4095.0f; filtered_voltage = alpha * new_voltage + (1 - alpha) * filtered_voltage; return (uint32_t)(filtered_voltage * 4095.0f / 3.3f); }优缺点:计算量小,能很好地抑制周期性干扰,适用于高频波动大的场合。但相位滞后明显,α的选择需要权衡响应速度和平滑度。
我的经验是:对于大多数读取电位器、电池电压等慢变信号的应用,“硬件上确保采样时间足够+软件上使用滑动平均滤波”是性价比最高的组合。采样时间通过实验确定:逐步减小采样时间,直到跳动开始明显增大,然后退回一级。滑动平均的长度N取4、8、16等2的幂次方,便于编译器优化除法运算。
6. 进阶话题:精度校准、内部参考与DMA传输
当你的单通道采集稳定工作后,可以探索这些进阶内容来提升项目的专业性和效率。
6.1 ADC校准:消除出厂误差
STM32的ADC在出厂时会有微小的增益和偏移误差。HAL库提供了校准函数来修正这些误差,提升绝对精度。
校准通常在ADC初始化之后、开始使用之前进行一次。CubeMX生成的代码里可能没有自动加入校准,我们需要手动添加。
在main.c的MX_ADC1_Init()函数调用之后,添加校准代码:
/* USER CODE BEGIN 2 */ // 启动ADC校准 if (HAL_ADCEx_Calibration_Start(&hadc1) != HAL_OK) { Error_Handler(); } // 校准完成后,ADC会自动处于关闭状态 printf("ADC Calibration Done.\r\n"); /* USER CODE END 2 */注意:校准过程会消耗几十到几百微秒的时间,且会修改ADC的内部校准寄存器。它主要修正的是ADC模块自身的误差,对于由外部参考电压(VDDA)不准、PCB布局、信号源阻抗等引入的误差无效。
6.2 使用内部参考电压(VREFINT)计算真实VDDA
前面我们计算电压时,默认VDDA=3.3V。但实际上,USB供电、LDO的精度都会导致VDDA在3.2V到3.4V之间波动。为了获得高精度的绝对电压测量值,我们可以利用芯片内部的一个已知精度的参考电压源(VREFINT)来反向推算当前的VDDA。
STM32F103内部有一个1.2V(典型值)的参考电压,连接到ADC的一个内部通道(通常是通道17)。它的值在芯片出厂时被测量并存储在系统存储区(Flash特定地址)中,称为VREFINT_CAL。在3.3V供电、30°C条件下,这个校准值对应ADC对VREFINT的测量结果。
计算真实VDDA的步骤:
- 读取校准值:从指定Flash地址读取
VREFINT_CAL。对于F103,这个地址通常是0x1FFFF7BA(2字节)。#define VREFINT_CAL_ADDR 0x1FFFF7BA uint16_t vrefint_cal = *(uint16_t*)VREFINT_CAL_ADDR; - 测量VREFINT的ADC值:配置ADC去采样内部通道17(具体通道号查数据手册),获取ADC原始值
adc_vrefint_raw。 - 计算VDDA:利用公式
VDDA = 3.3V * VREFINT_CAL / adc_vrefint_raw。这里3.3V是设计值,用于计算比例。 - 计算外部通道的真实电压:有了真实的VDDA,再计算外部通道电压:
Vexternal = VDDA * adc_external_raw / 4095。
代码示例片段:
float Get_Real_VDDA(void) { uint16_t vrefint_cal = *(uint16_t*)0x1FFFF7BA; // 假设已经配置并读取了内部参考通道的ADC值到变量 `adc_vrefint` float vdda = 3.3f * (float)vrefint_cal / (float)adc_vrefint; return vdda; // 返回计算得到的真实VDDA电压 }这个方法能有效消除供电电压波动带来的测量误差,特别适合用于电池供电设备中精确测量电池电压。
6.3 尝试DMA传输:解放CPU
在我们之前的轮询或中断方式中,每次ADC转换完成,都需要CPU介入去读取数据。当需要高速、连续采样时,这会大量占用CPU时间。DMA(直接存储器访问)就是为了解决这个问题而生的,它可以在外设(ADC)和内存(数组)之间直接搬运数据,无需CPU参与。
配置DMA单通道连续传输的思路:
- CubeMX配置:
- 在
ADC1配置中,启用DMA Continuous Requests。 - 在
DMA Settings中添加一个DMA请求,方向设为Peripheral To Memory,模式设为Circular(循环模式,缓冲区满了自动从头开始),数据宽度都设为Word(半字也行,对应16位)。 - 在
Parameter Settings里,将Continuous Conversion Mode设为Enabled(连续转换模式)。
- 在
- 代码修改:
- 定义一个全局数组作为DMA传输的目标缓冲区:
uint32_t adc_dma_buf[BUFFER_SIZE]; - 在初始化后,启动ADC的DMA传输:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buf, BUFFER_SIZE); - 此后,ADC会以最快的速度(受采样时间限制)连续转换,并将结果自动存入
adc_dma_buf。你可以通过计算两次读取缓冲区数据的时间差来获取采样率,或者处理缓冲区中的数据。 - 可以开启DMA传输完成中断或半传输中断,在中断里处理数据,实现“双缓冲”机制,避免处理数据时覆盖新数据。
- 定义一个全局数组作为DMA传输的目标缓冲区:
对于单通道采集,DMA的优势可能不那么明显,但这是通往多通道、高速采集的必经之路。理解了这个流程,以后再面对复杂的应用就会从容很多。
走到这里,你已经从一个ADC的“使用者”变成了“理解者”。从最基础的引脚配置、采样时间理解,到硬件噪声排查、软件滤波算法实现,再到利用内部参考进行校准和探索高效的DMA传输,这套组合拳足以应对绝大多数基于STM32F103C8的单通道ADC应用场景。记住,嵌入式开发中,稳定可靠的数据是一切上层应用的基础,而ADC正是获取这基础数据的关键门户。多动手测试,多思考数据背后的物理意义,你的项目就会越来越扎实。