《电池上云笔记》MCU 用 ADC 测电池电压的 7 个坑:从分压漏电到两点校准

举报
yd_287299255 发表于 2026/11/11 08:11:08 2026/11/11
【摘要】 本文以 1 节锂离子电池和 12 位 ADC 为例,所有数字均为**教学假设**,代码使用平台无关接口,不代表任何具体芯片的电气参数或实测结果。

MCU 用 ADC 测电池电压的 7 个坑:从分压漏电到两点校准

《电池上云笔记》第 4 篇|分类:物联网
关键词:MCU、ADC、电池电压、分压电阻、参考电压、采样时间、温漂、滤波、两点校准

一、为什么万用表是 4.20 V,固件却报 4.05 V?

一个常见场景:电池直接给低功耗物联网终端供电,MCU 的 ADC 经电阻分压测量电压。调试时发现:

  • 万用表和设备读数总差几十毫伏;
  • 无线发射时,电量一下子从 60% 跳到 20%;
  • 板子放一段时间,即使休眠也在耗电;
  • 常温校准正常,低温下读数又偏了。

问题未必出在 ADC 的位数上。**电池电压测量是一整条链路:电池状态 → 分压网络 → ADC 采样 → 参考电压 → 软件处理 → 业务判断。**任何一环出错,后面多平均几次都救不回来。

上一篇我们用一阶 RC 模型分析了电池在负载切换时的压降与恢复;这篇回到设备端,看看怎么把电压可靠地采上来。

二、先统一测量对象和计算方法

假设电池最高工作电压为 4.20 V,上下分压电阻均为 1 MΩ,ADC 参考电压为 3.30 V,ADC 为 12 位:

VBAT ── R上 ──●── ADC 输入
               │
               ├── C滤波 ── GND
               │
               R下
               │
              GND

ADC 节点与电池负极应有明确的共地参考;示意省略了开关、保护和去耦电路。分压系数 K = (R上 + R下) / R下;相等时 K = 2。用常见的工程近似式:

Vbat ≈ ADC码 / 4095 × Vref × K
电池侧的理想量化步进 ≈ 3300 mV × 2 / 4095 ≈ 1.61 mV

4095 是 12 位最大码值;实际量化器的理想码宽常按 Vref/4096 定义,具体转换和校准按芯片手册处理。1.61 mV 是电池端折算的理想码值间隔,不等于测量精度;偏置、增益、参考、电阻、噪声与时序都会叠加。不要把最大码当成可准确计算的正常值:它也可能意味着饱和。

下面的 Vbat 是端电压,不等于剩余电量。电池类型、负载、温度与静置时间不同,电压与 SOC 的关系也不同;尤其不能把恒压输出的 1.5 V 稳压电池直接套用普通碱性电池的电压–电量表。


三、坑 1:分压电阻一直连着,休眠时也在耗电

现象与原因

上、下各 1 MΩ 时,4.20 V 下分压支路电流为 4.20 / 2 MΩ = 2.1 μA。如果一直连接,按恒定电压粗略估算,1 年消耗约 18.4 mAh;实际随电池电压、温度和时间变化。2.1 μA 看起来很小,但对于整机休眠电流只有几微安的设备,不容忽略。

阻值减小可以减轻 ADC 驱动难度,却增大静态电流;阻值增大会降低分压电流,却让采样时间、漏电和噪声问题更突出。没有一个适用于所有芯片的“最佳阻值”。

改法:按需连接分压网络

可用合适的模拟开关或高边开关接通分压,再等待稳定并采样。控制示意:

io.divider_enable(io.ctx, true);  /* 接通:由硬件驱动实现 */
io.delay_ms(io.ctx, settle_ms);   /* 按实际 RC + 器件要求确定 */
/* 启动转换并处理结果,见文末完整代码 */
io.divider_enable(io.ctx, false); /* 所有成功/失败路径均须关断 */

不要把普通 MCU GPIO 直接当成任何电池电压都能承受的高边开关:GPIO 电平、MOS 栅源极电压、最大额定值、反向通路和上电/掉电时序都要核查。尤其是 VBAT 高于 MCU 电源或 MCU 断电时,ADC 引脚、开关控制端可能经保护二极管反向供电;要通过合适的开关、隔离和限流设计验证,不能只靠软件“关断”。实际休眠电流还包括开关漏电、PCB 污染和 ADC 输入漏电,要测整板。

四、坑 2:只看“什么时候采到”,不看“电池当时在做什么”

现象与原因

无线模块发射时,电流上升,电池内阻和极化会让端电压下跌;负载停止后电压又逐渐回升。同一块电池,两个时刻的端电压都可能测得很准,却回答不同问题。

采样状态 适合回答 局限
轻载且等待一段时间 做相对稳定的电压记录或辅助估计电量 不等于真正的开路电压;需要验证静置时间
典型工作负载 观察设备真实工作电压 和当时负载有关,不能直接套用空载表
无线发射峰值期间 排查电压跌落或欠压复位 ADC、前端 RC 与采样率必须足以捕捉峰值;普通慢采样不一定能抓住

改法:把采样时机变成明确的状态机

/* 平台示意:实际需由业务调度保证“无线与其他负载均处于约定状态” */
request_radio_idle();
wait_until_radio_idle();
delay_ms(characterized_relax_ms); /* 由实测确定;无通用固定值 */
measure_battery_mv(&mv);          /* 仅在其他负载条件满足时执行 */
record_sample(mv, SAMPLE_LIGHT_LOAD);

上述四个函数是需要你在项目里实现的伪接口,不是文末可独立编译的 API。不要将低通滤波器上的慢速读数当作真实发射瞬时最低值;抓瞬态应使用足够带宽的示波器或专用快速监测路径。电量显示与欠压保护也不能只靠一个慢滤波值:保护应依据器件规格、硬件比较器或专用保护电路设计。

五、坑 3:参考电压变了,公式还写死 3.30 V

现象与原因

ADC 测量的是输入相对参考电压的比例。假设 Vref 实际为 3.00 V,软件仍用 3.30 V 计算,且 4.20 V 电池经 1:2 分压后未饱和,理想近似读数会变为:

4.20 V × (3.30 / 3.00) ≈ 4.62 V

误差方向并不一定总是偏高;关键是实际参考值与计算值不一致。如果 ADC 参考与被测电池同源且路径成比例,甚至可能出现“电池电压变化,ADC 码几乎不变”的比值测量问题。

改法:使用已知且可验证的参考

  1. 优先查芯片手册:ADC 实际参考来自哪个引脚,允许范围和去耦要求是什么。
  2. 可选择满足精度与功耗要求的外部参考,或测量内部基准并结合该芯片的工厂校准值估算 Vref/VDDA。内部基准不一定能直接作为 ADC 参考;工厂校准地址、校准电压、分辨率和换算宏因型号不同而不同。
  3. 计算时传入当前有效的 Vref,而不是无条件使用名义值。
/* vref_mv 必须通过具体芯片手册提供的方法取得;无需假设某个固定地址 */
float raw_mv = (float)adc_code * vref_mv / 4095.0f * divider_gain;

参考源需要稳定、适当去耦并能驱动转换期间的瞬态电流;Vref 本身的温度误差也仍然存在。ST 的 ADC 应用笔记对参考源、采样时间和误差来源有详细说明,移植前请结合目标 MCU 数据手册阅读。([ST AN2834])

六、坑 4:高阻分压 + 短采样,ADC 还没充好就读数

现象与原因

SAR ADC 采样时,内部采样电容需要通过信号源、开关和走线充电。分压网络太高阻、采样窗口太短,或刚从另一通道切换过来时,结果可能偏向之前通道的电压。不同 MCU 的开关电阻、采样电容与允许源阻抗不同,不能套统一数字。

以 1 MΩ / 1 MΩ 分压、ADC 节点外接 100 nF 为例,分压器戴维南等效电阻为 500 kΩ,外部时间常数约为 50 ms。若节点从 0 V 充到最终值,单极点理想模型下:

误差比例 ≈ exp(-t / 50 ms)
达到约 0.1%:t ≈ 6.91 × 50 ms ≈ 345 ms
达到理想 12 位半 LSB 的满量程比例:t ≈ ln(8192) × 50 ms ≈ 451 ms

这是外部电容的理想阶跃估算,忽略容差、漏电、开关及 ADC 内部采样网络;只算它并不能证明转换精度合格。较大的电容能在采样瞬间提供电荷,但会延长接通后的稳定时间,也会让测量更难跟踪负载瞬态。

改法:同时解决外部稳定和 ADC 采样窗口

/* 两种等待不是一回事 */
divider_enable(true);
delay_ms(500);              /* 例:先等外部节点稳定;仅教学假设 */
adc_set_sample_time(...);   /* 具体寄存器或 HAL,按芯片手册设置 */
(void)adc_read();           /* 某些配置可丢弃首次转换;不能代替足够采样时间 */
code = adc_read();
divider_enable(false);

此外,检查 ADC 引脚的泄漏电流、开关漏电、温度、PCB 清洁度、相邻通道切换以及最大输入范围。必要时降低阻值或使用满足功耗和输入共模范围要求的缓冲器,然后做台架验证。上面的 adc_set_sample_time(...) 是示意,不可原样编译。([ST AN2834])

七、坑 5:温度变化后,把电池物理变化误当作 ADC 温漂

现象与原因

温度改变会同时影响两件事:

  • 测量链路:分压电阻的比值温漂、参考源温漂、ADC 和开关漏电;
  • 电池与负载:电池内阻、极化、可用容量,以及低温下负载端电压。

前者是测量误差,可通过器件选择与实验标定减小;后者可能是真实物理变化,不能为了让数字好看而盲目“修回 25 ℃”。两只同温漂标称的电阻,实际匹配和温度分布仍要验证。

改法:先标定测量链,再处理电池模型

保持精密电源和测量电路在可控状态下,在目标温区测量同一电压,拟合温度系数;下面只演示简单的一阶增益模型,alpha 必须来自实际测试,且只能在验证温区内使用:

/* 仅补偿测量链增益漂移,不补偿电池本身的温度行为 */
float compensate_chain(float mv, float temp_c, float alpha_ppm_per_c) {
    float factor = 1.0f + alpha_ppm_per_c * (temp_c - 25.0f) / 1000000.0f;
    return mv / factor; /* 调用者须先确保温区内 factor > 0 */
}

例如假设 α = 100 ppm/℃,-20 ℃ 相对 25 ℃ 的测量链增益变化为 -0.45%;对未补偿读数 3731.14 mV,除以 0.9955 得约 3748.00 mV。**这只是算法演示,不是某个电池或芯片的实测温漂。**电池 SOC 的温度补偿需要单独的电池实验和模型;本系列后续会展开。

八、坑 6:滤波越狠越好,结果把故障也滤没了

现象与原因

平均值能降低部分随机噪声,但对孤立尖峰不够稳健;中值滤波可以抑制少量离群值,却不能纠正参考漂移、分压误差或高阻采样不足。指数平滑能让显示稳定,但会引入延迟。

改法:把采样质量、显示和保护分开

/* 连续代码示例中的 x 已按升序排列,9 点中值对少量孤立尖峰稳健 */
uint16_t median = x[4];
/* 如用于显示,可再在时间维度平滑;alpha 越小响应越慢 */
display_mv += alpha * (new_mv - display_mv); /* 0 < alpha <= 1 */

首次数据应初始化 display_mv = new_mv,不要从 0 开始缓慢爬升。对于快速欠压、瞬态和故障保护,使用满足响应时间要求的独立路径;同时记录未经过慢速显示平滑的测量值、采样状态及异常标志。满量程或零码应作为需要排查的边界值,不能默默当作正常电池电压。

九、坑 7:只拿一块板一个电压校准,就以为全量程都准了

现象与原因

一个点只能修正一个自由度。测量链同时存在偏置和增益误差时,单点修正只能保证附近比较准,远离校准点时误差可能变大。

改法:两点校准,另用第三点验证

用可调精密电源代替电池,在设备实际测量链路的低、高两个代表点分别记录:设备原始估算值 raw1/raw2(mV)和标准仪器参考值 ref1/ref2(mV)。两点须间隔足够大、在设备安全输入范围内,且不要让 ADC 接近饱和。

gain   = (ref2 - ref1) / (raw2 - raw1)
offset = ref1 - gain × raw1
校正后电压 = gain × 原始估算电压 + offset
batt_cal_t cal;
if (!batt_cal_two_point(3000, 3015, 4100, 4110, &cal)) {
    /* 拒绝无效校准,保留原系数并报警 */
}

这组假设值得到 gain ≈ 0.995455,offset ≈ 28.636 mV;输入 3731.14 mV 后约为 3742.81 mV。校准时应稳定温度和负载、做好参考表溯源;系数按板保存到非易失存储,带版本、完整性校验与默认值。再用校准点之外的第三个点和高低温复测残差。两点校准不能消除非线性、时变干扰及硬件设计缺陷。

若同时做温漂补偿,先约定顺序并按同一顺序建立系数:例如本文简单流程先把测量链读数按温度模型换算至 25 ℃,再应用 25 ℃ 两点标定。非线性或温度相关偏置明显时,需要更完整的多温度模型。

十、完整可编译示例:9 点中值 + 两点校准

这份代码把“硬件接入”和“数据处理”分开。read_adc、delay_ms、divider_enable 是回调,示例 demo.c 用模拟输入验证,不依赖某款 MCU。移植时须自己实现超时、ADC 初始化、采样时间、参考电压测量、开关驱动、并发控制和故障处理。

关键顺序:接通 → 外部等待 → 丢弃一次转换 → 采 9 次 → 关断 → 检查边界值 → 中值 → 电压换算 → 校准。vref_mv 使用经过验证的实际参考值;调用时校准系数可以设为 {1, 0} 表示不做校准。9 个样本包含 0 或最大码时,示例直接返回失败,适合此例的非零电池测量范围;其他量程可按实际业务调整。

battery_adc.h

#ifndef BATTERY_ADC_H
#define BATTERY_ADC_H
#include <stdbool.h>
#include <stdint.h>

typedef struct { float gain, offset_mv; } batt_cal_t;
typedef struct {
    uint16_t (*read_adc)(void *ctx); /* blocking conversion; 0..adc_max */
    void (*delay_ms)(void *ctx, uint32_t ms);
    void (*divider_enable)(void *ctx, bool on);
    void *ctx;
} batt_io_t;

bool batt_cal_two_point(float raw1, float ref1, float raw2, float ref2, batt_cal_t *out);
bool batt_measure(const batt_io_t *io, uint16_t adc_max, float vref_mv,
                  float divider_gain, uint32_t settle_ms, batt_cal_t cal, float *mv);
float batt_temp_comp(float mv, float temp_c, float alpha_ppm_per_c);
#endif

battery_adc.c

#include "battery_adc.h"
#include <math.h>
#include <stddef.h>

bool batt_cal_two_point(float raw1, float ref1, float raw2, float ref2, batt_cal_t *out) {
    if (!out || !isfinite(raw1) || !isfinite(raw2) || !isfinite(ref1) ||
        !isfinite(ref2) || raw2 <= raw1 || ref2 <= ref1 || raw2 - raw1 < 100.0f)
        return false;
    out->gain = (ref2 - ref1) / (raw2 - raw1);
    out->offset_mv = ref1 - out->gain * raw1;
    return true;
}

bool batt_measure(const batt_io_t *io, uint16_t adc_max, float vref_mv,
                  float divider_gain, uint32_t settle_ms, batt_cal_t cal, float *mv) {
    if (!io || !io->read_adc || !io->delay_ms || !io->divider_enable || !mv ||
        adc_max == 0 || !isfinite(vref_mv) || vref_mv <= 0 ||
        !isfinite(divider_gain) || divider_gain < 1 ||
        !isfinite(cal.gain) || cal.gain <= 0 || !isfinite(cal.offset_mv)) return false;
    io->divider_enable(io->ctx, true);
    io->delay_ms(io->ctx, settle_ms);
    (void)io->read_adc(io->ctx); /* optional discard; actual timing per MCU */
    uint16_t x[9];
    for (int i = 0; i < 9; ++i) x[i] = io->read_adc(io->ctx);
    io->divider_enable(io->ctx, false);
    for (int i = 0; i < 9; ++i) { /* reject saturation or invalid conversion */
        if (x[i] == 0 || x[i] >= adc_max) return false;
        for (int j = i; j > 0 && x[j] < x[j-1]; --j) {
            uint16_t t = x[j]; x[j] = x[j-1]; x[j-1] = t;
        }
    }
    float estimate = (float)x[4] * vref_mv / (float)adc_max * divider_gain;
    *mv = cal.gain * estimate + cal.offset_mv;
    return isfinite(*mv);
}

float batt_temp_comp(float mv, float temp_c, float alpha_ppm_per_c) {
    /* alpha describes characterized measurement-chain gain drift, not battery chemistry */
    float factor = 1.0f + alpha_ppm_per_c * (temp_c - 25.0f) / 1000000.0f;
    return factor > 0.0f ? mv / factor : NAN;
}

demo.c:可重复的模拟测试

#include "battery_adc.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct { unsigned n; int enabled; } mock_t;
static void gate(void *ctx, bool on) { ((mock_t*)ctx)->enabled = on; }
static void delay(void *ctx, uint32_t ms) { (void)ctx; (void)ms; }
static uint16_t read_adc(void *ctx) {
    mock_t *m = ctx;
    static const uint16_t x[] = { 1000, 2315, 2316, 2315, 2317, 2314, 3000, 2315, 2316, 2315 };
    if (!m->enabled || m->n >= sizeof x / sizeof x[0]) abort();
    return x[m->n++];
}
int main(void) {
    mock_t m = {0}; batt_io_t io = {read_adc, delay, gate, &m};
    batt_cal_t unity = {1.0f, 0.0f}, cal; float mv;
    if (!batt_measure(&io, 4095, 3300, 2, 500, unity, &mv)) return 1;
    if (!batt_cal_two_point(3000, 3015, 4100, 4110, &cal)) return 2;
    printf("median raw = 2315; measured = %.2f mV\n", mv);
    printf("gain = %.6f; offset = %.3f mV; corrected = %.2f mV\n", cal.gain, cal.offset_mv, cal.gain * mv + cal.offset_mv);
    printf("temp-chain correction at -20 C, 100 ppm/C = %.2f mV\n", batt_temp_comp(mv, -20, 100));
    if (m.enabled || batt_cal_two_point(3000, 3000, 3000, 4100, &cal)) return 3;
    return fabsf(mv - 3731.14f) < 0.1f ? 0 : 4;
}

在安装 GCC 的电脑上编译:

gcc -std=c11 -Wall -Wextra -Werror -pedantic battery_adc.c demo.c -lm -o demo
./demo

实际运行输出:

median raw = 2315; measured = 3731.14 mV
gain = 0.995455; offset = 28.636 mV; corrected = 3742.81 mV
temp-chain correction at -20 C, 100 ppm/C = 3748.00 mV

示例中第一次读取 1000 被丢弃,9 次有效模拟输入中含一个 3000 的尖峰,中值为 2315。**这些是模拟输入与计算结果,不是板级测试。**温度结果单独展示温漂公式,不与两点校准叠加;真实项目应按上一节约定的顺序实现和标定。

十一、上板验证清单

  1. 安全与量程:覆盖最高电池电压、充电状态、容差、瞬态和 MCU 断电状态;确认 ADC 输入和开关不超绝对额定值,避免反向供电。
  2. 量化与误差预算:分别列出分压比、参考、ADC、泄漏、温漂与噪声;注意误差可能相关,不能只看理论 LSB。
  3. 休眠电流:测整个设备,分别测接通和关断分压时的电流,并在高温下检查漏电。
  4. 时间与源阻抗:对比不同外部等待和 ADC 采样时间;切换前一通道并重复测试;确认最坏情况下读数稳定。
  5. 电源与负载:用精密电源扫描整个电池工作范围;分别验证轻载、典型工作与发射工况。瞬态用足够带宽的仪器捕捉。
  6. 校准与温区:记录双点系数、参考仪器和条件,检查第三点、量产重复性,以及温区内的独立验证点。
  7. 异常与上报:检查超时、开关关断、极端码值和系数损坏;上云时同时记录采样时间、状态、温度、原始码、参考电压及校准版本,别只上传一个孤零零的“电量百分比”。

十二、总结

坑 先看什么 主要改法
分压漏电 整板休眠电流 选择阻值、按需接通并验证漏电
采样时机 负载和静置状态 明确轻载、工作负载或瞬态目的
参考电压 ADC 实际 Vref 验证参考与温漂,避免写死名义值
输入阻抗 外部 RC 与采样窗口 分别保证节点稳定与 ADC 采样建立
温度影响 测量链和电池各自变化 先标定链路,再单独建立电池模型
滤波算法 尖峰、延迟与保护 显示滤波与保护路径分离
校准方法 偏置、增益和温区 两点标定,独立第三点与温区验证

**一句话:先明确你要测哪一种电压,再把参考、分压、时序和校准条件记录清楚。**ADC 分辨率高,不代表结果天然可信。

下一篇预告:《库仑计数实战:电量计芯片 vs 软件积分,误差从哪里来》——看看电流偏置、采样频率和自放电如何把 SOC 误差一点点累积起来。

参考资料

  • STMicroelectronics, How to get the best ADC accuracy in STM32 microcontrollers, AN2834:ADC 误差、参考、输入源阻抗和采样时间。
  • STMicroelectronics, How to use VREFBUF peripheral on STM32 MCUs and MPUs, AN5690:参考缓冲及特定器件的 VREFINT 校准示例。
  • 目标 MCU 的数据手册、参考手册及电池/保护器件规格书:最终以实际型号的允许范围、时序和校准要求为准。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。