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

STM32 ADC采样与滤波实现18650锂电池电量检测

简介一套基于STM32F103C8T6的18650锂电池电量检测系统完整工程资料面向STM32入门学习者、毕业设计及课程设计学生。项目采用电阻分压法与均值滤波利用ADC测量锂电池电压并推算出电流和电量最终在OLED屏上实时显示覆盖从采样、滤波到显示的完整嵌入式开发流程。压缩包共190个文件大小6.64MB包含C源码、头文件、Keil工程配置、编译中间文件及电路原理图PDF等其中均值滤波代码与硬件分压电路设计均清晰可查便于直接移植或二次扩展。目前已有845人学习使用工程经过测试运行成功演示效果可见配套视频适合用于课程作业、工程实训、大创项目及初期产品原型验证可复制借鉴也可继续开发更多功能。1. 18650 锂电池电量检测先把 ADC 采样这一环想明白18650 锂电池的电量检测直接读 STM32 ADC 是行不通的电池满电 4.2V而 STM32 的 ADC 参考电压一般就是 3.3V电压超出量程会让采样值全程顶在 4095分压、滤波、查表这些后续操作全部失去意义。用电阻分压法把电压按比例降到 ADC 量程内再用均值滤波把内阻引起的采样抖动压掉最后根据校准系数换算出电压和剩余电量这是可落地的一条链路。这套基于 STM32 的 18650 电量检测系统看起来简单实际卡人的地方全在细节分压电阻阻值、ADC 采样周期、滤波窗口长度、零点漂移。适合做便携式设备、电池供电传感器或者是拿 STM32 练 ADC 应用的人顺着「硬件分压 → ADC 采样 → 滤波 → 电量计算 → 电流电能统计 → 校准」这条路走一遍单节 18650 的电量显示和电能统计就能落板。2. 电阻分压法设计分压网络、输入阻抗与 STM32 ADC 量程匹配2.1 为什么选电阻分压而不是直接接STM32 的 ADC 是逐次逼近型SAR输入范围 0 到 VREFVREF 引脚通常接到 VDD也就是 3.3V。18650 的放电截止电压一般设 2.8V满电 4.2V最高值已经超过 VDD如果直接把电池正极接到 ADC 引脚轻则采样值饱和在最大码值重则触发引脚保护甚至损坏芯片。所以要先把电压缩到 0~3.3V 的安全区间。电阻分压法用两个电阻把电池电压按比例降低结构最简单也不引入额外功耗以外的成本。缺点是输入阻抗高而 STM32 ADC 内部是一个采样保持电容要求信号源阻抗足够低否则在采样开关闭合的时间里电容充不满读到的码值会整体偏低。这也是很多 DIY 板子「同一个电池万用表量 3.9V单片机读出来只有 3.6V」的原因。所以电阻分压不只是一个分压比的计算还要和 ADC 采样时间配合起来看。分压网络的等效源阻抗是 R1 和 R2 并联这个值越小对采样电容充电越有利但待机功耗越大。做电池供电设备时这个功耗要从电池容量里扣除选参时要两边权衡。2.2 分压电阻阻值计算把 4.2V 映射到 0~3.3V设计目标是 18650 满电 4.2V 对应的 ADC 输入电压尽量靠近 3.0V留一点裕量防止电池过充到 4.3V 时采样值超过参考电压。取 R1100kΩ上分压电阻接电池正极R2200kΩ下分压电阻接地当电池电压 4.2V 时R2 两端电压为 4.2×200000/(100000200000)2.8V对应 ADC 码值 2.8/3.3×4095≈3475留了约 0.5V 的裕量。分压比是 1/1.5也就是说 ADC 读到的电压乘以 1.5 就是电池电压。这个比例的优点是计算简单缺点是源阻抗是 R1||R2≈66.7kΩ对 ADC 采样时间有一定要求。如果目标是把待机电流压到 1μA 以下可以把 R1 换成 1.2MΩR2 换成 2.4MΩ分压比不变但源阻抗约 800kΩ不加运放直接用 ADC 采样误差会非常大。这种情况下常见做法是加一个运放跟随器用低输出阻抗去驱动 ADC。下面是三组实测中比较常用的配置应用场景R1R2空载电流源阻抗是否需要运放常规静态电压监测100kΩ200kΩ14μA66.7kΩ不必须低功耗电池设备1.2MΩ2.4MΩ1.2μA800kΩ必须快速动态响应采样10kΩ20kΩ140μA6.7kΩ否注意 R1 和 R2 的精度。市面上 1% 精度的贴片电阻很便宜分压比误差会被后续校准吸收所以第一版可以用 1% 电阻校准后等效误差可以压到 0.5% 以内。温度系数也要看普通 0603 电阻温度系数 100ppm/°C 以内即可没必要上低温漂电阻。2.3 STM32 ADC 硬件初始化与单次采样代码分压输出接到 STM32 的 ADC 通道比如 PA1 对应 ADC1 的通道 1。以 STM32 HAL 库为例初始化代码常见写法是ADC_HandleTypeDef hadc1; void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig {0}; __HAL_RCC_ADC1_CLK_ENABLE(); hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_DISABLE; // 单通道模式 hadc1.Init.ContinuousConvMode DISABLE; // 单次转换读取控制 hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; // 软件触发 hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; // 右对齐输出0~4095 hadc1.Init.NbrOfConversion 1; // 转换1个通道 if (HAL_ADC_Init(hadc1) ! HAL_OK) { Error_Handler(); } sConfig.Channel ADC_CHANNEL_1; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; // 采样周期拉长适配高阻源 if (HAL_ADC_ConfigChannel(hadc1, sConfig) ! HAL_OK) { Error_Handler(); } }这里把采样周期设为 239.5 个 ADCCLK而不是常见的 1.5 周期。原因是分压网络源阻抗约 66.7kΩ过短的采样周期会让内部采样电容充不满。采样周期变长ADC 等效输入阻抗的匹配要求就放宽采样值更接近真实值。F1 系列 ADC 时钟最高 14MHz239.5 周期换算成时间大约 17.1μs每次转换不到 20μs对电压监测来说完全够用。如果项目里用的是 F4 系列同样把采样时间选到最高档即可。读取单个通道的函数uint16_t adc_read_channel(ADC_TypeDef *adc, uint32_t channel) { ADC_ChannelConfTypeDef sConfig {0}; uint16_t value 0; sConfig.Channel channel; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { value HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); return value; }每次重新配置通道是为了让电压和电流两个通道共用同一个 ADC 时切换后能稳定采样。实际项目里也可以用 ScanConvMode DMA 把多个通道一次扫完但单次转换代码更容易定位问题。2.4 采样参数采样周期、分辨率与参考电压的取舍STM32 的 ADC 分辨率默认 12 位对应 4095 个码值对 3.3V 参考电压的理论分辨率是 3.3/4096 ≈ 0.8mV。但这是理想值实际噪声和参考电压温漂会把有效分辨率拉到 10 位左右所以不要纠结 0.8mV 的理论值。电阻分压后整个测量链路的精度瓶颈往往在基准电压上。如果 VREF 直接用 VDD而 VDD 是 LDO 输出的 3.3VLDO 的温漂和噪声会直接叠加到采样结果里。想提升精度常见做法是把 ADC 参考电压接到外部基准比如 REF3033或使用 STM32 内部的 VREFINT 通道做补偿。VREFINT 是芯片内部固定的基准约 1.2V可以反推当前 VDD 的实际值再做软件补偿。对 18650 电量检测来说电池电压变化范围是 2.8~4.2V分压后是 1.86~2.8V用 3.3V 参考已经能分辨 0.8mV足够覆盖电量估算的精度要求所以 VDD 直连方案在大多数产品里够用外置基准只在需要做高精度电能计费时才值得加。3. 均值滤波在 ADC 采样中的应用去抖动与滑动窗口设计3.1 均值滤波为什么能压住锂电池电压的噪声18650 电池在工作状态下的电压不是平滑的。负载电流变化时电池内阻上会叠加一个瞬时压降比如 100mΩ 内阻、1A 电流就会瞬间掉 0.1V高频开关电路、PWM 负载切换还会在 ADC 输入端耦合出几毫伏到几十毫伏的毛刺。如果直接用单次 ADC 值去换算电压显示的电量百分比会一跳一跳甚至反复在 20% 和 30% 之间跳变。均值滤波通过把多次采样结果做平均能显著压低随机噪声对固定偏置无能为力得靠校准解决。均值滤波有好几种变体简单滑动平均、加权平均、中位值平均。对电池电压这种突发干扰较多的信号中位值平均滤波表现更好先取 N 次采样去掉最大值和最小值再把剩下的值求平均。这样能消除单点尖峰又保留了均值对随机噪声的平滑能力。缺点是每个滤波周期要做排序占用一点计算时间在 F103 上跑 N16 完全没压力。3.2 C 语言实现滑动均值滤波与中位值平均滤波滑动平均用一个环形缓冲区维护一个和每次进来一个新 ADC 值就替换掉最旧的那个然后输出平均值。代码很轻适合 ADC 采样率较高的场景。#define ADC_FILTER_N 16 uint16_t filter_buf[ADC_FILTER_N]; uint8_t filter_index; uint32_t filter_sum; void adc_filter_init(void) { memset(filter_buf, 0, sizeof(filter_buf)); filter_index 0; filter_sum 0; } uint16_t adc_mean_filter(uint16_t adc_value) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] adc_value; filter_sum adc_value; filter_index (filter_index 1) % ADC_FILTER_N; return (uint16_t)(filter_sum / ADC_FILTER_N); }这里 buffer 初始化为 0如果上电后立即调用前 N 次输出会被 0 拉低产生一个从 0 爬升到真实值的斜坡。解决方法是上电后先连续读 N 次填充缓冲区再开始输出滤波值或者把 buffer 初始化成第一次采集值。中位值平均滤波的实现稍复杂常见写法是uint16_t adc_median_mean_filter(uint16_t adc_value, uint16_t *temp_buf, uint16_t *sort_buf) { static uint8_t median_index 0; uint16_t sum 0; uint8_t i; temp_buf[median_index] adc_value; median_index (median_index 1) % ADC_FILTER_N; memcpy(sort_buf, temp_buf, sizeof(uint16_t) * ADC_FILTER_N); for (i 1; i ADC_FILTER_N; i) { uint16_t key sort_buf[i]; int8_t j i - 1; while (j 0 sort_buf[j] key) { sort_buf[j 1] sort_buf[j]; j--; } sort_buf[j 1] key; } for (i 2; i ADC_FILTER_N - 2; i) { sum sort_buf[i]; } return (uint16_t)(sum / (ADC_FILTER_N - 4)); }调用这个函数时temp_buf 需要保存最近 N 次原始采样值sort_buf 是排序临时数组两个数组都放在调用方。每次滤波的排序复杂度是 O(N^2)N16 时需要 120 多次比较消耗约几十微秒在电池采样这种低频场景下完全可接受。如果 N 拉到 64建议改用快速排序或者直接换滑动平均。3.3 把滤波后的 ADC 值换算成实际电压滤波后的码值要先换成分压网络的输出电压再除以分压比得到电池电压。直接浮点运算最直白#define ADC_REF_V 3.3f #define ADC_FULL 4095.0f #define DIV_RATIO 1.5f float cal_voltage(uint16_t adc_value) { return (float)adc_value * ADC_REF_V / ADC_FULL * DIV_RATIO; }但 F1 和 F4 单片机做浮点运算还是比整数慢数据量小时无所谓如果要做高频率采样和电能累计推荐用定点换算实际电池电压乘以 1000 后是一个整数毫伏值公式变成mV adc_value * 3300 / 4095 * 15 / 10用整数乘法除法完成最后再转成小数显示。这里 DIV_RATIO 要拆成 15/10因为 1.5 不是整数。为了防止中间结果溢出adc_value最大 4095乘以 3300 再乘 15 的结果是 202,702,500小于 32 位无符号整数上限所以用 uint32_t 存中间结果是安全的。1.5 这个参数不是死的它来自 R1 和 R2 的实际比例如果最终校准发现分压比偏了直接在代码里改成校准值就行这也是后面第 5 章校准的基础。3.4 OCV-SOC 查表法估算剩余电量直接读电压然后对照放电曲线估算电量这个方法叫 OCV-SOC 查表法。18650 的放电曲线在电量中段30%~70%比较平缓电压只下降 0.1V 对应 20% 的电量变化所以查表法只能做粗糙估计系统要求不高时够用。把一段静态电压和 SOC 对应关系放在 const 数组里例如下面的表开路电压VSOC%4.201004.10903.95753.85603.75453.65303.55203.40103.305查表时先找到电压所在的区间再做线性插值。这里有个坑这个表只在电池静置超过 30 分钟、并且没有充放电电流时才具备参考价值。负载变化时内阻压降会让电压偏低查出来的 SOC 会比实际低 10% 以上。所以很多方案里电压查表只用于校准电量计的零点真正使用时配合第 4 章的电流积分来做动态电量。4. 电流检测与电能累计采样电阻、差分放大与库仑积分4.1 电流检测电路采样电阻加差分放大标题虽然叫电量检测但完整的电能检测必须要电流数据。单节 18650 的放电电流范围从几十毫安到几安培不能直接交给 STM32 ADC 采样。最常见的方法是串联一个低阻值采样电阻比如 20mΩ然后测量电阻两端电压电流越大压降越明显。20mΩ 上 2A 电流产生的压降是 40mV这个电压太小直接用 ADC 是 0.8mV/码相当于只有 50 个码值可用分辨率太差所以要用差分放大器把信号放大到 ADC 量程的合适区间。常见做法是用 INA180 或类似的高侧电流检测放大器增益选 50 倍40mV × 50 2V落在 0~3.3V 量程内。如果选 20 倍增益满量程电流需要扩展到更高。下面是一个选参表格最大电流采样电阻采样压降增益满量程输出电压1A50mΩ50mV502.5V2A20mΩ40mV502.0V3A20mΩ60mV402.4V5A10mΩ50mV502.5V采样电阻自身功耗 I² × R2A 时用 20mΩ 是 80mW会发热。0.1% 精度的合金采样电阻虽然贵一点但对电流精度影响很大因为校准只能校准整体增益很难校准非线性。设计时还要注意采样电阻在电池正极侧和负极侧的区别高侧采样不会干扰系统地和电池负极的共地关系但运放的共模电压较高需要选高共模差分放大器低侧采样简单但会抬高负极电压对多板互连或充电检测可能造成干扰。4.2 电流 ADC 通道配置与零点漂移处理把差分放大器的输出接到 STM32 另一个 ADC 通道比如 PA2 对应通道 2。初始化方式和电压通道一样只是采样周期要看运放输出阻抗运放输出阻抗很低采样周期选 55.5 周期就行用不了最长档。更麻烦的是零漂运算放大器存在输入失调电压在电池没有过流时输出并不是干净的 0V而是带一个几十毫伏的偏置换算成电流可能显示 100mA这就没法用了。零点校准的做法是在系统初始化时先保证电池没有外部充放电电流连续读 16 次电流 ADC 值取平均存成 adc_offset。以后每次计算电流前先减去这个偏移量。代码里可以这样写int16_t adc_to_current(int16_t adc_value, int16_t adc_offset) { int32_t diff (int32_t)adc_value - adc_offset; // 差分输出电压 V diff * VREF / 4095 // 电流 I V / (增益 * 采样电阻) int32_t current_ma (diff * 3300) / 4095 * 1000 / (50 * 20); return (int16_t)current_ma; }这里的 50 是放大增益20 是采样电阻的毫欧值。注意用 int32_t 做中间运算防止 diff 为负时正负号出错。如果采样的是高侧电流运放输出是单端、只能输出正电压那电流方向就只能区分幅值无法在软件里直接判断充电和放电。一些方案会用双向电流运放配合偏置电压把 0A 放在 1.65V这样 ADC 读数大于基准就是充电小于就是放电。4.3 电能统计算法毫安时累计与瓦时换算有了实时电压和电流电能统计就是数学累加。常见的时间基准是定义一个定时器每 100ms 唤醒一次读取一次滤波后的电压和电流把这段时间内的容量增量累加。毫安时mAh的计算公式是采样周期小时× 电流毫安。100ms 是 1/36000 小时采样频率越高积分精度越高但定时器中断开销也越大。float capacity_mah; float energy_mwh; void soc_timer_update(uint16_t adc_voltage, int16_t adc_current_ma) { float vbat cal_voltage(adc_voltage); float ibat (float)adc_current_ma / 1000.0f; // 转成 A float interval_h 0.1f / 3600.0f; // 100ms 时长小时 capacity_mah ibat * 1000.0f * interval_h; // 乘 1000 回到 mAh energy_mwh vbat * ibat * 1000.0f * interval_h; }这里 capacity_mah 的单位是 mAhenergy_mwh 的单位是 mWh。注意电池容量一般标的是 18650 的 2500mAh库仑积分方向不同时累加减充电为正放电为负。这个算法假设定时器 100ms 是精确的如果系统还用 RTC 对时最好用定时器的实际节拍换算而不是用常量。此外电流采样误差会在积分过程中累积例如 5mA 的零漂持续 100 小时就是 500mAh会让电量显示飘掉 20%所以前面提到的零点校准需要定时做或者在电池完全静置时自动修正零点。5. 校准与进阶分压误差修正、三点校准与低功耗运行5.1 用高精度万用表做两点校准反推分压比虽然电阻标称 1%实际调试时最好拿到一组真实数据再定分压比。用 18650 充到 4.2V让系统跑起来用四位半万用表量电池两端电压同时从串口读出 ADC 滤波后的码值。多点采样后真实电压除以 ADC 电压就是实际分压比。连续在高电量和低电量各测一个点取平均值再写进代码或者存 flash。5.2 均值滤波窗口长度与响应速度的平衡窗口 N 不能拍脑袋定。N8 时反应快但噪声压制不够N32 时读数很稳但负载跳变后要 3 秒以上才能反映真实电压会让人误以为系统卡了。权衡做法是动态窗口启动时用 N32 快速填充之后切成 N8。休眠唤醒场景则每次唤醒后采 16 次直接取平均值不需要保留历史窗口逻辑更简单。5.3 低功耗模式下的定期唤醒采样技巧18650 容量大但检测系统自身电路如果一直跑几个 mA 的功耗也会把电池耗空。低功耗设计常见做法是让 STM32 进入 STOP 模式用 LPTIM 或 RTC 定时唤醒。每 5 秒唤醒一次连续读 16 次 ADC 取平均然后计算电压和电量存到备份寄存器或者 RTC 备份域再回到 STOP。这样一个采样周期里 MCU 工作时间大约是 160 次转换约 2ms平均电流能压到微安级别。注意 STOP 模式下 ADC 和主时钟都停了唤醒后要重新初始化 ADC并且等待参考电压稳定。另外校准系数建议存到内部 Flash 的最后一个扇区上电时读取如果用户更换了分压电阻进入校准模式后用串口更新系数比重新烧写固件方便得多。把校准系数放在 Flash 末尾扇区还有一个好处升级固件时不会覆盖下次上电仍然沿用上一次的校准结果这块在实际项目里值得提前规划好。本文还有配套的精品资源点击获取
分享:

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

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