UPS电池管理核心代码解析:状态机、SOC算法与保护逻辑

发布时间:2026/10/24 4:31:07
UPS电池管理核心代码解析:状态机、SOC算法与保护逻辑 做UPS的同行应该都有感触整机硬件拓扑翻来覆去就那几样但真正拉开产品差距的往往是电池管理那部分控制逻辑。上篇把采样电路、驱动通道和通信底层的代码拆完之后很多朋友留言问核心的状态机、SOC算法和保护链路到底怎么写。这篇就接着中篇往下走直接贴源代码逐段解析重点说清楚电池状态怎么切换、安时积分怎么做到不飘、保护逻辑怎么避免误动作。这部分内容适合正在做UPS或BMS嵌入式开发的工程师也适合想从裸机逻辑转向模块化状态机设计的同学源码按某量产机型的主控逻辑做了脱敏简化但整体思路可以直接往自己项目里搬。1. 电池状态机把分散的if判断收拢成一张状态表电池管理最忌讳的就是在中断里到处塞判断、在某个业务函数里临时加分支。跑的模块一多状态就乱今天加个温度保护明天加个充电超时最后整个工程的逻辑变成一团麻。我的做法是先定义状态再用一张状态转移表把切换关系固定下来。1.1 状态定义与转移表的数据结构电池侧的几个核心状态必须一开始就梳理清楚。我常用的最小集是待机BMS_IDLE、充电BMS_CHARGING、放电BMS_DISCHARGING、故障BMS_FAULT。有些项目还需要细分出预充电、浮充、休眠那就在这个基础上扩展但框架不要变。状态枚举定义如下typedef enum { BMS_IDLE 0, BMS_CHARGING, BMS_DISCHARGING, BMS_FAULT, BMS_STATE_NUM } bms_state_t;光有状态还不够切换条件必须集中管理。我习惯用一张线性表保存所有合法迁移路径每条路径包含当前状态、目标状态和判定函数指针typedef struct { bms_state_t cur; bms_state_t next; bms_state_t (*condition)(void); } bms_trans_t;这样写的好处是新增一个迁移关系时不用改业务代码只需要往这张表里追加一行。主循环只负责遍历这张表找到满足条件的迁移就执行逻辑非常直白。1.2 状态机主循环的代码实现下面是精简后的状态机执行核心所有状态切换都走这同一个入口static const bms_trans_t trans_table[] { {BMS_IDLE, BMS_CHARGING, check_charge_start}, {BMS_IDLE, BMS_DISCHARGING, check_discharge_start}, {BMS_CHARGING, BMS_IDLE, check_charge_done}, {BMS_CHARGING, BMS_FAULT, check_fault_condition}, {BMS_DISCHARGING, BMS_IDLE, check_discharge_end}, {BMS_DISCHARGING, BMS_FAULT, check_fault_condition}, }; void bms_state_machine_run(void) { bms_state_t next_state current_state; for (uint8_t i 0; i sizeof(trans_table) / sizeof(trans_table[0]); i) { if (trans_table[i].cur ! current_state) { continue; } next_state trans_table[i].condition(); if (next_state trans_table[i].next) { current_state next_state; on_state_changed(current_state); break; } } }这里有个容易被忽略的细节condition()返回的是目标状态还是BMS_STATE_NUM我的约定是满足条件就返回对应的目标状态不满足就返回BMS_STATE_NUM之外的占位值实际上我在工程里直接返回current_state这样主循环逻辑更简洁。on_state_changed()只做三件事清理该状态下的定时器标志、重设保护计数、打印状态切换日志。不要在回调里做复杂的PID运算或滤波否则主循环周期会被拉长状态切换的实时性就毁了。1.3 为什么用状态表而不是散装判断再说说为什么推荐状态表方案。早期项目里我也写过这样的代码一个充电函数里嵌套三层判断外电是否恢复、电池电压是否达标、温度是否超限全部塞在一起。短期能跑后期加一个充电超时保护或者继电器粘连检测就得在整个判断链里找插入点稍不留神就改坏一个分支。状态表的优势是逻辑可验证。你可以把整张表打印到串口或上位机上对照设计文档逐条审查每个状态有几个出口、每个迁移是否互斥、有没有状态死锁。这种可审查性在整机认证测试阶段特别重要因为审核人员会逐条核对保护逻辑状态表可以让评审过程顺畅很多。2. ADC采样链路所有算法都建立在准确的测量上状态机只是骨架肉是数据。UPS电池管理对电压、电流、温度的测量精度要求极高——欠压保护阈值差0.1V可能把好电池判坏电流采样差1%安时积分一小时就能偏出不少容量。这章专门拆采样和滤波。2.1 三路采样的时序与通道分配电池电压、充放电电流、电池温度这三路我一般用MCU自带ADC的多个通道配合DMA循环采样。采样时序上有个关键点必须避开功率管开关瞬间。逆变器或充电器工作时母线电压在开关频率下会剧烈波动如果ADC采样正好落在开关边沿读到的值可能叠加很大的尖峰。我的处理方式是用定时器触发ADC把触发点设置到PWM周期的中间时刻也就是开关动作充分稳定后的那段时间。这个经验在处理市电逆变切换的场合特别重要否则滤波算法怎么调都压不住周期性毛刺。2.2 滤波与标定代码解析原始ADC值不能直接用要先做标定再滤波。电压通道的标定公式是voltage (adc_value / 4096.0) * ref_voltage * divider_ratio其中divider_ratio是电阻分压网络的衰减系数这个系数需要用精密电阻实测校准不能只看原理图上的标称值。滤波我优先用一阶低通计算量小参数直观#define ADC_FILTER_ALPHA 0.2f static float filter_update(float raw, float last) { return last (raw - last) * ADC_FILTER_ALPHA; }ADC_FILTER_ALPHA取0.2意味着新值只占20%权重信号平滑度比较好滞后大约几十毫秒够保护逻辑用。如果项目对响应速度更敏感比如需要快速过流保护可以分两路处理一路用轻滤波做控制一路用原始值直接触发中断保护。不要把保护用的值和显示用的值混在同一个滤波链路里否则响应速度会被拖慢。滤波系数也有一套实用取值建议根据采样率和需求可以这样选场景采样周期推荐Alpha说明电池电压监测10ms0.1~0.2平滑优先避免误判充放电电流控制1ms0.3~0.5响应和控制精度平衡温度检测100ms0.05~0.1温度本身变化慢2.3 标定表的温度补偿陷阱电压标定还有个隐藏问题基准电压源随温度漂移。中低端产品常用MCU内部参考电压温漂系数可能到几十ppm每度。如果设备工作范围是-20℃到60℃全温度范围的电压测量误差可能超过0.5%对铅酸电池来说这个误差已经足以让浮充电压判断产生偏差。我现在的做法是出厂时做两点标定存到EEPROM里一个低电压点一个高电压点运行时线性插值。这个做法比单纯查表灵活也免去每个温度点都校准的麻烦。标定参数结构体大概长这样typedef struct { uint16_t adc_low; uint16_t adc_high; float volt_low; float volt_high; } adc_calib_t;每次开机读取标定参数计算斜率和偏移后续所有电压换算都走这套参数不用每次换算都查Flash。3. SOC估算的折中之道安时积分与开路电压的配合SOC荷电状态是用户最关心的数据也是电池管理里最难做准的部分。纯开路电压法在静态环境下很准但UPS电池大多数时间处于浮充、均充或放电状态电压一直在变化开路电压法根本拿不到稳定的采样窗口。纯安时积分法计算简单但电流传感器有零漂积分会一天天偏离真实值。3.1 为什么必须组合两种算法我的策略是双算法并行在线时以安时积分为主用开路电压法在特定时机做校正。具体来说积分负责放电过程的连续估算开路电压负责在长期静置后提供一个可靠的初值。还有一个所有UPS都该有的校正时机充电完成。电池充满时电压会稳定在一个比较明确的区间电流也会降到很小的尾电流。只要检测到电池电压高于充满判定电压且充电电流低于截止电流持续一段时间就可以毫不犹豫地把SOC强制修正到100%。这个操作比任何积分公式都靠谱因为它用的是物理事实而非数学推测。3.2 SOC更新代码与补偿边界以下是一段简化后的SOC更新逻辑里面包含了积分和充满校正#define BATTERY_CAPACITY_AH 9.0f // 9Ah电池按实际标称修改 #define FULL_CHARGE_V 13.8f #define CHARGE_TAIL_CURRENT 0.1f // 尾电流阈值0.1A static float soc_integral 100.0f; void soc_update(float current, float delta_t, float bat_voltage) { float delta_ah current * delta_t / 3600.0f; soc_integral - (delta_ah / BATTERY_CAPACITY_AH) * 100.0f; // 充满强制校正 if ((bat_voltage FULL_CHARGE_V) (current CHARGE_TAIL_CURRENT)) { soc_integral 100.0f; } // 容量边界限制 if (soc_integral 100.0f) { soc_integral 100.0f; } if (soc_integral 0.0f) { soc_integral 0.0f; } }这里有个容易踩的坑delta_t不能直接写成固定值哪怕你在主循环里固定每10ms调用一次。因为一旦某个耗时的保护函数抢占了CPU两次调用的间隔就会拉长如果用固定周期乘电流积分结果就偏小或偏大。我每次都用定时器记录真实间隔也就是上次调用到本次调用的时间差这样才能保证积分的精度。3.3 温度补偿与自放电修正安时积分还有一个必须考虑的因素温度对可用容量的影响。低温下铅酸电池能放出的容量明显下降如果不对容量做修正冬天UPS可能在设备显示还有30%电量的情况下直接关机。我的做法是按照电池温度对额定容量打一个折减系数static float capacity_compensate(float temperature) { if (temperature 25.0f) { return 1.0f; } if (temperature 0.0f) { return 0.7f; } return 1.0f - (25.0f - temperature) * 0.012f; }这个系数在 -10℃ 到 40℃ 范围内够用更多数据点可以根据电池厂家的放电曲线去拟合。自放电修正也一样长时间浮充状态下每天补偿零点几个百分点虽然数值小但积累半个月后对SOC显示观感影响很明显值得加进去。实际调试SOC模块时我最常遇到的现象是放电末端SOC掉得飞快从30%到0%可能只需要几分钟。这其实是铅酸电池放电曲线本身的特性不是代码bug但产品端往往需要做显示平滑处理否则用户会投诉。一个有效的处理是显示SOC不直接用积分结果而是再加一层低通滤波让跳变变得柔和。4. 充放电保护逻辑宁可误判不可漏判保护逻辑是整个电池管理里唯一不允许妥协的部分。SOC估错一回事保护失效就是安全问题了。但宁可误判不等于随便设阈值误判太多会导致频繁关机同样影响用户体验。4.1 过压、欠压、过温、过流的判定与恢复机制保护必须先区分动作条件和恢复条件。不要用同一个阈值做动作和恢复否则系统会在边界附近反复切换继电器频繁通断直接烧坏。典型的做法是加迟滞比如欠压动作电压10.5V恢复电压11.0V中间留0.5V死区。温度、过压同理。第二个要点是防抖。电压毛刺是客观存在的哪怕前面做了滤波仍然可能出现单个周期瞬时超过阈值的情况。如果每碰到一个毛刺就触发保护设备会频繁自锁。正确的做法是要求连续N次采样都超限才判定故障这个N根据采样周期和实际可容忍的判定时间来确定。4.2 保护链路的代码实现与防抖处理以下是一段欠压保护实现包含了防抖和恢复逻辑#define UV_TRIP_V 10.5f #define UV_RECOVER_V 11.0f #define UV_FAULT_COUNT 5 static uint16_t uv_count 0; void check_under_voltage(void) { if (battery_voltage UV_TRIP_V) { uv_count; if (uv_count UV_FAULT_COUNT) { set_fault_flag(FAULT_UNDER_VOLTAGE); } } else { // 只有恢复到回差之上才清零计数避免反复横跳 if (battery_voltage UV_RECOVER_V) { uv_count 0; clear_fault_flag(FAULT_UNDER_VOLTAGE); } } }这个代码里有一个值得注意的地方uv_count的清零条件不是滤波后的电压只要大于UV_TRIP_V就立刻清零而是只有大于UV_RECOVER_V才清零。这样处理的好处是当电压在10.5V和11.0V之间来回晃动时计数器不会反复增减给故障标志留出了稳定的判定空间。过流保护的实现思路类似但响应速度要求更高。我个人的经验是过流保护不要完全依赖主循环里的软件检测因为主循环周期可能到几十毫秒对于短路来说太慢了。硬件比较器或ADC快速中断配合门控电路做第一道防线软件保护作为第二道防线兜底。这个理念一定要在设计之初就定下来不能只靠代码扛。还有一个容易忽略的保护类型是充电过流。很多人只关注放电过流忽略充电回路也可能因为充电器失控而输出过大电流。代码层面需要单独为充电模式设置一路电流阈值动作后要区分是阀控器件故障还是市电波动造成的瞬态电流处置方式也不一样。4.3 故障恢复机制与运维友好的设计保护动作后怎么恢复这里要分两类。一类是可自动恢复的比如瞬时过流、充电过压等条件恢复后延迟一段时间自动复原另一类是不可自动恢复的比如电池电压长时间低于严重欠压阈值这个必须保持锁定等待人工介入。我的习惯是在代码里定义故障等级#define FAULT_LEVEL_RECOVERABLE 0 #define FAULT_LEVEL_LATCHED 1自动恢复的故障在清除条件满足后要加一个恢复延迟防止多个故障同时出现时系统马上恢复又马上保护。锁存故障则需要维护人员通过按键、上位机指令或断电重启来解除。锁存信息必须写进EEPROM或Flash否则掉电后故障状态丢失机器上电又认为一切正常这会让现场维护非常困惑。另外状态切换时一定要把故障标志显示在面板或通过串口输出至少要能看到当前保护原因。这对生产测试和售后排查的价值远超你的想象现场人员能看到FAULT_UNDER_VOLTAGE而不是只知道机器不工作沟通成本直接少一大半。5. 中篇复盘核心代码里最容易出问题的三个位置代码逻辑本身不难但把这些逻辑嵌进真实MCU环境时有几个位置非常容易翻车。我根据自己的项目经验挑三个典型问题聊一聊。5.1 浮点运算与堆栈开销SOC积分、滤波、标定换算大量用到浮点数。在带FPU的Cortex-M4/M7上问题不大但在Cortex-M0或者老旧的8位机上浮点运算不仅慢还可能在中断上下文里占用大量堆栈。我遇到过这样的情况主循环里跑浮点滤波很正常但把SOC积分放到定时器中断里后中断嵌套稍微深一点就堆栈溢出程序跑得时好时坏。排查到最后才发现是中断里的浮点中间变量占据了太多栈空间。现在的习惯是中断里只做必要标记和简单的原值读取所有浮点运算统一放到主循环或专门的任务上下文执行。这不仅能避开堆栈问题也让代码的可调试性变好。如果MCU计算能力实在紧张可以把滤波和积分改成定点数实现。比如电压值用mV单位存整数电流用0.001A单位存整数这样既避免了浮点开销又保持了足够精度只是写代码时需要对每个换算环节做好单位标注防止混用。5.2 掉电存储的时机与磨损均衡UPS断电瞬间是电池管理写Flash最频繁的时候要记录SOC、当前状态、故障标志。但Flash写寿命有限通常是几千到十万次如果每次断电都写同一片地址产品几年后就会在这个位置写坏。我采取的措施有两个。第一是只在关键时刻存储比如SOC变化超过1%、状态发生切换、故障锁存/解除不要在每轮主循环里都写。第二是用多个存储扇区轮换每次写一个递增的序列号上电时取序列号最大且校验正确的那个扇区作为有效数据。5.3 看门狗与死循环的取舍保护逻辑里避免不了等待恢复的场景有些初学者会直接写一个while(1)在那里死等美其名曰等待条件恢复。这个做法在带看门狗的场合会直接导致系统反复复位而复位之后保护状态还没存好又进入同样的死循环设备就在启动保护和解锁之间反复横跳。正确做法是回到主循环依赖状态机在这个周期结束后自动流转到等待状态等待条件满足后再切回正常状态。如果确实需要延时等待外部条件用超时计数来实现。看门狗是用来兜底异常死机的不是用来触发正常业务重置的这个边界一定要搞清楚。就我个人这几年的调试感受而言UPS电池管理这套代码的价值不在于某个函数写得多么精巧而在于整个系统在极端条件下的行为可预测。状态机把流转规则钉死保护逻辑用迟滞和防抖过滤掉噪声SOC算法在精度和计算量之间找平衡。把这几件事做好了设备在用户现场跑几年都不会出幺蛾子。写代码的兄弟建议把日志管脚留出来从第一版就把状态切换和故障原因打印出来后面所有排查工作都会顺畅很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询