做UPS相关开发的朋友应该都有同感:硬件电路定型之后,真正决定一台UPS“好不好用”的,往往不是功率拓扑,而是电池管理这一层软件写得到不到位。上一篇文章我们把整体任务调度和数据流理了一遍,这篇直接进核心代码,重点拆电池采样滤波、充电状态机、SOC估算、温度补偿和保护联动这几块。如果你正在做带电池管理的控制器,或者负责UPS、储能设备维护,文里的代码片段和参数标定经验可以直接拿去参考。
1. 拆代码之前,先把电池管理到底在管什么捋清楚
1.1 一套完整的电池管理,相当于给电池配了一个“小管家”
电池管理不是简单读个电压、报个警就完了。它实际上是三个角色的合体:一个是“管家”,负责控制充电过程,什么时候大电流充、什么时候小电流浮充、什么时候彻底停充;一个是“哨兵”,负责盯住电压、电流、温度,发现异常立刻介入,防止电池过充起火、过放报废;还有一个是“会计”,负责记录充了多少、放了多少、电池还剩下多少电,这个数据要送给显示面板、上位机监控和远程运维平台。
用大白话讲,UPS的铅酸电池如果一直大电流充,会发热、析气、电解液干涸,寿命断崖式下跌;如果长期不充满就一直浮充,又会造成电压虚高和极板硫化。放得太深,正极板容易软化脱落。这些后果都不是瞬间出现的,全都靠软件在日常运行中一点一点“管”出来。所以电池管理代码的核心目标就一句话:在安全边界内,让电池完成尽可能多的充放电循环,并且随时能告诉上层“现在还有几分钟后备时间”。
1.2 代码分层思路:采样层、决策层、执行层
我在前面的项目里把电池管理模块拆成了三层,实际维护成本低很多:
- 采样层:负责读ADC、滤波、标定、单位换算,输出干净的电压、电流、温度值。
- 决策层:基于采样值跑状态机、SOC估算、告警判断。这一层只认数据,不碰硬件。
- 执行层:根据决策结果去控制充电器使能、放电MOS开关、风扇/加热器、上报事件。
这么做最直接的好处是:现场出问题的时候,先看采样层数据对不对,再查决策层的状态迁移,最后看执行层有没有动作。三步定位,不用在几百行代码里大海捞针。而且换了不同型号的电池,只需要改参数,不用动状态机逻辑。下面这段结构体定义就是为这个目的设计的。
typedef struct { uint8_t cellNum; // 串联单体个数,12V电池记6 uint16_t capAh; // 额定容量 Ah,例如100 uint16_t floatVolt; // 单组浮充电压,单位 mV uint16_t mainVolt; // 单组均充电压,单位 mV int16_t tempCoeff; // 温度系数,-3.0mV/°C/cell,存为 -30 uint16_t overVolt; // 过压保护阈值 mV uint16_t underVolt; // 欠压保护阈值 mV uint16_t overTemp; // 过温保护阈值 0.1°C uint8_t chgEndCurr; // 均充转浮充电流阈值,单位 0.1A uint16_t mainTimeMax; // 均充最大保护时间,单位分钟 } BattCfg_t; typedef struct { uint16_t voltage; // 滤波后电压 mV int16_t current; // 电流 mA,放电为正,充电为负 int16_t temp; // 温度 0.1°C uint8_t state; // 当前状态机状态 uint8_t chgMode; // 均充/浮充/停充 uint16_t soc; // 剩余电量 0~1000,对应 0.0%~100.0% uint8_t alarms; // 告警位组合 uint32_t workMin; // 累计运行分钟数 } BattRt_t;运行时的实时数据全放在BattRt_t里,配置项全放在BattCfg_t里。上层要读电量、读告警,直接访问结构体成员;要改电池参数,通过配置接口更新BattCfg_t,然后全部存入外部存储。这样设计之后,后续做Modbus、SNMP、甚至是本地蓝牙调试接口都变得很顺,所有数据源都在这个结构体里。
1.3 主循环里怎么调度这些功能
有实时操作系统的话,可以把滤波放在10ms任务里,状态机和保护放在100ms任务里,SOC计算放在1s任务里。用裸机的话,最省事的方式是一个100ms周期的定时器中断驱动一个调度标志,主循环按优先级跑:
void main_loop(void) { while (1) { if (flag_10ms) { adc_sample_and_filter(); flag_10ms = 0; } if (flag_100ms) { batt_fsm_run(); alarm_check(); flag_100ms = 0; } if (flag_1s) { soc_update(); battery_log_update(); flag_1s = 0; } } }采样跑10ms,状态机跑100ms,SOC跑1s。这个节奏是我反复试下来比较稳的搭配:采样够快不会丢瞬态尖峰,状态机切换有100ms的时间尺度不至于太敏感,SOC本来就是个慢变量,1秒算一次完全够用。
2. 核心代码拆解:采样、状态机、SOC与保护是怎么配合的
2.1 采样是地基:不干净的电压值会让所有上层逻辑白写
做过现场调试的都知道,UPS内部开关电源带来的纹波和毛刺非常夸张,尤其是充电器从恒流切恒压那个瞬间,电压采样值会剧烈波动。如果直接用原始ADC值跑状态机,会出现各种假告警、假切换。所以采样层的第一件事就是滤波。
我常用的滤波方式是滑动平均,窗口取8到16个点,效果直观、算力要求低:
#define FILTER_WIN 10 static uint16_t volt_buf[FILTER_WIN]; static uint8_t volt_idx = 0; uint16_t voltage_filter(uint16_t adc_val) { uint32_t sum = 0; uint8_t i; volt_buf[volt_idx] = adc_val; volt_idx = (volt_idx + 1) % FILTER_WIN; for (i = 0; i < FILTER_WIN; i++) sum += volt_buf[i]; return (uint16_t)(sum / FILTER_WIN); }关于滤波窗口,我的经验是不要贪大。窗口越大,滞后越明显。比如窗口取20个点、采样周期10ms,那输出值要滞后200ms。电池电压变化本身没那么快,但如果后面叠加了保护逻辑,就等于所有保护都慢了200ms启动。10个点、100ms滞后,是我在实际设备上比较能接受的上限。
如果你对响应速度要求更高,可以用一阶低通滤波,代码更短:
uint16_t voltage_lpf(uint16_t new_val, uint16_t last_val, uint8_t coeff) { // coeff 取值 0~255,越大越平滑、越快越迟钝 return (uint16_t)((last_val * (256 - coeff) + new_val * coeff) >> 8); }一阶低通的相位滞后相对滑动平均更小,但缺点是要调系数,调参需要看真实波形。滑动平均胜在直观、好解释、好排查。两种我都用过,滑动平均更适合项目初期快速跑通。
采样处理还要注意一个地方:ADC的量程和分压电阻参数决定了电压分辨率。分压电阻一旦确定,代码里那个换算系数就固定了。建议把换算系数做成宏,并且在出厂测试时用万用表校对每一块板子。我遇到过一批板子分压电阻批次差异,导致12V电压读数偏差0.2V,欠压保护提前动作,电池还没放完就切到旁路了。后来改成软件标定,每块板烧写时写入校准系数,问题才解决。
2.2 充电状态机:均充、浮充、放电、待机之间的边界条件
铅酸电池充电控制的核心是状态机,状态切错了,后面全乱。我的状态定义如下:
- 待机(IDLE):没有市电异常、电池电压也正常,充电器不给电或者只给微弱的维护脉冲。
- 均充(MAIN):电池电量偏低时,用较大电流把主充阶段跑完。
- 浮充(FLOAT):电池基本充满,用恒定电压、小电流维持容量。
- 放电(DISCHARGE):市电中断,电池单独给负载供电。
- 故障(FAULT):过压、欠压、过温等严重条件下进入的保护状态。
这几个状态之间不是随便跳的,每个迁移都有明确的条件和回差。比如从浮充切回均充,不能看到电压比浮充电压低一点点就立刻切,那样会频繁抖动。正确的做法是:电压低于浮充电压一定回差,且持续一段时间。充电电流低于阈值且持续时间够长,才允许从均充转浮充。
下面这个switch语句是状态机的骨架:
void batt_fsm_run(void) { uint16_t v = g_rt.voltage; switch (g_rt.state) { case BATT_IDLE: if (g_rt.current > DISCHARGE_CURR && v > CUTOFF_UNDER_VOLT) { g_rt.state = BATT_DISCHARGE; } else if (v < (g_cfg.floatVolt - FLOAT_HYST)) { g_rt.state = BATT_CHG_MAIN; g_rt.chgMode = CHARGE_MODE_MAIN; } break; case BATT_CHG_MAIN: // 均充转浮充:电流不足、电压接近目标、持续足够时间 if (chg_curr_valid() && v >= g_cfg.mainVolt - MAIN_END_HYST) { if (main_charge_timeout_check()) { g_rt.state = BATT_CHG_FLOAT; g_rt.chgMode = CHARGE_MODE_FLOAT; } } else if (v > g_cfg.overVolt) { g_rt.state = BATT_FAULT; set_alarm(ALM_OVERVOLT); } break; case BATT_CHG_FLOAT: if (g_rt.current > DISCHARGE_CURR) { g_rt.state = BATT_DISCHARGE; } else if (v < (g_cfg.floatVolt - FLOAT_HYST)) { // 浮充电压掉下来了,说明可能带载或者电池亏电,需要重新均充 g_rt.state = BATT_CHG_MAIN; g_rt.chgMode = CHARGE_MODE_MAIN; } break; case BATT_DISCHARGE: if (g_rt.current < 0) { // 电流方向变负说明已经回到充电状态 if (v < (g_cfg.floatVolt - FLOAT_HYST)) g_rt.state = BATT_CHG_MAIN; else g_rt.state = BATT_CHG_FLOAT; } else if (v <= g_cfg.underVolt) { g_rt.state = BATT_FAULT; set_alarm(ALM_UNDERVOLT); } break; case BATT_FAULT: if (alarm_all_clear()) { g_rt.state = BATT_IDLE; } break; } }写这段代码的时候我吃了不少亏,简单说几个关键点。
首先是均充转浮充的“持续电流不足”判断,不能只看某一瞬间的电流值。充电器CC阶段大电流是2C都可能,CV阶段电流会指数级下降,但中间会有波动。如果电流一下降到阈值以下就立刻切浮充,实际电池电量只有85%左右,长期这样运行,电池容量会越变越小。我实际加了一个持续判断:电流低于阈值必须连续维持3分钟,且累计均充时间不低于1小时,才允许转换。
再看浮充温度漂移问题。浮充电压受温度影响很大,如果不做温度补偿,夏天在35℃环境下仍充13.6V,会是明显的过充,电池短时间内就会鼓包。这个在下文温度补偿部分单独算给你看。
2.3 SOC估算:开路电压法加安时积分的组合拳
SOC是用户最关心的指标,也是软件里最容易被吐槽的一块。显示面板上“电量还剩90%”和“还能撑15分钟”差距巨大,UPS的应用场景比电动汽车还要苛刻,因为它突发断电时必须在几毫秒内接管负载,SOC不准的直接后果是备电时间预估偏差,这在机房可是大事。
纯开路电压法不可行,因为电池在浮充状态下的端电压比开路电压高很多,无法直接换算。纯安时积分又会因为电流采样误差随时间累积漂移。我的做法是两者结合:正常运行时以安时积分为主,用开路电压法做校准锚点。
安时积分部分的代码:
static int32_t remain_ah; // 剩余安时,单位 mAh void soc_update(void) { int32_t sample_cur = g_rt.current; // mA int32_t cap = (int32_t)g_cfg.capAh * 1000; // 转 mAh // 每个1s周期积分:电流(mA) * 时间(h) = mAh remain_ah -= (sample_cur * 1000) / 3600; if (remain_ah > cap) remain_ah = cap; if (remain_ah < 0) remain_ah = 0; g_rt.soc = (uint16_t)((uint32_t)remain_ah * 1000 / (uint32_t)cap); }这里有个要注意的小细节:放电电流定义为正,所以剩余安时递减;充电电流为负,剩余安时递增。remain_ah初始化需要根据历史数据和电池状态估计,通常首次上电用开路电压查表。查表要用浮充结束后静置一段时间的电压,才能得到相对准确的开路电压值,动态过程中查出来的值会偏高。
校准逻辑则放在状态机切换点:
- 当电池在浮充状态下连续维持浮充电压超过48小时,且充电电流持续小于0.005C,判定为充满,SOC强制校正到接近100%。
- 当放电到欠压保护动作时,SOC强制校正到某个低值,比如1%左右,避免显示0%后还一直放电造成过深放电。
强制校正不能一步跳,否则用户看着电量从90%直接跳到100%会产生困惑。我的做法是限制SOC变化斜率,最多每分钟变化2个百分点,慢慢靠过去。软件里加这个渐变逻辑后,现场反馈明显好很多。
实际上,安时积分的核心瓶颈不在算法本身,而在电流采样精度。你用便宜的分流器加运算放大器,小电流段噪声会达到几十毫安,长时间累计下来误差非常可观。有条件上霍尔电流传感器,或者选用带高精度电流检测功能的库仑计芯片,会让SOC算法省很大力气。软件能补偿一部分,但传感器精度决定天花板。
2.4 温度补偿与保护联动
铅酸电池浮充电压必须随温度调整。温度升高时,析气电压降低,如果不降浮充电压,过充电流会让电池内部产气、失水,严重时鼓包。单格温度系数通常是-3mV/℃,也就是每升高1℃,每节2V单体电芯的浮充电压要降低3mV。6个单体的12V电池,系数就是-18mV/℃。
直接看代码:
uint16_t temp_compensated_voltage(uint16_t base_mv, int16_t temp_c) { // temp_c 单位是 0.1°C,基准25.0°C int16_t temp_diff_x10 = temp_c - 250; // 系数 g_cfg.tempCoeff 存的是 -30,即 -3.0mV/°C/单体 int32_t delta_mv = (int32_t)temp_diff_x10 * g_cfg.tempCoeff * g_cfg.cellNum / 100 / 10; return (uint16_t)((int32_t)base_mv + delta_mv); }计算过程帮大家捋一遍:某地下午机柜内温度达到40℃,标准浮充电压13.6V。温度差15℃,温度系数-3mV/℃/单体,6个单体,总补偿-270mV。浮充电压要降到13.33V。如果软件没有温度补偿,电池就一直按13.6V充,时间久了必然出问题。
温度补偿计算完成后,要同时联动到过压保护阈值。因为浮充电压降低了,过压保护阈值如果还固定不变,温度补偿后的电压离保护阈值就会很远,等于保护范围变宽,安全性下降。至少要把温度补偿量同步加到过压阈值上,或者保持过压阈值与浮充电压的差值恒定。我习惯用一个相对偏移量表示保护阈值,比如“浮充电压+1.2V”,这样温度一变,两个值一起动。
保护逻辑本身还要考虑防抖。示波器实测电池电压在负载切换的瞬间会出现几毫秒的尖峰,如果保护立即动作,会频繁误入FAULT状态。我的做法是:采样值超过阈值后启动一个500ms的延时计数,持续500ms才真正触发告警。这个延时比滤波的滞后大一个量级,能挡住绝大多数瞬态干扰。快速变化的过压尖峰则由硬件比较器去处理,软件侧重慢变化异常的判断。
3. 关键参数到底怎么定:一个100Ah电池组的标定全过程
3.1 一张表把参数先定死
以一组96V系统里常见的12V/100Ah单体电池为例,我们把参数集中列在下面,现场换电池品牌时直接对照厂家规格书调整:
| 参数 | 数值 | 备注 |
|---|---|---|
| 浮充电压 | 13.6V | 单组电压,下同 |
| 均充电压 | 14.2V | 均充阶段CV目标 |
| 均充返回电压 | 13.2V | 低于此值重新均充 |
| 均充转浮充电流 | 2.0A | 0.02C,100Ah电池 |
| 均充最短时间 | 1小时 | 防止误判充满 |
| 均充最大保护时间 | 10小时 | 超时强制转浮充 |
| 过压保护阈值 | 14.8V | 浮充电压+1.2V |
| 欠压保护阈值 | 10.8V | 每节1.8V,深度放电保护线 |
| 过温保护阈值 | 50℃ | 0.1℃精度,即500 |
| 温度系数 | -18mV/℃ | 单组(6个单体) |
| 放电截止电压 | 10.5V | 切旁路前的最后防线 |
这些数值不是拍脑袋定的。浮充电压13.6对于大多数12V铅酸电池来说在推荐范围内;均充14.2是极限充电电压。0.02C的转浮充电流也是铅酸蓄电池厂家标准建议值,不同厂家会在0.01C到0.03C之间浮动,具体看厂商文档。
3.2 两道关键计算:温度补偿量和安时积分误差
温度补偿前面已经算过,这里再给一个对比表方便查阅:
| 环境温度 | 补偿量 | 实际浮充电压 |
|---|---|---|
| 15℃ | +180mV | 13.78V |
| 25℃ | 0 | 13.60V |
| 35℃ | -180mV | 13.42V |
| 45℃ | -360mV | 13.24V |
可以看到温度越高浮充电压降得越多。45℃环境下如果还按13.6V充,过充电流会持续叠加在电池上,不出几个星期水就干透了。这个参数在仓储、机房等无空调场景里尤其关键,夏天遇到过多次电池鼓包,排查下来全是温度补偿没使能导致的。
再看安时积分误差的量化。假设放电电流真实值为15A,电流采样传感器在15A满量程下的精度是±300mA,即±2%。如果连续放电1小时,安时积分累计误差就是300mA×1h=300mAh,占总容量100Ah的0.3%。听起来不大,但如果连续运行一个月,期间多次冲放循环,误差会不断叠加,最高可能偏离十几个百分点。所以没有校准机制的纯安时积分,SOC必然越算越离谱。这就是我反复强调必须设浮充满充校准点和放电截止校准点的原因。
3.3 给状态机加“安全上限”,防止失控
参数标定时最容易忽略的是“保护性质的软限制”。我踩过最典型的一个坑:新电池低温环境下内阻较大,均充时电流下降得很慢,持续十几个小时都达不到转浮充的电流阈值。如果代码里没有均充最大保护时间,电池就一直被压在14.2V大电流源上,电解液持续失水,电池很快报废。后来我加了10小时强制转浮充逻辑,并且让这个逻辑在转浮充的同时上报一条“均充超时保护”记录,供运维人员分析,问题才彻底解决。
另一个安全上限是充电电流限制。电池亏电厉害的时候,充电器如果没有限制初始电流,可能直接顶着最大充电电流硬充,短时间内电池温升非常快。软件里通常限制初始充电电流不超过0.3C,温度超过45℃自动降半流。这些限制要优先于状态机切换执行,也就是在ADC采样完成后、状态机运行之前先做一遍粗筛,超过安全线直接走保护分支。
4. 真机调试中踩过的几个坑
4.1 误报过压:滤波没滤干,告警又没延时
第一次联调时,电池电压偶尔会冲到14.9V触发过压告警,但用万用表量端子电压只有14.2V。后来用示波器抓波形才发现是充电器开关管的开关毛刺叠加到了采样线上。加了10点滑动平均后毛刺还剩一点,真正见效的是“持续500ms才触发告警”的延时机制。滤波负责削掉高频噪声,延时负责挡住低频瞬态,两者配合才是完整的防误报方案。
4.2 浮充电压过高,电池鼓包:温度补偿没开
这个案例影响最大。一批电池运行不到半年,好几节出现明显鼓包,打开参数表一看,温度补偿系数配置为0。项目负责人解释是“担心温度补偿基准搞错,不敢开”。这个担心我能理解,但正确的做法不是关掉温度补偿,而是先按-3mV/℃/单体设置,再用精密恒温箱或现场温度表校正温度传感器的读数。我后来给这批设备做了软件升级,开了温度补偿,同时把温度超过50℃的告警策略从“仅上报”改为“自动降低浮充电压到安全值”,再没有出现过同类鼓包问题。
4.3 SOC越算越不准:积分散发的修正策略
设备运行一段时间后,监控平台上的SOC和实测电压对应关系越来越离谱。放电到电池电压已经接近欠压保护了,SOC还显示28%。排查发现问题有两个层面:一是电流传感器零点漂移,没有电流时读数也有几十毫安的偏差,长时间累计导致积分结果偏移;二是初始SOC是靠上次下电时的值恢复的,而下电时电池状态并没有记录到准确的剩余容量。
修正策略分三步:每次上电时增加开路电压静置校准,放电结束后强制把SOC拉低到与截止电压对应的百分比,充电完成后用浮充电流降到极低作为满充信号校准到接近100%。做完这三个修正点之后,SOC精度从“越用越离谱”提升到了“显示值与实际后备时间误差在10%以内”。这个精度对UPS应用来说已经足够实用。
4.4 浮充电流测不准,状态机卡在“均充”出不来
一个现场反复出现的问题:均充阶段电流已经降到0.5A,但状态机始终没有从均充切到浮充。查到最后是电流采样在1A以下严重非线性,ADC读数换算出来的电流值一直保持在阈值以上。解决办法不是单纯调阈值,而是给判定条件加“电压达到目标”和“持续运行时间满足”两个附加条件,用多个条件组合判充完成。这样即使电流采样在小信号段不够准,只要电压到了、时间到了,一样能切过去,不会一直卡在均充上。
4.5 现场调试的安全操作习惯
最后提醒一下动手调优时的安全准则。电池回路能释放的电流非常大,调试时务必做到:接线端子拧紧后再通电,防止接触电阻打火;先用可调电源模拟电池电压验证状态机逻辑,确认无误后再上真电池;第一次上真电池前,在充电输出端串一个功率电阻或限流开关,防止软件异常导致大电流失控。这些习惯看起来基础,但真的能救命。我身边见过不只一次因为调试时没加限流,状态机bug导致充电器满功率输出,最后电池接线柱附近浓烟滚滚的场面。
5. 后续还能往哪里扩展
这一版核心逻辑跑通后,后续值得做的东西其实已经比较清晰了。首先是通信协议对接,把实时电压、电流、温度、SOC、告警状态通过Modbus或SNMP送到动环监控平台;其次是电池组电压采集扩展,不只是整组电压,每节电池单独巡检,提前发现落后单节;然后是容量自学习,每次完整充放电循环后对比实际放出容量与额定容量,更新健康度指标,这个数据对预测性维护很有用。
如果你想在这块继续深入,建议下一步直接做电池组数据的历史记录和分析,把充放电循环次数、累计过温时间、总放电安时数存下来,后期做寿命预测就全靠这些数据。这也是我计划在下一篇拆解里重点展开的内容。
我在实际调试中最深的体会是:电池管理代码的难点根本不在语句写得有多巧妙,而在于边界条件的完整性和现场经验的沉淀。一个小的滤波窗口参数、一个温度补偿开关、一个告警延时计数,都可能决定设备在现场是稳定运行三年还是半年就进售后。上面这些内容如果你正在做UPS或者储能设备的软件开发,可以直接拿来当参考。照着搭一遍,大概率能避开我当年走过的大部分弯路。