
去年做一款电池供电的低成本传感器节点成本压力压得很死主控从某一颗进口MCU换到了普冉PY32F002BBOM一下子降了不少心里还挺美。结果样机一上电池ADC采出来的数据就开始“表演”了同一个温度点上午测和下午测读数能差出一大截。一开始我以为是热敏电阻分压电路的问题排查了一圈才发现根子出在ADC的参考电压上——VDD一路从3.3V掉到2.7V而ADC参考电压直接就是VDD参考都在漂采样值怎么可能稳得住。这就逼着我把内部参考电压VREFINT真正用起来顺手把误差补偿和低功耗采样这块一起做透了。这篇文章就把整个思考和实操过程记录下来给正在这颗芯片上折腾ADC的朋友一个参考。1. 先把问题定性便宜芯片配便宜参考电压1.1 PY32F002B的ADC到底优势在哪PY32F002B这颗料Cortex-M0内核主频最高能到24MHzFlash和RAM虽然不大但在传感器节点、小家电、玩具、电源管理等场景完全够用。关键是它的价格太有杀伤力一颗料比很多进口MCU便宜一大截所以这两年很多成本敏感的项目都在往它身上迁移我周边做灯控、做温湿度传感器、做电子烟方案的同行也都在用。这颗芯片内部集成了一路12位SAR型ADC支持多路外部通道还带一个内部参考电压通道VREFINT。12位分辨率在消费级产品里属于中规中矩的水平但它ADC的转换速度不算慢配合DMA和定时器触发也可以实现连续采样。不过最让我看重的是它带了VREFINT这就意味着我们可以摆脱对外部基准电压芯片的依赖也可以修正VDD参考电压漂移带来的测量误差这个价值在实际项目中非常大。1.2 参考电压漂移才是ADC不准的头号元凶很多工程师在用ADC的时候默认把VDD当成一个稳定的参考电压实际上VDD从来都不是稳定的尤其是电池供电的场合随着电量消耗VDD会逐步下降。ADC转换的本质是“比例测量”转换结果 Vin / Vref × 满量程这里Vref就是参考电压。如果Vref在变而代码里还按固定公式换算电压结果必定飘。我用一个具体场景说明假设被测电压是1.25VVDD从3.3V掉到2.4V未做补偿时按固定公式算出来的“电压值”能偏出5%以上。这个误差在12位ADC里已经属于灾难级别了对温度、压力、气体浓度这些模拟量传感器来说直接会导致最终测量值不可用。所以做电池供电产品参考电压问题不能回避。1.3 VREFINT的价值一个挂在芯片内部的“稳定标尺”VREFINT是芯片内部一个带隙基准电压源它输出一个大约1.2V的稳定电压理论上不受VDD变化影响。芯片出厂时在内部把VREFINT接到了ADC的某一个固定输入通道上我们不需要任何外部器件直接读ADC就能知道当前VREFINT的转换码值。它的核心用途不是直接测量外部信号而是作为一个“已知量”去反推当前ADC实际的参考电压。也就是说任何时候我读一下VREFINT通道的ADC码值就等于在问芯片“你现在内部的参考电压实际值是多少”拿到了这个实际参考电压再去修正其他通道的测量结果误差就被吃掉了。这个思路在STM32、GD32等系列里也很常见可以用一套方法通吃。2. 误差到底从哪来VREFINT不是万能神药2.1 带隙基准也有制造离散度VREFINT最基础的问题是它有一个“绝对准确度极限”。数据手册上会给出典型值比如1.2V但不同芯片之间因为晶圆制造工艺的偏差实际值可能在1.18V到1.22V之间浮动。这个误差属于系统性的固定偏差每个芯片不一样但如果代码里统一按1.2V去算就会引入最大的那部分误差。我最初就是直接按数据手册标称值1.2V参与计算结果测一个1.000V的基准电压读出来1.01V多误差已经超过1%了。对12位ADC来说1%的误差意味着大概40个LSB的偏差这在很多应用里已经不可接受。所以VREFINT可以用但必须经过校准不能直接拿标称值当真值用。2.2 ADC本身的失调与增益误差除了参考电压的离散ADC转换链路本身也不是完美无缺的。SAR型ADC内部有比较器失调、电容阵列失配等问题最终表现为转换结果的“零点偏移”和“增益误差”。也就是说哪怕参考电压是完全准确的ADC读出来的码值也可能整体偏大或偏小或者斜率不完全对。这个误差同样可以通过校准来修正只不过它和VREFINT校准是两码事。VREFINT校准解决的是“参考电压是多少”的问题ADC校准解决的是“转换器本身有多偏”的问题。在低成本芯片上厂家一般不会给每个ADC通道都做芯片级出厂校准所以我们要靠自己设计校准流程去修正这两层误差。2.3 温度漂移导致的缓慢漂移还有一个容易忽视的因素是温度。带隙基准虽然比齐纳二极管温漂小很多但仍然不是零温度系数。数据手册通常会给出VREFINT的温度系数典型值比如±20ppm/℃甚至更高。如果产品在不同季节、不同地区使用环境温度变化可能让VREFINT电压值发生几十到上百ppm的漂移最终反映到ADC结果上就是低温或高温环境下测量值偏差变大。温度漂移的处理比固定偏差棘手一些因为它是动态的。但好在VREFINT是一个可以实时读取的通道我们每次采样时同步读一次VREFINT的当前码值相当于拿到了“当前温度下VREFINT现在的实际状态”这就能补偿掉大部分温漂。真正难处理的是VREFINT本身随温度的非线性漂移这需要更高精度的两点或者多点标定才能解决。2.4 采样时间不足引发的“隐性误差”采样保持电路电容充电不足导致转换结果偏低这个问题和上面三样叠加在一起会让人觉得ADC“烂得没法用”。但很多时候芯片挺好纯粹是配置没到位。SAR ADC转换前需要一段时间让内部采样电容充电到输入电压如果采样周期太短电容充不满转换结果自然偏小。这属于使用层面的误差不是芯片本身的问题但它的影响非常明显必须单独拎出来注意。3. 误差补偿实操从单点到实时跟踪3.1 单点标定用一把好表求出你这颗芯片的VREFINT真值既然每颗芯片的VREFINT实际值不确定那我们就在生产或者调试阶段做一次标定把这个真值测出来然后存到Flash里以后每次开机直接读取使用。具体做法是这样用一台高精度稳压电源给板子供电同时用四位半以上的万用表实测VDD引脚电压记为VDD_measured。然后让芯片去读VREFINT通道的ADC码值记为vrefint_adc。这时候我们其实知道一个等式VREFINT_true × 4096 / vrefint_adc VDD_measured这是“比例法”的核心思路。整理一下就得到了这颗芯片VREFINT的真实电压VREFINT_true VDD_measured × vrefint_adc / 4096我这边实测一个样片VDD实测3.2989VVREFINT通道ADC码值平均在1490左右那么这颗芯片的VREFINT_true就是 3.2989 × 1490 / 4096算出来约1.1999V。很接近标称1.2V但如果你直接按1.2V算又差了一点点日积月累在高压输出场景就会被放大。把算出来的这个值写到Flash的某个参数区标定完成。3.2 运行时补偿每次采样都问一次“参考电压现在是多少”单点标定只在生产时做一次运行时我们要做的是“实时补偿”。思路是每次要测外部通道之前先读一次VREFINT通道的ADC码值然后用标定的VREFINT_true去算出当前实际的参考电压VREF_actualVREF_actual VREFINT_true × 4096 / vrefint_adc_now拿到当前参考电压后再测目标通道的码值就能算出外部电压的真实值V_channel adc_channel_raw × VREF_actual / 4096实测下来这套方案在VDD从3.3V掉到2.4V的过程中同一被测电压的换算结果误差能控制在0.2%以内效果非常明显。关键在于“每次采样都同步读一次VREFINT”不要偷懒只在开机时读一次因为电池放电过程是持续变化的只有实时跟踪才能保证精度。3.3 两步走ADC校准位和软件补偿组合除了VREFINT的电压值标定ADC本身还需要做一次校准。很多Cortex-M0系列的ADC模块里有一个校准机制启动ADC后需要执行一次自动校准流程在校准完成之前尽量不要去触发转换否则精度会受影响。这个动作虽然叫“校准”但它修的是ADC内部的失调误差和VREFINT的外部标定不是一个概念两个都要做。我的初始化顺序是打开ADC时钟等待VREFINT建立稳定执行ADC自动校准等待校准完成然后开始正常转换。如果跳过自动校准这一步最典型的表现是零点附近读数不为0或者所有采样值整体偏移十几个LSB。加了这个步骤之后表现会好很多。3.4 滤波最后一公里的稳定剂实时补偿解决了参考电压漂移ADC校准解决了卡脖子系统误差采样时间配置解决了充电不足但ADC采集出来的数据还是会有一个LSB到几个LSB的随机跳动这是由芯片底噪、电源纹波、输入信号阻抗等共同决定的。这时候就需要在软件层面滤波。我实测在PY32F002B上最好用的组合是“中值滤波滑动平均”。每轮采集5个值排序取中间值然后和最近4轮的中值再做一次平均。这样既能抑制毛刺尖峰又能让输出曲线平滑响应速度也不会拖沓。滤波放在补偿计算之后做先算出真实电压值再滤波或者先对码值滤波再算电压结果上差异不大但建议对最终物理量滤波更直观。4. 低功耗优化让ADC既能干活又少费电4.1 低功耗不只是“采完就睡”多数人接触低功耗MCU之后都会知道一个粗略的原则没事别跑时钟没事别开外设没事就睡觉。但落在ADC采样这件事上细节更多。ADC本身在转换时会消耗不小的动态功耗特别是在高频时钟驱动下所以低功耗ADC采样的核心思路是“尽快采完、采完就关、尽量少采”。每个采样周期的能耗 唤醒时间 × 唤醒功耗 转换时间 × 转换功耗 睡眠时间 × 睡眠功耗。实际项目中唤醒和转换时间通常占整个采样周期的比例很小大头都在放电电流只有微安级的睡眠期。你要做的就是把唤醒时间压缩到最短把转换时间也压缩尽早回到睡眠状态而不是让芯片在醒来之后磨磨蹭蹭。4.2 时钟选择先降频再采样PY32F002B的ADC时钟一般是从系统时钟分频得到的。分频系数越大ADC时钟越低转换速度越慢但功耗相对更低。在电池供电产品里我们不需要追求极高的采样率所以选择合适的分频系数把ADC时钟压到几百kHz级别既能保证采样精度又能显著降低转换功耗。我实测过采样周期固定情况下ADC时钟从高主频降到分频后的一两百kHz整机功耗虽然没有数量级变化但也有肉眼可见的降低。代价是单次转换时间变长了一些不过对于状态监控类应用几百微秒和几十微秒差别不大省电优先。4.3 外围电路功耗分压电阻是隐形电老虎一个经常被忽略的低功耗细节是ADC输入采样电路自身。很多传感器信号是通过电阻分压后送到ADC引脚的如果分压电阻选得太小比如两个10kΩ电阻那么在电池供电系统里这条分压链路本身就会持续消耗电流。对于一节纽扣电池来说几微安的漏电已经非常可观了。我的做法是首先用大电阻分压比如100kΩ级别甚至更高的电阻。其次在不需要采样的时候把分压网络的一端通过一个GPIO输出低电平关断或者用MOS管把传感器供电单独切断只留MCU本身在睡眠。第三在ADC引脚和地之间正常放置一个0.1μF左右的采样电容一方面减小采样瞬间对信号源的内阻要求另一方面也能在采样时提供稳定的电荷源。这些细节叠加起来整个待机电流才能压下来。4.4 转换后外设时钟的关闭顺序不要小看这个细节。ADC转换完成后需要做的事是禁止ADC模块、关闭ADC时钟、关闭对应GPIO的模拟功能时钟。有些人只做了ADC软件关闭却忘了分频器时钟还开着这一步偷懒可能导致待机电流凭空多出几百微安。在PY32F002B上我建议建立一个“采样完成后关闭所有采样相关时钟”的习惯。可以写成一个函数里面统一把ADC时钟关掉、把对应外设复位、把GPIO切回低功耗模式主循环里调用这个函数后再进入睡眠。顺序不要反先关模块再关时钟最后再睡中间不要穿插忘记的延时等待。4.5 定时采样策略用休眠时钟定时唤醒既然低功耗的重点是尽快回到睡眠那就需要一个可靠的唤醒源。PY32F002B通常内置低功耗定时器可以在睡眠模式下继续跑并周期性产生唤醒事件。我们的采样逻辑就可以这样组织平常睡在最深的停止模式下让低功耗定时器每1秒或者每10秒唤醒一次醒来后快速打开时钟、稳定VREFINT、切换分频、触发ADC、做补偿和滤波、关外设时钟然后再次进入停止模式。这套流程跑顺之后整个节点的平均功耗可以压到非常低的水平几节纽扣电池撑几个月不是问题。需要注意的是每次唤醒后代码要能正确判断“我是从低功耗模式回来的”该重新配置的寄存器都要重新配特别是一些掉电模式会丢失配置的外设。5. 完整代码实现可以直接抄的工程实践5.1 初始化与ADC校准下面这段是我在PY32F002B上实际跑通的初始化代码用标准外设库风格编写不同型号的库函数名可能会有细微差异但逻辑完全一致。void adc_init(void) { GPIO_InitTypeDef gpio_init; ADC_InitTypeDef adc_init; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); /* 配置ADC输入引脚为模拟输入 */ gpio_init.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; /* 外部采样通道 */ gpio_init.GPIO_Mode GPIO_Mode_AN; GPIO_Init(GPIOA, gpio_init); /* ADC分频降低时钟频率省电优先 */ RCC_ADCCLKConfig(RCC_PCLK2_Div8); adc_init.ADC_Mode ADC_Mode_Independent; adc_init.ADC_ScanConvMode DISABLE; adc_init.ADC_ContinuousConvMode DISABLE; /* 单次转换模式 */ adc_init.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; /* 软件触发 */ adc_init.ADC_DataAlign ADC_DataAlign_Right; adc_init.ADC_NbrOfChannel 1; ADC_Init(ADC1, adc_init); /* 启动ADC自动校准务必等待校准完成 */ ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_Cmd(ADC1, ENABLE); }5.2 读取VREFINT与外部通道核心函数为了保持代码简洁我把读VREFINT和读外部通道封装成两个函数内部都包含采样次数取矩平均的逻辑。#define ADC_CHANNEL_VREFINT ADC_Channel_17 /* 具体通道号以实际芯片为准 */ static uint16_t adc_read_channel(uint8_t channel) { uint32_t sum 0; uint8_t i; ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_239Cycles5); for (i 0; i 5; i) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); sum ADC_GetConversionValue(ADC1); } return (uint16_t)(sum / 5); } uint16_t adc_read_vrefint(void) { return adc_read_channel(ADC_CHANNEL_VREFINT); }采样时间我选的是ADC_SampleTime_239Cycles5这个时间比较长对高内阻的输入信号更友好采样电容能充分充电。如果你对采样速度敏感可以缩短到几十个周期但前提是输入源阻抗足够低。5.3 补偿计算与电压换算核心代码这里涉及VREFINT的真实电压标定值和实时码值。假设标定值已经存到了Flash里读取函数如下float vrefint_true_mv; /* 标定的VREFINT真实电压单位mV */ float adc_get_vref_actual_mv(uint16_t vrefint_adc_now) { return (vrefint_true_mv * 4096.0f) / vrefint_adc_now; } float adc_convert_to_voltage(uint16_t adc_value, float vref_actual_mv) { return ((float)adc_value * vref_actual_mv) / 4096.0f; } float adc_get_voltage_with_compensation(uint8_t channel) { uint16_t vrefint_adc adc_read_vrefint(); uint16_t ch_adc adc_read_channel(channel); float vref_mv adc_get_vref_actual_mv(vrefint_adc); return adc_convert_to_voltage(ch_adc, vref_mv); }注意这里我使用浮点数只是为了代码清晰实际工程里如果对性能有要求可以全部改成整数定点运算避免MCU在软浮点上的开销。但PY32F002B跑浮点也没太大压力看具体场景取舍。5.4 低功耗采样状态机主流程下面这个状态机可以放进一个周期任务里配合低功耗定时器唤醒完成“睡醒-采样-处理-再睡”的完整循环。typedef enum { ADC_STATE_IDLE, ADC_STATE_WAKE, ADC_STATE_SETTLE, ADC_STATE_SAMPLE, ADC_STATE_PROCESS, ADC_STATE_SLEEP } adc_state_t; volatile uint8_t g_adc_wake_flag 0; void adc_lowpower_task(void) { static adc_state_t state ADC_STATE_IDLE; uint16_t vrefint_adc; uint16_t ch_adc; float voltage_mv; switch (state) { case ADC_STATE_IDLE: if (g_adc_wake_flag) { g_adc_wake_flag 0; /* 唤醒后重新开启时钟无特殊情况需要重新初始化寄存器 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); state ADC_STATE_SETTLE; } break; case ADC_STATE_SETTLE: /* 让VREFINT稳定实测至少需要几十微秒 */ delay_us(100); state ADC_STATE_SAMPLE; break; case ADC_STATE_SAMPLE: vrefint_adc adc_read_vrefint(); ch_adc adc_read_channel(ADC_CHANNEL_0); state ADC_STATE_PROCESS; break; case ADC_STATE_PROCESS: /* 用上面补偿函数换算真实电压 */ voltage_mv adc_convert_to_voltage(ch_adc, adc_get_vref_actual_mv(vrefint_adc)); /* 这里做滤波算法然后更新全局变量保存结果 */ /* 关闭ADC和相关时钟 */ ADC_Cmd(ADC1, DISABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, DISABLE); /* 关闭采样通路GPIO时钟或切换IO状态 */ state ADC_STATE_SLEEP; break; case ADC_STATE_SLEEP: PWR_EnterSTOPMode(PWR_Regulator_LowPower, PWR_STOPEntry_WFI); /* 系统时钟需要重新配置按芯片手册要求处理 */ state ADC_STATE_IDLE; break; default: state ADC_STATE_IDLE; break; } }这套状态机的核心优点是把“稳定等待”“采样”“关闭外设”“睡眠”切成独立的阶段方便你后续在中间插入超时保护或者状态检查调试起来也直观。我把“100微秒稳定时间”放在采样之前这个时间对VREFINT输出稳定非常关键省掉之后首采的码值会偏低后面再采又会跳。5.5 生产阶段的自动标定流程产线标定比实验室手动标定更讲究效率。做法是先把板子接到一个已知的高精度电源上比如3.000V然后上位机通过串口发一条命令固件收到后先读VREFINT码值再通过串口回复上位机在电脑端计算VREFINT真实值然后写回Flash。也可以直接在固件里做预设VDD3000mV读取VREFINT码值算完写Flash上位机只负责触发。需要注意产线标定时供电电压必须非常准确否则标定完误差会整体偏。如果供电电压偏差0.5%那么标定出来的VREFINT真实值也会偏差0.5%后面无论怎么补偿都是这个底子。所以产线上尽量用精度高一点的稳压源或者用万用表实测后通过串口告诉固件不要完全依赖焊盘上写的“3.3V”。6. 实测数据与避坑指南6.1 校准前后对比数据会说话我在板子上做了一组对比测试VDD从3.3V降到2.4V外部输入固定接1.250V看ADC换算出来的电压值VDD (V)未补偿计算值 (V)未补偿误差补偿后计算值 (V)补偿后误差3.301.2681.44%1.2510.08%3.001.244-0.48%1.2520.16%2.701.209-3.28%1.248-0.16%2.401.178-5.76%1.2500.00%从这个表可以看得很清楚未补偿时VDD越低误差越大原因是拿固定的标称VREFINT去算参考电压VDD已经掉到2.4V了原始码值被拉高换算结果自然离谱。补偿之后全电压范围内都跑在0.2%以内这个精度在消费类产品里完全够用。6.2 采样周期对码值的影响我用一颗10kΩ内阻的信号源做测试不断加大ADC采样周期记录同一个输入信号的稳定码值。刚开始采样周期短读出来的值明显偏低随着采样周期逐渐加长码值一路爬升到后面开始不再变化就说明采样电容充饱了。这个测试方法不需要专业仪器用串口打印码值就能判断自己的采样周期配置是否合理。实际配置的时候我推荐先用本芯片最大的采样时间跑一遍确认测量正确性和稳定性然后再根据实际功耗需求逐步缩短采样时间找到一个“两者兼顾”的平衡点。千万不要一开始就把采样时间调到最短然后拿滤波去填坑那是本末倒置。6.3 常见问题排查小表现象排查方向解决办法首次采样值偏小唤醒后稳定时间不足增加100微秒以上的稳定延时或丢弃首采码值整体偏低且无法消除采样周期不够加大ADC采样时间Pin脚加0.1μF采样电容电池供电时读数时高时低参考电压未实时补偿每次采样同步读VREFINT用真实VREF算电压待机电流偏大分压电阻/ADC时钟未关用大电阻分压采样后关闭ADC时钟/模块零点偏移严重未做ADC校准执行ResetCalibration和StartCalibration流程高温低温测量值偏移VREFINT温漂采样链路温漂按温度区间做两点标定或接受当前精度范围6.4 关于4095和4096的执念网上经常有人争论满量程到底用4095还是4096。其实对这个精度级别的应用来说用4095还是4096理论上差异不到0.03%你如果非要在意这个差距说明你更应该关注的是PCB布板、电源纹波和VREFINT标定精度而不是这个除数。工程上我统一用4096因为ADS和代码实现里按2的12次方更自然而且大部分厂家的校准算法也是按这个量纲做的少一些纠结。6.5 单独说一下“首采陷阱”低功耗唤醒后的第一次转换是最容易踩坑的地方因为ADC模块和VREFINT需要时间重新上电建立稳定工作状态。我实测过刚唤醒就触发转换VREFINT的码值可能比稳定后低几十个LSB这直接导致补偿结果偏大。解决方式很简单在状态机里插入稳定延时或者直接对第一次转换结果做标记并丢弃。我两种方式都试过最终选择了“稳定延时丢首采”双保险效率和可靠性兼顾。最后的体会回头来看PY32F002B这颗芯片并不是不能做好ADC测量问题是它把参考电压、校准、低功耗这些“高级功能”的决策空间都丢给了开发者。你如果只是照着demo把ADC打开、读到码值就以为测的是真实电压那后面坑一定躲不掉。我在这颗芯片上跑通VREFINT补偿之后最大的感受是低成本的料反而更需要你对“测量链路”有一个清醒的理解把每一个误差源都补偿到位它一样能输出很漂亮的数据。如果你也在做类似项目建议先把“每次采样同步读VREFINT”这条原则刻在脑子里。不要嫌多读一个通道麻烦这一个动作就解决了电池供电最核心的参考电压漂移问题性价比极高。接下来再去优化采样周期、滤波和睡眠流程整个低功耗ADC采样系统才能真正做到既省电又准确。