作者:薛定谔的悦 | 平台:S32K146 + FreeRTOS | 源文件:
SocDisplay.c
前言
在储能BMS系统里,SOC(State of Charge,荷电状态)是用户最直接感知的核心指标。从EMS大屏到HMI触摸屏,SOC百分比就是电池的"油表"。
然而,算法层计算出来的SOC是一个不断跳变的原始值。把它直接推给显示层,用户看到的将是一个毫无规律、时快时慢、甚至会"倒退"的数字——这在工程上是不可接受的。
这套S32K146 BMS工程实现了一套完整的 SOC显示平滑系统,涉及速度自适应跟踪、端点加速、边界锁定、强制修正四个机制,总代码约350行,集中在 SocDisplay.c 一个文件中。本文将逐层拆解其实现逻辑。
一、为什么需要显示平滑
理解"为什么做"比"怎么做"更重要。SOC显示不平滑的根源有三类:
1.1 安时积分的固有噪声
安时积分法(Coulomb Counting)是这套工程的核心SOC算法。其原理是对电流采样值持续积分:
SOC(t) = SOC(t₀) + ∫I·dt / C_total
ADC采样的电流值本身存在噪声,微小的电流偏差经过积分累积后,会在SOC计算值上产生肉眼可见的抖动。即使实际电流完全稳定,电流传感器的零漂(几十mA量级)也会让SOC以微小但不规律的步长漂移。
1.2 保护触发时的强制修正
BMS的诊断层在检测到过充、过放保护时,会对SOC进行强制修正。例如:
- 过充保护触发 → SOC强制跳至100%
- 单体电压跌破低压阈值 → SOC强制修正至3%
这种修正对算法层是正确的,但从显示层看,就是一个瞬间的几十个百分点跳变。用户会看到:刚才还显示75%,下一秒突然变成100%。
1.3 充放电末期的收敛加速
充电末期,电流逐渐减小,安时积分的变化量也随之减小,SOC的实际增长越来越慢。但此时电池真实容量已经很高,显示值必须"加速追上"真实值,否则充满了电却显示不到100%,用户会误认为电池坏了。
这三类问题,决定了显示平滑算法必须是有状态的、方向感知的、速度自适应的,而不是简单的低通滤波。
二、整体架构:三层函数调用
在 GroupSocDisplayTask()(100ms周期调用)中,整个显示平滑流程分为三个步骤:
void GroupSocDisplayTask(void)
{
u16 aimSoc = 0;
u16 slowSoc = 0;
aimSoc = GetGRealSocTenThousAPI(); // 第一步:取算法层真实SOC
aimSoc = CalcSocChgDhgEndCorr(aimSoc); // 第二步:充放电末端修正
if(0xffff == sSlowDisplaySoc)
{
slowSoc = aimSoc; // 首次初始化:直接等于真实值
}
else
{
slowSoc = CalcSocSlowChang(aimSoc); // 第三步:平滑变化计算
}
UpdateSocSlowDisplay(slowSoc); // 第四步:端点锁定并更新输出
}
数值单位说明:整个模块内部使用"万分之一"作为SOC精度单位(10000 = 100%,9750 = 97.50%)。最终输出时转换为"千分之一"(1/1000)推给上层:
// UpdateSocSlowDisplay内部
gGSysCapInfo_61[eSysCapInfo61_SOC] = (sSlowDisplaySoc + 5) / 10;
// ^^^^^^^^^^^^^^ 四舍五入,转换为0.1%精度
核心状态变量只有一个:
static u16 sSlowDisplaySoc = 0xffff; // 平滑显示SOC中间变量(万分之一)
0xffff 是未初始化标志,首次运行时直接用真实SOC赋值,避免从0缓慢爬升的冷启动问题。
三、速度自适应的单向跟踪
3.1 核心设计思想
CalcSocSlowChang() 是整个平滑算法的核心。它的设计原则只有两条:
- 方向感知:充电时显示值只能增加,放电时只能减少,不允许逆向变化
- 步进叠加:每个周期的显示变化量 = 本周期真实SOC变化量 + 强制步进量(stepSoc)
这个"步进叠加"是关键。仅靠安时积分的变化量驱动显示,在充电末期电流极小时会完全停滞。强制步进保证了显示值一定在向目标值靠拢。
3.2 充电状态下的跟踪逻辑
// 判断当前状态:电流<0为充电
if(GetGSampOutCurrAPI() < 0)
{
state = 1; // 充电
}
充电时(state==1 且 sHisState==1),计算本周期容量变化:
changCap = ABS(sHisCap, inteCap); // 本周期充入电量(mAh)
changSoc = (u16)(changCap * 10000 / totalCap); // 换算为SOC变化量(万分之一)
设计细节:
sHisCap记录上一次调用时的积分电量,inteCap是当前值,差值就是本100ms内充入的电量。若差值为0(噪声消抖),保持上次的积分基准点不动。
当显示值落后于真实SOC(copySoc < aimSoc),叠加步进量:
changSoc += stepSoc; // 实际变化量 + 强制步进量
copySoc += changSoc; // 本次显示变化
if(copySoc > aimSoc) // 防止追过头
{
copySoc = aimSoc;
}
3.3 放电状态下的跟踪逻辑
放电时(state==2 且 sHisState==2),逻辑完全镜像:
changSoc += stepSoc; // 变化量+步进量
if(copySoc > changSoc)
{
copySoc -= changSoc; // 显示值下降
}
else
{
copySoc = 0; // 防止下溢
}
if(copySoc < aimSoc) // 防止降过头
{
copySoc = aimSoc;
}
3.4 显示值超前时的降速处理
如果显示值超过了真实SOC(例如充电过程中算法SOC被向下修正),不能直接让显示跳回,而是以真实变化量除以 SOC_SLOW_SPEED(= 5)的速度缓慢回落:
// 充电时 copySoc > aimSoc(显示超前)
changSoc = changSoc / SOC_SLOW_SPEED; // 降速到真实速度的1/5
copySoc += changSoc; // 继续缓慢增加(等待真实值追上)
这里有一个微妙处:充电时即使显示值超前,copySoc 仍然是加法(因为是充电状态,方向不变),只是速度放慢了,等待真实SOC追上来。这保证了"充电时SOC绝对不会在显示上减小"这一原则。
四、接近端点时加速显示
4.1 充电侧的分段加速(SOC趋向100%)
这是整个算法最精妙的部分。当电池快充满时,电流越来越小,安时积分驱动的 changSoc 趋近于零。如果没有步进补偿,显示SOC会在95%附近"卡住",充满了电却显示不出来。
分段加速的逻辑如下:
if(copySoc < aimSoc) // 显示滞后于真实SOC
{
if(aimSoc >= 9000) // 真实SOC > 90%,进入加速区
{
if(aimSoc >= 9750) // > 97.5%:最快
{
stepSoc = (aimSoc - copySoc) / 50;
// 剩余差距 / 50步 = 5s追完(50×100ms)
}
else if(aimSoc >= 9500) // > 95%
{
stepSoc = (aimSoc - copySoc) / 150; // 15s追完
}
else if(aimSoc >= 9300) // > 93%
{
stepSoc = (aimSoc - copySoc) / 200; // 20s追完
}
else // > 90%
{
stepSoc = (aimSoc - copySoc) / 300; // 30s追完
}
if(0 == stepSoc) // 差距极小时保底步进1
{
stepSoc = 1;
}
}
else if(aimSoc >= 8000) // 80~90%:中速区
{
stepSoc = (aimSoc - copySoc) / 600; // 60s追完
if(0 == stepSoc)
{
if(sTime >= 2) // 每3次调用(300ms)步进1
{
stepSoc = 1;
sTime = 0;
}
else
{
stepSoc = 0;
sTime++;
}
}
}
else // < 80%:最慢区
{
stepSoc = (aimSoc - copySoc) / 1200; // 120s追完
if(0 == stepSoc)
{
if(sTime >= 3) // 每4次调用(400ms)步进1
{
stepSoc = 1;
sTime = 0;
}
else
{
stepSoc = 0;
sTime++;
}
}
}
}
汇总为表格:
| 真实SOC区间 | 追赶时间(理论值) | 步进计算公式 | 体感 |
|---|---|---|---|
| > 97.5% | 5秒 | (aim - copy) / 50 | 快速收尾 |
| 95% ~ 97.5% | 15秒 | (aim - copy) / 150 | 加速趋近 |
| 93% ~ 95% | 20秒 | (aim - copy) / 200 | 平稳加速 |
| 90% ~ 93% | 30秒 | (aim - copy) / 300 | 缓慢加速 |
| 80% ~ 90% | 60秒 | (aim - copy) / 600 | 正常速度 |
| < 80% | 120秒 | (aim - copy) / 1200 | 最慢 |
设计意图:用户在接近充满时最敏感。在97.5%以上,5秒内完成收尾,避免充满了还在转圈;而在中间段(80%以下),120秒的追赶时间意味着显示不会因为电流噪声频繁抖动。
4.2 放电侧的镜像加速(SOC趋向0%)
放电侧完全对称,阈值反向:
| 真实SOC区间 | 追赶时间 | 体感 |
|---|---|---|
| < 3.5%(350/10000) | 5秒 | 快速告警 |
| 3.5% ~ 8%(800) | 15秒 | 加速下降 |
| 8% ~ 14%(1400) | 20秒 | 平稳加速 |
| 14% ~ 20%(2000) | 30秒 | 缓慢加速 |
| 20% ~ 25%(2500) | 60秒 | 正常速度 |
| > 25% | 120秒 | 最慢 |
// 放电端点加速示例
if(aimSoc <= 2000) // 真实SOC < 20%
{
if(aimSoc <= 350) // < 3.5%
{
stepSoc = (copySoc - aimSoc) / 50; // 5s追完
}
// ...
}
五、端点锁定机制
UpdateSocSlowDisplay() 在写入最终显示值之前,有一层边界保护逻辑:
5.1 99% 上锁(充电侧)
else if((slowSoc >= 9900) // 平滑值 >= 99%
&& (0 == EnerGetGroupVHLimStateHook())) // 且尚未触发高压告警
{
if(sSlowDisplaySoc <= 9900)
{
sSlowDisplaySoc = 9900; // 上限锁在99%,不显示100%
}
else if(slowSoc < sSlowDisplaySoc) // 如果是放电(值在减小)
{
sSlowDisplaySoc = slowSoc; // 允许继续减小
}
}
为什么不显示100%?
99% 锁定是一种"余量感"设计。用户看到100%会认为"满了,可以拔枪了",然而BMS内部可能还在做均衡补充充电。在高压告警(VHLimState)触发之前显示停在99%,意思是"快满了,但还在充"。只有当高压保护真正触发(充电真正完成)后,才会跳到100%。
5.2 1% 下锁(放电侧)
else if((slowSoc <= 100) // 平滑值 <= 1%
&& (0 == EnerGetGroupVLLimStateHook())) // 且尚未触发低压告警
{
if(sSlowDisplaySoc >= 100)
{
sSlowDisplaySoc = 100; // 下限锁在1%,不显示0%
}
else if(slowSoc > sSlowDisplaySoc) // 如果是充电(值在增加)
{
sSlowDisplaySoc = slowSoc; // 允许继续增加
}
}
SOC锁在1%不显示0%,是为了给用户"还有一点余量"的心理暗示,避免用户因为看到0%而恐慌。低压保护触发后才真正显示0%,表示"必须停止使用"。
5.3 EEPROM节流写入
每次更新完显示值,还有一个节流保存逻辑:
if((sShowCopy / 100) != (sSlowDisplaySoc / 100)) // 变化超过1%
{
EnerChangEepGNowSocHook((sSlowDisplaySoc / 10)); // 保存显示SOC到EEPROM
sShowCopy = sSlowDisplaySoc;
}
SOC以100ms为周期更新,如果每次都写EEPROM,一天会产生864000次写操作,远超Flash的擦写寿命(约10万次)。这里的设计是:只有显示变化超过1%时才写EEPROM,大幅降低写入频率,同时保证掉电后重启的SOC精度误差不超过1%。
六、强制修正:跳过平滑直接覆写
平滑跟踪保证了"正常工况"下的用户体验,但面对保护触发,必须有即时响应机制。这就是 CorrGDisplaySocByUser() 接口:
void CorrGDisplaySocByUser(u16 soc)
{
if(soc > 10000)
{
return; // 参数防护
}
sSlowDisplaySoc = soc; // 直接覆写平滑中间变量
UpdateSocSlowDisplay(soc); // 立即更新显示输出
}
这个函数完全绕过了 CalcSocSlowChang() 的缓慢追赶逻辑,直接将平滑中间变量设置为目标值,然后立即输出。
它由 CalcSocChgDhgEndCorr() 中的保护逻辑调用:
// 过充一级保护触发
if(EnerGetGroupVHProStateHook() > 0)
{
if((1 != sCorrFlag)
&& ((nowSoc < SOC_SLOW_VHER_SOC) || (sSlowDisplaySoc < SOC_SLOW_VHER_SOC)))
{
CorrGRemainCapBySocAPI(SOC_SLOW_VHER_SOC / 10); // 修正算法层SOC
CorrGNowCapBySocAPI(SOC_SLOW_VHER_SOC / 10); // 修正容量基准
sSlowDisplaySoc = SOC_SLOW_VHER_SOC; // 直接覆写显示值
aimSoc = SOC_SLOW_VHER_SOC; // 目标也设为100%
sCorrFlag = 1; // 标记已执行修正
}
}
sCorrFlag 是一个单次执行标志,防止保护状态持续时重复修正。在 UpdateSocSlowDisplay() 中,还有最终的兜底显示覆写:
if(EnerGetGroupVHProStateHook() > 0)
{
sSlowDisplaySoc = 10000; // 过充保护:直接显示100%
}
else if(EnerGetGroupVLProStateHook() > 0)
{
sSlowDisplaySoc = 0; // 过放保护:直接显示0%
}
各保护状态下的显示行为总结:
| 触发条件 | 宏定义常量 | 显示值 | 机制 |
|---|---|---|---|
| 过充一级保护(VH_PRO) | SOC_SLOW_VHER_SOC = 10000 | 100% | 直接覆写+算法修正 |
| 过放一级保护(VL_PRO) | SOC_SLOW_VLER_SOC = 0 | 0% | 直接覆写+算法修正 |
| 高压告警(VH_LIM) | SOC_SLOW_VH_SOC = 9950 | 99.5% | 平滑趋近,放电可减 |
| 低压告警(VL_LIM) | SOC_SLOW_VL_SOC = 50 | 0.5% | 平滑趋近,充电可增 |
七、充放电末端的电压辅助修正
在 CalcSocChgDhgEndCorr() 中,还有一套基于单体电压的辅助修正逻辑,这套逻辑在充放电末端发挥作用:
7.1 充电末端修正(以磷酸铁锂为例)
// 单体最高电压超过3.495V(接近充满)且SOC偏低
if((GetGCellMaxVoltAPI() >= SOC_V_CHG_UP_MAX_V) && (nowSoc < SOC_V_CHG_UP_LES_SOC))
{
CorrGNowCapBySocAPI(SOC_V_CHG_UP_LES_SOC / 10); // 强制SOC修正为96%
aimSoc = SOC_V_CHG_UP_LES_SOC; // 显示目标拉到96%
}
// 单体最高电压超过3.437V(充电初段末端)且SOC偏低
else if((GetGCellMaxVoltAPI() >= SOC_V_CHG_DN_MAX_V) && (nowSoc < SOC_V_CHG_DN_LES_SOC))
{
CorrGNowCapBySocAPI(SOC_V_CHG_DN_LES_SOC / 10); // 强制SOC修正为91%
aimSoc = SOC_V_CHG_DN_LES_SOC;
}
这是一套"电压-SOC交叉校验"机制:磷酸铁锂电池的充电末端电压特征非常明显(3.4V以上进入尾段),利用这一特征对安时积分的累积误差进行修正。
7.2 静置时的电压平台修正
系统静置超过600秒(SOC_SLOW_CORR_IDLE_T = 6000 × 0.1s)后,会基于开路电压(OCV)与SOC的对应关系进行修正:
if(sIdleTime >= SOC_SLOW_CORR_IDLE_T) // 静置10分钟
{
if(GetGCellMaxVoltAPI() < SOC_V_SEC_DN_MAX_V) // 单体最高电压很低
{
if(nowSoc > SOC_V_SEC_DN_MOS_SOC) // 但SOC偏高
{
CorrGNowCapBySocAPI(SOC_V_SEC_DN_MOS_SOC / 10); // 向下修正SOC
}
}
// ...其他电压区间类似处理
}
这种修正设计为单次触发,利用开路电压平台排除安时积分漂移。静置时间要求足够长(10分钟),确保电压已完全平衡,减少测量误差。
八、完整数据流梳理
算法层真实SOC(每10ms积分更新)
│
▼
GetGRealSocTenThousAPI() ← 取值,单位:万分之一
│
▼
CalcSocChgDhgEndCorr() ← 充放电末端修正 / 保护触发修正
│ ┌──────────────────────────────────────────┐
│ │ VH保护 → aimSoc=10000(100%) │
│ │ VL保护 → aimSoc=0(0%) │
│ │ 充电末端 → 电压辅助拉高SOC │
│ │ 放电末端 → 电压辅助压低SOC │
│ │ 静置10min → OCV平台校准 │
│ └──────────────────────────────────────────┘
│ aimSoc(修正后目标值)
▼
CalcSocSlowChang() ← 平滑追赶计算(核心)
│ ┌──────────────────────────────────────────┐
│ │ 判断充放电方向(state) │
│ │ 计算真实变化量 changSoc │
│ │ 查表得步进量 stepSoc(端点加速) │
│ │ 叠加:changSoc += stepSoc │
│ │ 单向更新:充电只加 / 放电只减 │
│ │ 防越界:不超过/不低于aimSoc │
│ └──────────────────────────────────────────┘
│ slowSoc(平滑值)
▼
UpdateSocSlowDisplay() ← 端点锁定+输出
│ ┌──────────────────────────────────────────┐
│ │ VH保护 → 强制100% │
│ │ VL保护 → 强制0% │
│ │ slowSoc≥99% 且无VH告警 → 锁99% │
│ │ slowSoc≤1% 且无VL告警 → 锁1% │
│ │ 转换单位:/10 → 千分之一(0.1%精度) │
│ │ 变化>1% → 写EEPROM │
│ └──────────────────────────────────────────┘
│
▼
gGSysCapInfo_61[eSysCapInfo61_SOC] ← 最终显示值(上层读取)
九、工程经验总结
1. 平滑不是低通滤波
简单的 display = 0.9×display + 0.1×real 对BMS毫无意义——它对充放电方向无感知,会产生"放电时SOC短暂增加"的悖论显示。这套工程选择的是有状态的、方向敏感的单向追赶,才是正确思路。
2. 步进叠加解决了末端卡顿问题
changSoc += stepSoc 这一行是灵魂。仅靠电流积分驱动,充电末期电流趋零时显示会完全停滞。强制步进保证显示"永远在动",向用户传达"充电进行中"的状态。
3. 分段加速是用户体验的精细调校
从120秒到5秒,六个加速档位是反复调试的结果。80%以下的缓慢追赶防止了中间段的抖动,97.5%以上的快速收尾保证了充满提示的及时性。这种非线性设计,不是算法的精妙,而是对用户心理的理解。
4. 端点锁定是用户引导的艺术
不显示100%(锁99%),是主动欺骗——但这种欺骗对用户有益。它给用户留了余量感,减少了"满了又没满"的焦虑。只有触发了实际的过充保护,才真正告诉用户"满了"。这是产品设计和嵌入式工程相交汇的地方。
5. EEPROM节流写入是寿命工程
100ms的更新周期,如果每次都写EEPROM,设备寿命按天计算。1%阈值的节流策略将写入频率降低了约100倍,且重启时SOC误差不超过1%,是性能与寿命的平衡点。
结语
SOC显示平滑,表面上是一个UI优化问题,本质上是算法层与显示层之间的接口设计问题。这套工程给出的答案是:用一个有状态的、速度自适应的单向跟踪器作为桥梁,辅以端点锁定和强制覆写的逃生通道,实现了在任何工况下都"符合直觉"的SOC显示。
SocDisplay.c 的350行代码,是BMS用户体验工程的缩影——它不属于任何核心算法,却是用户感知最直接的那层。