基于STM32的锂电池电量监测系统:从电压采集到SOC估算实战 简介本资源是一套面向嵌入式初学者与STM32开发者的锂电池电量监测实践项目聚焦电池电压采集、滤波处理、电量估算与实时显示等核心功能解决便携设备中电源状态可视化与续航预估的实际工程问题。压缩包共234个文件包含35个头文件.h定义外设驱动与算法接口33个C源文件.c实现ADC采样、分压计算、平均值滤波及SOC估算逻辑34个目标文件.o与33个依赖文件.d体现完整Keil MDK编译结构另有uvprojx工程配置、axf可执行镜像、pdf设计说明及mp4演示视频整体9.62MB开箱即用。已有55人学习下载配套资料涵盖从硬件连接电阻分压滤波电容、CubeMX基础配置到LCD显示驱动的全流程代码所有模块均经实测验证支持直接烧录运行并快速复现电压-电量映射效果。1. 项目概述与核心价值最近在做一个便携式设备项目里面用到了18650锂电池供电。项目做到一半客户提了个很实际的需求能不能在屏幕上实时显示电池还剩多少电最好还能在电量低的时候报警这听起来简单不就是读个电压嘛但真动手做起来发现从“读取电压”到“准确估算电量”之间隔着不少坑。电压和电量SOC State of Charge可不是简单的线性关系电池用久了、温度变了、充放电电流大小不同都会影响这个换算。市面上专用的电量计芯片固然方便但对于成本敏感或者想完全掌握底层逻辑的项目来说自己用MCU来实现这套监测与估算系统是个挺有挑战也很有成就感的选择。我这次选用的核心是STM32F103C8T6也就是大家常说的“蓝桥杯”或“最小系统板”上的那颗芯片。它价格亲民性能对于这种采集、计算、显示的任务绰绰有余关键是资源丰富ADC、定时器、通信接口一应俱全。整个系统的目标很明确第一高精度地采集锂电池的端电压第二基于采集到的电压、电流如果条件允许和温度数据运用合适的算法估算出当前的剩余电量百分比第三将结果通过OLED屏直观显示并设置电压/电量阈值进行报警。这套方案不仅适用于我手头的便携设备对于电动工具、小型储能装置、物联网传感器节点等需要电池供电且关心电量状态的场景都有直接的参考价值。2. 系统整体设计与硬件选型思路做硬件设计第一步永远是理清需求和约束。对于这个锂电池监测系统核心需求可以拆解为感知、计算、交互三个层面。感知层负责获取电池的“状态信号”主要是电压进阶一点还包括电流和温度。计算层是大脑负责处理原始信号并运行算法估算出电量。交互层则负责把结果告诉我们比如显示、报警或者上传数据。2.1 主控芯片为什么是STM32F103C8T6选择STM32F103C8T6是基于性能、成本和生态的综合考量。首先它内置的12位ADC精度足够应对锂电池监测。单节锂电池满电约4.2V放完电约3.0V有效测量范围在1.2V左右。12位ADC在3.3V参考电压下理论分辨率是3.3V / 4096 ≈ 0.8mV。这个分辨率足以分辨出百分之一电量变化对应的电压波动通常约几毫伏到十几毫伏。其次它拥有足够的外设资源。我们需要一个ADC通道采集电压可能需要一个定时器触发ADC实现定期采样比如每秒一次需要I2C或SPI驱动OLED屏幕需要GPIO控制LED或蜂鸣器做报警还需要USART或USB与上位机通信进行调试和数据标定。STM32F103C8T6的资源配置完全满足甚至还有富余。最后是其无与伦比的社区生态和成本优势。相关的教程、代码库、问题解答非常丰富各种最小系统板价格低廉极大降低了开发和试错成本。对于学习和中小批量产品原型来说它是平衡点上的最佳选择。2.2 电压采样电路设计精度从何而来直接拿MCU的ADC引脚去测电池电压是行不通的。一方面电池电压可能超过MCU的IO口耐受电压通常3.3V或5V另一方面直接连接也会让电池负载发生变化且缺乏保护。因此一个精密的分压电路是必须的。电路设计要点分压比计算假设电池最高电压为4.2V三元锂或3.6V磷酸铁锂MCU的ADC参考电压为3.3V。为确保电池在最高电压时ADC输入不超过3.3V需要设计分压电路。例如对于4.2V电池分压比应小于 3.3V / 4.2V ≈ 0.786。我常用两个高精度、低温漂的金属膜电阻比如R1100kΩ R2220kΩ分压比 R2/(R1R2) ≈ 0.687这样4.2V输入时ADC端电压约为2.88V留有足够余量。滤波与保护在分压点ADC输入引脚到地之间需要并联一个100nF~1uF的电容用于滤除高频噪声。同时可以在ADC输入引脚串联一个100Ω左右的小电阻并配合一个钳位二极管到3.3V防止意外过压冲击损坏MCU。阻抗匹配与误差STM32的ADC输入阻抗并非无穷大。如果分压电阻取值过大如MΩ级ADC采样时的输入电流会导致分压点电压被拉低产生误差。通常将分压电阻网络的总阻值控制在10kΩ~100kΩ之间能在功耗和精度间取得较好平衡。注意电阻的精度和温漂直接影响测量准确性。1%精度的电阻很常见但对于要求高的场合可以考虑0.1%精度的电阻。计算实际电压的公式为Vbat Vadc * (R1 R2) / R2。2.3 人机交互与辅助单元选型显示单元我选择了0.96英寸的OLED屏SSD1306驱动 I2C接口。它功耗低、显示对比度高、接口简单非常适合嵌入式显示。通过I2C只需两根线SCL SDA就能驱动节省了宝贵的IO口。报警单元使用一个GPIO口驱动一个有源蜂鸣器配合一个限流电阻如1kΩ即可。当电压或估算电量低于设定阈值时拉高GPIO蜂鸣器鸣响。通信接口预留一个USARTTX RX连接到USB转TTL模块用于打印调试信息电压、电量、算法中间变量等到电脑串口助手这是开发和标定过程中不可或缺的“眼睛”。3. 核心细节ADC采样与软件滤波实战硬件搭好了数据采集的准确性就交给了软件。STM32的ADC使用有不少门道直接读取单次转换结果往往噪声很大必须结合软件处理才能得到稳定可信的值。3.1 STM32 ADC配置要点与校准首先在CubeMX或直接寄存器配置中需要关注几个关键点时钟配置ADC时钟ADCCLK不能超过14MHz对于STM32F1。通常由APB2时钟分频得到确保其在安全范围内。采样时间采样时间越长ADC内部电容充电越充分结果越准确但转换速度越慢。对于我们的分压电路源阻抗约几十kΩ将采样时间设置为较长的周期是明智的比如239.5个周期。参考电压确保VREF稳定。如果板子有独立的VREF引脚最好接一个干净的3.3V基准源。对于最小系统板通常VREF与VDDA模拟电源相连这时务必确保VDDA供电稳定并接上推荐的去耦电容10uF 0.1uF。上电校准是必须的步骤。STM32的ADC存在偏移和增益误差库函数HAL_ADCEx_Calibration_Start()会执行校准并将校准因子存储起来在后续转换中自动补偿。这个操作一定要在ADC初始化之后、开始正式转换之前进行。3.2 多种软件滤波算法对比与实现单次ADC读数跳动是正常的我们需要用算法“平滑”它。算术平均滤波最简单有效。连续采样N次求和后取平均。N越大平滑效果越好但响应速度越慢。适用于变化缓慢的电压信号。我通常在定时器中断里每秒采样100次然后求平均作为这一秒的电压值。#define SAMPLE_TIMES 100 uint32_t adc_sum 0; for(int i0; iSAMPLE_TIMES; i){ adc_sum HAL_ADC_GetValue(hadc1); HAL_Delay(10); // 间隔10ms避免开关噪声影响 } uint16_t adc_average adc_sum / SAMPLE_TIMES;滑动平均滤波维护一个长度为N的队列每次新采样值入队并丢弃最旧的值然后计算队列中所有值的平均值。这种方式能兼顾实时性和平滑度对内存有一定要求。#define MOVING_AVG_SIZE 10 uint16_t adc_buffer[MOVING_AVG_SIZE] {0}; uint8_t buffer_index 0; uint32_t buffer_sum 0; // 在采样中断中 uint16_t new_val HAL_ADC_GetValue(hadc1); buffer_sum buffer_sum - adc_buffer[buffer_index] new_val; // 减去旧的加上新的 adc_buffer[buffer_index] new_val; buffer_index (buffer_index 1) % MOVING_AVG_SIZE; uint16_t filtered_val buffer_sum / MOVING_AVG_SIZE;一阶低通滤波指数加权平均这是一种在嵌入式领域非常受欢迎的滤波方法计算量小效果不错。公式为Y(n) α * X(n) (1-α) * Y(n-1)。其中X(n)是新采样值Y(n)是本次滤波输出Y(n-1)是上次输出α是滤波系数0α1。α越接近1响应越快但平滑性差α越接近0平滑性好但响应迟钝。它不需要存储历史数组节省内存。float alpha 0.1; // 滤波系数可调整 float filtered_voltage 0.0; // 在采样循环中 uint16_t adc_raw HAL_ADC_GetValue(hadc1); float current_voltage adc_raw * 3.3f / 4096.0f * (R1R2)/R2; // 换算为实际电压 filtered_voltage alpha * current_voltage (1 - alpha) * filtered_voltage;实操心得对于电池电压这种慢变信号我更喜欢算术平均滤波与一阶低通滤波结合使用。先用定时器触发ADC以较高频率如100Hz采样在一个控制周期如1秒内进行算术平均得到一个本周期内的“准稳定值”。再将这个准稳定值送入一阶低通滤波器进行进一步的平滑最终得到用于电量计算的电压值。这样既能抑制高频噪声又能保证系统对电量变化的响应速度。4. 锂电池电量估算算法深度解析这是本项目的灵魂所在。电量估算的难点在于锂电池的端电压与剩余电量SOC之间的关系是非线性的并且受温度、充放电倍率、电池老化程度SOH影响。4.1 基础方法查表法电压-SOC曲线法这是最简单直接的方法适用于对精度要求不高、工况稳定的场景。获取OCV-SOC曲线你需要知道你所用电池型号在特定温度如25°C、小电流静置后的开路电压OCV与SOC的对应关系曲线。这个曲线可以从电芯规格书中找到或者通过实验测量得到。制作查找表将曲线离散化在MCU中建立一个数组查找表。例如每5% SOC对应一个电压值。typedef struct { float voltage; // 电压单位V uint8_t soc; // 电量单位% } OcvSocTable; OcvSocTable soc_table[] { {4.20, 100}, {4.15, 95}, {4.10, 90}, {4.05, 85}, {4.00, 80}, {3.95, 70}, {3.90, 60}, {3.85, 50}, {3.80, 40}, {3.75, 30}, {3.70, 20}, {3.65, 15}, {3.60, 10}, {3.50, 5}, {3.30, 0} };查表与插值测量得到当前电池电压后在查找表中找到相邻的两个点电压值一个比测量值大一个比测量值小然后使用线性插值法计算SOC。uint8_t estimate_soc_by_voltage(float measured_voltage) { int table_size sizeof(soc_table)/sizeof(soc_table[0]); if(measured_voltage soc_table[0].voltage) return 100; if(measured_voltage soc_table[table_size-1].voltage) return 0; for(int i0; itable_size-1; i){ if(measured_voltage soc_table[i].voltage measured_voltage soc_table[i1].voltage){ // 线性插值 float voltage_range soc_table[i].voltage - soc_table[i1].voltage; float soc_range soc_table[i].soc - soc_table[i1].soc; float ratio (soc_table[i].voltage - measured_voltage) / voltage_range; return (uint8_t)(soc_table[i].soc - ratio * soc_range); } } return 0; // 理论上不会走到这里 }查表法的局限性它严重依赖“开路电压”。但我们的系统通常在电池工作时测量存在负载电流电池端电压会因内部阻抗产生压降IR Drop导致测量电压低于真实OCV从而使估算的SOC偏低。此外它无法应对电池老化带来的曲线漂移。4.2 进阶方法安时积分法Coulomb Counting这是目前最主流、相对准确的方法。其核心思想是对流入/流出电池的电流进行积分直接计算电荷量的变化。SOC(t) SOC(t0) (1 / Qn) * ∫ I(τ) dτ从t0到t 其中SOC(t0)是初始电量Qn是电池的额定容量单位Ah或mAhI是电流充电为正放电为负。实现步骤电流采样需要在电池回路中串联一个采样电阻如10mΩ使用STM32的另一个ADC通道配合运算放大器测量电阻两端的压差从而计算出瞬时电流I。这一步对运放的共模抑制比和零漂有要求。数值积分在MCU中积分通过累加来实现。我们以固定的周期Δt如1秒采样电流I(k)。SOC(k) SOC(k-1) (I(k) * Δt / 3600) / Qn * 100%注意单位换算I为AΔt为秒Qn为Ah时I*Δt/3600的单位是Ah。初始SOC确定安时积分法需要一个准确的起点SOC(t0)。通常可以在系统上电时让电池静置一段时间后使用上述的查表法基于OCV来获得一个比较可靠的初始SOC。容量校准与补偿电池容量Qn会随着老化SOH下降而减小。纯安时积分会累积误差。需要结合其他方法如满充判断、周期性的OCV校准来修正容量值和重置SOC。安时积分的优势与挑战优势是理论上只要电流测量准确就能实时跟踪电量变化不受负载影响。挑战在于电流测量的微小误差尤其是偏移误差会随着时间不断累积导致SOC漂移。因此高精度的电流检测电路和定期的“归零”校准至关重要。4.3 融合方案查表法 安时积分在实际项目中我倾向于采用一种混合策略来平衡复杂度和精度上电/静置阶段使用查表法。系统上电后若检测到电流极小接近零并持续一段时间如30秒则认为电池处于近似开路状态。此时用查表法估算SOC并将其作为安时积分法的初始值。这个值通常比较准确。工作阶段切换到安时积分法。在设备运行、有充放电电流时主要依靠安时积分来实时计算SOC的变化。这能有效跟踪动态过程中的电量消耗。定期校准在设备长时间静置或每次充满电检测到充电截止条件时再次用查表法或直接置为100%来修正安时积分法累积的误差重置SOC或修正容量Qn。这种方案在STM32F103C8T6上完全可以实现既能保证大部分时间的跟踪能力又能利用静置机会修正系统误差是一种性价比很高的实用方案。5. 系统软件架构与关键代码实现有了硬件和算法我们需要一个稳定的软件框架把它们组织起来。对于这种多任务采样、计算、显示、报警系统我强烈建议使用一个简单的时间片轮询或基于实时操作系统如FreeRTOS的架构。这里以时间片轮询为例因为它更轻量无需引入RTOS。5.1 主循环与任务调度设计我们利用SysTick定时器产生1ms的时基然后在这个时基上构建一个简单的调度器。// 在stm32f1xx_it.c的SysTick_Handler中 void SysTick_Handler(void) { HAL_IncTick(); system_tick; // 全局变量每1ms加1 } // 主循环 int main(void) { // 硬件初始化时钟、ADC、GPIO、I2C、定时器、UART等 HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_I2C1_Init(); MX_TIM2_Init(); // 用于定时触发ADC MX_USART1_UART_Init(); // ... 其他初始化 OLED_Init(); // OLED初始化 // 变量初始化 uint32_t last_adc_task 0; uint32_t last_calc_task 0; uint32_t last_display_task 0; float battery_voltage 0.0; uint8_t battery_soc 0; while (1) { uint32_t current_tick system_tick; // 任务1电压采样 (每10ms执行一次) if(current_tick - last_adc_task 10) { last_adc_task current_tick; adc_sample_task(); // 此函数内部进行ADC启动、读取、滤波 } // 任务2电量计算与报警判断 (每1000ms执行一次) if(current_tick - last_calc_task 1000) { last_calc_task current_tick; battery_voltage get_filtered_voltage(); // 获取滤波后的电压 battery_soc estimate_soc(battery_voltage, get_current()); // 估算SOC check_alarm(battery_voltage, battery_soc); // 检查是否触发报警 } // 任务3刷新显示 (每500ms执行一次) if(current_tick - last_display_task 500) { last_display_task current_tick; update_display(battery_voltage, battery_soc); } // 其他任务如串口数据发送、按键扫描等... // HAL_Delay(1); // 如果任务很轻可以加个小延时降低CPU占用 } }5.2 ADC定时采样与DMA传输优化为了不阻塞主循环并获得稳定的采样频率使用定时器触发ADC并配合DMA传输是更优的选择。CubeMX配置使能一个定时器如TIM2配置为更新事件触发频率设为你的采样率如100Hz。配置ADC1在“Trigger”选项中选择“Timer 2 Trigger Out event”。使能ADC的DMA模式设为循环模式Circular数据宽度为半字Half Word。代码实现#define ADC_BUFFER_SIZE 100 uint16_t adc_dma_buffer[ADC_BUFFER_SIZE]; volatile uint8_t adc_data_ready 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // DMA传输完成中断回调当缓冲区半满或全满时触发取决于DMA配置 adc_data_ready 1; } void adc_init_with_dma(void) { HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFFER_SIZE); HAL_TIM_Base_Start(htim2); // 启动定时器开始自动触发ADC } // 在电量计算任务中 if(adc_data_ready) { adc_data_ready 0; uint32_t sum 0; for(int i0; iADC_BUFFER_SIZE; i) { sum adc_dma_buffer[i]; } uint16_t adc_avg sum / ADC_BUFFER_SIZE; // ... 将adc_avg转换为电压并进行进一步滤波 }这种方式将ADC采样和CPU处理完全解耦CPU只需在DMA缓冲区满时一次性处理一批数据效率极高且采样间隔由硬件定时器保证非常精确。5.3 OLED显示与报警逻辑显示和报警是系统的输出界面要求清晰可靠。void update_display(float voltage, uint8_t soc) { char str_buf[32]; OLED_Clear(); // 显示标题 OLED_ShowString(0, 0, (uint8_t*)Battery Monitor, 16); // 显示电压 sprintf(str_buf, Voltage: %.2f V, voltage); OLED_ShowString(0, 2, (uint8_t*)str_buf, 12); // 第2行12号字体 // 显示电量百分比和简易条形图 sprintf(str_buf, SOC: %3d %%, soc); OLED_ShowString(0, 4, (uint8_t*)str_buf, 12); // 绘制一个简单的电量条 uint8_t bar_length (soc * 100) / 100; // 假设屏幕宽度100像素对应100% OLED_DrawRectangle(0, 50, bar_length, 58, 1); // 画实心矩形作为电量条 OLED_DrawRectangle(0, 50, 100, 58, 0); // 画边框 OLED_Refresh(); } void check_alarm(float voltage, uint8_t soc) { // 低压报警 if(voltage VOLTAGE_ALARM_THRESHOLD) { // 例如 3.3V HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_SET); // 可以在屏幕上显示报警图标 } else if(soc SOC_ALARM_THRESHOLD) { // 例如 10% HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_RESET); } }6. 系统标定、调试与常见问题排查系统搭建完成后标定和调试是确保精度的关键。6.1 电压测量通道的标定即使使用了精密电阻分压比和ADC参考电压的微小偏差也会引入系统误差。我们需要用更精密的仪器如六位半数字万用表进行标定。准备一个可调精密电源模拟电池电压如3.0V 3.6V 4.0V 4.2V。将电源接入你的电压采样电路。用万用表测量电源的实际输出电压V_real同时从串口打印出MCU通过ADC计算出的电压值V_mcu。在多个电压点记录下(V_real, V_mcu)数据对。通过线性拟合最小二乘法可以得到一个校正公式V_corrected k * V_mcu b。将k和b这两个系数存入MCU的Flash中每次计算电压时都应用这个校正。// 假设通过标定得到 k1.02 b-0.01 #define VOLTAGE_CALIB_K 1.02f #define VOLTAGE_CALIB_B -0.01f float get_calibrated_voltage(uint16_t adc_raw) { float v_mcu adc_raw * 3.3f / 4096.0f * (R1R2)/R2; return v_mcu * VOLTAGE_CALIB_K VOLTAGE_CALIB_B; }6.2 电量估算模型的建立与验证对于查表法你需要建立或获取准确的OCV-SOC表。实验法获取将电池用很小的电流如0.05C充满然后静置2小时以上测量其开路电压此时对应SOC100%。然后用恒定的小电流放电如0.05C每放出5%的容量根据额定容量计算就静置1-2小时测量开路电压。重复此过程直到放电截止。这样就得到了一组(OCV SOC)数据点。模型验证在设备实际工作中记录下静置后的电压和你系统估算的SOC。与理论曲线或安时积分结果如果电流测量准确进行对比评估查表法的误差。根据误差情况可以微调查找表中的数据点。6.3 常见问题与排查实录问题ADC读数跳动大不稳定。排查首先检查硬件。测量VDDA/VREF电压是否稳定分压点对地的滤波电容是否焊上PCB布局上模拟电源走线是否远离数字电源和高速信号线软件是否进行了校准采样时间是否设置得太短尝试增加软件滤波的采样次数或调整滤波系数。经验在ADC输入引脚就近接一个0.1uF和10uF的电容到地效果立竿见影。问题电量显示在带负载时骤降去掉负载后回升。原因这是典型的未考虑IR压降的影响直接使用了负载电压去查OCV-SOC表。解决如果无法测量电流则此方法无解只能用于静置状态估算。如果采用了安时积分法则工作时应以安时积分结果为准静置时再用电压法校准。问题安时积分法电量“跑飞”几天后显示电量与实际情况严重不符。排查电流零点漂移是罪魁祸首。即使电池不工作电流采样电路也可能输出一个非零的微小值偏置电压这个值会被持续积分。解决硬件上选择零漂移运放精心设计PCB布局减少热电动势和噪声。软件上在系统空闲电流理论上应为0时多次采样电流值计算其平均值作为“零点偏移量”在后续的所有电流读数中减去这个偏移量。需要定期重新校准这个零点。系统上必须结合静置电压校准来定期重置累积误差。问题OLED显示乱码或不显示。排查首先用逻辑分析仪或示波器检查I2C波形看SCL/SDA线上是否有正确的起始信号、地址、数据和停止信号。确认I2C地址是否正确通常0x78或0x7A。检查上拉电阻通常4.7kΩ是否已接。经验STM32的I2C外设有时比较“娇气”特别是时钟配置不对时容易卡死。如果软件I2C驱动稳定可以考虑使用GPIO模拟I2C时序虽然效率低些但非常可靠。问题系统功耗偏高。优化在不采样时将ADC切换到低功耗模式或关闭。降低主循环频率。将OLED显示改为仅在数据更新时刷新而不是定时刷新。考虑使用STM32的停机Stop或待机Standby模式在长时间无操作时进入深度睡眠通过外部中断如按键唤醒。这个基于STM32F103C8T6的锂电池监测系统从硬件选型、电路设计到软件滤波、核心算法再到系统架构和调试排错涉及了嵌入式开发的多个关键环节。它不是一个简单的电压表而是一个融合了模拟电路知识、数字信号处理、控制算法和嵌入式编程的综合项目。实现过程中最大的收获不是最终屏幕上跳动的那个数字而是对锂电池特性从模糊到清晰的理解以及对如何用低成本MCU解决一个实际工程问题的完整思考路径。当你成功地将估算误差控制在5%以内并且系统在各种工况下都能稳定工作时那种成就感是无可替代的。本文还有配套的精品资源点击获取