STM32 BMS SOC/SOH 估算核心算法深度解析:soc_estimator.c
本篇文章对嵌入式 BMS(电池管理系统)固件中 Core/Src/soc_estimator.c 源码进行逐段深度解析。该模块基于 SH367601 采样 AFE 与 STM32F103,是整块 BMS 中「最见功力」的核心算法单元——它回答了三个问题:电池还剩多少电(SOC)、电池用旧了没(SOH)、以及当前可用容量是多少(soh_capacity_mAh)。我们不仅讲「它做了什么」,更要讲「为什么这么做」以及「哪些工程细节容易踩坑」。
1. 文件总览与设计哲学
源码开头的注释几乎是整个模块的纲领:
/**
* SOC 策略:安时积分(主干) + OCV 查表校准(定期修正)
* SOH 策略:容量衰减 + 直流内阻增长 + 温升异常 三指标加权融合
*/
读懂了这一句,就抓住了全篇的脉络:
- SOC(荷电状态):主算法是安时积分(库仑计数),它实时准确但会随时间漂移;因此用OCV(开路电压)查表在特定时机去「纠偏」。
- SOH(健康状态):单一指标不可靠,采用三个维度加权——容量衰减、直流内阻(DCR)增长、温升异常。
这是工业界「保守但稳健」的经典组合,尤其适合 磷酸铁锂(LFP) 电芯——这类电芯的电压平台极平,光靠电压根本算不出 20%~80% 区间的 SOC,必须依赖安时积分 + 端点校准。
2. 关键宏与数据类型基础
#define CLAMP(val, lo, hi) ((val) < (lo) ? (lo) : (val) > (hi) ? (hi) : (val))
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
CLAMP:数值限幅宏,保证任何计算结果都在[lo, hi]区间内。全程几乎每个输出都套了它,避免积分/除法溢出产生荒谬值。ARRAY_SIZE:静态数组元素个数。注意:只对「同一数组」安全,若传入指针会得到错误的 sizeof 值——这也是本文后面要提醒的隐患。
3. OCV-SOC 查表:LFP 与 NCM 两套曲线
模块内置两张静态查表:ocv_table_lfp[](磷酸铁锂,默认)与 ocv_table_ncm[](三元锂,备用)。以 LFP 为例,它的电压-OCV 关系具有强非线性与平台区的特点:
/* 充电末端:电压爬升明显,OCV 可区分 */
{3650.0f, 100.0f}, /* 3.65V — 满充 */
{3400.0f, 98.0f },
{3360.0f, 95.0f }, /* 3.36V — 进入平台区前 */
...
/* 平台区:电压变化极小,OCV 精度差,主要靠安时积分 */
{3330.0f, 80.0f }, /* 3.330V — 平台区上限 */
{3315.0f, 60.0f },
{3300.0f, 30.0f }, /* 3.300V — 平台区下限 */
/* 放电末端:电压快速下降,OCV 再次可用 */
{2900.0f, 3.0f },
{2500.0f, 0.0f }, /* 2.50V — 放电截止 */
工程要点:
- LFP 在 3.30V~3.33V 之间,电压只变化 30mV 却覆盖了 30%~80% 的 SOC——这就是「平台区」。
- 数据来源注释写得很诚实:「典型 100Ah LFP 电芯,25℃,0.1C 放电 + 每 5% SOC 静置 2h 实测」。实际量产必须用自家电芯重新标定,这是此类代码最容易犯的错误——直接拿别人的曲线。
- OCV 测量的前提是充分静置(极化消失),否则测到的是端电压而非开路电压。
4. OCV → SOC 线性插值
static float ocv_to_soc(float voltage_mV) {
if (g_ocv_table == NULL || g_ocv_table_size < 2) return 50.0f;
/* 边界检查 */
if (voltage_mV >= g_ocv_table[0].voltage_mV) return 100.0f;
if (voltage_mV <= g_ocv_table[g_ocv_table_size - 1].voltage_mV) return 0.0f;
/* 线性插值 */
for (uint16_t i = 0; i < g_ocv_table_size - 1; i++) {
float v_high = g_ocv_table[i].voltage_mV;
float v_low = g_ocv_table[i + 1].voltage_mV;
if (voltage_mV <= v_high && voltage_mV > v_low) {
float ratio = (voltage_mV - v_low) / (v_high - v_low);
return g_ocv_table[i + 1].soc + ratio * (g_ocv_table[i].soc - g_ocv_table[i + 1].soc);
}
}
return 0.0f;
}
设计要点:
- 表按「电压从高到低」排列,插值区间用
voltage_mV <= v_high && voltage_mV > v_low划分,左闭右开避免边界重叠。 - 线性插值假设
OCV-SOC在小区间内近似线性,这在平台区误差极大——所以调用它的 OCV 校准逻辑必须「避开平台区」,稍后看到。 - 备用表未加载(
g_ocv_table == NULL)时返回 50% 兜底,避免野指针。
5. 电池组统计量计算
static void update_cell_statistics(void) {
const afe_data_t *afe = sh36760x_get_data();
if (!afe->data_valid) return;
/*** 求平均/最大/最小电压,以及最大/最小温度 ***/
}
- 从 AFE 驱动取数据,数据无效直接返回,保持上次统计。
- 计算
avg/max/min_cell_voltage_mV与max/min_temperature_C(温度原始值 ×0.1 转换,因为 AFE 上报的是 0.1°C 分辨率)。 - 此函数每次
bms_get_state()调用时刷新,供上层显示与保护判断使用。
6. SOC 主干算法:安时积分(库仑计数)
void soc_coulomb_update(void) {
float current_A = (float)afe->pack_current * 0.001f; /* mA → A */
float dt_h = (float)SAMPLE_INTERVAL_MS / 3600000.0f; /* ms → h */
float eta = (current_A >= 0) ? COULOMB_EFF_DISCHARGE : COULOMB_EFF_CHARGE;
g_bms.soc_est.coulomb_counter_mAh += current_A * 1000.0f * dt_h * eta;
float cap = g_bms.soc_est.soh_capacity_mAh;
if (cap < 100.0f) cap = NOMINAL_CAPACITY_mAh; /* 未标定时用标称值 */
float remaining = cap - g_bms.soc_est.coulomb_counter_mAh;
g_bms.soc_est.soc = (remaining / cap) * 100.0f;
g_bms.soc_est.soc = CLAMP(g_bms.soc_est.soc, 0.0f, 100.0f);
}
逐行解读:
- 电流方向约定:从代码上下文(
pack_current > 0时是放电、< 0是充电),本模块采用「放电为正、充电为负」的约定。注释里的「放电≈1.0,充电≈0.98」的eta是库仑效率(充进去的电不可能 100% 放出来,大电流还有热量损耗)。 - dt 换算:采样周期
SAMPLE_INTERVAL_MS(毫秒)除以 3600000 变成小时,因为安时积分单位是 Ah、要用「A × 小时」。 - 积分方向:库仑计数器以「已无法放出/需要充入的电量」累计。容量
cap减掉累计值得到 remaining,再除以 cap 得百分比。充电时 current 为负,coulomb_counter反而减小,SOC 上升——逻辑自洽。 - 容量来源:优先用
soh_capacity_mAh(来自 SOH 估算,随老化递减);若< 100mAh视为未标定,退回标称容量。这样 SOC 计算会自动响应电池老化。
隐患提示:纯安时积分没有「自我修正能力」。电流采样误差、AFE 偏移、长时间工作,都会让 SOC 缓慢漂移——这正是下面 OCV 校准存在的意义。
7. SOC 校准:三层策略(本模块精华)
鉴于 LFP 平台区 OCV 失效,作者设计了 三层校准策略,按可靠性递减:
7.1 策略一:满充端点自动校准(最可靠)
if (avg_v > (FULL_CHARGE_VOLTAGE_mV - 20)
&& afe->pack_current < 0 /* 正在充电 */
&& abs_current_mA < (NOMINAL_CAPACITY_mAh / 30)) /* 电流降到 C/30 以下 */
{
g_bms.soc_est.soc = 100.0f;
g_bms.soc_est.coulomb_counter_mAh = 0.0f;
g_bms.soc_est.soc_valid = 1;
...
}
- 条件:电压逼近满充限值(-20mV 容差)+ 正在充电 + 电流降到 C/30 以下(近似恒压涓流尾声)。此时可以确定「满充」了,SOC 强制 100%,库仑计清零。
C/30:即100Ah / 30 ≈ 3.3A。这是识别「连续充电结束、进入浮充」的行业常用判据。
7.2 策略二:满放端点自动校准(较可靠)
if (avg_v < (CUTOFF_VOLTAGE_mV + 100) /* 逼近放电截止电压 */
&& afe->pack_current > 0
&& abs_current_mA < (NOMINAL_CAPACITY_mAh / 10)) /* 放电电流 < C/10 */
{
g_bms.soc_est.soc = 0.0f;
g_bms.soc_est.coulomb_counter_mAh = g_bms.soc_est.soh_capacity_mAh; /* = cap,即放空了 */
...
}
- 条件:电压逼近截止值(+100mV)+ 放电 + 电流降到 C/10 以下(接近空载末端)。SOC 置 0,库仑计设为当前总容量——表示「还剩 0,已全部消耗」。
7.3 策略三:静置 OCV 查表(仅两端有效)
/* 条件 1:电流足够小 */
if (abs_current_mA > OCV_CALIB_CURRENT_mA) { g_bms.soc_est.rest_start_tick = 0; return; }
/* 条件 2:静置计时 ≥ OCV_CALIB_REST_TIME_MS(如 30min)*/
...
/* 条件 3:查表后判断是否处于平台区 */
float ocv_soc = ocv_to_soc(avg_v);
if (ocv_soc > 20.0f && ocv_soc < 80.0f) {
/* 平台区:仅当偏差 > 30% 才干预,否则置 0 计时器继续等 */
if (fabsf(ocv_soc - g_bms.soc_est.soc) > 30.0f) { /* 温和修正 */ }
} else {
/* 平台区外:正常阈值 OCV_CALIB_SOC_DELTA 校准 */
}
- 门槛:无负载静置足够久(让极化消散)后,才敢用 OCV。
- 平台区内(20%~80%):OCV 算出的值置信度低,仅当库仑计漂移超过 30% 时才「大剂量」修正,其余情况宁可不动——避免把误差换来换去。
- 平台区外(>80% 或 <20%):两端 OCV 可靠,用较小阈值
OCV_CALIB_SOC_DELTA正常校准,并把库仑计反算回与 SOC 一致,实现「纠偏后立即清零历史漂移」。 - 每个校准点都会记录
last_ocv_calib_tick,便于上层判断「多久没校准过」。
这套「平台区严格、区外宽松」的分段策略,是专门为 LFP 度身打造的,体现了作者对电芯特性的深刻理解。
8. SOH 维度一:容量追踪
void soh_capacity_tracking(void) {
/* 电流绝对值 > 100mA 才累计,区分充/放电 */
if (current_mA < -100.0f) /* 充电 */ charge_accumulated_mAh += |I|·dt;
if (current_mA > 100.0f) /* 放电 */ discharge_accumulated_mAh += I·dt;
/* 满充检测:电压≥满充-50mV 且 电流从未超过 C/25 */
...
/* 满放:从满充状态一路放到截止,记录本次循环测得的容量 */
if (charge_full_detected && avg_v < CUTOFF+100) {
measured_capacity_mAh = discharge_accumulated_mAh;
soh_capacity = 100 × measured / NOMINAL_CAPACITY;
}
}
- 门槛 100mA:小于此电流视为噪声或静态,不累计,避免误差滚雪球。
- 完整循环法测容量:先记录「满充」到「满放截止」之间的放电电荷量
discharge_accumulated_mAh,即为当前实际容量,除以标称得到soh_capacity(0~100)。 - 防溢出:当累计放电超过
2×标称(可能是长期没用满循环),强制钳到1.5×标称,防止数值爆炸。
9. SOH 维度二:直流内阻(DCR)测量
void soh_measure_dcr(voltage_before_mV, voltage_after_mV,
current_before_mA, current_after_mA) {
float dv = |V_after - V_before|; /* mV */
float di = |I_after - I_before|; /* mA */
if (di < 200.0f) return; /* 电流变化太小,无效 */
current_dcr_mOhm = dv / di * 1000.0f; /* mΩ */
soh_resistance = 100 × (1 - (current_dcr - NOMINAL_DCR) / NOMINAL_DCR);
}
- 利用「负载阶跃」瞬间的电压跳变 ΔV 除以电流跳变 ΔI,估算直流内阻(欧姆定律)。
di < 200mA直接拒绝,避免在电流噪声里算出假内阻。soh_resistance以内阻相对出厂值NOMINAL_DCR_mOhm的增大程度反推健康度:内阻越大 → SOH 越低。当内阻翻倍时该项归零。- 调用者需在合适的时机(如充放电切换瞬间)采集前后两帧电压电流,本函数只负责计算。
10. SOH 维度三:温升评估
void soh_evaluate_temp_rise(void) {
float current_A = |I|; /* 放电电流 */
float temp_now = TS1 × 0.1; /* 最靠近电芯的温度点 */
if (current_A > 0.5A && start_tick==0) { start_C=temp_now; start_tick=Tick; return; }
if (Tick - start_tick < 600000) return; /* 10 分钟后评估 */
actual_rise = temp_now - start_C;
i_normalized = current_A / (NOMINAL_CAPACITY/1000); /* 归一化到 1C */
normalized_rise = actual_rise / (i_normalized + 0.01);
soh_temperature = 100 × (1 - (normalized_rise - NOMINAL_TEMP_RISE)/NOMINAL_TEMP_RISE);
}
- 核心思想:内阻越大 → 同样电流下发热越多 → 温升越大 → SOH 越低。
- 在放电电流 > 0.5A 时记录起始温度,10 分钟后对比温升。
- 归一化到 1C 电流:把实际温升按电流折算成「1C 放电下的等效温升」,消除电流大小差异对温升的影响,才能跨工况比较。
- 分母
+0.01防除零。
11. SOH 加权融合
void soh_calculate_final(void) {
float soh = soh_capacity×W_cap + soh_resistance×W_res + soh_temperature×W_temp;
/* 无有效容量数据时,退化为仅用内阻+温升 */
if (measured_capacity_mAh < 100) soh = soh_resistance×0.6 + soh_temperature×0.4;
soc_est.soh = CLAMP(soh, 0, 100);
soc_est.soh_capacity_mAh = NOMINAL_CAPACITY × soh / 100; /* 反馈给 SOC 用 */
}
- 三项加权(权重由配置宏定义),默认以容量为主。
- 智能降级:若还没成功测过一次完整循环容量(
measured < 100mAh),容量项不可信,自动改用「内阻×0.6 + 温升×0.4」的临时权重——避免用一个假容量把 SOH 带偏。 - 最终输出的
soh_capacity_mAh回传给安时积分使用,形成「SOH → SOC」的闭环联动:电池越老,SOC 计算用的容量越小,百分比更真实。
12. 初始化与状态读取
void soc_estimator_init(void) {
memset(&g_bms, 0, sizeof(g_bms));
ocv_table_load(CELL_CHEMISTRY); /* 按配置选 LFP 或 NCM 表 */
g_bms.soc_est.soc = 50.0f; /* 上电默认 50% */
g_bms.soc_est.soh = 100.0f; /* 默认全新 */
g_bms.soh_est.current_dcr_mOhm = NOMINAL_DCR_mOhm;
/* TODO: 从 Flash 加载上次保存的 SOC/SOH 值 */
}
bms_system_t* bms_get_state(void) {
update_cell_statistics();
memcpy(&g_bms.afe, sh36760x_get_data(), sizeof(afe_data_t));
return &g_bms;
}
- 上电默认 SOC=50%、SOH=100%,内阻取出厂值——保守起步,等校准后收敛。
bms_get_state()是给上层/通信用的统一入口:每次调用刷新统计并同步 AFE 原始数据,返回全局结构体指针。
13. 遗留 TODO:Flash 持久化
void soc_save_to_flash(void) {
/* TODO: 将 SOC/SOH 保存到 STM32F103 内部 Flash 的最后一页
* 使用 HAL_FLASH_Program + HAL_FLASHEx_Erase
* - soc (float, 4B)
* - soh (float, 4B)
* - coulomb_counter_mAh (float, 4B)
* - soh_capacity_mAh (float, 4B)
* - CRC16 校验 (2B)
* Flash 地址:推荐 0x0800FC00 (最后一页起始) */
}
这是本文件的明面缺口。注释给出了完整的落盘方案:18 字节数据 + CRC16,写 STM32F103 最后一页(0x0800FC00,1KB 页)。为什么必须有它?因为 SOC 是「带电记忆」——若每次断电都从 50% 重新积分,长期累积的校准成果全部丢失。量产固件强烈建议实现:soc_loader_from_flash() 在 init 中调用,并在掉电/定时/满充满放校准后调用 save,写入前先擦页、写完回读校验。
14. 潜在缺陷与改进建议(评审视角)
| 问题 | 说明 | 改进建议 |
|---|---|---|
| 无 Flash 持久化 | 掉电丢失 SOC/SOH、库仑计 | 实现闪存储存,init 时加载,校准后保存 |
| OCV 校准依赖固定阈值 | 30% / OCV_CALIB_SOC_DELTA 为经验值 | 参数配置化,按电芯实测标定 |
| 安时积分无电流偏差校正 | 长时间累加漂移,靠 OCV 端点拉回 | 可引入电流传感器零点校准(零流时采样归零) |
| 温度采样只取 TS1 | 温升评估只用第一个温度点,代表性不足 | 取多点最高/平均,区分充电/放电温升 |
| OCV 需静置 30min 才校准 | 现实工况难得长时间无负载 | 考虑「部分静置」或带载 OCV 修正模型 |
| ARRAY_SIZE 用于指针有风险 | 若他人误改传指针,sizeof 出错 | 函数内显式传表大小,或加编译期断言 |
| 满放检测门槛较松 | C/10 + 100mV 可能提前误判 | 结合 SOC 趋势与计时器双重确认 |
| 临界电流符号 | 电流恰为 0 时按放电处理(eta 选错) | 用 >=/-=< 明确零流分支 |
15. 总结
soc_estimator.c 是一份注释详实、策略清晰、针对 LFP 高度定制的 BMS 核心算法实现。它的价值不在于代码量,而在于把「安时积分 + OCV 分段校准 + SOH 三维融合」这套工业标准做法落地得清楚明白,且处处体现了对 LFP 电压平台特性的敬畏。对于做 BMS 固件的工程师,这份代码是很好的学习范本——尤其是「平台区内轻干预、区外重校正」的 OCV 校准策略,以及「SOH 反哺 SOC 容量」的联动设计。
如果说有什么必须补的,那就是Flash 持久化与针对自家电芯的参数标定——前者让算法「记得住」,后者让算法「测得准」。补上这两块,这套 SOC/SOH 估算就能真正上量产。
本文由 BMS 固件源码分析撰写,源码位置 Core/Src/soc_estimator.c(STM32F103 + SH367601)。
发表回复