☰
UPS电池管理代码解析:SOC估算、保护逻辑与Modbus上报
2026/10/20 9:35:12 网站建设 项目流程

先把结论放前头:电池管理这套代码,最见功夫的不是主流程,而是那些藏在 if 判断里的去抖计数、查表数据和恢复条件。上篇我们把整体状态机、市电识别和充放电主流程跑通了,这次下篇专门拆三块:SOC 估算、保护逻辑、通信上报。每段代码我都贴了完整可编译的片段,也会说明参数为什么取这个值。没看过上篇也没关系,这次拆的模块是独立的,直接当参考代码看就行。

做 UPS 电池管理的人应该都有体会:主流程写通只算刚进门,真正让产品过检、过老化、跑到现场不误报的,是把估算和保护这些边角料抠细。这篇下篇我就按“采集-估算-保护-上报”这条路,从 ADC 滤波一路拆到 Modbus 状态帧,最后再把我在实际调试中踩过的坑一并交代了。

1. 整体定位:电池管理代码到底在管哪几件事

1.1 上篇回顾与下篇的边界

上篇文章里,我们完成了电池管理模块的骨架:主循环里有个状态机,处理市电正常、市电异常、电池供电逆变输出、市电恢复回充这些大状态切换,充电流程也搭出了“欠压预充-恒流-恒压-浮充”的框架。整个系统能转起来,但距离真正能交付到产线和售后手上,还有一段路。

这段“路”就是下篇要解决的问题,主要有三个:

第一,电池剩余电量 SOC 不能拍脑袋估。客户要看到屏幕上有百分比,这个百分比既要在空载时接近真实值,又要在带负载动态变化时不过度跳动。

第二,保护不能乱触发,也不能该触发时不触发。过压、过放、过温这些保护,必须扛得住现场电磁干扰和机械振动造成的瞬间波动。

第三,电池状态要能上报给主机或者运维平台,帧格式要规范,通信中断后本机还要能独立维持保护逻辑。

所以下篇的内容边界很清晰:ADC 采集层的数据处理、SOC 估算层算法、保护判断层的去抖与恢复逻辑、通信层的帧组装与看门狗。不涉及功率部分具体硬件选型,也不聊逆变算法。

1.2 软件分层与数据结构设计

整个电池管理模块我按下图逻辑组织的:ADC 采集 → 滤波校准 → 特征值生成 → 保护判断 → 状态机转移 → 通信上报。实际编码中不是一个函数打天下,而是每个环节独立成模块,靠一个全局上下文结构体串起来。

/* battery_mgmt.h */ #ifndef __BATTERY_MGMT_H__ #define __BATTERY_MGMT_H__ #include <stdint.h> #define BAT_SAMPLE_PERIOD_MS 10UL #define SOC_SAMPLE_MS 100UL typedef enum { CHG_STATE_IDLE = 0, CHG_STATE_TRICKLE, /* 涓流预充 */ CHG_STATE_CC, /* 恒流充电 */ CHG_STATE_CV, /* 恒压充电 */ CHG_STATE_FLOAT, /* 浮充 */ CHG_STATE_FAULT /* 故障停机 */ } chg_state_t; typedef enum { BAT_FAULT_NONE = 0x0000, BAT_FAULT_OVP = 0x0001, BAT_FAULT_UVP = 0x0002, BAT_FAULT_OTC = 0x0004, /* 充电过温 */ BAT_FAULT_OTD = 0x0008, /* 放电过温 */ BAT_FAULT_COMM = 0x0010 /* 通信超时 */ } bat_fault_t; typedef struct { uint16_t voltage_mv; int16_t current_ma; int16_t temp_x10; /* 温度, 0.1°C 单位 */ uint8_t soc; int32_t soc_ref_mas; /* 剩余电量参考值, mAs */ uint16_t capacity_mas; /* 电池满充容量, mAs */ chg_state_t chg_state; bat_fault_t fault; } battery_ctx_t; extern battery_ctx_t g_bat; #endif

全局结构体看起来不够“现代”,但在 MCU 上反而是最务实的设计:中断和主循环共享数据,只需要保证单个字段读写的原子性,不容易出现指针满天飞的问题。像 SOC 这种变量,三个模块都要读写,放全局变量能省掉大量接口传参,排查问题也方便。

2. 采集层:ADC 滤波与校准代码解析

2.1 为什么 UPS 环境的 ADC 必须做多级滤波

UPS 内部不是安静的地方。充电电路里的开关管、逆变桥的功率管都在高频开关,采样线哪怕做了屏蔽,ADC 引脚上依然会耦合进毛刺。我实测过:电池电压静态 12.6V 的时候,直接读 ADC 转成电压,能看到 12.3V 到 13.2V 之间乱跳。如果拿这种瞬时值去做保护判断,继电器分分钟误动作。

所以采样不能只读一次,也不能简单平均就算完。我用的组合办法是:先连续采 5 个点排序取中值,再把中值送入一个长度为 4 的滑动窗口做平均。中值滤波能干掉脉冲型毛刺,滑动平均能压住白噪声,两层配合,10ms 基本能出稳定结果。

/* adc_filter.c */ #define SAMPLE_GROUP 5 #define MID_WIN_SIZE 4 static uint16_t s_mid_win[MID_WIN_SIZE]; static uint8_t s_mid_idx = 0; uint16_t adc_sampling_group(const volatile uint16_t *adc_reg) { uint16_t seq[SAMPLE_GROUP]; uint16_t t; uint8_t i, j; /* 连采5个原始值 */ for (i = 0; i < SAMPLE_GROUP; i++) { seq[i] = *adc_reg; } /* 冒泡排序取中间值, 样本量小, 排序成本可忽略 */ for (i = 0; i < SAMPLE_GROUP - 1; i++) { for (j = i + 1; j < SAMPLE_GROUP; j++) { if (seq[j] < seq[i]) { t = seq[i]; seq[i] = seq[j]; seq[j] = t; } } } uint16_t mid = seq[SAMPLE_GROUP / 2]; /* 中值进滑动窗口, 再求平均 */ s_mid_win[s_mid_idx] = mid; s_mid_idx = (s_mid_idx + 1) % MID_WIN_SIZE; uint32_t sum = 0; for (i = 0; i < MID_WIN_SIZE; i++) { sum += s_mid_win[i]; } return (uint16_t)(sum / MID_WIN_SIZE); }

这里有个细节:冒泡排序复杂度看着不好看,但样本只有 5 个,总共就 10 次比较,在毫秒级循环里完全不是负担。真正要注意的是排序有副作用——原数组会被打乱,所以必须复制一份到局部数组。

窗口长度取 4 而不是更大,因为 UPS 的工况要求响应速度。负载突然从空载切到满载时,电池端电压会瞬间跌落,保护逻辑需要在几百毫秒内反应过来。窗口太长会把真实变化平滑掉,反而延误判断。

2.2 分压网络系数计算与软件校准

ADC 读到的是寄存器原始值,要变成工程上用的毫伏、毫安,换算公式必须和硬件电路严格对应。我这里用的典型 12V 铅酸电池监控电路:电池电压经两个电阻分压后进 ADC,分压比约 5.7;电流采样用霍尔传感器,零电流输出 2.0V,灵敏度 40mV/A。

/* adc_calib.c */ #define ADC_REF_MV 3300UL #define ADC_FULL 4095UL #define VBAT_DIV_X10 57UL /* (R1+R2)/R2 = (47k+10k)/10k = 5.7 */ #define IBIAS_MV 2000 /* 电流传感器零电流输出 */ #define ISENS_MV_PER_A 40 /* 40mV/A */ static int32_t adc2vbat_mv(uint16_t raw) { int32_t vbat; vbat = (int32_t)raw * ADC_REF_MV / ADC_FULL; /* 引脚电压,mV */ vbat = vbat * VBAT_DIV_X10 / 10; /* 还原电池电压 */ return vbat; } static int32_t adc2ibat_ma(uint16_t raw) { int32_t pinmv = (int32_t)raw * ADC_REF_MV / ADC_FULL; return (pinmv - (int32_t)IBIAS_MV) * 1000L / (int32_t)ISENS_MV_PER_A; }

这两个转换函数看起来简单,但有几个陷阱要注意。

第一,运算顺序不能乱。ADC 原始值先乘后除,乘积有可能超过 16 位,所以中间变量必须用 int32。即使 12 位 ADC 的最大值是 4095,乘以 3300 之后的数值已经到 1350 万了,用 uint16_t 中间变量一定会溢出。

第二,分压比要放大 10 倍存储,避免浮点。实际电阻阻值不可能精确等于标称值,上机前必须用高精度万用表量出真实分压比,再改这个宏定义。

第三,霍尔传感器的偏置电压也有个体差异,且随温度漂移。量产时不可能每台板子都改代码,所以要留软件校准参数。

typedef struct { int16_t volt_offset_mv; /* 电压零点偏移 */ int16_t volt_gain_x1000; /* 电压增益修正, 1000=1.000 */ int16_t curr_offset_ma; /* 电流表底偏移 */ } bat_cal_param_t; static int32_t adc2vbat_cal(uint16_t raw, const bat_cal_param_t *cal) { int32_t mv = adc2vbat_mv(raw); mv = mv * (int32_t)cal->volt_gain_x1000 / 1000L; mv += cal->volt_offset_mv; return mv; }

校准参数在产线老化阶段写入 Flash,通过串口命令现场修改,不需要动固件。

2.3 NTC 温度采样的查表实现

电池温度直接关系到充放电电压指标,不能省。NTC 热敏电阻的电压和温度关系是非线性的,最方便的做法是查表。表里存的是“ADC 原始值 → 0.1°C”,测试中由恒温箱标定出来。这里贴一个示意结构,具体数值要根据你 NTC 型号重新标定。

/* adc_ntc.c */ #define NTC_TABLE_NUM 10 static const uint16_t ntc_map[NTC_TABLE_NUM][2] = { { 234, 850 }, /* 85°C */ { 300, 750 }, { 390, 650 }, { 500, 550 }, { 650, 450 }, { 800, 350 }, { 1000, 250 }, { 1200, 150 }, { 1400, 50 }, { 1600, -50 }, /* -5°C */ }; static int16_t adc2temp_x10(uint16_t adc_raw) { uint16_t i; if (adc_raw >= ntc_map[0][0]) return ntc_map[0][1]; if (adc_raw <= ntc_map[NTC_TABLE_NUM - 1][0]) { return ntc_map[NTC_TABLE_NUM - 1][1]; } for (i = 1; i < NTC_TABLE_NUM; i++) { if (adc_raw >= ntc_map[i][0]) { uint16_t d_adc = ntc_map[i - 1][0] - ntc_map[i][0]; int32_t d_tmp = ntc_map[i - 1][1] - ntc_map[i][1]; return ntc_map[i][1] + (int32_t)(adc_raw - ntc_map[i][0]) * d_tmp / d_adc; } } return ntc_map[NTC_TABLE_NUM - 1][1]; }

注意表格顺序:ADC 值从小到大、温度从高到低排。插值计算时分子分母都是整数,乘法结果记得转 int32,不然低温段会算错。

2.4 采样时序安排

滤波做得再好,采样时序不合理也是白搭。我的经验是把 ADC 采样对齐到充电 PWM 的死区时间。说白了就是:充电 MOS 开关瞬间最容易产生尖峰干扰,软件上尽量躲开那个时刻读 ADC。

实际项目里我采用的方法很简单:PWM 中断里周期翻转一个标志位,主循环检测到标志后晚几十微秒再去触发 ADC 转换。这样采样点基本落在开关动作结束后的平稳区间,滤波压力会小很多。

如果 MCU 有硬件过采样功能也可以开,但要注意过采样率过大会延长单次采集时间。在 10ms 周期里做 5 点采样绰绰有余,不用焦虑。

3. SOC 估算:OCV 查表与库仑积分结合的工程实现

3.1 为什么只用电压或只用积分都不行

纯电压算 SOC 的毛病很明显:电池端电压受负载影响大,满载时电压掉得厉害,哪怕电量还有 70%,读出来也只有 11.8V,直接按表查可能查出 20%。负载一卸掉,电压又弹回去,SOC 数值跟着乱跳。

纯库仑积分的问题是累计误差。电流采样有零偏、有温漂,一两个小时没感觉,跑一天下来误差可能扩大 10% 以上,而且没有自愈能力。

所以工程上普遍用混合方案:静态时用开路电压查表修正 SOC,动态时用库仑积分跟踪变化,每次充满时强制校准到 100%。这是最稳的组合,没有之一。

3.2 开路电压查表与线性插值

下面这张表是 12V 铅酸电池静置 2 小时以上、25°C 环境下的开路电压与 SOC 对应关系,用在系统刚上电、电池没有负载时做初始 SOC 校准。

/* soc_ocv.c */ static const uint16_t ocv_soc_tbl[11] = { 10500, /* 0% */ 11100, /* 10% */ 11500, /* 20% */ 11750, /* 30% */ 11950, /* 40% */ 12100, /* 50% */ 12250, /* 60% */ 12400, /* 70% */ 12550, /* 80% */ 12650, /* 90% */ 12700, /*100% */ }; static uint8_t ocv2soc(uint16_t ocv_mv) { uint8_t i; if (ocv_mv >= ocv_soc_tbl[10]) return 100; if (ocv_mv <= ocv_soc_tbl[0]) return 0; for (i = 1; i <= 10; i++) { if (ocv_mv <= ocv_soc_tbl[i]) { uint16_t lo_mv = ocv_soc_tbl[i - 1]; uint16_t hi_mv = ocv_soc_tbl[i]; uint8_t lo_soc = (i - 1) * 10; uint8_t hi_soc = i * 10; uint32_t ratio = (uint32_t)(ocv_mv - lo_mv) * (hi_soc - lo_soc); uint32_t div = (uint32_t)(hi_mv - lo_mv); return (uint8_t)(lo_soc + ratio / div); } } return 100; }

这里插值的意义要看清:如果我们只查表,做不到整百分比的输出,用户看到 34%、78% 这种数值都是插值插出来的。插值的精度取决于表格点密度,取 10% 一档已经够用,没必要做更密,因为铅酸电池自身一致性也没那么高。

注意ratio/div是整数除法,算出来向下取整。要更平滑的话,可以把 SOC 差值改成 10000 倍再除,最后取整,但会让代码可读性变差。对于显示用途,整百分比足够。

3.3 库仑积分实现与防溢出设计

动态过程中 SOC 靠库仑积分跟踪。积分周期选了 100ms,电流单位毫安,时间单位毫秒,算出来的电荷单位是 mAs(毫安秒)。用 mAs 而不是 mAh,是为了避免单步积分量太小淹没在整数除法里。

/* soc_counter.c */ #define SOC_SAMPLE_MS 100UL void soc_init(uint16_t ocv_mv, uint16_t capacity_mah) { g_bat.capacity_mas = (int32_t)capacity_mah * 3600L; g_bat.soc_ref_mas = g_bat.capacity_mas * ocv2soc(ocv_mv) / 100; g_bat.soc = (uint8_t)(g_bat.soc_ref_mas * 100L / g_bat.capacity_mas); } void soc_update(int16_t current_ma) { if (g_bat.fault != BAT_FAULT_NONE) { return; /* 保护状态下SOC不再积分 */ } /* 电流传感器方向: 充电为正, 放电为负 */ g_bat.soc_ref_mas -= (int32_t)current_ma * (int32_t)SOC_SAMPLE_MS / 1000L; if (g_bat.soc_ref_mas > g_bat.capacity_mas) { g_bat.soc_ref_mas = g_bat.capacity_mas; } if (g_bat.soc_ref_mas < 0) { g_bat.soc_ref_mas = 0; } g_bat.soc = (uint8_t)(g_bat.soc_ref_mas * 100L / g_bat.capacity_mas); }

容量 10000mAh 换算成 mAs 是 3600 万,int32 完全扛得住。100ms 积分步进对应 100mA·100ms/1000 = 10mAs,精度足够。

满充校准的逻辑放在充电状态机里,状态机切到浮充且电流低于截止值时,直接调用soc_init(最高电压, 容量)强制把 SOC 写回 100%。浮充转充满是个缓慢过程,直接校准不会让显示跳变太突兀。

3.4 温度补偿对 SOC 与电压判断的影响

铅酸电池的特性受温度影响很明显,低温时放电容量下降,高温时充电电压要适当调低。SOC 估算里的温度补偿主要影响两个点:一是 OCV 查表前先把电压折算到 25°C,二是充电恒压目标值随温度修正。

折算公式不复杂:

/* 补偿方向与数值按铅酸 6 个单体合计: -18mV/°C */ #define TEMP_REF_C 25 #define TEMP_COMP_MV_C 18 uint16_t get_temp_compensated_ocv(uint16_t ocv_mv, int16_t temp_x10) { int16_t temp_c = temp_x10 / 10; int32_t comp = (int32_t)(TEMP_REF_C - temp_c) * TEMP_COMP_MV_C; return (uint16_t)(ocv_mv + comp); }

这行代码的意思是:温度比 25°C 高 10°C,实际 OCV 会比 25°C 标称值低 180mV 左右,查表前要先把测到的电压加上这个差值。温度补偿系数不能拍脑袋填,不同电池厂家的电解液配方不同,最好以电池规格书为准,或者自己实测拟合。

4. 保护逻辑:过压过放防误动的关键实现

4.1 过压保护为什么要设计“触发快、恢复慢”

保护逻辑最忌两件事:一是误跳,二是反复跳。现场环境里,电池电压不是一条干净的直线,充电器切换、继电器吸合瞬间,电压过冲波动时有发生。如果拿瞬时值做判断,过压几十毫秒就触发,然后电压回落后又恢复,再过冲再触发,设备会像一个抽搐的病人。

我的处理思路是“触发计数 + 恢复滞回”。触发方向上计数要快,比如持续 500ms 确认过压;恢复方向上不但要看电压回落到安全值以下,还要持续 2 秒以上才允许清除故障。这样既防止瞬时干扰,又避免故障反复清除、反复触发。

/* protect_ovp.c */ #define OVP_TRIG_MV 13500 #define OVP_HYS_MV 13100 #define OVP_CNT_RISE 50 /* 触发计数: 50*10ms=500ms */ #define OVP_CNT_FALL 200 /* 恢复计数: 200*10ms=2000ms */ static uint16_t s_ovp_cnt; static uint16_t s_ovp_hys_cnt; void ovp_protect_check(uint16_t vbat_mv) { if (g_bat.fault & BAT_FAULT_OVP) { /* 已在过压状态, 看电压是否跌到滞回点以下 */ if (vbat_mv < OVP_HYS_MV) { s_ovp_hys_cnt++; if (s_ovp_hys_cnt >= OVP_CNT_FALL) { g_bat.fault &= ~BAT_FAULT_OVP; s_ovp_hys_cnt = 0; } } else { s_ovp_hys_cnt = 0; } return; } /* 正常状态下, 电压超过触发点开始计数 */ if (vbat_mv > OVP_TRIG_MV) { s_ovp_cnt++; if (s_ovp_cnt >= OVP_CNT_RISE) { g_bat.fault |= BAT_FAULT_OVP; s_ovp_cnt = 0; } } else { s_ovp_cnt = 0; } }

这套逻辑里最容易被新手忽略的是恢复滞回值。电压回落到 13.1V 才允许恢复,而触发点是 13.5V,中间 0.4V 的窗口就是滞回区间。没有这个窗口,电压在 13.4V 附近反复横跳时,故障会不停置位、清除,上位机收到的报警消息刷屏。

4.2 过放保护与防止继电器来回切换

过放保护也需要同样的思路。UPS 从市电模式切到电池模式后,如果负载过重,电池电压可能快速下跌到低压阈值。等系统准备关机保护时,继电器断开、负载移除,电池电压又会回升,这时如果不做滞回,UPS 会立刻重新开机,再跌再关,来回打摆子,甚至损坏继电器触点。

实际参数我用的是:触发电压 10.5V,持续 1 秒;恢复电压 11.7V,持续 3 秒。恢复电压设在 11.7V 不是随意选的,它对应大约 30% 左右的 SOC。如果只回升到 11.0V 就恢复开机,电池电量所剩无几,开机后撑不了几秒又得关,毫无意义。

#define UVP_TRIG_MV 10500 #define UVP_HYS_MV 11700 #define UVP_CNT_RISE 100 /* 100*10ms = 1s */ #define UVP_CNT_FALL 300 /* 300*10ms = 3s */

注意过放保护触发后要做的动作不止是给故障位置位,还要关掉逆变输出并控制继电器断开。这个动作放在状态机里,由fault标志驱动。故障标志本身只负责告诉状态机“现在能不能工作”,不要在这里直接操作功率硬件。

4.3 过温保护与功率降额配合

电池温度保护不能只分“正常”和“故障”两档。我的产品里采用了三档策略:温度超过 55°C 时告警但继续运行,超过 60°C 时切断充电,超过 65°C 时切断充放电并锁存故障。温度跌回 50°C 以下并持续 1 分钟才允许自恢复。

这种分级的好处是:夏天机房温度偏高时,UPS 能继续维持供电,只是停止充电避免电池热失控;不到万不得已不切输出。切断输出对客户来说是重大事件,能少一次就少一次。

4.4 故障寄存器与状态机的交互

所有故障统一用一个 16 位寄存器管理,置位和清除都由各保护函数负责。状态机每周期读取故障寄存器,按照优先级排序处理:过温最高、过放次之、通信超时最低。

void battery_task_10ms(void) { uint16_t vbat = adc2vbat_cal(adc_sampling_group(&ADC_BAT_REG), &g_cal); int16_t ibat = adc2ibat_ma(adc_sampling_group(&ADC_CUR_REG)); int16_t ntc = adc2temp_x10(adc_sampling_group(&ADC_NTC_REG)); g_bat.voltage_mv = vbat; g_bat.current_ma = ibat; g_bat.temp_x10 = ntc; ovp_protect_check(vbat); uvp_protect_check(vbat); otc_protect_check(ntc); otd_protect_check(ntc); charge_fsm_run(vbat, ibat); soc_update(ibat); }

任务里先刷新采集值,再跑保护,最后跑状态机和 SOC。注意保护判断必须跑在状态机前面,因为状态机每一步转移时都要拿最新的故障标志来决策。顺序反了会出现一帧延迟,别小看这 10ms,关键时刻会多放一次错误动作。

5. 通信与状态上报:Modbus 帧组装、CRC 校验与通信看门狗

5.1 帧结构定义与状态打包

UPS 电池管理模块通常通过 RS485 与上位机或者监控板通信。工程上最常用 Modbus RTU,简单可靠,调试工具也多。上报帧结构如下:地址 + 功能码 + 数据 + CRC16。

/* modbus_report.c */ typedef struct { uint8_t addr; uint8_t func; uint8_t payload[8]; uint8_t len; /* payload长度 */ } modbus_frame_t; void pack_status_frame(modbus_frame_t *f) { f->addr = 0x01; f->func = 0x03; f->len = 6; f->payload[0] = (uint8_t)(g_bat.voltage_mv >> 8); f->payload[1] = (uint8_t)(g_bat.voltage_mv & 0xFF); f->payload[2] = (uint8_t)((uint16_t)g_bat.current_ma >> 8); f->payload[3] = (uint8_t)((uint16_t)g_bat.current_ma & 0xFF); f->payload[4] = g_bat.soc; f->payload[5] = (uint8_t)g_bat.fault; }

电压和电流都按大端字节序放,这是 Modbus 协议的标准做法。current_ma是有符号数,转成uint16_t再加>>8,避免编译器做符号位扩展时把负数的补码高字节搞错。

5.2 CRC16 实现与校验细节

Modbus RTU 用的 CRC16 多项式是 0xA001,实现起来很容易踩坑。标准过程是:CRC 初值 0xFFFF,每字节先异或,再右移 8 次,按需异或 0xA001。

static uint16_t modbus_crc16(const uint8_t *ptr, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *ptr++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }

发送时把 CRC 低字节放在高字节前面,这是 Modbus RTU 的规定,别搞反。我自己就犯过这个错:取了个低字节放前面、高字节放后面的顺序,结果和调试工具对不上,排查了好久。

void send_status_report(void) { uint8_t buffer[256]; uint16_t crc; buffer[0] = 0x01; buffer[1] = 0x03; buffer[2] = (uint8_t)(g_bat.voltage_mv >> 8); buffer[3] = (uint8_t)(g_bat.voltage_mv & 0xFF); buffer[4] = (uint8_t)((uint16_t)g_bat.current_ma >> 8); buffer[5] = (uint8_t)((uint16_t)g_bat.current_ma & 0xFF); buffer[6] = g_bat.soc; buffer[7] = (uint8_t)g_bat.fault; crc = modbus_crc16(buffer, 8); uart_send_bytes(buffer, 8); uart_send_byte(crc & 0xFF); uart_send_byte(crc >> 8); }

CRC 计算的对象是全部数据字节,不包含 CRC 本身。如果上位机反馈 CRC 错误,优先检查地址、功能码之外的字节长度,特别是发送缓冲有没有被其他任务改写。

5.3 通信看门狗:断链不等于不保护

主机和电池管理模块之间是主从关系,主机定时查询。但极端情况下通信线松了、主机死机了,电池模块不能因为没有收到查询就消极怠工。所以我加了一个通信看门狗:任何一帧有效查询都会刷新喂狗计数,主循环里计数递减到 0 就置通信故障位。

#define COMM_TIMEOUT_CNT 3000 /* 3000*10ms = 30s */ static uint16_t s_comm_wdt; void comm_wdt_feed(void) { s_comm_wdt = COMM_TIMEOUT_CNT; } void comm_wdt_tick(void) { if (s_comm_wdt > 0) { s_comm_wdt--; } } void comm_wdt_check(void) { if (s_comm_wdt == 0) { g_bat.fault |= BAT_FAULT_COMM; } }

看门狗超时时间别设太短。上位机如果本身有 5 到 10 秒的轮询周期,你设 30 秒完全够用;要是设 3 秒,碰上主机开机初始化慢,分分钟误报通信故障。通信故障置位后,最保险的动作是保持本地运行,不对外切断充放电,只在上位机恢复后自动清除故障。

这个“本地自治”的思路是整个电池管理系统的底线。无论上位机在不在、通信通不通,电池的过压、过放、过温保护都必须依靠本机自己的逻辑跑起来。通信层永远只能扮演“状态观察员”的角色,不能当“总指挥”。

6. 实际调试中踩过的坑与调参心得

6.1 滤波窗口调大后,保护响应慢到误事

最初我把滑动窗口长度设为 8,想着滤波更干净。结果做负载突变实验时发现,满载切出瞬间电压跌落被平滑得太厉害,过放保护比预期晚了将近 200ms 才触发。虽然没损坏东西,但已经逼近安全边界。

后来我把窗口从 8 改成 4,中值样本从 5 改到不变,整条链路对阶跃响应的延迟控制在 100ms 以内。滤波不是越强越好,要在“毛刺抑制率”和“阶跃响应速度”之间找平衡点。UPS 这种电源产品,宁可接受一点显示噪声,也得保证保护动作够快。

6.2 浮充阶段 SOC 永远停在 99%

跑老化测试时发现一个怪现象:电池明明已经在浮充电压下挂了好几个小时,SOC 显示却一直停在 99%,永远到不了 100%。查下来是浮充电流本身很小,接近截止电流值,但仍然高于程序里判定“充满”的门槛,导致状态机一直停在浮充段,没有触发满充校准。

后来我把浮充超时作为第二个充满条件:浮充超过 24 小时或者浮充电流降到 1/20 额定值,就强制执行满充校准。这个逻辑非常实用,因为实际工况里很多电池长期挂在浮充状态下,SOC 必须稳定在 100%,不能因为“永远差一口气”让用户产生焦虑。

6.3 继电器断开瞬间电压回冲导致过放误报

这是我最头疼的一个问题。满载放电触发欠压保护后,继电器断开,电池负载瞬间消失,端电压会回冲 0.3 到 0.6V。如果代码在保护动作后只等了几十毫秒就检查电压是否低于恢复点,就会看到电压“恢复”了,然后误判为可以恢复输出。

回冲电压持续的时间大概在一到两秒,尤其是电池内阻偏大时回冲幅度更明显。应对办法就是我前面说的恢复滞回加长持续计数:恢复必须电压低于 11.7V 且持续 3 秒才允许。这 3 秒足够让回冲过程过去,也让电池状态稳定下来。

6.4 温度补偿系数不是所有电池通用的

有一版样机在夏季高温环境做测试,充电电压被温度补偿拉低后,电池一直充不到满,SOC 始终偏低。负责补偿的同事说:“温度高了降压不正好吗?”表面上逻辑没错,但系数取大了,降得太多,恒压段的实际电压低于电池真实的浮充需求,充电电流过早下降,电池当然充不满。

后来查了电池规格书,又用恒温箱做了一组不同温度下的充电试验,把补偿系数从 24mV/°C 修正到 18mV/°C,问题才解决。这里特别提醒:网上能找到的“默认值”都是参考,电池不同、厂家不同、配方不同,最终系数必须以规格书和实测拟合为准。别把参数写死在代码里就完事,至少留一个调试接口能调整。

6.5 调试日志的价值远超预期

最后再分享一个老生常谈但真重要的做法:把关键变量的历史值通过串口周期打印出来。我在调试采样滤波和 SOC 时,串口里输出“vbat, ibat, ntc, raw_adc, fault”一行文本,配合上位机存日志,问题定位效率高好几倍。

真正上线后,日志还能帮售后定位“客户说 UPS 总是报警”背后的真实原因。是电池老化容量下降,还是现场电压波动大,数据说话比任何猜测都靠谱。我建议在产品固件里留一个调试串口或者后台日志接口,额度不大,但关键时刻能救命。

这套代码写到这里,充放电切换、SOC 估算、保护判断、通信上报这几层算是完整闭环了。我个人在实际项目里的体会是:电池管理模块的代码量通常不大,但每个阈值、每个计数、每个表项背后都对应着真实电池的脾气和应用环境的干扰。写代码时多问一句“这个值异常了会发生什么”,往往就能避开一个售后难题。后续如果想继续扩展,还可以把这个模块的基础部分移植到储能产品、电动车 BMS 的监控子板上,核心的数据结构和保护思路是通用的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询